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
33 changes: 33 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,39 @@ way, even fully logged in. A shell alias masked the symptom in manual
terminal testing (`which`/`command -v` resolve aliases; `child_process.spawn`
never does). `resolveAugmentedPath()` now also checks `~/.local/bin`.

### Fixed — a granted `memory` tool no longer lets a sub-agent write past its parent's scope (#904, part of #860)

2026-08-27 — A sub-agent that had 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. A
sub-agent reaching it read and wrote 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 too. Granting a sub-agent the
memory tool is ordinary operator configuration, and the per-agent boundary it
crossed predates the memory-ACL epic entirely.

The grant is now served by a tool bound to the same turn-scoped store the
parent's own dispatch uses: `Orchestrator.dispatchToolInner` publishes that
handler for the lifetime of a domain-tool dispatch, and
`adaptNativeToolForSubAgent` takes the resolver as a **required** parameter, so a
call site that forgets to thread it fails `typecheck` instead of silently
degrading to the unscoped store — the same hardening #903 applied to
`dispatchTool` / `dispatchToolDeadlined` / `dispatchToolInner`.

Two consequences worth knowing:

- The grant used to be a **silent no-op** on a default install: the shipped
providers register handler-only (no wire-spec) and the adapter dropped such
entries. It is now honoured — with the parent turn's scope.
- **Fail-closed, never fallback.** With no turn-bound store — a detached
`ask_<slug>_start` runner, or any call outside an orchestrator turn — the tool
refuses instead of reaching for a wider one.

