feat(agents): messaging rides the per-project capability switch - #237
Merged
Conversation
…tch, the notice and the Settings row all appear generated The mechanism is PR #213's: one line in PROJECT_CAPABILITIES plus a copy entry, and the strict === true file round-trip, the one-time clone notice (machine-local kept/declined ack) and the generated Settings -> Agents row all cover the new key with zero new mechanism. Project/ProjectFileV1 gain the optional agentMessaging field; tests name the messaging capability specifically so the generic coverage is pinned, not assumed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… off the store Replaces the fail-closed () => false placeholder (PR #212) with the real per-project check: projectCapabilityGrantedFor(project, 'agentMessaging') — the strict === true flag in the git-shared project.json AND this machine's recorded 'kept' answer to the clone notice. NEVER the raw file bit (projectCapabilityFlagInFile): during the pending-notice window and after a recorded decline the flag answers true while delivery must refuse, and the new suite goes red on exactly that swap (PR #213 review, I2). - WorkspaceStore.capabilityProjectFor: the capability view of one project (strict flags from the shared file, answers from the machine-local index entry — a file-borne capabilityAck is a forgery and is never read), same id semantics as persistedCanvases so scope and switch agree on identity. - messagingEnabledVia: the production wiring as one testable call. - main/index.ts wires the IPC deps through both; nothing else changes. - agent-messaging-switch.test.ts drives the REAL control path, half of it over a REAL WorkspaceStore on disk: unanswered/declined/forged/hand-edited all refuse as notPermitted (switch-off); flag+kept delivers to the pane write. Mutation-checked 4/4 (flag-for-grant swap, file-borne ack, entry ack dropped for cwd and inline entries). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rant can never speak for another's pane Closes PR #237 review I-1 (confused deputy, proved by execution): panes are keyed by the BARE node id globally, so a hostile/cloned project A with agentMessaging granted that merely LISTS ungranted project B's node id resolved same-project to A, passed A's switch, and delivered into B's one global pane the moment the user kept A's clone notice. The grant now gates the project that OWNS the target pane, resolved at scope time: resolveDeliveryScope computes the set of projects claiming the target id; more than one owner is refused as the new, named notPermitted (ambiguous-target-node-id) — never 'pick the sender's', which was exactly the hole — and a unique target resolves to ITS owning project (by construction the one the sender shares), whose id is what the switch is then evaluated against. The #208 residual note in agent-message-scope.ts documented this as 'cannot close'; it is now closed at the reachable layer and the docblock rewritten to say what holds instead of what leaks. Tests: the reviewer's exact scenario over a REAL WorkspaceStore (granted A listing ungranted B's live node id -> refused, nothing written) plus scope unit coverage: both directions of a duplicate refused, the refusal carries its own reason through decideDelivery and is terminal, and a unique target still resolves. Mutation-checked 2/2: reverting to source-project grant resolution turns the confused-deputy test back into 'delivered' (3 red); renaming the refusal to cross-project also goes red. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ot reach a pane it did not spawn CRITICAL (PR #237 re-review): the round-1 ambiguity check needs TWO store claimants, so when the victim's real project is ABSENT from the persisted store a granted hostile project A that lists the live pane's node id is the sole store claimant — ambiguity never fires, scope resolves same-project to A, A's switch passes, and the envelope reaches the victim's one global tmux pane (driven end-to-end: outcome=delivered, reply.ok=true). The store's node-set is attacker-writable (project.json lists ids A never spawned), so it cannot be the ownership authority. The distinguishing fact is RUNTIME: which project's create() actually spawned the pane. A new in-memory ledger (src/core/agents/pane-ownership.ts) records nodeId -> owning projectId on a GENUINE FRESH spawn only (spawnNew, gated on the manager's existing 'fresh' signal), so an attach/co-attach to a session another project spawned never claims it. The owner is the renderer's machine-local project id (PtyCreateOptions. ownerProjectId), never the git-shared file id. The delivery gate now reads the LEDGER for the target's true owner and evaluates the grant against THAT; the store's projectId is a cross-check. No entry (restart / never spawned here) or a disagreement -> refused with the new, terminal reason unproven-target-owner. forgetPaneOwner on session end. Cold state is fail-CLOSED by decision: after an app restart the tmux server survives and re-open ATTACHES (fresh=false, nothing recorded), so a re-attached pane is unproven and messaging to it is refused until a true respawn. No repopulation on attach — no cross-restart signal exists that a hostile agent's shell could not also write (a tmux session-env var), and guessing owner on attach is how the attacker would re-acquire it. Tests (real store + real ledger, all mutation-checked): the re-reviewer's exact attack (victim absent, pane live, granted attacker) -> refused, sent:[] (red-first: pre-fix delivered); ledger-empty restart -> refused; ledger owner disagrees with a granted sole claimant -> refused; legitimate self-delivery (ledger owner == store project, granted+kept) -> delivered. Ledger unit suite for record/overwrite/forget/unproven-default. Mutations 2/2: bypass the ledger (fall back to store projectId) flips the attack to delivered; drop the owner!==projectId cross-check leg -> attack+disagree red. Residual stated in pr6-report.md: re-attached-after-restart panes fail closed until respawn (safe, deliberate); a durable userData ledger would remove that cost at the workspace.json trust level (future work). The fresh-gate itself is covered by unit semantics + the documented spawnNew contract, not a node-pty spawn/attach integration test (env-flaky). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… fresh-gate PR #237 re-review (security-closed) left two Minors before merge, both fail-closed, neither a hole. Minor 1 (parity): ModalTerminal was the one renderer transport.create site not threading ownerProjectId. It co-attaches to an already-proven TerminalNode pane in the common case, but as the FIRST/sole fresh spawner of a node (a card for a node whose project is not the active canvas) it recorded the pane as unproven and messaging to it failed closed. It now passes ownerProjectId using the CARD project it already resolved (sshConnectionScope(ssh) ?? activeProjectId), deliberately not the active canvas id. Both create sites now thread the owner identically. Minor 2 (doc): corrected the round-2 residual note in pr6-report.md. Phones spawn through the host renderer TerminalNode, which threads ownerProjectId, so there is no direct phone/relay fresh-spawn path that fails closed; the only residual is the deliberate re-attach-after-restart refusal. Optional refactor (taken, clean): extracted the load-bearing fresh-gate as the pure shouldRecordOwnership(fresh, persistKey, owner), used by spawnNew, with its own unit suite. Records only a genuine fresh spawn with a persistKey and a known owner; attach / no-persistKey / no-owner do not. Mutation: dropping the fresh===true leg (record on attach) turns the suite red, so the confused-deputy-via-restart hole now has an automated pin. Full vitest run: 466 files / 6491 tests passed, 0 failed. typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eneskirca
enabled auto-merge
August 15, 2026 18:56
aedanilyak
added a commit
to Adscale-R-D/nodeterm
that referenced
this pull request
Aug 17, 2026
…/notify) Asked whether we couldn't just make it work, instead of refusing it more politely. We can, and it was a wiring change: `src/main/agent-messaging.ts` imported nothing but `core` and `shared`, and its entire surface is the injected AgentMessagingDeps — every one of whose fields the server already had (PtyManager for the pane probes and the framed write, WorkspaceStore for the delivery scope and the capability grant, SettingsStore for custom agents, the board-log router, and paneOwnerProject, which has been sitting in core since PR eneskirca#237 with a comment saying it was put there for both shells). Only the boot wiring was main-side. So: the module moves to core/agents/, and both shells call one factory, `initAgentMessaging` (core/agents/agent-messaging-boot.ts), which builds the deps, arms the deliver-on-idle queue and registers agent:message-deliver on whichever platform is installed. A factory rather than two deps objects because those nine fields are duplicated SECURITY wiring, and the two likeliest to drift are the two that must not: messagingEnabled reading the GRANT (never the raw project.json flag) and paneOwnerProject being consulted at all. NOTHING WAS LOOSENED to make the new edition work. Same per-project capability grant (off by default: strict `=== true` in the git-shared project.json AND this machine's recorded 'kept' ack), same runtime pane-ownership gate, same flow budgets, same verified-only route in hook-server. `wake`/`isHibernated` stays renderer state with no shell-side signal on EITHER shell — a pre-existing residual, unchanged. Two shell-specific answers, both explicit: `isRemoteNode` is REQUIRED rather than defaulted (desktop asks ptyManager.sshRemoteForNode; the server answers a constant false, which is complete there because SSH projects are desktop-only on that edition), and the queue-flush leg rides wireAgentStatus's new `onMessagingEvent`. That leg is the subtle one: miss it and deliveries to an idle target still work, so it looks wired, while every delivery to a BUSY target is answered `queued` and waits forever — the flush trigger is the target's own `done` event. agent-messaging-both-shells.test.ts pins both legs at source level, because this repo has shipped a one-shell hook change three times and a boundary test cannot tell you a call is missing. Also fixes the refusal wording that prompted the question. It read "Canvas control is not available on the nodeterm Server Edition" for a single refused verb — true while every verb was refused, a lie once most worked. An agent asked for `send`, was told the whole feature was missing, and asked its user to check an unrelated Settings switch. Refusals now name the VERB and say other verbs work; the regression is pinned. EDITION_UNSUPPORTED_VERBS is down to `browser`, which is genuinely structural. Relay tabs still refuse messaging, deliberately: a delivery there types into a pane on the HOST while the gate chain would be evaluated against the guest's own stores. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Messaging PR 6 of the agent-messaging series — now a small PR by design: the generic per-project capability mechanism landed in #213, and #212 shipped the
send/reply/notifyverbs fail-closed behindmessagingEnabled: () => false. This PR is the wiring between the two.The verbs go live ONLY per project, and only with BOTH halves of the grant: the strict
agentMessaging === trueflag in the git-shared.nodeterm/project.jsonAND this machine's recorded 'kept' answer to the one-time clone notice (projectCapabilityGrantedFor). A flag that is on but unanswered, a recorded decline, a forged file-bornecapabilityAck, and a hand-edited"true"all keep every delivery refused asnotPermitted (switch-off).Commits
fc1e1003— feat(project): agentMessaging joins the capability registryOne line in
PROJECT_CAPABILITIESplus aPROJECT_CAPABILITY_COPYentry (src/shared/project-capabilities.ts), andagentMessaging?: booleanonProject/ProjectFileV1. Everything else appears generated by #213's mechanism, with zero new code:=== truevalidation, hostile-input refusals, and the no-bytes-when-off file round-trip (the registry-iterating suites now run for the new key);agentMessaging: truethrough the real dialog and pins messaging's own copy;042aa409— feat(agents): messagingEnabled is the capability GRANT, read per call off the storeThe real work:
WorkspaceStore.capabilityProjectFor(projectId)— the capability view of one project: strict flags from the shared file, answers from the machine-local index entry only (a repo hand-carryingcapabilityAckis a forgery and is never read). Same id semantics aspersistedCanvases, so delivery scope and the switch agree on project identity.messagingEnabledVia(getProject)insrc/main/agent-messaging.ts— the production wiring as one testable call:projectCapabilityGrantedFor(getProject(projectId), 'agentMessaging'). NeverprojectCapabilityFlagInFile— the raw file bit answerstruein exactly the states that must refuse (pending notice, recorded decline), and the new suite goes red on that swap (the feat(projects): the per-project capability mechanism #213 review's I2 trap, mutation-checked).src/main/index.tsreplacesmessagingEnabled: () => falsewith the wired check. Read per call: a decline or an off-toggle refuses the very next delivery.Evidence
src/main/agent-messaging-switch.test.tsruns the REAL control path (deliverFromControl), half of it over a REALWorkspaceStoreon disk: (a) switch absent →switch-off(the feat(agents): the send, reply and notify verbs #212 test also stays green), (b) flag true + ack pending or declined → still refused, (c) flag true + 'kept' → delivered to the pane-write layer. Forged file-ack and hand-edited"true"cases run through a fresh store load.npm run typecheckclean; fullnpx vitest runat HEAD: 465 files / 6474 tests passed, 0 failed (1 file / 12 tests skipped = the opt-in Docker SSH suite).Surfaces: Desktop is the only surface with the verbs; the Server Edition still answers
control-unsupported-on-this-edition(untouched); mobile is unaffected (never a sender).🤖 Generated with Claude Code
Fix round 1 (review: spec PASS / quality CHANGES-REQUIRED)
I-1 (Important — confused deputy, closed):
6b867a6d. The switch was evaluated against the source's project while panes are keyed by the bare node id globally, so a granted project A whose file listed ungranted project B's node id could deliver into B's live pane once A's clone notice was kept (proved by execution in review). The grant now gates the project that OWNS the target pane:resolveDeliveryScopecomputes the set of projects claiming the target id — more than one owner is refused as the new, namednotPermitted (ambiguous-target-node-id)(never "pick the sender's"), and a unique target resolves to its owning project, whose grant the switch then checks. The reviewer's exact scenario runs as a test over a REAL WorkspaceStore (granted A listing ungranted B's node id → refused, nothing written), plus scope-unit coverage for both directions, the named reason carried throughdecideDelivery, terminality, and the unique-target control. Mutation-checked: reverting to source-project resolution turns the attack test back intodelivered(3 red); renaming the refusal tocross-projectalso goes red. The scope docblock's "cannot close" residual note is rewritten to state what now holds.M-1 (report tally): corrected in the report — the registry-drop mutation's 3 red span two files (2 in
project-capabilities.test.ts, 1 in the Settings-row suite).M-2 (CapabilityNotice mount pin): carried forward as a tracked concern — a real reachability assertion needs a full Canvas.tsx mount (9.2k-line monolith, no harness), and a source-text grep test is this repo's documented anti-pattern. Fails safe if regressed (permanently switch-off). Listed for device verification.
Full
npx vitest runat6b867a6d: 465 files / 6477 tests passed, 0 failed (12 skipped = opt-in Docker suite).Fix round 2 (re-review: new CRITICAL — the round-1 residual was FAIL-OPEN)
9ecd0c5f— prove pane ownership at runtime. The round-1 ambiguity check needs two store claimants; when the victim's real project is ABSENT from the persisted store, a granted hostile project A that lists the live pane's node id is the SOLE store claimant, so ambiguity never fires, scope resolves same-project to A, A's switch passes, and the envelope reaches the victim's one global tmux pane (driven end-to-end:delivered,reply.ok:true).project.json's node-set is attacker-writable, so it cannot be the ownership authority.The distinguishing fact is runtime — which project's
create()actually spawned the pane. A new in-memory ledger (src/core/agents/pane-ownership.ts) recordsnodeId → owning projectIdon a GENUINE fresh spawn only (spawnNew, gated on the manager's existingfreshsignal), so an attach/co-attach never claims a session another project spawned; the owner is the renderer's machine-local project id (PtyCreateOptions.ownerProjectId), never the file id. The delivery gate reads the LEDGER for the target's true owner and evaluates the grant against THAT, with the store as a cross-check. No entry (restart / never spawned here) or a disagreement → refused with the new terminal reasonunproven-target-owner.Cold state is fail-CLOSED by decision: after an app restart the tmux server survives and re-open ATTACHES (
fresh=false, nothing recorded), so a re-attached pane is unproven and messaging to it is refused until a true respawn. No repopulation on attach — no cross-restart signal exists that a hostile agent's shell could not also write.Tests (real store + real ledger, mutation-checked): the re-reviewer's exact attack → refused,
sent:[](red-first: pre-fixdelivered); ledger-empty restart → refused; ledger owner disagrees with a granted sole claimant → refused; legitimate self-delivery → delivered. Mutations 2/2: bypass the ledger (fall back to storeprojectId) flips the attack todelivered; drop theowner !== projectIdcross-check → attack+disagree red.Residual stated plainly: re-attached-after-restart panes fail closed until respawn (safe, deliberate); a durable userData ledger would remove that cost at the
workspace.jsontrust level (future work). Thefresh-gate itself is covered by unit semantics + the documentedspawnNewcontract, not anode-ptyspawn/attach integration test (environment-flaky) — flagged for device verification.Full
npx vitest runat9ecd0c5f: 466 files / 6487 tests passed, 0 failed (12 skipped = opt-in Docker suite).Fix round 3 (re-review: SECURITY-CLOSED — two Minors before merge)
f2a99056— kanban modal ownership parity + an automated pin for the fresh-gate.ModalTerminal.tsxwas the one renderertransport.createsite not threadingownerProjectId. Harmless when it co-attaches to an already-proven pane, but as the FIRST/sole fresh spawner of a node (a card for a node whose project is not the active canvas) that pane recorded as unproven and messaging to it failed closed. It now passes the CARD's project (the one the modal already resolved), not the active canvas id. Both create sites thread the owner identically.TerminalNode, which threadsownerProjectId, so there is no phone/relay fresh-spawn path that fails closed. The only residual is the deliberate re-attach-after-restart refusal.shouldRecordOwnership(fresh, persistKey, owner)with its own unit suite; a mutation dropping thefresh === trueleg (record on attach) turns it red — the fresh-gate now has an automated guard, not just a co-located comment.Full
npx vitest runatf2a99056: 466 files / 6491 tests passed, 0 failed (12 skipped = opt-in Docker suite); typecheck clean.