Skip to content

Commit b4c9f8c

Browse files
GabrielDraporclaude
andcommitted
feat(agent): capture policy for persisted tool I/O (redact, allowlist, truncate, budget)
The redactSecrets gh*_ pattern keeps the plan's documented fix ($1 capture group preserving the prefix) instead of the initial pattern + normalizer, since the normalizer is a no-op once the prefix is preserved on match. Updated the pre-existing "records tool args..." persister test to use download_traces (allowlisted) instead of get_traces so its result assertion still holds under the new policy — get_traces is not allowlisted and would now have its result withheld. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DopjNH21gLaDtWeeMDEHX
1 parent e01b9fb commit b4c9f8c

5 files changed

Lines changed: 221 additions & 10 deletions

File tree

frontend/ee/agent/src/__tests__/stream-persister.test.ts

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,26 @@ const thinkingDelta = (delta: string): AgentEvent =>
1717
assistantMessageEvent: { type: "thinking_delta", delta } as never,
1818
}) as AgentEvent;
1919

20-
const toolStart = (id: string, args: Record<string, unknown> = {}): AgentEvent => ({
20+
const toolStart = (
21+
id: string,
22+
args: Record<string, unknown> = {},
23+
toolName = "get_traces",
24+
): AgentEvent => ({
2125
type: "tool_execution_start",
2226
toolCallId: id,
23-
toolName: "get_traces",
27+
toolName,
2428
args,
2529
});
2630

27-
const toolEnd = (id: string, result: unknown = "ok", isError = false): AgentEvent => ({
31+
const toolEnd = (
32+
id: string,
33+
result: unknown = "ok",
34+
isError = false,
35+
toolName = "get_traces",
36+
): AgentEvent => ({
2837
type: "tool_execution_end",
2938
toolCallId: id,
30-
toolName: "get_traces",
39+
toolName,
3140
result,
3241
isError,
3342
});
@@ -96,17 +105,20 @@ describe("StreamPersister", () => {
96105

97106
it("records tool args from start and result from end in metadata", async () => {
98107
const { persister, calls } = makePersister();
99-
persister.onEvent(toolStart("t1", { query: "errors" }));
100-
persister.onEvent(toolEnd("t1", { rows: [] }, true));
108+
// download_traces is capture-policy-allowlisted, so its result is kept
109+
// (as a redacted/bounded string) rather than withheld — see Task 9.
110+
persister.onEvent(toolStart("t1", { query: "errors" }, "download_traces"));
111+
persister.onEvent(toolEnd("t1", { rows: [] }, true, "download_traces"));
101112
await persister.finish();
102113

103114
expect(calls).toHaveLength(1);
104115
expect(calls[0].role).toBe("tool_step");
105116
expect(calls[0].metadata).toEqual({
106117
toolCallId: "t1",
107-
toolName: "get_traces",
118+
toolName: "download_traces",
108119
args: { query: "errors" },
109-
result: { rows: [] },
120+
result: '{"rows":[]}',
121+
outputBytes: 11,
110122
isError: true,
111123
});
112124
});
@@ -204,4 +216,44 @@ describe("StreamPersister", () => {
204216
expect(errorSpy).toHaveBeenCalled();
205217
errorSpy.mockRestore();
206218
});
219+
220+
it("withholds bash output but keeps its size; keeps download_traces output", async () => {
221+
const calls: AppendCall[] = [];
222+
const p = new StreamPersister(async (role, content, metadata) => {
223+
calls.push({ role, content, metadata });
224+
});
225+
p.onEvent({
226+
type: "tool_execution_start",
227+
toolCallId: "1",
228+
toolName: "bash",
229+
args: { command: "cat secrets" },
230+
});
231+
p.onEvent({
232+
type: "tool_execution_end",
233+
toolCallId: "1",
234+
toolName: "bash",
235+
result: "ghp_" + "x".repeat(40),
236+
isError: false,
237+
});
238+
p.onEvent({
239+
type: "tool_execution_start",
240+
toolCallId: "2",
241+
toolName: "download_traces",
242+
args: {},
243+
});
244+
p.onEvent({
245+
type: "tool_execution_end",
246+
toolCallId: "2",
247+
toolName: "download_traces",
248+
result: '{"spans":[]}',
249+
isError: false,
250+
});
251+
await p.finish();
252+
const bash = calls.find((c) => c.metadata?.toolName === "bash")!.metadata!;
253+
expect(bash.result).toBeUndefined();
254+
expect(bash.outputBytes).toBe(44);
255+
expect(bash.withheld).toBe("not-allowlisted");
256+
const dl = calls.find((c) => c.metadata?.toolName === "download_traces")!.metadata!;
257+
expect(dl.result).toBe('{"spans":[]}');
258+
});
207259
});

frontend/ee/agent/src/stream-persister.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AgentEvent } from "@earendil-works/pi-agent-core";
2+
import { applyCapturePolicy } from "@traceroot/core";
23
import type { TokenUsageData, TurnAttribution } from "./session.js";
34

45
/** Signature of SessionManager.appendMessage — injected so the persister is testable. */
@@ -31,6 +32,8 @@ export class StreamPersister {
3132
private thinking = "";
3233
/** args by toolCallId, captured at tool_execution_start (end events lack args) */
3334
private pendingToolArgs = new Map<string, Record<string, unknown>>();
35+
/** Per-run capture-policy budget accumulator (see applyCapturePolicy). */
36+
private readonly captureState = { spentBytes: 0 };
3437

3538
constructor(private readonly append: AppendMessageFn) {}
3639

@@ -51,11 +54,18 @@ export class StreamPersister {
5154
if (event.type === "tool_execution_end") {
5255
const args = this.pendingToolArgs.get(event.toolCallId) ?? {};
5356
this.pendingToolArgs.delete(event.toolCallId);
57+
const captured = applyCapturePolicy(
58+
{ toolName: event.toolName, args, result: event.result },
59+
this.captureState,
60+
);
5461
this.enqueue("tool_step", "", {
5562
toolCallId: event.toolCallId,
5663
toolName: event.toolName,
57-
args,
58-
result: event.result,
64+
args: captured.args,
65+
...(captured.result !== undefined ? { result: captured.result } : {}),
66+
outputBytes: captured.outputBytes,
67+
...(captured.truncated ? { truncated: true } : {}),
68+
...(captured.withheld ? { withheld: captured.withheld } : {}),
5969
isError: event.isError,
6070
});
6171
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from "vitest";
2+
import { applyCapturePolicy, redactSecrets } from "../lib/capture-policy.ts";
3+
4+
describe("redactSecrets", () => {
5+
it.each([
6+
["ghp_abcdefghijklmnopqrstuvwxyz0123456789", "ghp_[REDACTED]"],
7+
["gho_abcdefghijklmnopqrstuvwxyz0123456789", "gho_[REDACTED]"],
8+
["sk-proj-abcdefghijklmnopqrstuvwxyz0123456789", "sk-[REDACTED]"],
9+
["AKIAIOSFODNN7EXAMPLE", "AKIA[REDACTED]"],
10+
["Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc.def", "Authorization: Bearer [REDACTED]"],
11+
["OPENAI_API_KEY=abc123def456", "OPENAI_API_KEY=[REDACTED]"],
12+
])("redacts %s", (input, expected) => {
13+
expect(redactSecrets(input)).toBe(expected);
14+
});
15+
it("leaves ordinary text alone", () => {
16+
expect(redactSecrets("git log --oneline -10")).toBe("git log --oneline -10");
17+
});
18+
});
19+
20+
describe("applyCapturePolicy", () => {
21+
it("keeps result for allowlisted tools, redacted and truncated", () => {
22+
const state = { spentBytes: 0 };
23+
const r = applyCapturePolicy(
24+
{
25+
toolName: "download_traces",
26+
args: { traceId: "t" },
27+
result: "token ghp_" + "x".repeat(40) + " " + "y".repeat(9000),
28+
},
29+
state,
30+
);
31+
expect(r.result).toContain("ghp_[REDACTED]");
32+
expect(r.result!.length).toBeLessThanOrEqual(8_192 + 1);
33+
expect(r.truncated).toBe(true);
34+
expect(r.withheld).toBeNull();
35+
expect(state.spentBytes).toBeGreaterThan(0);
36+
});
37+
it("withholds result for non-allowlisted tools but reports its size", () => {
38+
const r = applyCapturePolicy(
39+
{ toolName: "bash", args: { command: "ls" }, result: "a".repeat(500) },
40+
{ spentBytes: 0 },
41+
);
42+
expect(r.result).toBeUndefined();
43+
expect(r.outputBytes).toBe(500);
44+
expect(r.withheld).toBe("not-allowlisted");
45+
});
46+
it("degrades to size-only once the per-run budget is spent", () => {
47+
const state = { spentBytes: 262_144 };
48+
const r = applyCapturePolicy({ toolName: "download_traces", args: {}, result: "small" }, state);
49+
expect(r.result).toBeUndefined();
50+
expect(r.withheld).toBe("budget");
51+
});
52+
it("redacts inside args too", () => {
53+
const r = applyCapturePolicy(
54+
{
55+
toolName: "bash",
56+
args: { command: "curl -H 'Authorization: Bearer abc.def.ghi'" },
57+
result: "",
58+
},
59+
{ spentBytes: 0 },
60+
);
61+
expect(JSON.stringify(r.args)).toContain("Bearer [REDACTED]");
62+
});
63+
});

frontend/packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export * from "./model-pricing/index.ts";
4848
// Shared types
4949
export * from "./types/index.ts";
5050

51+
// Capture policy for persisted tool I/O (agent StreamPersister + SDK captureToolIo)
52+
export * from "./lib/capture-policy.ts";
53+
5154
// NOTE: pi-ai Model resolver lives at `@traceroot/core/model-resolver` (subpath).
5255
// We do NOT re-export it here — pulling pi-ai into the main barrel would bundle
5356
// Node-only code (`node:fs`, etc.) into the Next.js client. Server-side
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* One policy for what tool I/O may be persisted — used by the agent's StreamPersister
3+
* (ai_messages.tool_step rows) and by the SDK's captureToolIo hook (spans), so both
4+
* stores hold exactly the same content. Order matters: redact, then allowlist, then
5+
* truncate, then budget — truncating first could split a token and defeat a pattern.
6+
*/
7+
export interface CaptureBudget {
8+
perStepBytes: number;
9+
perRunBytes: number;
10+
}
11+
export const DEFAULT_CAPTURE_BUDGET: CaptureBudget = { perStepBytes: 8_192, perRunBytes: 262_144 };
12+
13+
/** Tools whose output is data the customer already owns inside TraceRoot. */
14+
export const OUTPUT_ALLOWLIST: ReadonlySet<string> = new Set([
15+
"download_traces",
16+
"download_session",
17+
"submit_result",
18+
]);
19+
20+
const PATTERNS: Array<[RegExp, string]> = [
21+
[/\b(gh[pousr]_)[A-Za-z0-9]{20,}/g, "$1[REDACTED]"],
22+
[/\bsk-[A-Za-z0-9_-]{16,}/g, "sk-[REDACTED]"],
23+
[/\bAKIA[0-9A-Z]{12,}/g, "AKIA[REDACTED]"],
24+
[/(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/g, "$1[REDACTED]"],
25+
[/\b([A-Z][A-Z0-9_]{2,}(?:KEY|TOKEN|SECRET|PASSWORD))=([^\s'"]+)/g, "$1=[REDACTED]"],
26+
];
27+
28+
export function redactSecrets(text: string): string {
29+
let out = text;
30+
for (const [re, rep] of PATTERNS) out = out.replace(re, rep);
31+
return out;
32+
}
33+
34+
function redactDeep(value: unknown): unknown {
35+
if (typeof value === "string") return redactSecrets(value);
36+
if (Array.isArray(value)) return value.map(redactDeep);
37+
if (value && typeof value === "object") {
38+
return Object.fromEntries(
39+
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, redactDeep(v)]),
40+
);
41+
}
42+
return value;
43+
}
44+
45+
function toText(value: unknown): string {
46+
if (typeof value === "string") return value;
47+
try {
48+
return JSON.stringify(value) ?? "";
49+
} catch {
50+
return String(value);
51+
}
52+
}
53+
54+
function truncateTo(text: string, bytes: number): { text: string; truncated: boolean } {
55+
const buf = Buffer.from(text, "utf8");
56+
if (buf.length <= bytes) return { text, truncated: false };
57+
return { text: buf.subarray(0, bytes).toString("utf8") + "…", truncated: true };
58+
}
59+
60+
export function applyCapturePolicy(
61+
input: { toolName: string; args: unknown; result: unknown },
62+
state: { spentBytes: number },
63+
budget: CaptureBudget = DEFAULT_CAPTURE_BUDGET,
64+
): {
65+
args: unknown;
66+
result?: string;
67+
outputBytes: number;
68+
truncated: boolean;
69+
withheld: "not-allowlisted" | "budget" | null;
70+
} {
71+
const args = redactDeep(input.args);
72+
const raw = toText(input.result);
73+
const outputBytes = Buffer.byteLength(raw, "utf8");
74+
if (!OUTPUT_ALLOWLIST.has(input.toolName)) {
75+
return { args, outputBytes, truncated: false, withheld: "not-allowlisted" };
76+
}
77+
if (state.spentBytes >= budget.perRunBytes) {
78+
return { args, outputBytes, truncated: false, withheld: "budget" };
79+
}
80+
const { text, truncated } = truncateTo(redactSecrets(raw), budget.perStepBytes);
81+
state.spentBytes += Buffer.byteLength(text, "utf8");
82+
return { args, result: text, outputBytes, truncated, withheld: null };
83+
}

0 commit comments

Comments
 (0)