Skip to content

fix(agents): Agent-Teams-3 closure — DEK wrap on approve, trusted-author card gate - #409

Merged
th3-br41n merged 1 commit into
mainfrom
feat/agent-teams-3-close
Aug 1, 2026
Merged

th3-br41n merged 1 commit into
mainfrom
feat/agent-teams-3-close

Conversation

@th3-br41n

Copy link
Copy Markdown
Contributor

Closes the two items slice 2's security review left documented rather than fixed, plus a third gap verified.

(a) The DEK gap — closed

The approve path wrote dekId: null, so a channel-approved object had no per-entity DEK and no member wrap. It could not be wrapped for sharing until the next vault open's retro-wrap pass — fail-closed (no plaintext leak), but second-class.

The mint + wrap + rollback + zeroing sequence is now one shared seam, packages/shell/src/main/entities/create-with-dek.ts, used by both entities.create and the approve path — rather than a second implementation of a security-critical ordering. The approve path's Pending re-check rides the seam's guard hook so it stays atomic with the create (the terminal-decision property is unchanged).

An explicit DekWrapFailurePolicy enum picks the honest answer per caller:

  • RollbackRow for entities.create — nobody has seen the row, so un-create it and let the throw reach the caller (byte-identical to today).
  • KeepRow for approve — the user has already been told the object exists; deleting it because a Y.Doc round-trip failed would be a worse lie than a missing wrap, and the row still carries a real DEK so the sync wire path never sees an ambiguous null-DEK row. This is the posture retro-wrap-deks.ts already takes for the identical case.

No DEK store ⇒ Unavailable. There is no path to an approval without a key.

(b) The card phishing surface — closed

Chat rendered an approve/discard card from any message carrying the agentProposal property, so a card synced in from another vault showed an Approve button that main could only ever refuse.

Card rendering is now gated on the message's host-written author (ownerAppId = the row's created_by, which no app can set) resolving to a shell-authored Agent/v1 record — the app-side mirror of main's readTrustedAgentDef notion, minus the crypto half main re-checks anyway. A foreign or non-agent-authored card renders as an ordinary message, whose plain body already reads honestly on its own. The parser stays fail-closed, and so does the default: omit the trust context and no card is ever produced.

(c) The third gap — verified structural, not a bug

Row / database / code-file proposals are genuinely unreachable from a channel, for two independent reasons: those verbs are Agent-app-local and are not in PROPOSE_DESCRIPTORS at all, and the runner filters its offered set on isChannelProposeVerb besides. Pinned by a retained probe that grants the agent every grantable propose dispatch (including intents.dispatch:propose-row, which the starter agents really hold) and scripts the model to call each scoped-out verb by name before settling on a task: the system prompt never names them, and the run's only card is the task.

Method

Failing test first for (a) and (b) — both watched red, then fixed. (c) passed on the first run and is recorded as confirmed-structural rather than claimed as a fix. Every probe is retained.

Gates

Both run by me on this diff; the run records go in the harness ledger.

pentest — pass (first run of the closure probes failed as expected on (a) and (b); that is the recorded shape). 14 new adversarial probes land as fence 5 in agent-teams-pentest.test.ts, all against real tmpdir vaults with the real repo / entities service / ledger / DEK store / crypto / createAgent:

  • card forgery and settling through the real entities service (agentProposal is stripped from create and update, so an app can neither plant, settle, nor re-open a card)
  • type substitution — five attempts including Agent/v1, an unhonourable kind, and non-canonical "Task" / "task " — only the descriptor's type is ever written
  • provenance forgery via reserved keys smuggled into the staged fields
  • every foreign-author stamp the receive paths produce, plus a card whose agent was deleted after staging
  • key hygiene — two approvals mint two distinct AAD-bound keys, each wrapped for its own entity + type, and the plaintext buffer is zeroed by the time the call returns (asserted on the live buffer, not a copy)
  • a 5-way approve race — one entity, one key, one wrap, no orphan entity_deks row
  • capability scarcity and no-session-identity, both leaving the card Pending
  • non-canonical encodings (the class that broke an earlier fix on this track) — uppercase / whitespace / homoglyph / double-colon fingerprint spellings against both halves of the new Chat gate, and that an app id can never contain :

security-review — pass, two nits found and fixed in the pass: toMessage's new parameter was positional, so .map(toMessage) passed the array index as the trust context (a live crash — it fired in widget-data.ts); it is now an options bag so the same mistake degrades to the fail-closed default. And the Chat module re-declared AGENT_TYPE locally; it now imports the shared constant from @brainstorm-os/sdk-types.