Unchanged and still true: the `claude-cli` provider never constructs the
`Orchestrator`, so `context_memory` remains inert there (#899).


### Added — team uninstall for provisioned agent identities (#900, part of #860)

2026-08-27 — Assigning an agent to a Team was one-way: `DELETE
Expand Down
18 changes: 15 additions & 3 deletions docs/teams-multi-agent-identities.md
Original file line number Diff line number Diff line change
Expand Up @@ -1015,9 +1015,21 @@ Tiefe Details zur Scope-Auflösung, zur Turn-Bindung und zu den Tests stehen in
- **Der Schalter wirkt nur auf dem Orchestrator-Pfad.** Läuft ein Agent über den
`claude-cli`-Provider, beantwortet ein `CliChatAgent` den Turn, nicht der
`Orchestrator` — die Bindung wird dort nie gebildet, und der Modus bleibt folgenlos.
Ebenso greift die ACL nicht für ein **Sub-Agent**, dem das native `memory`-Tool
direkt zugeteilt wurde: dessen Handler zeigt auf den undekorierten Store. Beides ist
älter als diese Wave und in #899 dokumentiert (siehe dort den Befund im PR).
Das ist älter als diese Wave und in #899 dokumentiert (siehe dort den Befund im PR).
- **Sub-Agenten mit `memory`-Grant: seit #904 geschlossen.** Ein Sub-Agent, dem das
native `memory`-Tool zugeteilt ist, bekam seinen Handler früher aus der
prozessweiten `NativeToolRegistry` — und der gehört dem Memory-*Provider*-Plugin und
hängt am **undekorierten** Store. Damit lag der Schreibzugriff nicht nur außerhalb
der Kontext-ACL, sondern auch außerhalb der älteren Pro-Agent-Isolation
(`orchestrator:<slug>:*`). Der Grant läuft jetzt über genau den turn-gebundenen,
gescopten Store, den auch der Dispatch des Elternagenten benutzt. Zwei Folgen, die
man kennen sollte:
- Der Grant war vorher faktisch **wirkungslos** (die beiden ausgelieferten
Memory-Provider registrieren handler-only, ohne Wire-Spec, und der Adapter ließ
solche Einträge fallen). Ab jetzt ist er wirksam — mit dem Scope des Elternturns.
- **Fail-closed statt Fallback:** Ist kein turn-gebundener Store da — etwa in einem
abgekoppelten `ask_<slug>_start`-Runner oder außerhalb eines Orchestrator-Turns —
verweigert das Tool den Aufruf, statt auf einen weiteren Store auszuweichen.

### Was in Arbeit ist

Expand Down
13 changes: 12 additions & 1 deletion middleware/packages/harness-orchestrator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,14 @@ export type {
SubAgentGraph,
SubAgentToolDeps,
} from './registry/subAgentTools.js';
// #904 — the scoped `memory` tool a granted sub-agent gets, and the tool name
// both the orchestrator's dispatch and the grant adapter key on.
export {
createScopedMemorySubAgentTool,
MEMORY_TOOL_NAME,
SUB_AGENT_MEMORY_UNBOUND_ERROR,
} from './registry/subAgentMemoryTool.js';
export type { SubAgentMemoryResolver } from './registry/subAgentMemoryTool.js';
export {
DEFAULT_ORCHESTRATOR_MODEL,
resolveAgentModelRouting,
Expand Down Expand Up @@ -525,7 +533,10 @@ export {
// Teardown failures are reported, never thrown — see `runGeneratorInContext`.
onTurnTeardownError,
} from './turnContext.js';
export type { TurnContextValue } from './turnContext.js';
export type {
SubAgentMemoryHandler,
TurnContextValue,
} from './turnContext.js';
export {
setMcpPrivacyBypassServers,
isMcpServerPrivacyBypassed,
Expand Down
29 changes: 27 additions & 2 deletions middleware/packages/harness-orchestrator/src/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ import type {
} from './llmProviderSeam.js';
import { streamMessageEvents } from './streaming.js';
import { steeringBus } from './steeringBus.js';
import { MEMORY_TOOL_NAME } from './registry/subAgentMemoryTool.js';
import {
buildDateHeader,
today,
Expand Down Expand Up @@ -1129,7 +1130,10 @@ async function restoreFollowUpsForUser(
return out;
}

const MEMORY_TOOL_NAME = 'memory';
// `MEMORY_TOOL_NAME` now lives in `registry/subAgentMemoryTool.ts` so the
// orchestrator's dispatch and the sub-agent grant adapter cannot drift apart
// (#904) — a sub-agent path keyed on a different literal would silently reopen
// the unscoped-store bypass.
const MEMORY_TOOL_TYPE = 'memory_20250818';
const MEMORY_BETA_HEADER = 'context-management-2025-06-27';

Expand Down Expand Up @@ -6864,7 +6868,28 @@ export class Orchestrator {
if (!this.isToolAvailable(domainTool.agentId)) {
return `Error: tool \`${name}\` is unavailable — plugin \`${domainTool.agentId}\` has not completed its connection/auth setup.`;
}
return domainTool.handle(input, observer);
// #904 — publish THIS turn's scoped memory handler (`memoryHandler`
// above: the turn-bound stack when one is bound, the build-time
// agent-scoped one otherwise) for the lifetime of the delegation, so a
// sub-agent granted the native `memory` tool writes through the same
// store the parent's own dispatch uses. Without it the sub-agent resolved
// `memory` from the process-wide registry, whose handler is the memory
// PROVIDER plugin's — bound to the undecorated root, i.e. outside both
// the per-agent `orchestrator:<slug>:*` subtree and the chat-context ACL.
//
// Ambient here, an explicit parameter in `dispatchTool*`: `DomainTool`'s
// contract is `handle(input, observer)` and has no seam for a third
// argument. What makes that acceptable is the direction of failure — a
// lost scope makes the sub-agent's memory tool REFUSE the call
// (`SUB_AGENT_MEMORY_UNBOUND_ERROR`), it never falls back to anything
// wider. Deny on loss, never widen.
const ctx = turnContext.current();
if (memoryHandler === undefined || ctx === undefined) {
return domainTool.handle(input, observer);
}
return turnContext.run({ ...ctx, subAgentMemoryHandler: memoryHandler }, () =>
Promise.resolve(domainTool.handle(input, observer)),
);
}
return `Error: unknown tool \`${name}\`.`;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { LocalSubAgentTool } from '@omadia/plugin-api';

import type { SubAgentMemoryHandler } from '../turnContext.js';

/**
* Name of the Anthropic-native memory tool. Single source of truth: the
* orchestrator's dispatch, the tool-list assembly and the sub-agent grant
* adapter all key on the same literal, and a sub-agent path that spelled it
* differently would silently reopen the hole this module closes (#904).
*/
export const MEMORY_TOOL_NAME = 'memory';

/**
* Resolves the memory handler bound to the turn currently delegating to this
* sub-agent, or `undefined` when there is none.
*
* `undefined` MUST mean "refuse", never "fall back to something wider" — see
* {@link createScopedMemorySubAgentTool}.
*/
export type SubAgentMemoryResolver = () => SubAgentMemoryHandler | undefined;

