Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,33 @@
property, about 80 bytes per tool, but the profile surcharge measured the
schemas without it. It now measures the schemas as that connection is served
them. (#515)
- **A shared connection refuses a write under an identity nobody declared.**
When several conversations shared one `plumb serve`, a state-changing call
carrying a per-call identity that no `session_start` on that connection had
declared (a model typing `plumb_agent: "my-session"`, or a client sending
`_meta` it never announced) was admitted. It got a fresh per-agent state
seeded from the connection's workspace, so a relative write landed in whatever
checkout the connection held, often another agent's. Now such a call is
refused, and the refusal says to call `session_start` first, with the
`workspace` the agent works in so that declaring does not leave it on another
agent's checkout. Reads are never refused. A connection used by one
conversation only (a main thread and its own subagents, as in the Claude Code
CLI) needs no declaration. A subagent stamped
`<conversation>/<agent>` is admitted on its conversation's declaration and
works in its conversation's workspace rather than the connection's. It starts
there, and it follows when its conversation later moves itself to another
workspace, unless the subagent chose a workspace of its own. So a subagent of
an agent working in a worktree no longer writes into the main checkout. If
such a subagent moves the connection's pin (`scope: "connection"`), it stays
on its conversation's workspace, and `session_start` now says so rather than
naming the connection's new root as where its relative paths go.
Declarations are saved under the proxy session and restored when it
reconnects, including after a daemon restart. One is reclaimed only when the
idle reaper finds its `plumb serve` disconnected and the declaration older
than `[session] persist_state_ttl_minutes` (24 hours by default), and each
state-changing call refreshes it at most once per quarter of that (at most an
hour). With `persist_state` off, or after such a reclaim, an agent is refused
once and declares again with `session_start` (#513).
- **A connection that closes mid-attach no longer leaks a language server,
and a burst of roots notifications settles on the newest roots.** When
a client reported a workspace change (`notifications/roots/list_changed`)
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ Rung 1 outranks client roots because it is the workspace the caller chose, not w

**Single-workspace-per-connection contract.** Once a connection has attached a workspace, every path-bearing tool refuses paths outside the connection's allowed roots with a `workspace boundary violation` error (the allowed set is the workspace plus `extra_roots` read-write and `read_roots` / Go dependency roots read-only). `rename_symbol` also boundary-checks each output URI before applying. To switch projects, call `session_start` with an explicit `workspace`: a deliberate `workspace` arg re-pins the connection (re-attaching LSP/topology/quality/config) rather than being refused — clients may reuse one `plumb serve` across chats, so a fresh chat is not a fresh connection. The pin is **sticky once explicit** (issue #182): after a pin was set by `session_start`, a conflicting re-pin to a different project is refused unless the caller passes `force: true`, and a `roots/list_changed` that drops the pinned root can no longer move it — a peer agent multiplexed over the same `plumb serve` connection must not silently steal another agent's workspace (the refusal error names the `force: true` remediation, so a deliberate switch still self-heals; a roots- or auto-attach-held pin is not sticky, so the first explicit pin always lands). A connection that hits a violation is marked `Health: blocked` for the TUI, as is a refused sticky re-pin (cleared by the next successful explicit `session_start`, same-root or forced). `git`'s `repo` arg defaults to the pinned workspace when omitted.

**Shared connections key the pin per logical agent.** When a client identifies its agents (per-call `_meta` or the runtime-stamped argument on every call; `session_start.session_id` identifies only that call), each gets its own shard: its own pin, read/write trackers, undo store, rate budget, LSP routing and workspace-wide diagnostics/symbol aggregates. Three consequences are refusals a caller can meet. A shard seeded from the connection's pin is correctable only within the same tree; an unrelated workspace is refused, and the refusal carries `kind: pin_refused` with `details.scope = "agent"` — where `force: true` moves that agent's shard alone. A shard that has never chosen a workspace and is refused one it declared is RECORDED, and that agent's path-bearing calls are then refused by name until the declaration lands, so a relative path or a defaulted `git` repository cannot resolve inside the seeded root (another conversation's workspace); `session_start` is deliberately not behind that gate, so the remedy stays reachable. A refusal at `details.scope = "connection"` is a different animal: there `force: true` moves the pin every agent on the connection resolves against, so a client must surface it rather than retry it automatically.
**Shared connections key the pin per logical agent.** When a client identifies its agents (per-call `_meta` or the runtime-stamped argument on every call; `session_start.session_id` identifies only that call), each gets its own shard: its own pin, read/write trackers, undo store, rate budget, LSP routing and workspace-wide diagnostics/symbol aggregates. A shard is served to a state-changing call only when the identity's conversation half has declared itself through a successful `session_start` on the connection (a hook-stamped `<conversation>/<agent>` rides its conversation); an undeclared identity is refused with that remedy instead of being handed a shard seeded from the connection's root (#513), except while every identity on the connection shares one conversation. A subagent's shard is seeded from its conversation's chosen root (its shard, or its persisted pin after a restart), follows its conversation when the conversation re-pins itself, is not dragged by a connection move while it sits on its conversation's chosen root, and inherits its conversation's refused declaration; a subagent that chose a root of its own keeps it. Whether a connection move drags a shard is one predicate (`followsConnectionLocked`), shared by the move and by `session_start`'s report of where the mover now resolves, so the two agree on which shards follow; on a connection's first pin there is no previous root to drag from, so a fresh shard is left at no workspace (#567). Declarations are persisted under the proxy session and restored on reconnect, including across a daemon restart. The idle reaper reclaims one only when it finds the serve disconnected and the declaration older than `persist_state_ttl_minutes`; the conversation's admitted state-changing calls refresh it at most once per min(TTL/4, 1 h). One so reclaimed, or any with `persist_state` off, must declare again. Three consequences are refusals a caller can meet. A shard seeded from the connection's pin is correctable only within the same tree; an unrelated workspace is refused, and the refusal carries `kind: pin_refused` with `details.scope = "agent"` — where `force: true` moves that agent's shard alone. A shard that has never chosen a workspace and is refused one it declared is RECORDED, and that agent's path-bearing calls are then refused by name until the declaration lands, so a relative path or a defaulted `git` repository cannot resolve inside the seeded root (another conversation's workspace); `session_start` is deliberately not behind that gate, so the remedy stays reachable. A refusal at `details.scope = "connection"` is a different animal: there `force: true` moves the pin every agent on the connection resolves against, so a client must surface it rather than retry it automatically.

## Persistence layout

Expand Down
11 changes: 10 additions & 1 deletion docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,16 @@ shares one pin unless each call carries a logical-agent identity — per-call
Code's PreToolUse hook). `session_start.session_id` identifies only that call.
A client that sends none still shares one pin, and its anonymous
state-changing calls are refused once two identities have been seen — see
[Known gaps](#known-gaps).
[Known gaps](#known-gaps). A per-call identity whose conversation never
declared itself through `session_start` on the connection is refused the same
way rather than given a fresh shard of the connection's root (#513), unless
every identity on the connection belongs to one conversation. Declarations
persist under the proxy session and survive a daemon restart that the serve
reconnects across. The idle reaper reclaims one only when it finds the serve
disconnected and the declaration older than `persist_state_ttl_minutes`;
state-changing calls refresh it at most once per min(TTL/4, 1 h). With
`persist_state` off, or after such a reclaim, the agent is refused once and must
call `session_start` again.

### A2 — Path escape via alias or traversal

Expand Down
9 changes: 8 additions & 1 deletion docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,14 @@ key, or `plumb_agent`, placed by a client **runtime** as a top-level key inside
`plumb hooks install claude-code` PreToolUse hook is the emitter — it stamps
every `mcp__plumb__*` call and also fills `session_id` on this tool); and
`session_id` itself, which identifies this call only: on a connection other agents
share, a later write without a per-call identity is refused. Pass a stable value per agent:
share, a later write without a per-call identity is refused. It is also the
declaration a per-call identity needs there: a state-changing call whose
conversation half no successful `session_start` on the connection has declared
(its `session_id`, or the per-call identity it ran under) is refused with that
remedy, plus `workspace` so the declared agent is not left on the connection's
root, unless every identity on the connection belongs to one conversation. A
subagent stamped `<conversation>/<agent>` rides its conversation's declaration
and works in its conversation's workspace, following it when it re-pins. Pass a stable value per agent:
the conversation id for a main thread, `<conversation>/<agent>` for a subagent.
The session **record** — the name mail is addressed to, `plumb mail
--external-id`, name inheritance on resume — is linked to the conversation half,
Expand Down
9 changes: 7 additions & 2 deletions internal/cli/boundary_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,17 @@ func (s *connSession) checkBoundaryFor(ctx context.Context, path string, want to
// this can never block the call that clears it.
func (s *connSession) declarationRefusedErr(ctx context.Context) error {
id := mcp.LogicalAgentFromCtx(ctx)
p, ok := s.pendingDeclarationFor(id)
p, inherited, ok := s.pendingDeclarationForCall(ctx)
if !ok {
return nil
}
whose := "its session_start"
if inherited {
// A subagent inherits its conversation's refused declaration (#513).
whose = fmt.Sprintf("its conversation %q's session_start", linkageIDOf(id))
}
return toolerror.Wrap(
fmt.Errorf("refusing this call for logical agent %q: its session_start naming %s was refused, so the agent is still on %s — a workspace it never chose, and possibly another conversation's. A path-bearing call here would quietly operate on that project. Re-issue session_start with workspace + session_id + force: true (on a shared connection force moves only THIS agent's shard), then retry", id, p.requested, p.sittingOn),
fmt.Errorf("refusing this call for logical agent %q: %s naming %s was refused, so the agent is still on %s — a workspace it never chose, and possibly another conversation's. A path-bearing call here would quietly operate on that project. Re-issue session_start with workspace + session_id + force: true (on a shared connection force moves only THIS agent's shard), then retry", id, whose, p.requested, p.sittingOn),
toolerror.KindWorkspaceBoundary,
toolerror.ClassPassForce,
toolerror.WithTool("session_start"),
Expand Down
80 changes: 16 additions & 64 deletions internal/cli/conn_agent_shard.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ type agentShard struct {
// "did this agent choose its root?" must accept either — see the
// declaration-refusal marker in repinAgent.
restored bool
// parentSeeded: seeded from its conversation's PERSISTED pin (parent not in
// memory), so a connection move must not drag it (#513).
parentSeeded bool

// rosterID is the session.Info registered for THIS agent, so the workspace
// it actually works in lists it (issue #472). Empty until the agent holds a
Expand Down Expand Up @@ -111,6 +114,12 @@ func (s *connSession) shardFor(ctx context.Context) *agentShard {
writeLimiter: tools.NewRateLimiter(s.store.Current().Edits.RateLimitPerMinute, time.Minute),
pinOrigin: v.pinOrigin,
}
// A hook-stamped subagent starts where its CONVERSATION chose to work, not
// where the connection happens to sit (issue #513 review). Seeding it from
// the connection pin sent a subagent of a parent that had re-pinned itself
// to a worktree into whichever checkout the connection held — another
// agent's — the exact misroute the declaration gate exists to prevent.
s.seedFromParentLocked(sh)
// Restore a pin this agent persisted before the restart (PLAN-286): it takes
// precedence over the connection's current pin. A pin that no longer verifies
// is ignored, so the shard keeps the connection's root rather than resurrecting
Expand Down Expand Up @@ -174,7 +183,7 @@ func (s *connSession) buildAgentPolicy(root, language string) *tools.PathPolicy
// back to the connection's pin when the connection is not shared (or the call is
// unattributed). workspace() stays the ctx-less default for background goroutines.
func (s *connSession) workspaceFor(ctx context.Context) string {
if _, pending := s.pendingDeclarationFor(mcp.LogicalAgentFromCtx(ctx)); pending {
if _, _, pending := s.pendingDeclarationForCall(ctx); pending {
// A refused declaration leaves nothing trustworthy to anchor to: the
// shard's root is one this agent explicitly tried to leave. "" makes the
// implicit resolvers — relative paths, git's default repository,
Expand Down Expand Up @@ -302,11 +311,15 @@ func (s *connSession) repinAgent(ctx context.Context, root, language string, ori
// agent then blocks on shardsMu behind it — all waiting on one agent's disk
// I/O. Registered before the unlock defer so LIFO runs it AFTER sh.mu is
// released, and it re-takes the lock itself.
var syncRoot, syncLang string
var syncRoot, syncLang, movedFrom string
defer func() {
if syncRoot != "" {
s.syncAgentRoster(sh, syncRoot, syncLang)
}
// After sh.mu is released: it takes shardsMu, then each shard's mu.
if movedFrom != "" {
s.followParentShard(sh.id, movedFrom)
}
}()
sh.mu.Lock()
defer sh.mu.Unlock()
Expand Down Expand Up @@ -402,6 +415,7 @@ func (s *connSession) repinAgent(ctx context.Context, root, language string, ori
// used to wipe an agent's dirty-guard writes and undo history (PLAN-428).
// The connection path keeps them under the same rule.
if root != prev {
movedFrom = prev
sh.readTracker.Reset()
sh.writeTracker.Reset()
sh.undoStore.Reset()
Expand Down Expand Up @@ -443,68 +457,6 @@ func (s *connSession) seedShardOnLink(linkage string) {
sh.readTracker.Hydrate(s.readTracker.Records())
}

// followConnectionShards re-seeds every shard that never chose a workspace of
// its own (!selfPinned) from the connection's NEW pin, after the connection
// itself moved away from prevRoot. A shard is seeded from the connection pin at
// first use — shardFor caches it BEFORE repinAgent can refuse, so one refused
// ask left the agent cached at a root whose sticky seed then refused the
// agent's next, entirely legitimate call (the exact PLAN-398 reproduction),
// while a fresh agent asking the same thing succeeded: the fresh shard seeded
// from the CURRENT pin, the stale one had not followed. Re-seeding here restores
// the invariant "a seeded shard sits where the connection sits" without
// touching shards whose agent deliberately pinned elsewhere — per-agent
// isolation means the connection's move cannot drag an agent that chose its own
// root. Runs OUTSIDE the connection mutate lane, in the documented lock order
// (shardsMu before sh.mu, s.mu innermost), so the per-tool-call hot path's lock
// pattern is unchanged; the writes mirror repinAgent's success path, held under
// one sh.mu acquisition each.
//
// Returns the ids of the agents whose shards followed, so session_start can
// tell the caller how many other agents its connection move took with it
// (issue #517).
func (s *connSession) followConnectionShards(prevRoot string) (followed []string) {
if prevRoot == "" {
return nil
}
v := s.view()
if v.acquiredRoot == "" || v.acquiredRoot == prevRoot {
return nil
}
s.shardsMu.Lock()
defer s.shardsMu.Unlock()
for _, sh := range s.shards {
sh.mu.Lock()
if sh.selfPinned || sh.root != prevRoot {
sh.mu.Unlock()
continue
}
sh.root = v.acquiredRoot
sh.language = v.acquiredLanguage
sh.pinOrigin = v.pinOrigin
// The connection landed on the root this agent asked for, so what its
// refusal was about is now simply true; holding the gate would refuse
// calls that are safe again.
if p, ok := s.pendingDeclarationFor(sh.id); ok && p.requested == sh.root {
s.clearDeclarationRefused(sh.id)
}
sh.policy = s.buildAgentPolicy(sh.root, sh.language)
sh.readTracker.Reset()
sh.writeTracker.Reset()
sh.undoStore.Reset()
// Copy what the calls below need while the lock is still held.
// shardsMu does not exclude repinAgent — that takes sh.mu alone — so
// reading sh.root/sh.language after the unlock would race a concurrent
// per-agent re-pin and could persist a root this shard no longer has.
// Same rule persistReadShard states: the shard's root is read under sh.mu.
root, language := sh.root, sh.language
sh.mu.Unlock()
followed = append(followed, sh.id)
s.rehydrateReadsForAgent(sh, root)
s.persistPinForAgent(sh, root, language, v.pinOrigin)
}
return followed
}

// persistReadShard mirrors a per-agent recorded read to the durable store, keyed
// by (proxy session ID, logical-agent ID, workspace) so a shared connection's
// per-agent reads survive a daemon restart. The shard's root is read under
Expand Down
Loading
Loading