feat(console): the agent's refusal is the profile-delete preview - #172
Conversation
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>
🛡️ AI Agentic Security Code Review1 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
🗺️ Scan CoverageModules scanned: 2 · with findings: 2 · files: 3 · findings: 3
Executive Summary
🔒 Security IssuesConfirmed Vulnerabilities (1)🟡 Unquantified 'Unbind them and delete' confirmation when persona list is null
🧠 AI Triage:
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. Vulnerable lines: 975, 1010 🔁 Reproduction Steps:
🔎 Evidence: 💥 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:
⚖️ Triage Factors:
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:
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: Secure code: Additional recommendations:
|
| 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:
- Open two operator sessions (or one session plus a concurrent programmatic binding call) against the same holder/profile.
- In session A, click Delete on a profile that currently has 2 personas bound; observe the refusal renders 'Unbind 2 and delete'.
- Before clicking confirm in session A, from session B (or via personaBindingSet directly) bind a third persona to the same profile.
- 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.
- 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
expectedVersionfield 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 deleteand calls run(true) on click, which invokespersonaProfileDelete(managerSender, { ...parties, profileId: profile.profileId, unbind })per the snippet in the STRIDE-3 evidence; ProfileDeleteParams in packages/core/src/admin/persona.ts includes an optionalexpectedVersion?: numberfield 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.
Details
🛡️ Threat Model & Affect Analysis — PR #172
| Field | Value |
|---|---|
| Repository | OpenVTC/vta-browser-plugin |
| Branch | feat/persona-delete-refusal → main |
| 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:
- 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.
- 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.
- 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.
- 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.
- 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:
- 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). - 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. - 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. - 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.
- 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:
- 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].
- 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.
- 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.
- 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.
- 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:
- 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.
- 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 }.
- 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.
- 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'.
- 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:
- An operator with legitimate access clicks 'Unbind N and delete' in the DeleteProfile component, triggering run(true) -> personaProfileDelete(managerSender, {...parties, profileId, unbind:true}).
- 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.
- 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.
- 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.
- 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:
- Operator attempts to delete a profile and the agent refuses with PROFILE_DELETE_BOUND, naming bound personas via details.personaDids.
- 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. - 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').
- 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.
- 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:
- 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.
- DeleteProfile.run's catch block (packages/extension/src/manager/panes/persona.tsx) falls through the
e instanceof RelayTaskError && e.code === PROFILE_DELETE_BOUNDcheck and lands in the genericsetPhase({ kind: 'error', message: e instanceof Error ? e.message : String(e) })branch. - 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.
- 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.
- 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:
- 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).
typeof details !== 'object'andArray.isArray(dids)checks pass because they only test structural shape, not prototype chain integrity.- The
(details as { personaDids?: unknown }).personaDidscast and subsequentdids.every((d) => typeof d === 'string')do not detect pollutedArray.prototypeorObject.prototypemethods that could have been tampered with earlier in the message-parsing pipeline (outside the scope of this specific function). - If any polluted prototype property is later relied upon elsewhere in the extension (e.g., a
.lengthgetter override or atoStringoverride on a returned DID string used in security-relevant string comparison), it could cause subtle behavioral deviations. - This is a low-likelihood, defense-in-depth-relevant issue since personasBlockingDelete's own logic (strict
typeof/Array.isArray/everychecks) 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:
- 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.
- 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.
- 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.
- 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.
- 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:
- 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.
- 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.
- 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.
- 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.
- 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:
- 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.
- 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.
- 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. - 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.
- 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:
- 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.
- 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 thee instanceof RelayTaskError && e.code === PROFILE_DELETE_BOUNDcheck. - 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.
- 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.
- 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
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
📊 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 | |
|---|---|
| 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)
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
codeanddetails, 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 profilesendsunbind: false; if personas are presenting it, the agent refuses withpersona/profile/delete:boundanddetails.personaDids, and the console renders that refusal as the preview — naming the personas — withUnbind 3 and deletebesideCancel.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_BOUNDandpersonasBlockingDeleteare 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 asTrustTaskCode::new_extended(slug, "bound"), compared with===and never parsed.personasBlockingDeleteparses unvalidated wire data, which is the one place a client is entitled to be paranoid, and two of its decisions are load-bearing: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.Five tests in
admin.persona.mjscover 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 sixAssert*steps inci.ymlrun 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.