/**
* Model-facing spec for a sub-agent's `memory` tool.
*
* The top-level orchestrator advertises memory as Anthropic's typed tool
* (`{type: 'memory_20250818', name: 'memory'}`), a shape `LocalSubAgentToolSpec`
* cannot express — it is a `{name, description, input_schema}` contract. So the
* six commands `MemoryToolHandler` implements are spelled out here instead. The
* HANDLER is unchanged either way: the same parser, the same store, the same
* result strings, so a sub-agent's writes are indistinguishable from the
* parent's once they reach storage.
*/
const SUB_AGENT_MEMORY_TOOL_SPEC: LocalSubAgentTool['spec'] = {
name: MEMORY_TOOL_NAME,
description:
'Read and write the long-term memory of the agent that delegated to you. ' +
'Paths live under /memories. Commands: view (path, optional view_range), ' +
'create (path, file_text), str_replace (path, old_str, new_str), ' +
'insert (path, insert_line, insert_text), delete (path), ' +
'rename (old_path, new_path).',
input_schema: {
type: 'object',
properties: {
command: {
type: 'string',
enum: ['view', 'create', 'str_replace', 'insert', 'delete', 'rename'],
description: 'Which memory operation to perform.',
},
path: { type: 'string', description: 'Target path under /memories.' },
file_text: { type: 'string', description: 'File content for `create`.' },
view_range: {
type: 'array',
items: { type: 'number' },
description: 'Optional [start, end] line range for `view`.',
},
old_str: { type: 'string', description: 'Text to replace, for `str_replace`.' },
new_str: { type: 'string', description: 'Replacement text, for `str_replace`.' },
insert_line: { type: 'number', description: 'Line to insert after, for `insert`.' },
insert_text: { type: 'string', description: 'Text to insert, for `insert`.' },
old_path: { type: 'string', description: 'Source path, for `rename`.' },
new_path: { type: 'string', description: 'Destination path, for `rename`.' },
},
required: ['command'],
},
};

/** Returned verbatim to the sub-agent's model when no turn store is bound. */
export const SUB_AGENT_MEMORY_UNBOUND_ERROR =
'Error: tool `memory` is unavailable — this delegation is not bound to a ' +
'scoped memory store, and writing to the unscoped one is not permitted.';

/**
* The `memory` tool a sub-agent gets when an operator grants it (#904).
*
* What it deliberately does NOT do is resolve `memory` out of the process-wide
* `NativeToolRegistry`. That entry belongs to the memory PROVIDER plugin
* (`@omadia/memory`, `@omadia/memory-postgres`) and is bound to the raw root
* store — the one below every scoping wrapper. A sub-agent dispatching through
* it reads and writes outside its parent agent's `orchestrator:<slug>:*`
* subtree, and, with the chat-context ACL enabled, outside its team's and
* channel's tiers as well.
*
* Instead the tool resolves the handler the PARENT's own dispatch is using for
* the turn that is delegating right now — the turn-bound stack
* `MemoryBinder.forOrigin` produced, or the build-time agent-scoped handler
* when context memory is off. Sub-agent and parent therefore share one scope by
* construction rather than by two code paths agreeing.
*
* **Fail closed.** With no bound handler the call is refused. This is the
* property that makes an ambient resolver acceptable here: `DomainTool.handle`
* takes `(input, observer)` and nothing else, so a scoped store cannot be
* threaded in as a parameter the way #903 threaded `turnMemory` through
* `dispatchTool`. A lost async context therefore denies the tool — it can never
* silently widen its reach, which is the failure mode that made this a
* vulnerability in the first place.
*/
export function createScopedMemorySubAgentTool(
resolveTurnMemory: SubAgentMemoryResolver,
): LocalSubAgentTool {
return {
spec: SUB_AGENT_MEMORY_TOOL_SPEC,
handle: async (input: unknown): Promise<string> => {
const handler = resolveTurnMemory();
if (handler === undefined) return SUB_AGENT_MEMORY_UNBOUND_ERROR;
return handler.handle(input);
},
};
}
40 changes: 40 additions & 0 deletions middleware/packages/harness-orchestrator/src/turnContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,35 @@ export interface TurnContextValue {
* so memory did reach the user either way.
*/
memoryFileRead?: { value: boolean };
/**
* #904 — the memory-tool handler bound to the turn that is currently
* delegating to a sub-agent.
*
* Installed by `dispatchToolInner` in a nested scope around a SINGLE
* domain-tool dispatch, and read by the `memory` tool an operator granted to
* that sub-agent. It carries the very handler the parent's own dispatch uses
* for this turn — the turn-bound stack `MemoryBinder.forOrigin` produced, or
* the build-time agent-scoped one when context memory is off — so the
* sub-agent writes inside the same scope as its parent instead of into the
* undecorated root store the memory provider plugin registered.
*
* Undefined outside a domain-tool dispatch, and that means the sub-agent's
* memory tool REFUSES the call. Deny, never widen: this is the whole reason
* an ambient field is acceptable for a security boundary that #903
* deliberately threaded as an explicit parameter elsewhere — losing this
* scope closes the tool, whereas losing `turnMemory` in the orchestrator
* would have silently reopened a wider store.
*/
subAgentMemoryHandler?: SubAgentMemoryHandler;
}

/**
* Structural view of `MemoryToolHandler` (`@omadia/memory`). Declared here
* rather than imported so `turnContext` — which every layer imports — keeps its
* dependency-free shape.
*/
export interface SubAgentMemoryHandler {
handle(input: unknown): Promise<string>;
}

const storage = new AsyncLocalStorage<TurnContextValue>();
Expand Down Expand Up @@ -370,6 +399,17 @@ export const turnContext = {
currentTurnDate(): string {
return storage.getStore()?.turnDate ?? today();
},
/**
* #904 — the scoped memory handler of the turn delegating to the sub-agent
* that is executing right now, or `undefined` outside a domain-tool dispatch.
*
* Callers MUST treat `undefined` as "refuse the memory call". Falling back to
* a registry-resolved handler here would restore exactly the bypass this
* accessor exists to close.
*/
currentSubAgentMemoryHandler(): SubAgentMemoryHandler | undefined {
return storage.getStore()?.subAgentMemoryHandler;
},
};

/**
Expand Down
42 changes: 40 additions & 2 deletions middleware/src/agents/subAgentToolHydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ import type { LocalSubAgentTool } from '@omadia/plugin-api';
import {
buildSubAgentDomainTools,
createLongRunningSubAgentTool,
createScopedMemorySubAgentTool,
mcpNativeHandler,
mcpToolNameFromRef,
mcpToolToNativeSpec,
turnContext,
MEMORY_TOOL_NAME,
type DomainTool,
type ResumableTaskSource,
type TaskStore,
Expand All @@ -35,6 +37,7 @@ import {
type McpToolDescriptor,
type NativeToolRegistry,
type SkillRow,
type SubAgentMemoryResolver,
type SkillToolBindingRow,
type SubAgentRow,
type ToolGrantRow,
Expand Down Expand Up @@ -185,11 +188,41 @@ export function mcpRowToConfig(row: McpServerRow): McpServerConfig {
};
}

/** Adapt a top-level native tool (handler + spec) into a sub-agent tool. */
/**
* The production resolver: the memory handler bound to the turn that is
* delegating to this sub-agent right now, installed by
* `Orchestrator.dispatchToolInner` around the domain-tool dispatch. Outside a
* delegation it returns `undefined` and the memory tool refuses the call.
*/
export const turnScopedMemoryResolver: SubAgentMemoryResolver = () =>
turnContext.currentSubAgentMemoryHandler();

/**
* Adapt a top-level native tool (handler + spec) into a sub-agent tool.
*
* `resolveTurnMemory` is REQUIRED, not optional, and that is the point (#904).
* The `memory` grant must never be served from `registry` — that entry is the
* memory provider plugin's handler on the UNDECORATED root store, and a
* sub-agent dispatching through it reads and writes outside its parent agent's
* `orchestrator:<slug>:*` subtree and, with the chat-context ACL on, outside
* its team's and channel's tiers too. A required parameter means a new call
* site that forgets to thread the scoped store fails `typecheck` instead of
* silently degrading to the unscoped one — the same hardening #903 applied to
* `dispatchTool` / `dispatchToolDeadlined` / `dispatchToolInner`.
*/
export function adaptNativeToolForSubAgent(
registry: NativeToolRegistry,
toolRef: string,
resolveTurnMemory: SubAgentMemoryResolver,
): LocalSubAgentTool | undefined {
// Checked BEFORE the registry lookup: `memory` must not be resolvable from
// the process-wide registry on ANY registration shape. The two shipped
// providers register handler-only (no `spec`), which the guard below happened
// to drop — an accident of spec assembly, not a boundary, and one that a
// single `register()` with a spec would have removed.
if (toolRef === MEMORY_TOOL_NAME) {
return createScopedMemorySubAgentTool(resolveTurnMemory);
}
const reg = registry.get(toolRef);
if (!reg?.handler || !reg.spec) return undefined;
const handler = reg.handler;
Expand Down Expand Up @@ -312,7 +345,12 @@ export function registerDbSubAgentTools(
defaultMaxIterations: deps.defaultMaxIterations ?? 8,
mcpManager: deps.mcpManager,
mcpServersById,
nativeTool: (ref) => adaptNativeToolForSubAgent(deps.nativeToolRegistry, ref),
nativeTool: (ref) =>
adaptNativeToolForSubAgent(
deps.nativeToolRegistry,
ref,
turnScopedMemoryResolver,
),
...(deps.blockedMcpGrant ? { blockedMcpGrant: deps.blockedMcpGrant } : {}),
...(deps.hostIsCliProvider !== undefined
? { hostIsCliProvider: deps.hostIsCliProvider }
Expand Down
Loading
Loading