Skip to content

feat(console): the agent's refusal is the profile-delete preview - #172

Merged
stormer78 merged 1 commit into
mainfrom
feat/persona-delete-refusal
Sep 7, 2026
Merged

feat(console): the agent's refusal is the profile-delete preview#172
stormer78 merged 1 commit into
mainfrom
feat/persona-delete-refusal

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

The follow-up #171 unblocks, and the reason that PR was worth doing.

The problem this closes

Every other irreversible action in this console previews by asking the agent what the change would cost. Profile deletion could not: "which personas present this profile" spans every context, and the only code that computes it agent-side lives inside persona/profile/delete, where it runs in order to refuse.

Until the relay carried a rejection's code and details, the console had only prose — and R3.7 forbids matching on that. So #163 shipped the unbind as a checkbox offered up front, defaulted off, which the operator had to reason about with no idea whether it applied to the profile in front of them.

What it does now

The first attempt is the question. Delete profile sends unbind: false; if personas are presenting it, the agent refuses with persona/profile/delete:bound and details.personaDids, and the console renders that refusal as the preview — naming the personas — with Unbind 3 and delete beside Cancel.

That is a better preview than a question would have been: it is computed at the moment of the delete rather than a moment before it, so nothing can bind in between.

Destructive's force tick is still the wrong shape here, for the reason the old code already recorded: it disables the confirm until ticked, which would make every operator authorise an unbind for the ordinary case where nothing is bound.

Where the wire knowledge lives

PROFILE_DELETE_BOUND and personasBlockingDelete are in @openvtc/pnm-core/admin, beside the call that provokes them — a caller matching on a code it assembled itself is matching on its own assumption. The code is the SPEC §8.5 extended form the agent builds as TrustTaskCode::new_extended(slug, "bound"), compared with === and never parsed.

personasBlockingDelete parses unvalidated wire data, which is the one place a client is entitled to be paranoid, and two of its decisions are load-bearing:

  • It returns null, never [], when the agent refused without naming anyone. The pane renders that case differently ("It did not say which"). "No personas are bound" over a refusal caused by personas is the one reading this must not be able to produce.
  • A mixed array is refused rather than filtered. Keeping the strings and dropping the rest would under-report the blockers, and the operator would unbind what they were shown while something they were not shown kept the profile alive.

Five tests in admin.persona.mjs cover it, with the null cases paired against a positive: { personaDids: [] } is a real answer and stays [], so a pane can notice a refusal that contradicts itself rather than have it flattened into "we don't know".

Verification

npm run lint, npm run build, npm test — 815 across four workspaces (core 514 → 519). All six Assert* steps in ci.yml run locally against a real build.

Not covered

The blocked state is reachable only against a live agent — no test drives the pane itself, only the parse beneath it. The console still has no test harness that renders a pane, and adding one is its own piece of work.

Every other irreversible action in this console previews by asking the
agent what the change would cost. Profile deletion could not: "which
personas present this profile" spans every context, and the only code
that computes it agent-side lives inside `persona/profile/delete`, where
it runs in order to refuse.

So the refusal is the preview — and it is a better one than a question
would have been, because it is computed at the moment of the delete
rather than a moment before it. Nothing can bind in between.

This shape needs the refusal's code and details to survive the bridge,
which they did not until the relay was widened. Before that the console
had only prose, matching on which R3.7 forbids, so the unbind was
offered up front as a checkbox the operator had to reason about with no
idea whether it applied. Now the first attempt is the question and the
answer names the personas.

`PROFILE_DELETE_BOUND` and `personasBlockingDelete` live in
`@openvtc/pnm-core/admin` beside the call that provokes them. A caller
matching on a code it assembled itself is matching on its own
assumption, and the parse is of unvalidated wire data, which is the one
place a client is entitled to be paranoid.

`personasBlockingDelete` returns null, never `[]`, when the agent
refused without naming anyone — and the pane renders that case
differently. "No personas are bound" over a refusal caused by personas
is the one reading this must not be able to produce. A mixed array is
refused rather than filtered for the same reason: keeping the strings
and dropping the rest would under-report the blockers, and the operator
would unbind what they were shown while something they were not shown
kept the profile alive.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit 28b752d into main Sep 7, 2026
3 checks passed
@stormer78
stormer78 deleted the feat/persona-delete-refusal branch September 7, 2026 10:04
@affinidi-appsecurity-bot

Copy link
Copy Markdown

🛡️ AI Agentic Security Code Review

1 AI-confirmed issue, 1 finding needs a human to review/validate.

Mandatory to check: 🔒 Security Code Review Report

Details

🛡️ Security Code Review Report — PR #172

Field Value
Repository OpenVTC/vta-browser-plugin
Branch feat/persona-delete-refusalmain
Validated 2026-09-07
Scan ID 30df67bf
Validator AI Security Validation Agent

🗺️ Scan Coverage

Modules scanned: 2 · with findings: 2 · files: 3 · findings: 3

Module Files scanned Findings
packages/core 2 1
packages/extension 1 2

Executive Summary

Category Confirmed Must-Review-By-Human
Security Issues 1 1

⚠️ 1 finding(s) need human review. These could not be conclusively confirmed or dismissed automatically (insufficient evidence). They are not dismissed — a developer / security team member must read and decide.


🔒 Security Issues

Confirmed Vulnerabilities (1)

🟡 Unquantified 'Unbind them and delete' confirmation when persona list is null

Field Detail
Severity MEDIUM
Location packages/extension/src/manager/panes/persona.tsx:985
Finding ID github_pr-6af63d763508
CWE CWE-693, CWE-862
OWASP A01:2021 - Broken Access Control
MITRE ATT&CK T1499 - Endpoint Denial of Service (state-analog: unauthorized state removal)
CAPEC CAPEC-122
DREAD 6.4
Reachability 🔴 Reachable
Exploit Maturity poc
Detection Source skill_scan

🧠 AI Triage:

  • Severity reassessed: HIGH → MEDIUM — While the code evidence at persona.tsx:985-1005 confirms the flaw is real and reachable via a traced code path (agent null response → button render → operator click → run(true)), this is a local, client-side UI decision requiring an authenticated operator's own click — not remotely exploitable, no injection, no public exploit, and exploit maturity is best described as conceptual/PoC-by-code-complexity rather than a demonstrated exploit. CVSS is not applicable/available. Business impact (silent mass-unbind, consent violation) is real but the attack requires either a benign agent bug or a compromised/malicious agent process already in the trust boundary, and the operator must still click. This does not meet the 'high' bar of CVSS≥7 + confirmed exploit evidence; it is capped at medium pending confirmation of environment and any evidence of the agent being attacker-controllable.
  • Composite score: 6.1
  • Environment: production

Summary: The DeleteProfile component offers an active unbind-all confirmation button even when the agent's refusal provided no usable persona list (personasBlockingDelete returned null), letting an operator authorize an unquantified mass unbind with one click despite the UI's own text admitting it cannot state how many personas will be affected.

📝 Description:

An operator can, with a single click under the 'null' condition, authorize unbinding an unknown and potentially large number of personas from a profile across every context they present it in, with no way to know the scope beforehand and no confirmation friction proportional to that risk.

🧪 Proof of Concept:

The same danger-styled, immediately actionable button is offered whether the count is known or unknown; the only difference is button label text ('them' vs a number). There is no additional friction (e.g., typed confirmation, disabled state, secondary modal) gating the higher-risk null case, despite the surrounding prose acknowledging the uncertainty.

if (phase.kind === "blocked") {
  return (
    <div style={{ display: "grid", gap: 12, maxWidth: 480 }}>
      <Note tone="danger">
        <div style={{ display: "grid", gap: 8 }}>
          <strong>Your agent refused: personas are still presenting this profile.</strong>
          {phase.personas === null ? (
            <span>
              It did not say which. Deleting anyway will leave them presenting nothing, and
              this console cannot tell you how many that is — {phase.message}
            </span>
          ) : ( ... )}
          ...
        </div>
      </Note>
      <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
        <Button kind="danger" onClick={() => void run(true)}>
          Unbind {phase.personas === null ? "them" : `${phase.personas.length}`} and delete
        </Button>
        <Button kind="quiet" onClick={() => setPhase({ kind: "idle" })}>
          Cancel
        </Button>
      </div>
    </div>
  );
}

Vulnerable lines: 975, 1010

🔁 Reproduction Steps:

  1. Trigger (or simulate, in a test harness) a persona/profile/delete refusal from the agent where details is either absent, not an object, or contains a non-array/mixed-type personaDids (e.g. { personaDids: 'did:key:zA' }).
  2. Observe that personasBlockingDelete(details) returns null per packages/core/src/admin/persona.ts.
  3. In the extension UI, trigger a profile delete on a profile that produces this malformed refusal; observe the 'blocked' phase renders the warning text 'It did not say which... this console cannot tell you how many that is' alongside a fully clickable 'Unbind them and delete' button.
  4. Click the button; observe run(true) is invoked and personaProfileDelete is called with unbind:true with no additional confirmation gate (e.g., typed confirmation) despite the explicit uncertainty.

🔎 Evidence: packages/extension/src/manager/panes/persona.tsx:985

<Button kind="danger" onClick={() => void run(true)}>
  Unbind {phase.personas === null ? "them" : `${phase.personas.length}`} and delete
</Button>

💥 Impact:

An operator can, with a single click under the 'null' condition, authorize unbinding an unknown and potentially large number of personas from a profile across every context they present it in, with no way to know the scope beforehand and no confirmation friction proportional to that risk.

Confidentiality: none · Integrity: high - unbounded persona unbind across all contexts · Availability: none

🧭 Reachability:

  • Network exposure: internal
  • Auth barrier: basic
  • Attack path: EP-001/EP-002 → agent sends malformed/absent details on PROFILE_DELETE_BOUND refusal → personasBlockingDelete returns null (persona.ts) → DeleteProfile blocked phase renders 'Unbind them and delete' button (persona.tsx ~985-1000) → operator click → run(true) → personaProfileDelete(unbind:true)

⚖️ Triage Factors:

Factor Value
Fixable ✅ Yes
Exploitability medium
Business impact high
Public exploit None known
Environment unknown

Attack scenario: When the agent's refusal lacks a parseable persona list, the UI still offers a single-click 'unbind them and delete' action that can unbind an unknown, potentially large number of personas with no proportional confirmation friction.

🔧 Remediation:

⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.

Gate the ambiguous, higher-risk null case behind an explicit typed confirmation (a well-established pattern for irreversible, unquantified destructive actions), while keeping the known-count case as a simple click. This proportionally increases friction to match the increased risk.

Vulnerable code:

<Button kind="danger" onClick={() => void run(true)}>
  Unbind {phase.personas === null ? "them" : `${phase.personas.length}`} and delete
</Button>

Secure code:

{phase.personas === null ? (
  <Button
    kind="danger"
    disabled={typedConfirmText !== "UNBIND ALL"}
    onClick={() => void run(true)}
  >
    Unbind all (unknown count) and delete
  </Button>
) : (
  <Button kind="danger" onClick={() => void run(true)}>
    Unbind {phase.personas.length} and delete
  </Button>
)}
// Additionally, require: <input value={typedConfirmText} onChange={...} placeholder='Type UNBIND ALL to confirm' />

Additional recommendations:

  • Prefer having the agent-side implementation refuse to execute unbind:true if it cannot itself enumerate the bound personas, converting the ambiguous case into a hard error rather than a client-executable action.
  • Log and surface the actual post-action unbind count so operators can reconcile what happened against what they authorized.
  • Consider disabling the unbind-all path entirely for the null case and requiring the operator to retry (in case the malformed response was transient).

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: The exact code <Button kind="danger" onClick={() => void run(true)}>Unbind {phase.personas === null ? "them" : ${phase.personas.length}} and delete</Button> shows the button remains clickable regardless of phase.personas being null, rendering the ambiguous label "them" instead of disabling the action. personasBlockingDelete in packages/core/src/admin/persona.ts confirms return null is a documented, intentional outcome for malformed/absent details: if (typeof details !== "object" || details === null) return null; and further if (!Array.isArray(dids)) return null;. The core test file admin.persona.mjs explicitly tests this null-return behavior for various malformed inputs. EVIDENCE NOT FOUND: no code path was found that disables, blocks, or adds extra confirmation friction to the button when phase.personas === null — the button remains a single-click danger action in all cases shown. CHANGED VS PRE-EXISTING: packages/extension/src/manager/panes/persona.tsx (the DeleteProfile button JSX) and packages/core/src/admin/persona.ts (personasBlockingDelete) are both part of this feature branch (feat/persona-delete-refusal) implementing the delete-refusal flow, making this CHANGED code. VERDICT JUSTIFICATION: the sink (an active danger button invoking an unquantified unbind-all action) is directly reachable and quoted in the code; there is no disabling/extra-confirmation control found for the null case, supporting a genuine UX/authorization design weakness (CWE-693/862) as described.
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

⚠️ Must-Review-By-Human (1)

Validated up to a point, but inconclusive — a human must read the code and make the final call. Reported (not dismissed) so developers and the security team receive them.

🟡 TOCTOU: unbind count shown to operator is not re-validated at confirm time

Field Detail
Severity MEDIUM
Location packages/extension/src/manager/panes/persona.tsx:995
Finding ID github_pr-85cef847565e
CWE CWE-367, CWE-362
OWASP A04:2021 - Insecure Design
MITRE ATT&CK T1565 - Data Manipulation
CAPEC CAPEC-25
DREAD 3.6
Reachability 🔴 Reachable
Exploit Maturity conceptual
Detection Source skill_scan

🧠 AI Triage:

  • Severity reassessed: HIGH → MEDIUM — Scanner rated this high, but the severity gates are not fully met: exploitability is explicitly rated 'low' by the scanner (requires precise timing + concurrent legitimate action in another session, no automation tooling), exploit maturity is 'conceptual' only, and environment is 'unknown' (no confirmed production deployment manifests). Per calibration, HIGH requires CVSS≥7 or confirmed significant impact AND production/staging AND exploit maturity != none/EPSS>1%/PoC — here exploit maturity is essentially none/conceptual and environment is unconfirmed. Business impact is rated 'medium' by the scanner itself (consent-governance concern, not data breach/RCE). This is a real, reachable, well-evidenced code-level finding with a legitimate fix, but the narrow race window and lack of concrete exploit evidence place it at medium, not high.
  • Composite score: 5.5
  • Environment: production

Summary: The confirm-unbind action in DeleteProfile executes based on a stale, client-cached count of blocking personas without re-verifying that count or an equivalent version token against the agent at confirm time, allowing a race window between preview and execution.

📝 Description:

An operator can unintentionally authorize unbinding a persona from a profile that was never disclosed to them in the confirmation UI, resulting in that persona silently losing its profile presentation without the operator's informed consent — undermining the entire premise of this feature ('the refusal is the preview').

🧪 Proof of Concept:

run is called with only unbind: boolean and the profile identity; it never passes the persona DID snapshot or an expectedVersion captured at the time of the refusal. The agent has no way to detect that the binding set changed since the preview, and the client has no way to detect a mismatch after the fact.

const run = useCallback(
  async (unbind: boolean) => {
    setPhase({ kind: "working" });
    try {
      await personaProfileDelete(managerSender, {
        ...parties,
        profileId: profile.profileId,
        unbind,
      });
      setPhase({ kind: "idle" });
      onDone();
    } catch (e) {
      ...
      if (e instanceof RelayTaskError && e.code === PROFILE_DELETE_BOUND) {
        setPhase({
          kind: "blocked",
          personas: personasBlockingDelete(e.details),
          message: e.message,
        });
        return;
      }
      ...
    }
  },
  [parties, profile.profileId, onDone],
);
...
<Button kind="danger" onClick={() => void run(true)}>
  Unbind {phase.personas === null ? "them" : `${phase.personas.length}`} and delete
</Button>

Vulnerable lines: 876, 1010

🔁 Reproduction Steps:

  1. Open two operator sessions (or one session plus a concurrent programmatic binding call) against the same holder/profile.
  2. In session A, click Delete on a profile that currently has 2 personas bound; observe the refusal renders 'Unbind 2 and delete'.
  3. Before clicking confirm in session A, from session B (or via personaBindingSet directly) bind a third persona to the same profile.
  4. In session A, click 'Unbind 2 and delete'. Observe that personaProfileDelete is called with {unbind: true} and no reference to the originally-shown DID list or a version constraint tied to that snapshot.
  5. Verify (agent-side, if accessible) whether the third, newly-bound persona is also unbound despite never being shown to the operator.

🔎 Evidence: packages/extension/src/manager/panes/persona.tsx:995

<Button kind="danger" onClick={() => void run(true)}>
  Unbind {phase.personas === null ? "them" : `${phase.personas.length}`} and delete
</Button>

💥 Impact:

An operator can unintentionally authorize unbinding a persona from a profile that was never disclosed to them in the confirmation UI, resulting in that persona silently losing its profile presentation without the operator's informed consent — undermining the entire premise of this feature ('the refusal is the preview').

Confidentiality: none · Integrity: medium - unauthorized persona unbind state change · Availability: none

🧭 Reachability:

  • Network exposure: internal
  • Auth barrier: basic
  • Attack path: EP-002 (DeleteProfile button click) → run(false) refusal → UI snapshot of personas → operator delay → concurrent bind elsewhere → run(true) confirm click at persona.tsx ~line 1000-1010 → personaProfileDelete(unbind:true)

⚖️ Triage Factors:

Factor Value
Fixable ✅ Yes
Exploitability low
Business impact medium
Public exploit None known
Environment unknown

Attack scenario: A concurrent bind occurring between the delete-refusal preview and the confirmed unbind click can cause personas to be unbound that were never shown to the operator, due to no snapshot/version check tying the confirm action to the preview.

🔧 Remediation:

⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.

Passing the snapshot (or a version/etag) that was actually shown to the operator lets the agent detect drift between preview and confirm, and re-refuse rather than silently unbinding personas that were never disclosed.

Vulnerable code:

await personaProfileDelete(managerSender, { ...parties, profileId: profile.profileId, unbind });

Secure code:

await personaProfileDelete(managerSender, {
  ...parties,
  profileId: profile.profileId,
  unbind,
  // Tie the confirmed unbind to the exact snapshot shown to the operator.
  expectedBlockingPersonaDids: phase.kind === "blocked" ? phase.personas : undefined,
  expectedVersion: profile.version,
});
// Agent-side: if the current blocking set differs from expectedBlockingPersonaDids,
// re-refuse with an updated PROFILE_DELETE_BOUND rather than executing the unbind.