Still open (recorded, not fixed)

  • created_by from the type prefix. Live-sync's installWrap materialises an inbound row with createdBy = type.slice(0, type.indexOf("/")), and the type rides a member-sealed wrap — so a remote member can cause a local row stamped with the reserved shell principal by sealing a type like shell/Anything/v1. Not an agent forgery: both trusted-agent gates conjoin created_by === "shell" with the exact brainstorm/Agent/v1 type, and those are mutually exclusive on that path. Outside this diff; pinned by a NOTE probe so a change to either half turns red.
  • No real-shell exercise. No Electron shell was launched, so the broker/preload/renderer path, live envelope validation in situ, the Chat UI in a browser, and a relay round-trip of an approved object are unexercised. The member wrap is asserted at the installEntityWrap seam, not through the real ydoc worker.

Verification

bun run typecheck (packages + all 20 apps) clean · bun run lint clean (biome + all 10 ratchets, no baseline moved) · full bun --bun vitest run: 17,005 passed, 3 skipped, 1 todo, 0 failed.

🤖 Generated with Claude Code

…hor card gate

Slice 2's security review passed with two items documented rather than fixed.
Both are closed here, plus a third gap verified.

(a) The approve path wrote `dekId: null`, so a channel-approved object had no
    per-entity DEK and no member wrap — it could not be wrapped for sharing
    until the next vault open's retro-wrap pass. Fail-closed, but second-class.

    The DEK mint + wrap + rollback + zeroing sequence is now ONE shared seam
    (`entities/create-with-dek.ts`) used by both `entities.create` and the
    approve path, rather than a second implementation of a security-critical
    ordering. The approve path's Pending re-check rides the seam's `guard` hook
    so it stays atomic with the create. An explicit `DekWrapFailurePolicy`
    picks the honest answer per caller: RollbackRow for `entities.create`
    (nobody has seen the row), KeepRow for approve (the user has been told the
    object exists; the row still carries a real DEK, so the wire path never
    sees an ambiguous null-DEK row — the posture retro-wrap already takes).
    No DEK store ⇒ Unavailable, never a second-class row.

(b) Chat rendered an approve/discard card from ANY message carrying the
    `agentProposal` property, so a card synced in from another vault showed an
    Approve button that main could only ever refuse — a UX lie and a phishing
    surface. Card rendering is now gated on the message's HOST-written author
    (which no app can set) resolving to a SHELL-authored `Agent/v1` record —
    the app-side mirror of main's `readTrustedAgentDef` notion. The parser
    stays fail-closed, and so does the default: omit the trust context and no
    card is ever produced.

(c) Verified, not fixed: row / database / code-file proposals are structurally
    unreachable from a channel (those verbs are Agent-app-local and are not in
    `PROPOSE_DESCRIPTORS` at all, and the runner filters on
    `isChannelProposeVerb` besides). Pinned by a retained probe that grants the
    agent every propose dispatch and scripts the model to call each scoped-out
    verb by name.

Failing-test-first for (a) and (b); (c) passed on the first run and is recorded
as confirmed-structural. 14 new adversarial probes land as pentest fence 5 —
card forgery/settling through the real entities service, type substitution
including non-canonical spellings, provenance forgery, every foreign-author
stamp, key reuse + AAD binding + zeroing of the live buffer, a 5-way approve
race, capability scarcity, and non-canonical fingerprint spellings against both
halves of the new Chat gate. All refused; no new blocking finding. One hazard
recorded as a NOTE probe, not fixed (outside this diff, no reachable exploit):
live-sync's installWrap stamps `created_by` from the entity TYPE PREFIX, so a
member can cause a row attributed to the reserved `shell` principal — harmless
only while every consumer conjoins the type, which both agent gates do.

`<gates: pentest, security-review>`

Full suite 17,005 passed / 0 failed; typecheck (packages + 20 apps) and lint
(biome + all 10 ratchets, no baseline moved) clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@th3-br41n
th3-br41n merged commit a0a5db4 into main Aug 1, 2026
3 checks passed
@th3-br41n
th3-br41n deleted the feat/agent-teams-3-close branch August 1, 2026 08:41
th3-br41n added a commit that referenced this pull request Aug 1, 2026
#409 landed while this branch was in flight. The only conflict was in
`mention-runner.test.ts`, where both sides appended a test at the same point:
#409's CLOSURE (c) probe (the scoped-out propose verbs are structurally
unreachable) and this branch's two delegation probes. Both are kept — they
assert disjoint properties of the same runner.

`mention-runner.ts` and `index.ts` auto-merged: #409 touched the reply-writing
tail and the proposal-service wiring; this branch touched the tool set, the
dispatch, and the assignment subscriber.

Verified after the merge: agents + chat suites 277 passed, typecheck clean,
lint clean (biome + all 10 ratchets).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant