Skip to content

Commit a438026

Browse files
committed
Fix cross-subtree trace leak and add an aggregate output cap
Review on #599 found two real issues: - findAgentTraceDir walked the whole (flat, shared) subagents/ tree from the root workdirBase, so a Tier-2 nested orchestrator could read any worker's trace, not just its own descendants. Wired assertCanTargetAgent (authority.ts's first live call site) using the worker's own SubAgentSessionStore id and the store's existing parentSessionId chain, rather than reshaping the on-disk layout — the disk tree is deliberately flat across the whole fleet (shared by worktrees and intervention logs too), so a structural per-subtree root would be a much larger change. To make that id available, run.ts now names a worker's trace directory after its session-store id when one is supplied (task-tool.ts passes it), instead of always minting a fresh disk-only id. - The per-entry, entry-count, and turn-window caps multiply (500 * 4,000 = 2,000,000 chars). Added a total-output character cap that stops filling entries once reached and reports the remainder via the existing `omitted` block. Noted but not changed: readAllTurns loads each full segment before bounds apply. Segments are already bounded to ~256KB by the writer, so this isn't unbounded, but avoiding the read entirely needs a line-count index or a streaming reader — left as a follow-up rather than expanding this fix.
1 parent c193943 commit a438026

8 files changed

Lines changed: 210 additions & 13 deletions

File tree

src/agent/tools.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,10 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
310310
}),
311311
]
312312
: []),
313-
// Tier 1: the primary session is always an orchestrator, so this is
314-
// unconditional wherever task/search_agents are mounted.
313+
// Tier 1: the primary session is always an orchestrator and may
314+
// target any worker (assertCanTargetAgent's rule), so no authority
315+
// context is passed here — omitting it is treated as unrestricted,
316+
// matching Tier 1's actual authority.
315317
createReadAgentTraceTool(args.subAgent.getWorkdirBase),
316318
]
317319
: []),

src/subagent/run.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -485,8 +485,16 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
485485
// Every worker at every nesting depth is created under the same root
486486
// workdirBase (nestedDispatch.getWorkdirBase is threaded through
487487
// unchanged, never rebound to this worker's own dir), so the trace
488-
// reader's search root is that same function.
489-
createReadAgentTraceTool(nd.getWorkdirBase),
488+
// reader's search root is that same function. Descendant-only
489+
// scoping is enforced inside the tool via assertCanTargetAgent,
490+
// reusing the fleet nodes SubAgentSessionStore already tracks and
491+
// this worker's own store id (params.id) — not the disk layout,
492+
// which is intentionally flat across the whole fleet.
493+
createReadAgentTraceTool(nd.getWorkdirBase, {
494+
actorId: params.id,
495+
tier,
496+
getNodes: () => nd.sessions?.list() ?? [],
497+
}),
490498
];
491499
}
492500

@@ -584,7 +592,14 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
584592
},
585593
});
586594

587-
const workdir = join(params.workdirBase, "subagents", generateSessionId());
595+
// Reuse the caller's session-store id as the on-disk directory name
596+
// when it is safe as a path segment, so read_agent_trace's descendant
597+
// check can walk the same parentSessionId chain SubAgentSessionStore
598+
// already tracks instead of needing a second, disk-only identity
599+
// scheme.
600+
const safeRequestedId =
601+
params.id !== undefined && /^[A-Za-z0-9_-]+$/.test(params.id) ? params.id : undefined;
602+
const workdir = join(params.workdirBase, "subagents", safeRequestedId ?? generateSessionId());
588603
await mkdir(workdir, { recursive: true });
589604
// One record per stop/nudge, with its measured value beside its threshold,
590605
// written into this leaf's own trace dir (CL-6938).

src/subagent/task-tool.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,10 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
804804
...sandbox,
805805
cwd: worktreeCwd ?? deps.cwd,
806806
workdirBase: deps.getWorkdirBase(),
807+
// Same id as the SubAgentSessionStore record so read_agent_trace's
808+
// descendant check (authority.ts assertCanTargetAgent) can reuse the
809+
// store's parentSessionId chain instead of a second identity scheme.
810+
...(session !== undefined ? { id: session.id } : {}),
807811
provider,
808812
...(settings !== undefined ? { settings } : {}),
809813
...(catalog !== undefined ? { catalog } : {}),

src/subagent/trace-reader.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
listUniqueSubdirs,
1010
readAgentTrace,
1111
MAX_TRACE_ENTRY_LIMIT,
12+
MAX_TRACE_TOTAL_CHARS,
1213
MAX_TRACE_TURN_WINDOW,
1314
} from "./trace-reader.js";
1415