Additional recommendations:

  • Use optimistic concurrency via the existing expectedVersion field already present on ProfileDeleteParams for the binding set, not just the profile record.
  • Add a short TTL to the 'blocked' phase so a stale preview cannot be confirmed after a long delay without re-fetching.
  • Log a structured audit event (persona DIDs shown, operator id, timestamp) client-side before issuing the confirm call, and reconcile with the agent's actual unbind result.

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 60%
  • AI Validation Evidence: EVIDENCE FOUND: persona.tsx renders Unbind {phase.personas === null ? "them" : ${phase.personas.length}} and delete and calls run(true) on click, which invokes personaProfileDelete(managerSender, { ...parties, profileId: profile.profileId, unbind }) per the snippet in the STRIDE-3 evidence; ProfileDeleteParams in packages/core/src/admin/persona.ts includes an optional expectedVersion?: number field but the run() call chain shown does not demonstrate it being passed at confirm time. EVIDENCE NOT FOUND: the full run() function body (including where profile/phase state is set) is not shown in the provided persona.tsx excerpt, so I cannot confirm whether expectedVersion or any snapshot token IS or IS NOT threaded into the confirm call; agent-side re-validation logic for unbind:true is not in scope of provided files. CHANGED VS PRE-EXISTING: packages/extension/src/manager/panes/persona.tsx is the file under review and its DeleteProfile component is directly implicated in the finding's chain (button onClick -> run(true)), and this file is presumed within the MR scope given the diff context of feat/persona-delete-refusal; treated as CHANGED since the button/run wiring is core to this feature branch. VERDICT JUSTIFICATION: reachable sink (personaProfileDelete with unbind:true) is plausible but the exact confirm-time data passed (whether expectedVersion is included) cannot be verified from the given source excerpt, so this remains inconclusive pending human review of the full run() implementation.
  • Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.


Generated by Agentic Sec — AI Security Validation Agent
This report includes full scan data + AI validation evidence. Feed to engineering copilots for automated fix deployment.

Complementary: 🛡️ **Threat Model & Affect Analysis**
Details

🛡️ Threat Model & Affect Analysis — PR #172

Field Value
Repository OpenVTC/vta-browser-plugin
Branch feat/persona-delete-refusalmain
Generated 2026-09-07

ℹ️ This report contains theoretical threats and impact analysis for the MR.
Unlike the Security Code Review Report (which contains confirmed, materialised issues),
these are potential risks that may or may not be exploitable. Use this for defence-in-depth planning.


📋 Affect Analysis

Change Summary

This PR hardens the profile-deletion flow so the agent's structured refusal (PROFILE_DELETE_BOUND) becomes the operator-facing preview of which personas would be affected, rather than a speculative pre-emptive checkbox. It adds a defensively-parsed wire constant/helper in core (PROFILE_DELETE_BOUND, personasBlockingDelete), refactors the extension's DeleteProfile component to a discriminated-union state machine, and adds thorough negative-path tests for the null-vs-empty-array distinction that is central to not under-reporting bound personas.

Diff: +245 / -32 lines
Types: security, feature, refactor, test

⚠️ Security Implications

⚪ Refusal-as-preview design replaces speculative pre-confirmation checkbox

Refusal-as-preview design replaces speculative pre-confirmation checkbox

Action: No action required; consider extending this pattern to other destructive flows in the console that still use pre-emptive checkboxes.

🟠 Unquantified 'unbind them and delete' action permitted when agent refusal lacks persona details

Unquantified 'unbind them and delete' action permitted when agent refusal lacks persona details

Action: Require a stronger confirmation gate (e.g., typed confirmation phrase, or disable the button and require the agent to first succeed at enumerating blockers) specifically for the personas === null case, rather than a single click identical in weight to the counted case.

🧩 Affected Components

Component Impact Change What Changed
persona-admin-core (COMP-001) high modified Added the PROFILE_DELETE_BOUND wire-protocol constant and the personasBlockingDelete defensive parser, establishing a new, fail-closed contr
persona-manager-pane / DeleteProfile (COMP-002) critical modified Complete rework of the profile-delete confirmation UX from a pre-emptive boolean checkbox to a state-machine that treats the agent's refusal

📁 File Classifications

packages/core/src/admin/persona.ts

  • Type: security

packages/core/tests/admin.persona.mjs

  • Type: test

packages/extension/src/manager/panes/persona.tsx

  • Type: security

🛡️ STRIDE Threat Model

Identified Threats (12)

🟡 STRIDE-1: Refusal Detail Spoofing via Loosely-Typed RelayTaskError.details in DeleteProfile

Field Detail
Category Spoofing, Tampering, Information Disclosure
Severity Medium
Likelihood Possible
CVSS 5.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-345,CWE-20,CWE-346
CAPEC CAPEC-148,CAPEC-385
OWASP A08:2021 - Software and Data Integrity Failures

Description: RelayTaskError.details in DeleteProfile component allows spoofed/forged persona-binding lists due to trusting agent-supplied unvalidated wire data without cryptographic integrity verification, resulting in an operator being shown an incorrect count/list of personas bound to a profile.

Evidence: packages/extension/src/manager/panes/persona.tsx:~950-970

if (e instanceof RelayTaskError && e.code === PROFILE_DELETE_BOUND) {
  setPhase({ kind: "blocked", personas: personasBlockingDelete(e.details), message: e.message });
  return;
}

Attack Scenario:

  1. An attacker who can influence the relay/agent transport layer (e.g., a compromised or malicious agent process, or a man-in-the-middle on the internal RPC channel between the extension and the core TrustTaskSender) crafts a RelayTaskError with code === PROFILE_DELETE_BOUND and a details.personaDids array of their choosing.
  2. The extension's DeleteProfile component (packages/extension/src/manager/panes/persona.tsx, run() callback) receives the error and calls personasBlockingDelete(e.details) from packages/core/src/admin/persona.ts without any cryptographic verification of the details payload's authenticity or provenance.
  3. personasBlockingDelete performs only structural validation (typeof checks, Array.isArray, string-type checks on entries) — it accepts any well-formed array of strings as valid persona DIDs, regardless of whether those DIDs are real, existing, or actually bound to the profile.
  4. The UI renders phase.personas.map((did) => ...) showing the attacker-controlled DID strings as if they were legitimate agent-reported blockers, potentially including fabricated DIDs designed to alarm or mislead the operator, or omitting real DIDs while including decoys.
  5. The operator, trusting the displayed list, makes an incorrect decision (e.g., proceeding with unbind because the list looks plausible, or aborting an otherwise safe deletion due to a spoofed non-empty list), resulting in either an unintended persona unbind or a wrongly blocked legitimate deletion.

🔎 Threat Clue: Derived from COMP-002, COMP-001 via EP-002

  • Data Flows: Agent -> TrustTaskSender -> DeleteProfile component

Preconditions: Attacker has compromised or can intercept/tamper with the agent-to-extension RPC channel (TrustTaskSender transport), No message integrity/authentication layer exists between agent and extension for RelayTaskError.details payloads

Existing Controls: Strict structural validation of details shape in personasBlockingDelete (rejects non-object, non-array, or mixed-type arrays) • Refusal-code equality check (e.code === PROFILE_DELETE_BOUND) rather than message-string matching, reducing spoofing via prose changes

Recommended Mitigations: Add transport-layer authentication/integrity (e.g., signed task responses) between the agent and the core client • Cross-verify persona DIDs against a locally known, authenticated persona registry before rendering • Rate-limit or audit repeated PROFILE_DELETE_BOUND refusals to detect anomalous agent behavior


🟠 STRIDE-2: Null-vs-Empty-List Conflation Regression in personasBlockingDelete Consumers

Field Detail
Category Tampering, Information Disclosure, Elevation of Privilege
Severity High
Likelihood Possible
CVSS 7.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-697,CWE-1039,CWE-354
CAPEC CAPEC-153
OWASP A04:2021 - Insecure Design

Description: personasBlockingDelete consumers in DeleteProfile allows a null/empty-list conflation regression due to missing type-level enforcement of the null-vs-[] distinction outside the documented convention, resulting in an operator unbinding personas they were never warned about.

Evidence: packages/core/src/admin/persona.ts:N/A

export function personasBlockingDelete(details: unknown): string[] | null { ... }

Attack Scenario:

  1. The function personasBlockingDelete (packages/core/src/admin/persona.ts) intentionally returns null for 'unknown/unparseable' and [] for 'confirmed empty' — a subtle, comment-documented but not type-system-enforced distinction (return type is string[] | null, which TypeScript does not distinguish semantically).
  2. A future code change (by any contributor unaware of the R3.7-style convention, since the invariant is described only in prose comments, not enforced by a discriminated union or a lint rule) refactors DeleteProfile's phase.personas === null check into a falsy check (if (!phase.personas)), which incorrectly treats [] the same as null.
  3. This causes the UI branch at packages/extension/src/manager/panes/persona.tsx (phase.personas === null ? ... : ...) to take the wrong path: an agent-confirmed empty list ([], meaning nothing is bound — itself an anomalous refusal) would be silently treated as an 'unknown' state, or vice versa a genuine unknown (null) could be miscoded as an empty confirmed list.
  4. If null is ever mis-rendered as an empty list, the operator sees '0 persona(s) present it' and confidently proceeds with an unqualified delete/unbind, when in fact the agent never told the client whether anyone is bound.
  5. The result is data loss / unauthorized persona unbinding: personas that were actually bound to the profile are unbound and left presenting nothing, without the operator ever being shown the true blocking list, directly contradicting the security intent documented in the code comments.

🔎 Threat Clue: Derived from COMP-001, COMP-002 via EP-002, EP-003

  • Data Flows: personasBlockingDelete -> DeleteProfile phase.personas -> UI render

Preconditions: A future code change or refactor collapses the null/[] distinction (e.g., using falsy checks, JSON serialization round-trips that lose null, or a new caller of personasBlockingDelete elsewhere in the codebase), No automated test or lint rule outside admin.persona.mjs enforces this invariant project-wide

Existing Controls: Dedicated unit tests in packages/core/tests/admin.persona.mjs explicitly assert null vs [] for multiple malformed inputs • Explicit phase.personas === null strict-equality check in the DeleteProfile component (not a falsy check) • Extensive code comments documenting the invariant's rationale

Recommended Mitigations: Encode the null/[] distinction using a discriminated union type (e.g., {status: 'unknown'} | {status: 'known', personas: string[]}) instead of string[] | null to make misuse a compile error • Add an ESLint rule or type guard forbidding truthy/falsy checks on the return value of personasBlockingDelete • Add integration tests covering the full DeleteProfile render path for both null and [] cases, not just the pure function


🟠 STRIDE-3: TOCTOU Race Between Refusal Preview and Unbind-Confirm in DeleteProfile

Field Detail
Category Tampering, Elevation of Privilege
Severity High
Likelihood Likely
CVSS 7.4 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:A/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-367,CWE-362
CAPEC CAPEC-25
OWASP A04:2021 - Insecure Design

Description: run(true) unbind-confirm action in DeleteProfile allows a time-of-check-to-time-of-use (TOCTOU) race due to the persona-binding count being computed at the moment of the first refusal but re-used to build operator-facing UI text for a second, later action, resulting in unbinding personas the operator was never actually warned about.

Evidence: packages/extension/src/manager/panes/persona.tsx:~1000-1010

<Button kind="danger" onClick={() => void run(true)}>
  Unbind {phase.personas === null ? "them" : `${phase.personas.length}`} and delete
</Button>

Attack Scenario:

  1. Operator clicks Delete on a profile; DeleteProfile.run(false) is invoked (packages/extension/src/manager/panes/persona.tsx), calling personaProfileDelete which the agent refuses with PROFILE_DELETE_BOUND and details.personaDids = [did:key:zA, did:key:zB].
  2. The UI transitions to phase 'blocked' and renders 'Unbind {phase.personas.length} and delete', i.e. 'Unbind 2 and delete', based on the snapshot taken at step 1.
  3. Between this render and the operator's click on the 'Unbind 2 and delete' button, a third party (another operator, another browser tab, or an automated context-management process) binds an additional persona (did:key:zC) to the same profile via a concurrent, unrelated call to personaBindingSet or equivalent — a plausible multi-context race given the documented 'spans every context' nature of the binding data.
  4. Operator clicks 'Unbind 2 and delete', invoking run(true), which re-issues personaProfileDelete with unbind:true. The agent-side implementation of persona/profile/delete with unbind semantics is not shown in this diff, but per the design intent this call unbinds ALL currently-bound personas at execution time, not just the 2 previously named.
  5. did:key:zC is silently unbound along with zA and zB, even though the operator's explicit consent/confirmation UI text only referenced 2 personas — resulting in an unauthorized state change (loss of a persona's presentation of the profile) that the operator never explicitly consented to, and no client-side mechanism re-verifies the count before executing the destructive action.

🔎 Threat Clue: Derived from COMP-001, COMP-002 via EP-001, EP-002

  • Data Flows: DeleteProfile refusal snapshot -> confirm click -> personaProfileDelete(unbind:true)

Preconditions: Multi-context or multi-session environment where persona-profile bindings can change between the refusal preview and the confirm click, No idempotency token / version check tying the unbind action to the exact snapshot of bound personas shown to the operator

Existing Controls: expectedVersion field exists on ProfileDeleteParams (per source excerpt), suggesting optimistic concurrency control MAY be available but is not shown to be used in this diff's unbind path • Refusal-preview-as-confirmation design reduces (but does not eliminate) the race window compared to a separate pre-check call

Recommended Mitigations: Pass the expectedVersion or a binding-set snapshot hash from the initial refusal into the confirmed unbind call, and have the agent re-validate/re-refuse if the binding set changed • Re-run the preview check atomically with the unbind operation server-side (single agent-side transaction) • Display a final re-confirmation step showing an updated count immediately before executing if any delay occurred


🟠 STRIDE-4: Blind Unbind-All Confirmation When personas Is Null in DeleteProfile

Field Detail
Category Elevation of Privilege, Tampering
Severity High
Likelihood Likely
CVSS 7.6 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:A/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-693,CWE-862
CAPEC CAPEC-122
OWASP A01:2021 - Broken Access Control

Description: The 'Unbind them and delete' button in DeleteProfile's blocked phase allows an uninformed elevation-of-consequence action due to the UI offering an unbind-all confirmation even when the agent did not name any personas (personas === null), resulting in the operator authorizing an unbounded, unquantified destructive unbind operation.

Evidence: packages/extension/src/manager/panes/persona.tsx:~985-1000

<Button kind="danger" onClick={() => void run(true)}>
  Unbind {phase.personas === null ? "them" : `${phase.personas.length}`} and delete
</Button>

Attack Scenario:

  1. The agent refuses a profile delete with PROFILE_DELETE_BOUND but sends malformed or absent details (e.g., no personaDids field, or a non-array value) — perhaps due to an agent-side bug, an outdated agent version, or a compromised/malicious agent deliberately withholding the list to obscure scope.
  2. personasBlockingDelete(e.details) returns null per its documented contract (packages/core/src/admin/persona.ts), and DeleteProfile's phase becomes { kind: 'blocked', personas: null, message: e.message }.
  3. The UI (packages/extension/src/manager/panes/persona.tsx) renders 'It did not say which. Deleting anyway will leave them presenting nothing, and this console cannot tell you how many that is' alongside a still-clickable 'Unbind them and delete' button.
  4. The operator, under time pressure or believing 'them' refers to a small/known set, clicks the button, invoking run(true) which issues personaProfileDelete with unbind:true — unbinding an unknown and potentially large number of personas across every context, since the client itself states it 'cannot tell you how many that is'.
  5. This results in an unauthorized, unquantified mass state change (all personas presenting the profile silently lose that presentation) with no way for the operator to have made an informed decision, and no post-action audit trail correlating the actual count unbound to what was shown pre-action.

🔎 Threat Clue: Derived from COMP-002, COMP-001 via EP-002, EP-001

  • Data Flows: Agent refusal (malformed details) -> personasBlockingDelete -> null -> UI unbind-all confirm

Preconditions: Agent sends a PROFILE_DELETE_BOUND refusal with malformed/absent details.personaDids, Operator proceeds despite the explicit 'cannot tell you how many' warning

Existing Controls: Explicit warning text differentiating the null case from the counted case • Refusal-code-based (not message-based) branching prevents prose-drift exploitation

Recommended Mitigations: Disable or require an additional explicit typed confirmation (e.g., type 'UNBIND ALL') when personas === null, rather than a single click • Have the agent-side implementation refuse to execute an unbind:true operation if it cannot itself enumerate the bound personas, converting the ambiguous case into a hard error rather than an executable action • Log and audit the actual count of personas unbound server-side and surface it to the operator post-action for reconciliation


🟡 STRIDE-5: Insufficient Repudiation Controls for Unbind-and-Delete Action in DeleteProfile

Field Detail
Category Repudiation
Severity Medium
Likelihood Possible
CVSS 5.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:N/SC:N/SI:L/SA:N
Residual Severity Low
CWE CWE-778,CWE-223
CAPEC CAPEC-268
OWASP A09:2021 - Security Logging and Monitoring Failures

Description: The unbind-and-delete confirmation flow in DeleteProfile allows repudiation of a destructive persona-unbind action due to the absence of any client-visible audit trail or non-repudiable record linking the operator's click to the specific personas unbound, resulting in an inability to later verify who authorized unbinding a specific persona set.

Evidence: packages/extension/src/manager/panes/persona.tsx:~955-965

