feat(agents): rate limit, fan-out cap, and cross-project refusal - #208
Merged
Conversation
The control surface has never had a throttle. What it has is timeouts (SLOWLORIS_MS -> CONTROL_CEILING_MS, the 120s pendingControl bound) and a one-at-a-time confirmation serializer. A timeout is not a throttle and a modal is not a budget — and messaging skips the modal entirely once the per-project switch is on, so even the accidental brake is gone. Two limits, both PRE-PROBE, both refusing with the existing `rateLimited` member of the outcome union rather than a new shape: - Per PAIR, 10s (#98's number, now enforced). Keyed by sender alone, an orchestrator fanning out to four workers starves itself; keyed by target alone, one node's window is held shut by whoever wrote to it last and every other conversation with it pays. Keyed by the pair, the budget belongs to the conversation — and B->A is a different pair, so a reply is never throttled by the message it answers. - Per TURN, 4 per sender. A turn is identified by the budget entry itself, created by the turn's first send and destroyed by the sender's own `newTurn` — the same edge the renderer's per-turn fan-out already uses. A state transition would reset it many times inside one turn; a session id would never reset it at all. State is process memory, swept on read. A LOST limiter is one extra message per pair after a restart; a STUCK one is a permanently silent agent, so every path that could produce one fails open: entries are evicted rather than filtered, a backwards clock jump drops an entry instead of parking it in the future, and a fan-out budget whose reset signal never arrives expires after TURN_STALE_MS. Ships inert: nothing calls any of it until the verbs land. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d off the store `resolveDeliveryScope` answers "is this target mine to write to?" from the SERIALIZED projects store — the same array `routeControlSource` and `storedNodeListing` already read — and produces the `notPermitted` fact `decideDelivery` already takes. There is no parameter for a live node list, which is the point: React Flow holds only the active project's nodes, so resolving a target against `nodesRef` answers "not on the canvas" for every node outside the current tab. Fails CLOSED, because what is being authorised is a write into someone else's terminal: a source in no known project, a target in no known project, and a target in a project the sender does not share are all `cross-project`. A duplicate node id (a cloned project.json) resolves to the sender's own project. Closed projects are deliberately not filtered — their tmux sessions keep running, and a delivery goes to a pane, not to a canvas. `send`/`reply` are declared outside `needsLiveCanvas` so a delivery can never travel the camera: travelling would hijack the human's view on a background agent's say-so, and the `setActive` on the way would clear that node's unread badge — a message silently erasing the signal a human relies on (G5). The SELF-SEND half was already added to `decidePreProbe` in #207 and is not re-implemented here; it is verified by running it, including that the two guards produce the identical refusal so a trace cannot depend on which fired. Ships inert: `parseControlRequest` still refuses `send` and `reply` as unknown verbs, asserted by running it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d id could collide two conversations Review fixes for #208. FIX A — `sweep` now drops everything on a non-finite `now`. Every comparison in it is false for NaN, so an entry became immortal, and the two budgets then failed in OPPOSITE directions: the pair limiter fails open by accident (its `retryAfterMs` is NaN and `decidePreProbe` refuses on `> 0`) while a fan-out budget already at the cap returns a real 10_000 forever — a permanently silent sender, the exact failure the module's header claims not to have. Unreachable while `now` is `Date.now()`. NaN is the only value the guard is load-bearing for: +Infinity is already dropped by the staleness arm and -Infinity by the future arm, so swapping `!Number.isFinite` for `Number.isNaN` is an equivalent mutation, and the test says so instead of pretending to pin it. FIX B — `resolveDeliveryScope` refuses an id outside `isSafeNodeId`, as its own `unaddressable-node-id` reason rather than as `cross-project`: such an id can be listed in the sender's OWN project, so the scope word would be a false statement about the reason. Node ids come off `.nodeterm/project.json` and the load path validates nothing (`isSafeNodeId` is applied at `PtyManager.create()`, which is not that path), so this was the only place on the messaging path that asks. It closes two live holes: `pairKey` is injective only over ids that cannot contain its NUL separator (`pairKey('x'+NUL+'y','z') === pairKey('x','y'+NUL+'z')`, a 10 s throttle landing on the wrong conversation), and `sessionName()` sanitises rather than refuses, so declared `v/1` and `v_1` name ONE tmux session. FIX C — two false comments corrected, not coded around: 1. "the ambiguity never reaches the pane" was wrong. A session's only key is the bare node id (`byPersistKey`, `paneOwner(persistKey)`, tmux `nt-<nodeId>`); no project id appears in that namespace and nothing de-duplicates ids, so a project.json that merely lists another project's node id resolves `same-project` and lands in that node's one global pane. The comment now says this is a routing answer, not an isolation guarantee, and records that PR 5's delivery call must run `isSafeNodeId(targetNodeId)` before `paneOwner`. 2. The `needsLiveCanvas` rationale named the wrong half. Canvas routes by SOURCE, so the declaration stops a trip to the SENDER's project; never travelling to the TARGET's is `resolveDeliveryScope` having no live-node parameter. Both comments now say which guarantee is whose. Also documents the known cost the review measured: obeying the fan-out floor when `newTurn` never arrives costs 30 refused attempts before the backstop opens (TURN_STALE_MS / FANOUT_RETRY_AFTER_MS). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pull Bot
pushed a commit
to jasonkneen/nodeterm
that referenced
this pull request
Aug 15, 2026
…gacy path
The two messaging verbs exist end to end. At the route, `requiresVerified` in hook-server.ts
admits only a `verified` caller, checked on the VERDICT before identityGate's decision is
consulted: an invented kid is FOREIGN, therefore legacy, therefore never verified — the escape
that defeats the latch and the dated window does not defeat this — and
`settings.hookIdentityStrict: false` releases neither. There is no upgrade population to
protect, so the routes are fail-closed from day one, with a flat named refusal
("Agent messaging refused.") that offers no token or restart advice.
Canvas.tsx handles send/reply BEFORE its source-routing machinery — they are store-answered
(needsLiveCanvas false, pre-positioned), so a background orchestrator's message can never travel
the human's view or clear an unread badge on the way (G5) — validates the args and the source's
control capability, wraps the call in guardConcurrentRestart so a delivery cannot land inside a
wake's un-submitted resume line, and forwards to main.
Main's new agent-messaging service is the ONE caller of deliverAgentMessage (eneskirca#207): scope off the
serialized store (isSafeNodeId first), the per-project switch (constraint 11 — wired () => false
until PR 6 ships the validated field, so every delivery answers notPermitted switch-off), flow
control (eneskirca#208; only a write that reached the pane consumes budget), the deps record over
PtyManager/mirror/token files, the trace through the same board-log router the IPC handler uses,
and a rendered reply whose retry advice is sourced from RETRYABLE in words — stalled answers ok
with 'do not retry', because an ok:false stalled is how a message gets delivered twice.
Delivery transport: the envelope is framed by bracketedInjection in core and moved byte-for-byte
by a new framed paste plan (load-buffer stdin → gated copy-mode cancel → paste-buffer -d -r, no
-p, no separate Enter) — stdin keeps every payload off argv and off the remote shell line, and
assertFramedPayload makes the one unsanitized path refuse anything that is not bracketedInjection
output. agent-message.realtty.test.ts now drives this exact plan against a real tmux and a real
paste-aware bash. The #{bracket_paste_flag} probe is NOT reintroduced (pty-manager.ts's deleted
note stands); bracketPasteRequested is wired true with the residual named as a TODO.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull Bot
pushed a commit
to jasonkneen/nodeterm
that referenced
this pull request
Aug 15, 2026
…h the one delivery path PR eneskirca#98 (parsa222) designed `notify` as a fixed, app-authored prompt a canvas agent can send a linked agent, with the rule that made it safe: "The app owns the entire prompt so the source cannot inject instructions through command arguments." This commit SUPERSEDES eneskirca#98 rather than rejecting it: the verb, its `--node` requirement and its exact `--text` refusal ('notify does not accept --text') survive verbatim, and its 10-second per-pair throttle survives as the flow module's PAIR_MIN_INTERVAL_MS (eneskirca#208) — the same number, now shared with send/reply so one conversation has one window. What changed versus eneskirca#98's implementation: the body is substituted in MAIN (NOTIFY_BODY), so even a forged IPC request cannot put caller text into the envelope — the renderer's --text refusal is UX, the substitution is the boundary, and the test proves a hostile body never reaches the pane. Delivery goes through the identical deliverAgentMessage pipeline (identity gates, idle gate, pane probes, receipt, trace) instead of a bare sendText, notify is verified-only like its siblings, and the per-project switch governs it. eneskirca#98's "check your configured inbox" wording is dropped — the product has no inbox concept — in favour of pointing at the linked context the notified agent can actually read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eneskirca
added a commit
that referenced
this pull request
Aug 15, 2026
…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>
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.
PR 4 of the agent-messaging plan: flow control. It ships inert —
parseControlRequeststillrefuses
sendandreplyas unknown verbs (asserted by running it), so nothing in this PR isreachable by any agent until PR 5 lands the verbs. It builds directly on #207's
decidePreProbe,the 16-member outcome union, and
RETRYABLE.The pair limit is keyed by the PAIR, and the two alternatives each break something
checkPairRate(src, dst, now),PAIR_MIN_INTERVAL_MS = 10_000(#98's number, now enforced).The map key writes its separator as the six-character escape (backslash-u-0000), never a raw byte:
a raw NUL makes git, grep and ripgrep skip the entire source file in silence (commit 4c0e8c3,
twice).
What identifies a turn
checkFanOut(src, now),FANOUT_PER_TURN = 4, reset bynoteNewTurn(nodeId).A turn is the budget entry itself — created by the turn's first send, destroyed by the sender's
own
newTurn. That is the same edge the renderer's per-turn fan-out already uses (normalize.ts:"true only for a genuine new turn … so the renderer can clear per-turn fan-out without clearing on
every mid-turn tool event"). The three things that look like turn ids and are not:
workingtodone) flips several times inside one turn on a tool-eventburst. A cap that resets mid-turn is not a cap.
block wearing a budget's clothes.
one.
Only the SOURCE's own
newTurnresets it — if the target's did, every delivery would refill thebudget that sent it, because a delivery is what starts the target's turn.
Where the state lives, and which way it fails
Process memory, module scope, swept on read. Nothing persisted, deliberately:
10 s window and a full fan-out budget. Bounded, self-healing, invisible.
every path that could produce one fails OPEN:
the map grows looks correct to every test that reads through the same bound, and leaks);
would otherwise silence an agent for the length of the step);
TURN_STALE_MS(5 min) — so a shell that forgets to wire
noteNewTurnover-sends four messages per idle fiveminutes instead of silencing every sender for the rest of the run.
Both limits are pre-probe: they answer from a
Map, so a refusal costs no tmux round-trip and,on an SSH project whose ControlMaster has died, no real login. Asserted by running a delivery with a
probe that counts its calls (0 when rate-limited, 1 on the control). Both refuse with the existing
rateLimitedmember, soRETRYABLEalready answers for them. A fan-out refusal reports the pairinterval as
retryAfterMs— a floor, not a promise:0would be read as "no limit" bydecidePreProbe, whose refusal isretryAfterMs > 0, and the cap would silently stop existing.A check never starts a window. Only
noteSentrecords, and only a delivery that actually reacheda pane calls it. Otherwise a delivery refused as
targetBusy— a RETRYABLE outcome the caller wastold to come back from — would burn the budget it never used, and four such retries would silence
the sender for its whole turn. The cost of that choice: refusals themselves are unthrottled, which
is acceptable precisely because they are free.
Cross-project, resolved off the store
resolveDeliveryScope(projects, src, dst)takes the serialized projects array — the same onerouteControlSourceandstoredNodeListingread — and has no parameter for a live node list.That is the guarantee, and the test checks it by effect: a node the live canvas has but the store
does not is refused. It fails closed (a source in no known project, a target in no known
project, and a target in an unshared project are all
cross-project, which is not retryable),resolves a duplicate node id from a cloned
project.jsonto the sender's own project, and does notfilter closed projects — their tmux sessions keep running and a delivery goes to a pane.
send/replyare declared outsideneedsLiveCanvas, so a delivery can never travel the camera:travelling would hijack the human's view on a background agent's say-so and the
setActiveon theway would clear that node's unread badge — a message silently erasing the signal a human relies on
(G5).
The self-send half was already landed by #207 (
decidePreProbe, ahead of everything but anexplicit
notPermitted) and is not re-implemented here. It is verified by running it, includingthat both guards produce the identical
notPermitted{reason:'self-send'}so a trace cannot dependon which one fired, and that the backstop sits ahead of the rate limiter so a self-send is never
reported as a wait. The guard is exactly as strong as the plan describes; its only softness is that
it skips when either id is absent, which is documented in #207 and is the correct reading of
"absent" (a caller that supplies neither id has not made a self-send claim to check).
Three surfaces
src/coreships on both shells) with no consumer —messaging does not exist there, and Task 5.3 makes that a named terminal refusal rather than a
throttle-shaped one.
(Correction C1) and is covered like any other: the budget belongs to the pair, so two
orchestrators messaging one phone-spawned node get two independent windows.
Mutation results
24 mutations applied one at a time (revert, run, restore) against the five suites this PR touches.
24/24 killed. One SURVIVED on the first pass and is the false green: dropping the separator from
pairKeyentirely left the collision test green, because its id set contained no pair that collidesunder plain concatenation (
'a-b'+'c'is not'a'+'b-c'). Adding'ab'/'bc'— which do collide —makes it red. The test looked exhaustive (81 pairs, all distinct) and proved nothing about the thing
it was named for.
Also caught while writing this: the test file itself was written with raw NUL bytes in that same
test — the exact hazard the key's docblock warns about, reproduced by the tool writing the file.
Both files were scanned for raw NULs before commit; there are none.
Killed mutations, in groups:
pairKeywithout a separator; sweep without pair eviction; sweepwithout the clock-backwards guard; sweep without turn-budget expiry;
retryAfterMsreporting thewhole interval instead of the remainder; the fan-out cap off by one; the fan-out cap disabled;
noteSentforgetting the pair window;noteSentnot incrementing;noteNewTurnas a no-op;noteNewTurnresetting every sender;FANOUT_RETRY_AFTER_MS = 0;checkFlowLimitsforgettingeither half;
resetMessageFlowforgetting a map;flowStatsreturning a constant; the scoperesolver without its self-send arm, admitting any known target, resolving the owner off the target,
always claiming the target exists, and never refusing; #207's self-send backstop removed; and
sendor
replydropped from the store-answered set.One branch was deleted rather than kept:
checkFlowLimitsoriginally picked the larger of thetwo waits, and the "pair is larger" arm is unreachable while
FANOUT_RETRY_AFTER_MSequalsPAIR_MIN_INTERVAL_MS. It is now a fixed precedence (fan-out first) with a test pinning theconstant relationship that makes the precedence sound.
Tests
New:
agent-message-flow.test.ts(29),agent-message-scope.test.ts(15). Extended:controlRouting.test.ts(+1),canvas-control-core.test.ts(+1).npm run typecheckclean; fullnpx vitest run= 6199 passed, 4 failed — the 3node-pty-patchand 1webgl-addon-pairassertions that are environmental on this Linux host and fail identically on
origin/main.Deviation from the plan
Tasks 4.1 and 4.2 are one commit, not two: the sweep, the eviction contract and the failure
directions are shared between the two limits, and splitting the file down the middle would have
produced a first commit that could not state its own invariants.
Review fixes (commit 633a8e6)
FIX A — non-finite
now.sweepnow clears both maps whennowis not finite. Everycomparison in it is false for
NaN, so an entry became immortal and the budgets then failed inopposite directions: the pair limiter open by accident (
retryAfterMsisNaN, anddecidePreProberefuses on> 0), the fan-out cap closed forever once at the cap — a permanentlysilent sender, the failure the header claims immunity from.
NaNis the only value the line isload-bearing for;
+Infinityis dropped by the staleness arm and-Infinityby the future arm, so!Number.isFinitevsNumber.isNaNis an equivalent mutation (measured), and the test now saysthat instead of claiming to pin it.
FIX B —
isSafeNodeIdat the boundary.resolveDeliveryScoperefuses either id outside[A-Za-z0-9._-], ahead of the self-send arm, as its own reasonunaddressable-node-idratherthan
cross-project— such an id can be listed in the sender's own project, so the scope word wouldbe a false statement about the reason (new member on
NotPermittedReason;RETRYABLEis keyed byoutcome kind, so nothing else changes). This closes the separator collision in
pairKeyand thev/1vsv_1tmux fold. Did the widened id set kill the separator mutation? No — and it couldnot: with a NUL separator only a NUL-bearing id can collide, and no id in that 81-pair set can
contain one. The false green did move one layer down, exactly as the review said. It is now killed
by an explicit test that exhibits the collision (built with
String.fromCharCode(0), so no escapesurvives a file write) and then pins the validator that makes the premise true.
FIX C — the two false comments. Corrected in place, not coded around: the duplicate-id note now
says it is a routing answer and not an isolation guarantee (sessions key on the bare node id, so a
project.jsonlisting another project's id lands in that node's one global pane), and records thatPR 5's delivery call must run
isSafeNodeId(targetNodeId)beforepaneOwner. TheneedsLiveCanvasrationale now names the right half: Canvas routes by SOURCE, so the declarationstops a trip to the sender's project; never travelling to the target's is
resolveDeliveryScopehaving no live-node parameter. Both files cross-reference each other so PR 5 cannot inherit the
wrong model.
Also added: the fan-out floor's known cost (30 refused attempts before the backstop opens when
newTurnnever arrives), and a note thatScopeProject.nodesis required — a synthesised arraywithout it throws rather than failing closed, which is the caller's to own.
Mutations after the fixes: the original 24 re-run and all still red; 8 new ones on the fixed
lines, 7 red (guard removed, guard clears only one map, validator removed, validator checks only the
source, only the target, reports
cross-project, runs after the self-send arm) and 1 equivalent(
Number.isFinitetoNumber.isNaN), documented in the code rather than papered over. 31 killed,1 equivalent, 0 survivors.
Tests:
agent-message-flow.test.ts33,agent-message-scope.test.ts36,controlRouting.test.ts13,canvas-control-core.test.ts25. Full run 6223 passed, same 4environmental failures.
🤖 Generated with Claude Code