Skip to content

fix: sub-agent memory tool writes through the scoped store - #908

Merged
Weegy merged 2 commits into
mainfrom
fix/subagent-memory-scoping
Aug 27, 2026
Merged

fix: sub-agent memory tool writes through the scoped store#908
Weegy merged 2 commits into
mainfrom
fix/subagent-memory-scoping

Conversation

@Weegy

@Weegy Weegy commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Refs #904, part of #860. Closes nothing.

A sub-agent that has been granted the native memory tool resolved its handler out of the process-wide NativeToolRegistry. That entry belongs to the memory provider plugin (@omadia/memory, @omadia/memory-postgres) and is bound to the undecorated root store — the one below every scoping wrapper. Reached that way, a sub-agent reads and writes outside its parent agent's orchestrator:<slug>:* subtree, and, with the chat-context ACL from #881 enabled, outside its team's and channel's tiers as well.

The per-agent boundary is the part that makes this worth its own issue: it is not a gap in new functionality, it predates the memory epic, and granting a sub-agent the memory tool is ordinary operator configuration.

The red test, first — and what it actually showed

test/orchestrator/subAgentMemoryScoping.test.ts drives a REAL turn in the style of contextMemoryTurnBinding.test.ts (#903): the parent Orchestrator delegates to a real LocalSubAgent, which calls the granted tool through its own tool loop, and the assertion reads the physical path at the undecorated root. Every decorator sits above that recorder, so what arrives there is what actually hit storage.

Against origin/main, all five cases were red — in two different ways, and the difference matters:

✖ a spec-carrying `memory` registration is not a way around it
  AssertionError: sub-agent wrote to the UNDECORATED root — the scoping wrapper was skipped
    actual: '/memories/note.md'

✖ with context memory OFF the write stays inside the agent tree
  AssertionError: the sub-agent produced NO write at all — the granted memory tool never reached storage
    actual: 0

The first line is the vulnerability, reproduced by a real turn: the bytes landed at /memories/note.md — the raw store root, outside /memories/orchestrators/w5-agent/ entirely. Any other agent's tree is a sibling of that path.

The second line is the finding the issue did not predict, and it is worth stating plainly: on a default install the grant is currently a silent no-op. Both shipped memory providers register handler-only (ctx.tools.registerHandler('memory', …), no wire-spec, because the kernel emits the memory_20250818 spec itself), and adaptNativeToolForSubAgent drops registry entries without a spec. So the hole is masked today — by a guard that exists for spec assembly, not for scoping, and that one register() call with a spec removes. That is the shape of a latent hole, not a closed one, and the test pins both shapes so neither can come back.

The fix

The sub-agent tool path now receives the same turn-bound, scoped store the parent's dispatch receives, instead of resolving the root store itself.

  • registry/subAgentMemoryTool.ts (new)createScopedMemorySubAgentTool(resolve) builds the memory tool a granted sub-agent gets. It never touches the registry; it dispatches to whatever handler the parent's turn bound. Also the new home of MEMORY_TOOL_NAME, so the orchestrator's dispatch and the grant adapter cannot key on different literals.
  • orchestrator.tsdispatchToolInner publishes memoryHandler (the turn-bound stack from MemoryBinder.forOrigin, or the build-time agent-scoped handler when context memory is off — the same value its own memory branch uses) into a nested turnContext scope for the lifetime of a domain-tool dispatch, beside the subAgentDatasetSink / subAgentBypassFlag scope that is already established there.
  • subAgentToolHydration.tsadaptNativeToolForSubAgent takes resolveTurnMemory as a required third parameter and intercepts memory before the registry lookup, on every registration shape.

Why ambient here, when #903 insisted on a parameter

DomainTool's contract is handle(input, observer). There is no seam for a third argument, and the sub-agent's tool loop runs inside the plugin's own async chain — so the store cannot be threaded the way #903 threaded turnMemory through dispatchTool.

What makes that acceptable is the direction of failure. #903's objection to an ambient binding was that losing it degrades silently to a wider store. Here a lost scope makes resolve() return undefined and the tool refuses the call. Deny on loss, never widen. Mutation B below demonstrates exactly that: removing the publication produces zero writes, not root writes.

The required parameter

Same hardening as #903 applied to dispatchTool / dispatchToolDeadlined / dispatchToolInner: a new call site that forgets to thread the scoped store fails typecheck rather than quietly falling back to the unscoped one.

Mutation evidence

Each assertion was verified load-bearing by breaking the invariant and confirming it turns red.

A — adapter stops intercepting memory (grant served from the registry again). All five red; the spec-carrying case reports actual: '/memories/note.md' — the raw root. The exposure is back.

B — orchestrator stops publishing the turn-bound handler. All five red, all with 0 writes. The fail-closed direction, demonstrated: losing the scope denies the tool, it does not widen it.

C — drop the argument at the call site.

src/agents/subAgentToolHydration.ts:349:13 - error TS2554: Expected 3 arguments, but got 2.
            adaptNativeToolForSubAgent(deps.nativeToolRegistry, ref),
  src/agents/subAgentToolHydration.ts:216:3
    resolveTurnMemory: SubAgentMemoryResolver,
    An argument for 'resolveTurnMemory' was not provided.

After the fix, all five pass.

Two behaviour changes worth reading before merging

  • The grant becomes effective. It used to be dropped on a default install (see above). From now on it works — with the parent turn's scope. That is the correct reading of an operator's grant, but it is a change: a sub-agent that was silently tool-less now has a memory tool. Its model-facing spec spells out the six commands MemoryToolHandler implements, because LocalSubAgentToolSpec is a {name, description, input_schema} contract and cannot express Anthropic's typed {type: 'memory_20250818'} shape. The handler is identical either way — same parser, same store, same result strings.
  • Deferred sub-agents cannot use memory. ask_<slug>_start runs in a detached runner outside the turn's AsyncLocalStorage scope (longRunningTool.ts already documents the same loss for subAgentOwnerPluginId), so the tool refuses there. Fail-closed, and now written down.

Other tool paths I checked

Path Verdict
MCP grants on the sub-agent path Clean — dispatch goes through McpManager, never the memory store.
Top-level native grants Clean — an explicit no-op; native tools are process-wide and reached through the orchestrator's own dispatch, which is scoped.
Plan-runner (@omadia/plugin-plan-runner) Clean — no memory-store use at all.
Conductor / workflow runs Clean — no memory-store use at all.
Plugin ctx.memory accessor Already scoped per plugin AND per orchestrator (/memories/orchestrators/<slug>/plugins/<id>/), resolved from the turn context at call time.
Deferred sub-agent tasks Fail-closed by construction after this change (see above).
Admin/operator memory routers Root store handed in deliberately, behind operator auth, and documented as such at the call site.
ctx.tools.invoke(name, input) Not clean — same shape, different actor. Filed separately as #909 rather than folded in here.

ToolsAccessor.invoke dispatches straight to entry.handler by name, so any installed tool-kind plugin can call invoke('memory', …) and reach the same undecorated root — bypassing the per-plugin ctx.memory scoping it is otherwise held to, and needing no permissions.memory declaration to do it. It is structurally a different decision from this one (plugin surface, manifest/permission model, and the accessor's own docstring already accepts that it bypasses per-turn hooks), so it gets its own issue rather than a quiet fix in a sub-agent PR.

claude-cli, unchanged

The claude-cli provider never constructs the Orchestrator, so context_memory stays inert there (#899). This PR does not change that. It does mean a CLI sub-agent's memory tool finds no bound store and refuses — the fail-closed path, not a new behaviour worth hiding. Documented, not silently fixed.

Docs

  • docs/teams-multi-agent-identities.md §10 — the known-limits bullet that named this hole is split: the claude-cli half stays (still true), the sub-agent half is replaced with what now holds, including both behaviour changes above.
  • docs/CHANGELOG.md[Unreleased] entry.

Gates

npm run typecheck ✅ · npm run typecheck:test ✅ (ratchet: 370 known, no regressions) · npm test7788 tests, 7772 pass, 0 fail · eslint on every touched file ✅. web-ui is untouched.

A sub-agent granted the native `memory` tool resolved its handler out of
the process-wide NativeToolRegistry. That entry belongs to the memory
provider plugin and is bound to the undecorated root store, so the write
landed below every scoping wrapper: outside the parent agent's
orchestrator:<slug>:* subtree, and outside the team/channel tiers of the
chat-context ACL.

The grant now runs on the same turn-bound, scoped handler the parent's own
dispatch uses. dispatchToolInner publishes it for the lifetime of a
domain-tool dispatch; adaptNativeToolForSubAgent takes the resolver as a
required parameter so a forgotten argument breaks typecheck instead of
degrading silently. Absent binding refuses the call rather than falling
back to a wider store.

Refs #904, part of #860
@Weegy
Weegy merged commit 1826e91 into main Aug 27, 2026
8 checks passed
@Weegy
Weegy deleted the fix/subagent-memory-scoping branch August 27, 2026 15:03
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