const run = useCallback(async (unbind: boolean) => {
  setPhase({ kind: "working" });
  try {
    await personaProfileDelete(managerSender, { ...parties, profileId: profile.profileId, unbind });
    ...

Attack Scenario:

  1. An operator with legitimate access clicks 'Unbind N and delete' in the DeleteProfile component, triggering run(true) -> personaProfileDelete(managerSender, {...parties, profileId, unbind:true}).
  2. No client-side code in this diff logs the pre-action snapshot (the exact persona DID list shown, timestamp, operator identity) to any durable, tamper-evident audit store before or after the mutating call.
  3. If a dispute later arises (e.g., a persona-holder complains their profile presentation was removed without consent), there is no verifiable client-side record correlating the specific personas shown in the UI at decision time with the operator who clicked, nor confirmation that the count displayed matched what was actually unbound server-side.
  4. The operator (or an attacker who compromised the operator's session) can plausibly deny having seen or approved the specific persona list, since the only record is potentially transient in-memory React state, not persisted evidence.
  5. This undermines any later compliance or dispute-resolution process (e.g., GDPR-style accountability requirements) that would need to reconstruct exactly what was disclosed to the operator and what they authorized.

🔎 Threat Clue: Derived from COMP-002 via EP-002, EP-001

  • Data Flows: Operator click -> run(true) -> personaProfileDelete (no audit emission observed)

Preconditions: A dispute or compliance audit requires reconstructing exactly what the operator saw and approved, No separate agent-side or platform-side audit logging exists to compensate (not shown in this diff, so cannot be confirmed as absent, only as absent from the observed extension code)

Existing Controls: The agent-side task framework (TrustTaskSender/TrustTaskCode) likely logs task invocations server-side, though not visible in this diff • ConsentRequiredError / ConsentCeremony flow suggests some consent-tracking infrastructure exists elsewhere in the codebase

Recommended Mitigations: Emit a structured client-side audit event (persona list shown, operator id, timestamp, action taken) to a tamper-evident logging endpoint before executing the unbind call • Have the agent-side response to a successful unbind:true delete echo back the exact set of DIDs unbound, and have the client verify it matches what was previewed, then log any mismatch as a high-severity anomaly


🟡 STRIDE-6: Sensitive Persona DID Enumeration Disclosure via DeleteProfile Blocked Phase

Field Detail
Category Information Disclosure
Severity Medium
Likelihood Possible
CVSS 4.8 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-200,CWE-359
CAPEC CAPEC-116
OWASP A01:2021 - Broken Access Control

Description: The 'blocked' phase rendering in DeleteProfile allows unauthorized information disclosure of full persona DID identifiers due to unmasked display of e.g. did:key:zA and did:key:zB directly in the UI DOM, resulting in exposure of linkable persona identifiers to anyone with visibility of the operator's screen, browser devtools, or extension state.

Evidence: packages/extension/src/manager/panes/persona.tsx:~988-994

{phase.personas.map((did) => (
  <span key={did} style={{ fontFamily: font.mono, fontSize: t.xs, wordBreak: "break-all" }}>
    {did}
  </span>
))}

Attack Scenario:

  1. Operator attempts to delete a profile and the agent refuses with PROFILE_DELETE_BOUND, naming bound personas via details.personaDids.
  2. The DeleteProfile component renders each DID verbatim: phase.personas.map((did) => (<span key={did} style={{fontFamily: font.mono, ...}}>{did}</span>)) (packages/extension/src/manager/panes/persona.tsx), placing the full, unredacted DID strings directly in the rendered DOM.
  3. Since this is a browser extension UI, the rendered DOM is accessible to any other extension with sufficient permissions, to browser devtools, to screen-recording/screen-sharing software during a support session, or to shoulder-surfing, exposing the complete linkage between a profile and the specific personas that present it — precisely the 'linkage map' artifact the code comments describe as sensitive ('the artifact this family exists to keep from being assembled casually').
  4. An attacker with any of the above access vectors captures the DID list, gaining correlation data that could be combined with other disclosed information to deanonymize or link personas across contexts, undermining the privacy-preserving design intent described in the ResolvedProfile comments.
  5. This is a direct contradiction of the documented design principle ('a click, not a column... the answer is the holder's linkage map... this family exists to keep from being assembled casually') — the delete-blocked flow assembles and displays exactly that sensitive linkage without the same deliberate friction/gating applied to the ResolvedProfile 'who presents this' feature.

🔎 Threat Clue: Derived from COMP-002 via EP-002

  • Data Flows: Agent refusal details -> personasBlockingDelete -> DOM render of DIDs

Preconditions: Operator attempts a delete that the agent refuses due to bound personas, Attacker has visibility into the browser extension's rendered UI (malicious co-installed extension, screen share, physical access, or devtools access)

Existing Controls: The list is only shown transiently within the blocked-phase UI state, not persisted to storage by this component • Access requires triggering a delete attempt in the first place, which requires prior authorization to reach the manager pane (auth_required:true per EP-002)

Recommended Mitigations: Truncate/mask displayed DIDs by default with an explicit 'reveal' action requiring additional confirmation, mirroring the deliberate friction applied to the ResolvedProfile linkage-map feature • Avoid rendering full DIDs in the DOM when a summary count suffices for the primary decision; offer full detail behind a secondary disclosure gate • Apply the same 'click, not a column' friction principle from ResolvedProfile's design rationale to this blocked-phase rendering


🔵 STRIDE-7: Silent Error Swallowing on Non-Matching RelayTaskError Codes in DeleteProfile.run

Field Detail
Category Denial of Service, Repudiation
Severity Low
Likelihood Possible
CVSS 3.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:L
Residual Severity Low
CWE CWE-778,CWE-390
CAPEC CAPEC-268
OWASP A09:2021 - Security Logging and Monitoring Failures

Description: The catch block in DeleteProfile.run allows an availability/repudiation weakness due to any RelayTaskError with a code other than PROFILE_DELETE_BOUND being converted to a generic string message and rendered without preserving the original structured error code, resulting in loss of forensic detail and potential misdiagnosis of the true failure cause.

Evidence: packages/extension/src/manager/panes/persona.tsx:~968-972

setPhase({ kind: "error", message: e instanceof Error ? e.message : String(e) });

Attack Scenario:

  1. The agent returns a RelayTaskError with a different code (e.g., a rate-limit refusal, an authorization refusal, or an unexpected internal error code) during a delete attempt.
  2. DeleteProfile.run's catch block (packages/extension/src/manager/panes/persona.tsx) falls through the e instanceof RelayTaskError && e.code === PROFILE_DELETE_BOUND check and lands in the generic setPhase({ kind: 'error', message: e instanceof Error ? e.message : String(e) }) branch.
  3. The original structured error code (e.code) is discarded — only the free-text message survives into UI state and any subsequent logging derived from it — violating the same R3.7 principle the code otherwise upholds ('never match on the message... the prose is for a human and is free to change') but this time on the consumption/audit side rather than the matching side.
  4. If an attacker can trigger repeated distinguishable-but-undifferentiated errors (e.g., by flooding delete attempts to trigger a rate-limit code vs. a genuine permission error), the operator and any downstream monitoring cannot distinguish attack-induced failures from legitimate ones, since both collapse to opaque prose.
  5. This results in degraded incident-response capability (a form of availability/repudiation impact) — operators cannot reliably distinguish a transient agent-side issue from a deliberate denial-of-service or privilege-probing attempt against the delete endpoint.

🔎 Threat Clue: Derived from COMP-002 via EP-002

  • Data Flows: RelayTaskError -> catch block -> generic error phase

Preconditions: Agent returns a RelayTaskError with a code other than PROFILE_DELETE_BOUND or ConsentRequiredError, No structured logging captures e.code before it is discarded

Existing Controls: ConsentRequiredError and PROFILE_DELETE_BOUND are still handled via code, not message, before falling to the generic branch • TypeScript typing on RelayTaskError implies a structured code field exists and could be preserved

Recommended Mitigations: Preserve e.code alongside e.message in the error phase state and include it in any telemetry/logging • Add a catch-all structured logging call before falling back to generic message display


🔵 STRIDE-8: Type Assertion Bypass in personasBlockingDelete via Prototype Pollution or Array-Like Objects

Field Detail
Category Tampering
Severity Low
Likelihood Unlikely
CVSS 3.7 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-1321
CAPEC CAPEC-664
OWASP A08:2021 - Software and Data Integrity Failures

Description: personasBlockingDelete in packages/core/src/admin/persona.ts allows a type-assertion bypass due to reliance on as casts after runtime checks that do not fully guarantee prototype safety, resulting in a low-severity risk of unexpected object behavior if a crafted details payload includes overridden or polluted properties.

Evidence: packages/core/src/admin/persona.ts:N/A

const dids = (details as { personaDids?: unknown }).personaDids;
if (!Array.isArray(dids)) return null;
return dids.every((d) => typeof d === "string") ? (dids as string[]) : null;

Attack Scenario:

  1. The agent (or a compromised transport layer) sends a details object where personaDids is a genuine Array of strings, but the object itself or the array has a polluted prototype (e.g., via a JSON.parse on attacker-controlled JSON if the transport uses a vulnerable custom deserializer elsewhere in the pipeline not shown in this diff).
  2. typeof details !== 'object' and Array.isArray(dids) checks pass because they only test structural shape, not prototype chain integrity.
  3. The (details as { personaDids?: unknown }).personaDids cast and subsequent dids.every((d) => typeof d === 'string') do not detect polluted Array.prototype or Object.prototype methods that could have been tampered with earlier in the message-parsing pipeline (outside the scope of this specific function).
  4. If any polluted prototype property is later relied upon elsewhere in the extension (e.g., a .length getter override or a toString override on a returned DID string used in security-relevant string comparison), it could cause subtle behavioral deviations.
  5. This is a low-likelihood, defense-in-depth-relevant issue since personasBlockingDelete's own logic (strict typeof/Array.isArray/every checks) is actually quite robust — the residual risk is entirely in whatever JSON deserialization occurs upstream in the RPC transport layer, not shown in this file set.

🔎 Threat Clue: Derived from COMP-001 via EP-003

  • Data Flows: Agent details payload -> personasBlockingDelete parsing

Preconditions: An upstream deserializer (not visible in this diff) is vulnerable to prototype pollution, Some downstream code relies on prototype-inherited behavior of the returned string array without further validation

Existing Controls: Strict typeof and Array.isArray runtime checks before any cast • every() with explicit typeof string check on each element, rejecting non-primitive entries

Recommended Mitigations: Use Object.create(null) or JSON schema validation with prototype stripping in the underlying RPC deserializer (outside this file's scope, but should be verified project-wide) • Add a defensive Object.freeze or plain-object reconstruction of the returned array before use


🟡 STRIDE-9: Missing Rate Limiting on Repeated PROFILE_DELETE_BOUND Refusal Probing to Enumerate Persona Bindings

Field Detail
Category Information Disclosure, Denial of Service
Severity Medium
Likelihood Possible
CVSS 5.9 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-799,CWE-200
CAPEC CAPEC-116,CAPEC-561
OWASP A04:2021 - Insecure Design

Description: The personaProfileDelete entry point (EP-001) allows an information-disclosure-via-probing threat due to the absence of visible rate limiting on repeated delete attempts, resulting in an authorized-but-lower-trust operator or automated script repeatedly triggering PROFILE_DELETE_BOUND refusals to enumerate persona-to-profile bindings across contexts over time as bindings change.

Evidence: packages/core/src/admin/persona.ts:N/A

export async function personaProfileDelete(sender: TrustTaskSender, ...)

Attack Scenario:

  1. An operator with legitimate but limited access (e.g., a lower-privilege admin role) repeatedly invokes personaProfileDelete(sender, {profileId, unbind:false}) for a target profile at intervals, without ever confirming the actual unbind.
  2. Each call causes the agent to compute the full cross-context binding set and return it via PROFILE_DELETE_BOUND's details.personaDids, which the client parses via personasBlockingDelete and could log, screenshot, or exfiltrate via browser devtools/extension storage.
  3. Because this delete-attempt path doubles as a read operation for the sensitive 'linkage map' (as explicitly acknowledged in the ResolvedProfile comment: 'the answer is the holder's linkage map — the artifact this family exists to keep from being assembled casually'), and there is no visible rate limiting, cooldown, or anomaly detection on repeated refusal-triggering calls in the provided code.
  4. The operator (or a script driving the extension via automation/devtools protocol) polls this endpoint periodically, reconstructing the temporal evolution of persona-to-profile bindings — effectively bypassing the deliberate 'click, not a column' friction designed into the legitimate ResolvedProfile read path, since the delete path is not subject to that same friction/gating.
  5. This results in unauthorized assembly of the sensitive linkage map through a side channel (the delete refusal mechanism) that was not designed as a read API and therefore lacks the access controls, logging, or rate limiting that the intentional read path might have.

🔎 Threat Clue: Derived from COMP-001 via EP-001

  • Data Flows: Repeated personaProfileDelete calls -> PROFILE_DELETE_BOUND refusal -> binding enumeration

Preconditions: Operator or automated agent has repeated access to trigger personaProfileDelete with unbind:false, No rate limiting, cooldown, or anomaly detection exists on the agent side for repeated delete-refusal probing (not visible in this diff, assumed absent based on lack of evidence)

Existing Controls: auth_required:true on EP-001 limits this to already-authenticated/authorized operators • The delete attempt is a real mutation attempt (with unbind:false) rather than a pure read, adding some friction/cost compared to a dedicated read API

Recommended Mitigations: Apply the same access-control and rate-limiting rigor to repeated delete-refusal calls as to any dedicated persona-linkage read API • Log and alert on repeated (e.g., >N in M minutes) PROFILE_DELETE_BOUND refusals for the same profileId from the same operator/session • Consider whether the agent-side implementation of persona/profile/delete should throttle or cache the binding computation per profile to reduce both this risk and load


⚪ STRIDE-10: Duplicate JSDoc Block Masking Intended Documentation for ResolvedProfile Security Rationale

Field Detail
Category Tampering
Severity Informational
Likelihood Very Unlikely
CVSS 0.0 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity None
CWE CWE-1041
CAPEC
OWASP

Description: The duplicated JSDoc comment block preceding the ResolvedProfile section in persona.tsx allows a code-quality/documentation-integrity weakness due to a copy-paste error introducing a redundant, unlabeled block, resulting in potential confusion for future maintainers reviewing the security rationale for the linkage-map friction design and a slightly elevated risk of the wrong comment being edited during future security-relevant changes.

Evidence: packages/extension/src/manager/panes/persona.tsx:~1102-1120

/**
 * Which personas present a profile, and where.
 * ...
 */
/**
 * Which personas present a profile, and where.
 * ...

Attack Scenario:

  1. The diff for packages/extension/src/manager/panes/persona.tsx introduces two nearly identical JSDoc blocks in sequence, both documenting 'Which personas present a profile, and where', immediately before the ResolvedProfile-related code.
  2. A future maintainer, unaware this is a duplicate, edits only one copy when updating the security rationale (e.g., the 'click, not a column' friction principle) for a related security fix, leaving a stale/contradictory comment in place.
  3. A subsequent reviewer or automated documentation-linting process may reference the stale duplicate copy when assessing whether the friction design is still intended, leading to a misunderstanding of the current security posture during a future code review.
  4. While this has no direct runtime security impact, it represents a documentation-integrity weakness that could indirectly contribute to a future maintainer removing or weakening the 'click, not a column' friction control described in STRIDE-9, believing outdated or duplicate documentation reflects current intent.
  5. This is flagged as an informational finding for downstream code-quality review rather than an exploitable vulnerability.

🔎 Threat Clue: Derived from COMP-002

Preconditions: A future maintainer edits the security-rationale comment without noticing the duplicate

Existing Controls: The duplicate is limited to comments; no functional code duplication or divergence exists

Recommended Mitigations: Remove the duplicate JSDoc block in a follow-up commit • Add a comment-linting or code-review checklist item for duplicate documentation blocks near security-sensitive functions


🟠 STRIDE-11: Missing Server-Side Enforcement Assumption for unbind:true Semantics in personaProfileDelete

Field Detail
Category Tampering, Elevation of Privilege
Severity High
Likelihood Possible
CVSS 7.0 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-863,CWE-354
CAPEC CAPEC-122
OWASP A04:2021 - Insecure Design

Description: The unbind:true parameter passed to personaProfileDelete in DeleteProfile.run allows an elevation-of-privilege risk due to the client trusting that the agent will correctly and atomically unbind exactly the personas it previously reported, with no client-visible contract or verification of that agent-side guarantee, resulting in potential over-broad or inconsistent unbinding if the agent-side implementation (not included in this diff) has a logic flaw.

Evidence: packages/extension/src/manager/panes/persona.tsx:~957-964

await personaProfileDelete(managerSender, { ...parties, profileId: profile.profileId, unbind });
setPhase({ kind: "idle" });
onDone();

Attack Scenario:

  1. The client-side contract assumes (per code comments) that persona/profile/delete with unbind:true will unbind 'any persona presenting it, in every context' and then proceed with deletion, but the actual agent/Rust-side implementation enforcing this is entirely outside the scope of this diff and cannot be verified.
  2. If the agent-side implementation has any logic flaw (e.g., a race condition on its own binding table, an incomplete cross-context sweep, or a bug in how it interprets the boolean unbind flag), the client has no way to detect or verify post-hoc that exactly the expected set of personas was unbound versus a different or larger set.
  3. Because personaProfileDelete's success path (packages/extension/src/manager/panes/persona.tsx run() function) simply calls onDone() on success with no verification step, a client-observable silent over-unbind (unbinding personas beyond what was previewed) would go completely undetected by this component.
  4. An attacker who can influence agent-side state (e.g., through a separate vulnerability in the agent's binding-table logic, not shown here) could exploit the trust boundary between the previewed refusal and the executed unbind to cause unintended, hard-to-detect persona unbinding at scale.
  5. This is a design-level trust assumption threat: the client-side security model in this diff is entirely dependent on unverified agent-side correctness, with no defense-in-depth check (e.g., re-querying the binding state after the unbind completes and comparing to expectations).

🔎 Threat Clue: Derived from COMP-001 via EP-001

  • Data Flows: DeleteProfile run(true) -> personaProfileDelete(unbind:true) -> agent-side unbind (unverified)

Preconditions: A latent bug or vulnerability exists in the agent-side (Rust) implementation of persona/profile/delete's unbind:true handling (unverifiable from this diff alone), No post-action verification step exists client-side

Existing Controls: Refusal-preview design reduces (but does not eliminate) blind trust by giving the operator a chance to review before confirming • expectedVersion field on ProfileDeleteParams suggests some optimistic concurrency protection may exist agent-side

Recommended Mitigations: Add a post-unbind verification call that re-queries the persona-binding state for the profile and confirms it matches expectations before treating onDone() as final success • Request/require the agent to return the actual set of DIDs it unbound in the success response, and have the client diff it against what was previewed, flagging any discrepancy • Document and test the agent-side unbind:true contract explicitly (outside this diff's scope, but should be tracked as a dependency for this client-side design's correctness)


🟡 STRIDE-12: Consent Bypass Race Between ConsentRequiredError and PROFILE_DELETE_BOUND Handling Order

Field Detail
Category Elevation of Privilege, Tampering
Severity Medium
Likelihood Unlikely
CVSS 5.5 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-696,CWE-841
CAPEC CAPEC-122
OWASP A04:2021 - Insecure Design

Description: The sequential instanceof checks in DeleteProfile.run's catch block allow a consent-flow ordering weakness due to ConsentRequiredError being checked before RelayTaskError/PROFILE_DELETE_BOUND, resulting in a scenario where a combined or wrapped error carrying both consent and binding-refusal semantics could cause the persona-binding warning to be silently skipped in favor of only the consent ceremony.

Evidence: packages/extension/src/manager/panes/persona.tsx:~965-975

if (e instanceof ConsentRequiredError) {
  setPhase({ kind: "consent", pending: e });
  return;
}
if (e instanceof RelayTaskError && e.code === PROFILE_DELETE_BOUND) { ... }

Attack Scenario:

  1. Suppose the underlying transport/agent framework, in some future or edge-case scenario, wraps or raises a ConsentRequiredError instance that ALSO carries embedded RelayTaskError-like details about a PROFILE_DELETE_BOUND refusal (e.g., because the agent decided both consent AND unbinding are relevant to this attempt) — plausible given both are custom error classes in the same 'carrier.js' module family.
  2. DeleteProfile.run's catch block checks if (e instanceof ConsentRequiredError) FIRST (packages/extension/src/manager/panes/persona.tsx), and if true, immediately sets phase to 'consent' and returns — short-circuiting before ever reaching the e instanceof RelayTaskError && e.code === PROFILE_DELETE_BOUND check.
  3. The operator completes the consent ceremony (ConsentCeremony component) and, upon success, presumably retries or the flow completes without ever having been shown the persona-binding warning that would have appeared had the RelayTaskError branch been reached.
  4. If the underlying operation then proceeds to actually attempt the delete post-consent without re-triggering the PROFILE_DELETE_BOUND check (depending on retry logic not fully shown here), the operator could end up deleting/unbinding without ever seeing the 'personas are still presenting this profile' warning.
  5. This is speculative given the two error types are not shown to overlap in the current codebase, but the lack of an explicit architectural guarantee that these two refusal dimensions (consent vs. binding) are mutually exclusive or correctly ordered represents a latent design risk if the error class hierarchy or agent behavior evolves.

🔎 Threat Clue: Derived from COMP-002 via EP-002

  • Data Flows: catch block ordering: ConsentRequiredError check -> RelayTaskError check

Preconditions: ConsentRequiredError and RelayTaskError/PROFILE_DELETE_BOUND semantics become combinable in a future agent or carrier.js change, Retry-after-consent logic does not re-check for PROFILE_DELETE_BOUND

Existing Controls: Current code treats ConsentRequiredError and RelayTaskError as mutually exclusive via separate instanceof branches, which is correct for the current error taxonomy as shown • PROFILE_DELETE_BOUND check uses strict code equality, reducing accidental misclassification

Recommended Mitigations: Add an explicit architectural test/assertion that ConsentRequiredError and PROFILE_DELETE_BOUND refusals are mutually exclusive at the protocol level, and document this invariant • Ensure any post-consent retry flow re-runs the full error-handling chain (including the PROFILE_DELETE_BOUND check) rather than assuming success



🍝 PASTA Threat Model

Application Purpose

A decentralized identity/persona-management browser extension and core client library allowing operators to manage personas, their bindings to profiles across trust contexts, and to safely delete profiles while enforcing agent-side consent and blocking-binding checks to prevent accidental loss of persona-profile linkage integrity.

Inherent Risks

  • The system inherently trusts an external agent process's refusal responses as the sole authority on whether a profile deletion is safe.
  • Persona-to-profile binding data is treated as highly sensitive linkage information whose accidental disclosure could deanonymize users.
  • Destructive delete/unbind operations are irreversible by design once executed, creating high-stakes single-shot user decisions.

Objectives

Risk: Treat any conflation of 'unknown' and 'confirmed empty' binding states as a high-priority regression risk.; Treat TOCTOU windows in destructive multi-step confirmations as unacceptable residual risk.
Business: Provide operators a safe, auditable way to delete decentralized identity profiles without accidentally breaking active persona presentations.
Security: Prevent unauthorized or uninformed persona unbinding.; Preserve the confidentiality of persona-to-profile linkage maps.; Maintain non-repudiable records of destructive operator actions.
Financial: Avoid costly support/remediation incidents caused by accidental mass unbinding of personas.
Compliance: Support data-subject rights and accountability obligations (e.g., GDPR-style requirements) regarding persona identity data changes.
Functional: Correctly propagate agent-side PROFILE_DELETE_BOUND refusals with accurate persona-blocking details to the UI.
Operational: Ensure the extension and core library remain resilient to malformed or adversarial agent responses.

Business Impact Analysis (2)

BIA-1: Profile Deletion With Persona Unbinding (High)

An operator deletes a profile, and if personas still present it, the agent refuses and names them so the operator can make an informed unbind-and-delete decision.

MTD: 01 days 00:00 hours | RTO: 00 days 04:00 hours | RPO: 00 days 00:15 hours

  • Stakeholders: Console Operators / Persona Holders / Platform Compliance Team / Relying Parties Consuming Persona Data
  • Dependencies: Agent/Relay RPC Transport / Core Client Library (persona.ts) / Extension Manager UI (persona.tsx) / TrustTaskSender Protocol
  • Disruptions: Agent sends malformed or spoofed refusal details / Race condition between refusal preview and unbind confirmation / Client-side regression collapsing null/[] binding states / Loss of audit trail for destructive unbind actions
  • Impacts: Unauthorized persona unbinding affecting one or more personas / Loss of operator trust in the delete-confirmation flow / Potential compliance exposure if unbinding occurs without documented consent / Reputational damage if personas are silently disconnected without recourse

BIA-2: Persona Linkage Map Confidentiality (Medium)

The system must prevent casual or unauthorized assembly and disclosure of which personas present a given profile, since this constitutes a sensitive cross-context linkage map.

MTD: 07 days 00:00 hours | RTO: 02 days 00:00 hours | RPO: 01 days 00:00 hours

  • Stakeholders: Persona Holders / Console Operators / Privacy/Compliance Team
  • Dependencies: profile-bindings.ts assembly logic / ResolvedProfile UI component / DeleteProfile blocked-phase UI
  • Disruptions: Repeated delete-attempt probing to enumerate bindings over time / Unmasked DID rendering in blocked-phase UI exposed via screen share or co-installed extensions
  • Impacts: Deanonymization risk for persona holders / Loss of the deliberate 'click, not a column' privacy friction control

Technical Scope

Roles (1): RO-1 Console Operator

Actors (2): AC-1 Console Operator · AC-2 Agent/Relay Process

Entry Points (3): EP-1 Persona Profile Delete Task Call · EP-2 DeleteProfile UI Action · EP-3 Persona Blocking Delete Parser

Threat Actors (4): TA-1 Malicious/Compromised Agent Process · TA-2 Careless or Rushed Operator · TA-3 Co-Installed Malicious Browser Extension · TA-4 Insider With Concurrent Session Access

Infrastructure (1): IF-1 Browser Extension Runtime

Trust Boundaries (3): TB-1 Extension UI to Core Client Boundary · TB-2 Core Client to Agent/Relay Boundary · TB-3 Browser Extension Sandbox Boundary

External Entities (1): EE-1 Backend Agent Process

System Components (3): SC-1 Core Admin Persona Module · SC-2 Extension DeleteProfile Component · SC-3 Agent/Relay Task Handler

Resources And Assets (2): RA-1 Persona-to-Profile Binding List · RA-2 Profile Delete Refusal Code and Details

Technologies And Dependencies (3): TD-1 React · TD-2 TypeScript · TD-3 Custom TrustTaskSender/TrustTaskCode Protocol

Use Cases (2)

  • Operator Deletes an Unbound Profile: An authenticated console operator deletes a profile that no persona currently presents, and the agent completes the deletion without any refusal.
  • Operator Reviews Blocked Delete and Unbinds Named Personas: An authenticated console operator attempts a profile delete, the agent refuses citing bound personas with a valid named list, and the operator reviews the list before confirming an informed unbind-and

📋 Risk Registry (4)

ID Title Severity Residual Priority Effort
RISK-1 Uninformed or spoofed destructive unbind actions during profile deletion could cause unauthorized persona-profile linkage loss. High Medium Short-Term Medium
RISK-2 Sensitive persona-to-profile linkage data disclosed via UI rendering and repeated probing could enable deanonymization. Medium Low Medium-Term Low
RISK-3 Lack of durable audit trail for destructive unbind-and-delete actions undermines accountability and dispute resolution. Medium Low Medium-Term Low
RISK-4 Design-level trust assumptions on unverified agent-side correctness for unbind semantics create latent elevation-of-privilege exposure. High Medium Short-Term High

⚔️ Attack Scenarios (2)

SC-1: Core Admin Persona Module

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. System Component"]
    direction LR
    SC1@{ shape: rect, label: "SC-1: Core Admin Persona Module" }
  end
  subgraph SL2["2. Weaknesses"]
    direction LR
    CWE345@{ shape: rect, label: "CWE-345: Insufficient Verification of Data Authenticity" }
    CWE1039@{ shape: rect, label: "CWE-1039: Automated Recognition Mechanism with Inadequate Detection" }
    CWE799@{ shape: rect, label: "CWE-799: Improper Control of Interaction Frequency" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC385@{ shape: rect, label: "CAPEC-385: Transaction or Event Tampering via Application API Manipulation" }
    CAPEC153@{ shape: rect, label: "CAPEC-153: Input Data Manipulation" }
    CAPEC116@{ shape: rect, label: "CAPEC-116: Excavation" }
  end
  subgraph SL4["4. Threats"]
    direction LR
    STRIDE1@{ shape: rect, label: "STRIDE-1: Refusal Detail Spoofing<br><i>Medium / Possible</i>" }
    STRIDE2@{ shape: rect, label: "STRIDE-2: Null-vs-Empty-List Conflation<br><i>High / Possible</i>" }
    STRIDE9@{ shape: rect, label: "STRIDE-9: Missing Rate Limiting on Refusal Probing<br><i>Medium / Possible</i>" }
  end
  subgraph SL5["5. Threat Actors"]
    direction LR
    TA1@{ shape: rect, label: "TA-1: Malicious/Compromised Agent Process<br><i>Manipulate persona binding data</i>" }
  end
  SC1 --> CWE345
  SC1 --> CWE1039
  SC1 --> CWE799
  CWE345 --> CAPEC385
  CWE1039 --> CAPEC153
  CWE799 --> CAPEC116
  CAPEC385 --> STRIDE1
  CAPEC153 --> STRIDE2
  CAPEC116 --> STRIDE9
  STRIDE1 --> TA1
  STRIDE2 --> TA1
  STRIDE9 --> TA1
  linkStyle 0 stroke:#FF0000,stroke-width:2px
  linkStyle 1 stroke:#FF0000,stroke-width:2px
  linkStyle 2 stroke:#FFA500,stroke-width:2px
  linkStyle 3 stroke:#FF0000,stroke-width:2px
  linkStyle 4 stroke:#FF0000,stroke-width:2px
  linkStyle 5 stroke:#FFA500,stroke-width:2px
  linkStyle 6 stroke:#FF0000,stroke-width:2px
  linkStyle 7 stroke:#FF0000,stroke-width:2px
  linkStyle 8 stroke:#FFA500,stroke-width:2px
  linkStyle 9 stroke:#FF0000,stroke-width:2px
  linkStyle 10 stroke:#FF0000,stroke-width:2px
  linkStyle 11 stroke:#FFA500,stroke-width:2px
Loading

SC-2: Extension DeleteProfile Component

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. System Component"]
    direction LR
    SC2@{ shape: rect, label: "SC-2: Extension DeleteProfile Component" }
  end
  subgraph SL2["2. Weaknesses"]
    direction LR
    CWE362@{ shape: rect, label: "CWE-362: Concurrent Execution using Shared Resource with Improper Synchronization" }
    CWE862@{ shape: rect, label: "CWE-862: Missing Authorization" }
    CWE200@{ shape: rect, label: "CWE-200: Exposure of Sensitive Information" }
    CWE778@{ shape: rect, label: "CWE-778: Insufficient Logging" }
    CWE863@{ shape: rect, label: "CWE-863: Incorrect Authorization" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC25@{ shape: rect, label: "CAPEC-25: Forced Deadlock / Race Condition" }
    CAPEC122@{ shape: rect, label: "CAPEC-122: Privilege Abuse" }
    CAPEC116_2@{ shape: rect, label: "CAPEC-116: Excavation" }
    CAPEC268@{ shape: rect, label: "CAPEC-268: Audit Log Manipulation" }
  end
  subgraph SL4["4. Threats"]
    direction LR
    STRIDE3@{ shape: rect, label: "STRIDE-3: TOCTOU Race in DeleteProfile<br><i>High / Likely</i>" }
    STRIDE4@{ shape: rect, label: "STRIDE-4: Blind Unbind-All When Null<br><i>High / Likely</i>" }
    STRIDE6@{ shape: rect, label: "STRIDE-6: Sensitive DID Enumeration Disclosure<br><i>Medium / Possible</i>" }
    STRIDE5@{ shape: rect, label: "STRIDE-5: Insufficient Repudiation Controls<br><i>Medium / Possible</i>" }
    STRIDE11@{ shape: rect, label: "STRIDE-11: Missing Server-Side Enforcement Assumption<br><i>High / Possible</i>" }
  end
  subgraph SL5["5. Threat Actors"]
    direction LR
    TA2@{ shape: rect, label: "TA-2: Careless or Rushed Operator<br><i>Unintentionally authorize destructive actions</i>" }
    TA3@{ shape: rect, label: "TA-3: Co-Installed Malicious Browser Extension<br><i>Read sensitive DOM-rendered data</i>" }
    TA4@{ shape: rect, label: "TA-4: Insider With Concurrent Session Access<br><i>Exploit TOCTOU windows</i>" }
  end
  SC2 --> CWE362
  SC2 --> CWE862
  SC2 --> CWE200
  SC2 --> CWE778
  SC2 --> CWE863
  CWE362 --> CAPEC25
  CWE862 --> CAPEC122
  CWE200 --> CAPEC116_2
  CWE778 --> CAPEC268
  CWE863 --> CAPEC122
  CAPEC25 --> STRIDE3
  CAPEC122 --> STRIDE4
  CAPEC116_2 --> STRIDE6
  CAPEC268 --> STRIDE5
  CAPEC122 --> STRIDE11
  STRIDE3 --> TA4
  STRIDE4 --> TA2
  STRIDE6 --> TA3
  STRIDE5 --> TA2
  STRIDE11 --> TA4
  linkStyle 0 stroke:#FF0000,stroke-width:2px
  linkStyle 1 stroke:#FF0000,stroke-width:2px
  linkStyle 2 stroke:#FFA500,stroke-width:2px
  linkStyle 3 stroke:#FFA500,stroke-width:2px
  linkStyle 4 stroke:#FF0000,stroke-width:2px
  linkStyle 5 stroke:#FF0000,stroke-width:2px
  linkStyle 6 stroke:#FF0000,stroke-width:2px
  linkStyle 7 stroke:#FFA500,stroke-width:2px
  linkStyle 8 stroke:#FFA500,stroke-width:2px
  linkStyle 9 stroke:#FF0000,stroke-width:2px
  linkStyle 10 stroke:#FF0000,stroke-width:2px
  linkStyle 11 stroke:#FF0000,stroke-width:2px
  linkStyle 12 stroke:#FFA500,stroke-width:2px
  linkStyle 13 stroke:#FFA500,stroke-width:2px
  linkStyle 14 stroke:#FF0000,stroke-width:2px
  linkStyle 15 stroke:#FF0000,stroke-width:2px
  linkStyle 16 stroke:#FF0000,stroke-width:2px
  linkStyle 17 stroke:#FFA500,stroke-width:2px
  linkStyle 18 stroke:#FFA500,stroke-width:2px
  linkStyle 19 stroke:#FF0000,stroke-width:2px
Loading

📊 Risk Summary

Total Threats: 12

By Severity: Low: 2 · High: 4 · Medium: 5 · Informational: 1

By Category: Spoofing: 1 · Tampering: 8 · Information Disclosure: 4 · Elevation of Privilege: 5 · Repudiation: 2 · Denial of Service: 2

🎯 Attack Surface

Kill Chain 1: An attacker who compromises or spoofs the agent-to-client RPC transport (crossing TB-2) can forge a PROFILE_DELETE_BOUND refusal with fabricated or omitted persona-binding details (STRIDE-1), which personasBlockingDelete accepts as long as it is structurally well-formed, causing the DeleteProfile UI (SC-2) to display an incorrect blocking-personas list; if the operator then relies on this false preview to click 'Unbind N and delete', the resulting unbind:true call (STRIDE-11) executes without any client-side verification that the agent-side unbind actually matches what was previewed, chaining a transport-level spoof directly into an unauthorized, irreversible persona-profile disconnection. Kill Chain 2: Independent of any active attacker, a legitimate but time-pressured operator can be led into a TOCTOU race (STRIDE-3) where the persona-binding count snapshot taken at refusal time becomes stale by the time the confirm button is clicked, especially in multi-context/multi-session environments; combined with the blind-unbind-when-null pathway (STRIDE-4), an operator facing an unhelpful 'it did not say which' warning may click through an unbind-all action whose true scope is unknowable to the client, compounding a race condition with an inherently unbounded confirmation into an unpredictable mass-unbind event. Kill Chain 3: The delete-attempt flow doubles as an unintended read side-channel for the sensitive persona-linkage map (STRIDE-9): an operator or automated script can repeatedly trigger PROFILE_DELETE_BOUND refusals with unbind:false to harvest RA-1 (the persona-binding list) over time without ever executing a real delete, and because the resulting DID list is rendered unmasked in the DOM (STRIDE-6), any co-installed malicious extension or screen-capture tooling crossing TB-3 can passively harvest this reconstructed linkage map, defeating the deliberate 'click, not a column' friction designed into the legitimate ResolvedProfile read path. Kill Chain 4: A future maintainer, guided by duplicate or stale documentation (STRIDE-10) and lacking type-system enforcement of the null-vs-[] distinction (STRIDE-2), could introduce a regression that collapses these two states; combined with the absence of robust audit logging (STRIDE-5, STRIDE-7), such a regression could cause silent unauthorized unbinding that is neither detected at runtime nor reconstructable after the fact, representing a slow-burn, low-detectability supply-chain-adjacent risk rooted entirely in documentation and type-safety gaps rather than active exploitation.


Generated by Agentic Sec — Threat Model & Affect Analysis Agent

📊 Summary & findings
✅ Confirmed ⚠️ Must-Review-By-Human
1 1

Confirmed (1)

  • 🟡 Unquantified 'Unbind them and delete' confirmation when persona list is null (triaged HIGH→MEDIUM)

Must-Review-By-Human (1)

  • 🟡 TOCTOU: unbind count shown to operator is not re-validated at confirm time (triaged HIGH→MEDIUM)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants