fix(identity): refuse a shared-connection write under an undeclared identity - #535
Merged
Merged
Conversation
golimpio
pushed a commit
that referenced
this pull request
Sep 30, 2026
…ion works B1: a hook-stamped subagent <conv>/<x> is admitted on its conversation's declaration, but shardFor seeded its shard from the CONNECTION pin, so a subagent of a parent that had re-pinned itself to a worktree wrote into another agent's checkout. The shard is now seeded from the parent's shard when the parent chose or restored a root, or from the parent's persisted pin after a restart, and inherits the parent's refused declaration until it chooses a root of its own. Also from the review: - a connection whose identities all share one linkage (a main thread that never called session_start plus its own subagents) is admitted; another linkage breaks that and is refused; - the after-tool declaration is exercised through the desktop harness with a _meta-only session_start that carries no session_id; - an admitted state-changing call refreshes (UPDATE only) its declared linkage's row, and the restart limits are documented honestly; - the refusal says when the session_id it names is the conversation's; - session_start validates `detail` before it links, so a failing call declares nothing; - a degraded identity recovery restores declarations when it converges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
golimpio
pushed a commit
that referenced
this pull request
Sep 30, 2026
…ion works B1: a hook-stamped subagent <conv>/<x> is admitted on its conversation's declaration, but shardFor seeded its shard from the CONNECTION pin, so a subagent of a parent that had re-pinned itself to a worktree wrote into another agent's checkout. The shard is now seeded from the parent's shard when the parent chose or restored a root, or from the parent's persisted pin after a restart, and inherits the parent's refused declaration until it chooses a root of its own. Also from the review: - a connection whose identities all share one linkage (a main thread that never called session_start plus its own subagents) is admitted; another linkage breaks that and is refused; - the after-tool declaration is exercised through the desktop harness with a _meta-only session_start that carries no session_id; - an admitted state-changing call refreshes (UPDATE only) its declared linkage's row, and the restart limits are documented honestly; - the refusal says when the session_id it names is the conversation's; - session_start validates `detail` before it links, so a failing call declares nothing; - a degraded identity recovery restores declarations when it converges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
golimpio
pushed a commit
that referenced
this pull request
Sep 30, 2026
…sation A subagent's shard was seeded from its conversation only at creation. One that had made any call while its conversation was still on the connection seed (a background or continued subagent) stayed there when the conversation then re-pinned itself, so its relative writes and git's default repository stayed in another agent's checkout. repinAgent now re-seeds, after the unlock, every <conv>/* shard still on the old root that never chose or restored one (followParentShard). A connection-scope move no longer drags a subagent sitting on its conversation's chosen root (followConnectionShards skips a parent-seeded shard, and one whose parent chose or restored its root; the parent's flag is read before the child is locked). Also: a failed declaration refresh releases its claimed slot; tests pin one DB touch per refresh interval and the legacy-heal retry's restore; the CHANGELOG and docs state the subagent behaviour and the refresh slack precisely. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
golimpio
force-pushed
the
atlas/fix-513-undeclared-identity
branch
from
September 30, 2026 21:25
dfa557e to
62b1235
Compare
…dentity On a connection shared by several logical agents, a state-changing call carrying a per-call identity that no session_start had declared was admitted, handed a fresh shard seeded from the connection's root, and its relative write landed in the connection's workspace (#513). The gate now also refuses, on a shared connection counting the caller, an identity whose linkage (the conversation half) was never declared through a successful session_start: its session_id, or the per-call identity it ran under. A hook-stamped <conv>/<agent> rides its conversation's declaration. Declarations persist under the proxy session (sessionstate v9, declared_linkage) and are restored on reconnect, together with the identity record's own linkage, so a restart does not refuse an agent that declared. Closes #513 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion works B1: a hook-stamped subagent <conv>/<x> is admitted on its conversation's declaration, but shardFor seeded its shard from the CONNECTION pin, so a subagent of a parent that had re-pinned itself to a worktree wrote into another agent's checkout. The shard is now seeded from the parent's shard when the parent chose or restored a root, or from the parent's persisted pin after a restart, and inherits the parent's refused declaration until it chooses a root of its own. Also from the review: - a connection whose identities all share one linkage (a main thread that never called session_start plus its own subagents) is admitted; another linkage breaks that and is refused; - the after-tool declaration is exercised through the desktop harness with a _meta-only session_start that carries no session_id; - an admitted state-changing call refreshes (UPDATE only) its declared linkage's row, and the restart limits are documented honestly; - the refusal says when the session_id it names is the conversation's; - session_start validates `detail` before it links, so a failing call declares nothing; - a degraded identity recovery restores declarations when it converges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sation A subagent's shard was seeded from its conversation only at creation. One that had made any call while its conversation was still on the connection seed (a background or continued subagent) stayed there when the conversation then re-pinned itself, so its relative writes and git's default repository stayed in another agent's checkout. repinAgent now re-seeds, after the unlock, every <conv>/* shard still on the old root that never chose or restored one (followParentShard). A connection-scope move no longer drags a subagent sitting on its conversation's chosen root (followConnectionShards skips a parent-seeded shard, and one whose parent chose or restored its root; the parent's flag is read before the child is locked). Also: a failed declaration refresh releases its claimed slot; tests pin one DB touch per refresh interval and the legacy-heal retry's restore; the CHANGELOG and docs state the subagent behaviour and the refresh slack precisely. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… move Round-2 confirmation of #535: the `sh.root != prevRoot` check in followParentShard was not equivalent, as had been claimed. A forced connection move racing the parent's own move could drag the subagent off prevRoot after followConnectionShards read parentChose=false. The check then skipped it, stranding the subagent on the connection's new root (possibly another agent's workspace) while its parent sat elsewhere. The reviewer reproduced this 3 of 10 times with the window widened; with the check dropped, 10 of 10 converged and the integration suite passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
golimpio
force-pushed
the
atlas/fix-513-undeclared-identity
branch
from
September 30, 2026 22:11
62b1235 to
b15efd2
Compare
review) Merged with #533, session_start's report of a connection-scoped move could name a workspace the caller does not use. The report decided "follows the connection" from the shard's own flags, while followConnectionShards also leaves a subagent on its conversation's chosen root. A subagent of a conversation working in a worktree that moved the connection was told its relative paths now went to the new root; they still went to the worktree. followsConnectionLocked is now the one predicate, used by the move and by the report, with the parent's flag read under shardsMu before the shard is locked. The report also does what the caller's next call does: a subagent with no shard yet is seeded from its conversation's root, and an inherited refused declaration reports nothing, as workspaceFor resolves. A restored shard is still dragged off the connection's previous root as before; whether it should be is #527's question. The merge with #533 had taken conn_agent_shard.go to 610 lines, so the move and the predicate now live in conn_agent_shard_follow.go. Also from the review: - N2: since #550 nothing is pruned at daemon start and the reaper spares connected serves, so the code, test and docs no longer say a restart ages declarations out. The refresh stays: it covers a reaper pass that finds the serve between connections, costs one throttled UPDATE, and touches no evidence that anything windows by. - N3: BackdateSession now ages declared_linkage, and seedAgedSession seeds one, so the start-up and reaper retention tests cover it. - N5: the undeclared-identity refusal also asks for workspace, since declaring alone can leave the agent on another agent's checkout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: gentle-cobra
Brings in #555 (per-agent task config). No conflicts. Plumb-Session: golden-moose
…t settle The confirming review of #535 found two claims stronger than the code. "The report cannot disagree with the move" holds for WHICH shards follow, not for every outcome: on a connection's first pin the move has no previous root to drag from, so a fresh shard stays at "" while the report names the new root (filed as #567). "The one window left" omitted a second, report-only window: the conversation self-pinning between the parentChose read and the shard read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: crisp-eagle
golimpio
approved these changes
Oct 1, 2026
golimpio
left a comment
Contributor
There was a problem hiding this comment.
Taken over to merge at the owner's request. Review rounds: the author's own two, my round with one blocker (B1, a report/reality mismatch), the takeover fix, and a confirming round with no blockers that included lock-order race stress and mutants. Comment and doc overclaims were softened; the first-pin case is filed as #567.
atlas-from-plumb
enabled auto-merge
October 1, 2026 05:05
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.
Why
On a connection shared by several logical agents, a state-changing call carrying a per-call identity that no
session_starthad declared was admitted. It might be a model typingplumb_agent: "my-session", or a client sending_metait never announced.refuseadmitted any non-empty id, andshardFormade the id a fresh shard seeded from the connection's root, so a relative write landed in whatever checkout the connection held. That is the misroute (#512) the fail-closed ceiling exists to prevent.Change
internal/cli/conn_logical_agent.go,refusal). On a connection that is shared once the caller is counted, a state-changing call is refused when its identity's linkage (linkageIDOf, the conversation half) was never declared through a successfulsession_start. This is the samesharedWithpredicateshardForroutes on, so the gate refuses exactly the calls that would be given a per-agent shard. The undeclared refusal has its own message: callsession_startwith thatsession_idfirst.session_startis not state-changing, so that step is always reachable. Reads are never refused. A single-agent connection is unaffected.internal/cli/conn_logical_agent_declared.go).session_start'ssession_idcounts as a declaration (vialinkExternalID, sorecordAttachdeclares). So does the per-call identity that a successfulsession_startran under (after-tool hook). A failedsession_startdeclares nothing, and no other tool does. A hook-stamped subagent<conv>/<agent>is admitted on its conversation's declaration.declared_linkage, restored inonProxySession). Declarations persist under the proxy session and come back on reconnect. So does the identity record's own linkage, which Prune never reclaims. Restoring can only admit more, never refuse more, so it cannot cause the kind of lockout that led toseedLogicalAgentsFromStatebeing unwired.logical_agentrows are deliberately not used as a source: they record every observed identity, so an invented id that made one admitted read would come back declared. Known limit, documented in the code: a declaration older than the TTL that a restart pruned, from a conversation that is not the identity record's linkage, has to re-declare. The refusal tells it how.tools.md,architecture.md,threat-model.md) and a CHANGELOG entry.Tests
conn_logical_agent_declared_test.go):<declared-conv>/xare admitted, while<undeclared>/xis refused;session_startdeclares;desktop_connector_identity_integration_test.go,InventedIdentityIsRefusedNotMisrouted). Two agents are declared, and a thirdplumb_agentid's relative write is refused, lands in neither checkout, and leaves no identity behind. Controls: the declared agent's write lands in its own worktree, and a subagentconv-x/subis admitted and recorded. Callingsession_startthen admits the invented id.INVENTED.mdin the main checkout. A gate test built on the origin/main API fails the same way.len(seen)>1predicate; keying on the full id; a failedsession_startor any tool declaring; restore not wired; each restore source removed; the declaration not persisted; the attach channel not declaring; Prune keeping rows; restoring fromlogical_agent; a refused call recording its identity; the v9 migration missing. All 14 went red.go test ./... -count=1andgo test -tags=integration ./internal/cli/both exit 0. The changelog placement check passes.Review fold-in (dfa557e)
shardForseeded<conv>/<x>from the connection pin, so a subagent of a parent that had re-pinned itself to a worktree wrote into another agent's checkout. It is now seeded from the parent's shard when the parent chose or restored a root, or from the parent's persisted pin after a restart. It also inherits the parent's refused declaration until it chooses a root of its own. This applies to relative paths and to git's default repository (workspaceFor). The desktop control now usesconv-y/suband checks that its write lands in conv-y's worktree.session_start, plus its own subagents (the Claude Code CLI topology). Such a connection is admitted. An identity with another linkage breaks this and is refused.OnAfterTool. A_meta-onlysession_startwith nosession_idthen declares its identity.persist_state_ttl_minutes, and not at all withpersist_stateoff.session_idto declare.session_startdeclares nothing.session_startvalidatesdetailbefore linking, so a call that fails on a baddetailno longer declares.Round-2 fold-in (62b1235, rebased on main)
repinAgentmoves a conversation,followParentShardruns after the unlock. It re-seeds every<conv>/*shard still on the old root that never chose a root of its own and was not restored from its own saved pin. The new root applies to relative paths and git's default repository alike.followConnectionShardsno longer drags a subagent sitting on its conversation's chosen root. It checks the parent's flag, read before the child is locked, or, if the parent is not in memory after a restart, a flag the child recorded when it was seeded from the parent's persisted pin. No path ever holds two shards' locks at once.root != prevRootcheck infollowParentShard. I believe that mutant is equivalent: a subagent that never chose a root always sits on its conversation's previous root. The code comment says the check is defensive.Taken over (review of the merge with #533)
The original session went idle, so a second session took over the PR. Main is merged in three times, by merge commits with no rebase: #533 and #550, then #534 and #557, then #555. None of those merges conflicted.
B1: the connection-scope report could name the wrong workspace. fix(session_start): report which pin a re-pin moved (#517) #533's
connScopeCallerRootdecided whether the caller follows the connection from the shard's own flags (selfPinned,restored). This PR'sfollowConnectionShardsalso leaves a subagent on its conversation's chosen root (parentSeeded, or the parent's flag). So a subagent of a conversation working in a worktree could move the connection withscope: "connection"and be told its relative paths now went to the new root. They still went to the worktree. The fix isfollowsConnectionLocked, now the single predicate used by both the move and the report. It reads the parent's flag undershardsMubefore the shard's lock is taken, so no path nests two shards' locks. The report now also does what the caller's next call would do:workspaceForresolves.A restored shard is still dragged off the connection's previous root, as on main. Whether it should be is Pins no agent chose are persisted (followConnectionShards) or replayed (agent-scope session_start) #527's question, and this PR leaves it unchanged. The move and the predicate now live in
conn_agent_shard_follow.go, because the fix(session_start): report which pin a re-pin moved (#517) #533 merge had takenconn_agent_shard.goto 610 lines. With fix(roots): no attach after close; coalesce roots/list_changed (#514) #534, a move on a closed connection fails inattachOrRepinTo(errConnClosed) before the report runs.N2: wording since fix(daemon): stop pruning session state at start-up #550. Nothing is pruned at daemon start any more, and the reaper spares connected serves. The code comments, the test comment, the CHANGELOG,
architecture.mdandthreat-model.mdtherefore no longer say that a restart ages declarations out. A declaration is reclaimed only when a reaper pass finds the serve disconnected and the row older than the TTL. The refresh is kept. It covers a pass that catches the serve between connections. It costs one throttled UPDATE per linkage, and it skews nothing: no query windows bydeclared_linkage.updated_at. The "Known limit" in the Restart bullet above is superseded by this.N3.
Store.BackdateSessionnow agesdeclared_linkage.seedAgedSessionandexpendableRowsseed and count a declaration, so the fix(daemon): stop pruning session state at start-up #550 start-up and reaper tests cover the table. The reaper test fails if the table is left out (mutant M7).N5. The undeclared-identity refusal now also asks for
workspace, because declaring alone can leave the agent on another agent's checkout.tools.mdand the CHANGELOG say the same.Tests
conn_repin_scope_subagent_test.gohas three cases, a positive control, a persisted-parent case and a rendered check. Each case moves the connection from a subagent and asserts that the report's next root equalsworkspaceFor. The cases are: on the conversation's chosen root (the reviewer's repro), no shard yet, a refused declaration inherited from the conversation, and seeded from the conversation's persisted pin. A positive control covers a conversation that never chose a root, where the subagent follows. A rendered end-to-end check usessession_startitself. All of these were red before the fix: four mismatches plus the rendered line. The control was green.parentSeededor the parent's flag; no inherited-declaration check; no shard materialisation; dropping the restored exception;BackdateSessionwithoutdeclared_linkage; and the refusal withoutworkspace. Two were also run through plumb'smutation_testover all of./internal/cli/, and both were killed.go test ./... -count=1,go test -tags=integration ./internal/cli/,-race(-count=5) on the report, follow and subagent tests,golangci-lint run ./..., andmake check-size check-brief check-changelog check-changelog-placement. They ran withGOWORK=off,GOTMPDIR=.testcacheand a privateXDG_DATA_HOME.CI
The previous head's
integration (macos-latest)failure wasTestProjectWatchManager_PlumbDirSwapKeepsWatching. That test belongs to theTestProjectWatchManager_*family, which flakes on macOS runners independently of this PR. The same family failed in runs 36698756418 (main), 36797533087 (#534), 34302623228 and 34069246977. This PR touches no watcher code, and a separate fix for the family is in progress.Out of scope here, still open: #527 parts 1 and 2.
Closes #513
🤖 Generated with Claude Code