@@ -153,6 +154,27 @@ describe("readAgentTrace", () => {
153154
expect(result.omitted!.hint.length).toBeGreaterThan(0);
154155
});
155156

157+
test("never exceeds the total-output character cap regardless of entry/window caps", async () => {
158+
const root = tempDir();
159+
const childDir = path.join(root, "subagents", "worker-1");
160+
const turns = Array.from({ length: 600 }, (_, i) => ({
161+
role: "assistant",
162+
content: [{ type: "text", text: `turn ${i} `.repeat(1000) }], // ~5,000 chars each
163+
}));
164+
writeTurns(childDir, turns);
165+
166+
const result = await readAgentTrace(root, "worker-1", {
167+
fromTurn: 0,
168+
toTurn: 600,
169+
limit: MAX_TRACE_ENTRY_LIMIT,
170+
});
171+
const totalChars = result.entries.reduce((sum, e) => sum + e.content.length, 0);
172+
expect(totalChars).toBeLessThanOrEqual(MAX_TRACE_TOTAL_CHARS);
173+
expect(result.entriesTruncated).toBe(true);
174+
expect(result.omitted).not.toBeNull();
175+
expect(result.omitted!.reason).toContain("total output cap");
176+
});
177+
156178
test("never exceeds the hard entry-limit cap regardless of requested limit", async () => {
157179
const root = tempDir();
158180
const childDir = path.join(root, "subagents", "worker-1");

src/subagent/trace-reader.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
* SubAgentSessionStore (which a process restart or a killed worker can
1111
* leave with nothing).
1212
*
13-
* Every read here is bounded: a fixed turn window, a fixed entry count, and
14-
* a per-entry character cap, each capped again by a hard maximum regardless
15-
* of what the caller asks for. No argument combination can pull an
16-
* unbounded blob into the parent's context.
13+
* Every read here is bounded on four independent axes — turn window, entry
14+
* count, per-entry characters, and total output characters (the first three
15+
* multiply, so they are each capped again by a total-output ceiling) — each
16+
* with a hard maximum regardless of what the caller asks for. No argument
17+
* combination can pull an unbounded blob into the parent's context.
1718
*/
1819

1920
import fs from "node:fs";
@@ -29,6 +30,10 @@ export const MAX_TRACE_TURN_WINDOW = 200;
2930
export const DEFAULT_TRACE_ENTRY_LIMIT = 200;
3031
export const MAX_TRACE_ENTRY_LIMIT = 500;
3132
export const MAX_TRACE_ENTRY_CHARS = 4_000;
33+
// Per-entry/entry-count/turn-window caps each bound one axis, but multiply
34+
// together (500 entries * 4,000 chars = 2,000,000 chars in one call). This
35+
// caps the total regardless of how the other axes are combined.
36+
export const MAX_TRACE_TOTAL_CHARS = 20_000;
3237

3338
// A pathological or runaway fleet tree should fail the search cheaply rather
3439
// than walk forever; a worker this deep or a fleet this large is itself a
@@ -196,6 +201,18 @@ function parseTurnsTolerant(text: string): { turns: RawTurn[]; warnings: number
196201
return { turns, warnings };
197202
}
198203

204+
/**
205+
* Reads and parses every segment before the caller's window/limit bounds
206+
* apply, so a not-yet-rotated active segment is loaded whole regardless of
207+
* how small a slice the caller actually wants. In practice each segment is
208+
* itself bounded to ~256KB by the writer (createSegmentedJSONLWriter's
209+
* DEFAULT_MAX_SEGMENT_BYTES), so this cannot grow unboundedly with a
210+
* worker's total history the way reading turns.jsonl as one file could —
211+
* but avoiding this read entirely (only touching the segments the requested
212+
* turn range actually falls in) needs either a cheap line-count index or a
213+
* streaming reader, which is a larger change than this fix; tracked as a
214+
* follow-up rather than expanding this one.
215+
*/
199216
async function readAllTurns(dir: string): Promise<{ turns: RawTurn[]; warnings: number }> {
200217
const segments = await listSegmentFiles(dir, TURNS_FILE);
201218
const turns: RawTurn[] = [];
@@ -325,6 +342,8 @@ export async function readAgentTrace(
325342

326343
const entries: TraceEntry[] = [];
327344
let entriesTruncated = false;
345+
let totalChars = 0;
346+
let stopReason: "entry-limit" | "total-chars" | null = null;
328347
let lastReadTurn = fromTurn;
329348
outer: for (let i = fromTurn; i < toTurn; i++) {
330349
lastReadTurn = i;
@@ -335,8 +354,15 @@ export async function readAgentTrace(
335354
if (kindsFilter !== null && !kindsFilter.has(entry.kind)) continue;
336355
if (entries.length >= limit) {
337356
entriesTruncated = true;
357+
stopReason = "entry-limit";
358+
break outer;
359+
}
360+
if (totalChars + entry.content.length > MAX_TRACE_TOTAL_CHARS) {
361+
entriesTruncated = true;
362+
stopReason = "total-chars";
338363
break outer;
339364
}
365+
totalChars += entry.content.length;
340366
entries.push(entry);
341367
}
342368
}
@@ -349,9 +375,12 @@ export async function readAgentTrace(
349375
const omitted: TraceOmission | null =
350376
turnsBefore > 0 || turnsAfter > 0
351377
? {
352-
reason: entriesTruncated
353-
? "entry limit reached before the requested turn range finished reading"
354-
: "turn window bounded to the default/requested range",
378+
reason:
379+
stopReason === "entry-limit"
380+
? "entry limit reached before the requested turn range finished reading"
381+
: stopReason === "total-chars"
382+
? `total output cap (${MAX_TRACE_TOTAL_CHARS} chars) reached before the requested turn range finished reading`
383+
: "turn window bounded to the default/requested range",
355384
turnsBefore,
356385
turnsAfter,
357386
hint:

src/subagent/trace-tool.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import os from "node:os";
44
import path from "node:path";
55

66
import { createReadAgentTraceTool } from "./trace-tool.js";
7+
import type { FleetNode } from "./authority.js";
78

89
function tempDir(): string {
910
return fs.mkdtempSync(path.join(os.tmpdir(), "trace-tool-"));
@@ -45,4 +46,74 @@ describe("createReadAgentTraceTool", () => {
4546
expect(text).toContain("worker-1");
4647
expect(text).toContain("hello");
4748
});
49+
50+
describe("descendant-only scoping (two sibling subtrees under one flat root)", () => {
51+
// Every worker at every nesting depth lands under the same root
52+
// subagents/ dir (see run.ts), so on disk workerA1 and workerY are
53+
// indistinguishable siblings. Authority comes entirely from the fleet
54+
// node list (parentSessionId chain), not from directory structure.
55+
const nodes: FleetNode[] = [
56+
{ id: "orchA" },
57+
{ id: "workerA1", parentSessionId: "orchA" },
58+
{ id: "orchB" },
59+
{ id: "workerY", parentSessionId: "orchB" },
60+
];
61+
62+
function setUpRoot(): string {
63+
const root = tempDir();
64+
writeTurns(path.join(root, "subagents", "workerA1"), [
65+
{ role: "assistant", content: [{ type: "text", text: "from A1" }] },
66+
]);
67+
writeTurns(path.join(root, "subagents", "workerY"), [
68+
{ role: "assistant", content: [{ type: "text", text: "from Y" }] },
69+
]);
70+
return root;
71+
}
72+
73+
test("orchestratorA can read its own descendant workerA1", async () => {
74+
const root = setUpRoot();
75+
const tool = createReadAgentTraceTool(() => root, {
76+
actorId: "orchA",
77+
tier: "nested-orchestrator",
78+
getNodes: () => nodes,
79+
});
80+
if (tool.kind !== "string") throw new Error("expected string tool");
81+
const text = await tool.handler({ target: "workerA1" }, new AbortController().signal);
82+
expect(text).toContain("from A1");
83+
});
84+
85+
test("orchestratorA cannot read workerY, a sibling subtree's worker", async () => {
86+
const root = setUpRoot();
87+
const tool = createReadAgentTraceTool(() => root, {
88+
actorId: "orchA",
89+
tier: "nested-orchestrator",
90+
getNodes: () => nodes,
91+
});
92+
if (tool.kind !== "string") throw new Error("expected string tool");
93+
const text = await tool.handler({ target: "workerY" }, new AbortController().signal);
94+
expect(text).toContain("Error:");
95+
expect(text).not.toContain("from Y");
96+
});
97+
98+
test("an actor with no resolvable session id is denied entirely", async () => {
99+
const root = setUpRoot();
100+
const tool = createReadAgentTraceTool(() => root, {
101+
actorId: undefined,
102+
tier: "nested-orchestrator",
103+
getNodes: () => nodes,
104+
});
105+
if (tool.kind !== "string") throw new Error("expected string tool");
106+
const text = await tool.handler({ target: "workerA1" }, new AbortController().signal);
107+
expect(text).toContain("Error:");
108+
expect(text).not.toContain("from A1");
109+
});
110+
111+
test("Tier 1 (no authority context) can read any worker", async () => {
112+
const root = setUpRoot();
113+
const tool = createReadAgentTraceTool(() => root);
114+
if (tool.kind !== "string") throw new Error("expected string tool");
115+
const text = await tool.handler({ target: "workerY" }, new AbortController().signal);
116+
expect(text).toContain("from Y");
117+
});
118+
});
48119
});

src/subagent/trace-tool.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,27 @@ import {
1919
type TraceEntryKind,
2020
type TraceReadResult,
2121
} from "./trace-reader.js";
22+
import {
23+
assertCanTargetAgent,
24+
FleetAuthorityError,
25+
type FleetNode,
26+
type SubagentTier,
27+
} from "./authority.js";
28+
29+
/**
30+
* Descendant-scoping context for a Tier 2 nested orchestrator's copy of this
31+
* tool. `actorId` is this worker's own SubAgentSessionStore id (the same id
32+
* used as its on-disk directory name — see run.ts) and `getNodes` returns
33+
* the live fleet so `assertCanTargetAgent` can walk the existing
34+
* parentSessionId chain rather than trusting a per-caller check that could
35+
* be forgotten at a future mount site. Omit entirely for Tier 1 (the
36+
* primary orchestrator), which may target anyone.
37+
*/
38+
export interface ReadAgentTraceAuthority {
39+
actorId: string | undefined;
40+
tier: SubagentTier;
41+
getNodes: () => readonly FleetNode[];
42+
}
2243

2344
const TRACE_ENTRY_KINDS: readonly TraceEntryKind[] = [
2445
"text",
@@ -103,14 +124,38 @@ function formatTraceResult(result: TraceReadResult): string {
103124
return lines.join("\n");
104125
}
105126

106-
export function createReadAgentTraceTool(getRootWorkdirBase: () => string): AgentTool {
127+
export function createReadAgentTraceTool(
128+
getRootWorkdirBase: () => string,
129+
authority?: ReadAgentTraceAuthority,
130+
): AgentTool {
107131
return stringTool({
108132
definition: readAgentTraceDefinition,
109133
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {
110134
const parsed = ReadAgentTraceArgs(rawArgs);
111135
if (parsed instanceof type.errors) {
112136
return `Error: read_agent_trace requires target (string); ${parsed.summary}`;
113137
}
138+
if (authority !== undefined) {
139+
// Fails closed: an actor whose own store id could not be resolved
140+
// (no session record for this dispatch) must never be trusted with
141+
// fleet-wide read access, mirroring CL-6941's unresolved-tier rule.
142+
if (authority.actorId === undefined) {
143+
return (
144+
"Error: read_agent_trace is unavailable for this worker (no resolvable session " +
145+
"id to scope descendant access)."
146+
);
147+
}
148+
try {
149+
assertCanTargetAgent(
150+
{ id: authority.actorId, tier: authority.tier },
151+
parsed.target,
152+
authority.getNodes(),
153+
);
154+
} catch (cause) {
155+
if (cause instanceof FleetAuthorityError) return `Error: ${cause.message}`;
156+
throw cause;
157+
}
158+
}
114159
try {
115160
const result = await readAgentTrace(getRootWorkdirBase(), parsed.target, {
116161
...(parsed.kinds !== undefined ? { kinds: parsed.kinds } : {}),

src/subagent/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ export type NestedDispatchDeps = SubAgentSandboxDeps & {
7676
export type RunSubAgentParams = {
7777
cwd: string;
7878
workdirBase: string;
79+
/**
80+
* Stable id for this worker's on-disk trace directory (subagents/<id>).
81+
* Callers that track a session store (task-tool.ts) pass the same id as
82+
* the SubAgentSessionStore record so read_agent_trace's descendant check
83+
* can reuse the store's existing parentSessionId chain instead of a
84+
* second identity scheme. Falls back to a fresh generated id when unset
85+
* or unsafe for a path segment.
86+
*/
87+
id?: string;
7988
provider: SubAgentProvider;
8089
settings?: Settings;
8190
catalog?: readonly ProviderCatalogEntry[];

0 commit comments

Comments
 (0)