Skip to content

Commit 424df45

Browse files
committed
Archive authorized primary-session evidence before lossy transforms
Capture the post-policy representation that enters primary history — messages, tool args lifecycle, assistant text, structured/error results, MCP blocks, and attachment/overflow blob provenance — into a session-owned evidence archive with completeness certificates. Workers omit the hook.
1 parent 189df62 commit 424df45

17 files changed

Lines changed: 1399 additions & 32 deletions

docs/IMPLEMENTATION.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ src/
8080
state.ts RunState JSON save/load
8181
compactor.ts Context compactor
8282
summarizer.ts Model-backed structured compaction summary (+ deterministic fallback)
83+
compaction-archive.ts Primary-only authorized evidence archive (post-policy capture)
84+
compaction-archive-schema.ts Archive occurrence / completeness certificate schemas
8385
run-sink.ts Run-level event sink
8486
stream-consumer.ts Async stream consumer with error handling
8587
hooks.ts Lifecycle hooks: discovery, turn collector, run summary

src/agent/posix-tool-plugins.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from "../plugins/read-file-guard-plugin.js";
2424
import type { PermissionGate } from "../permission/gate.js";
2525
import { createWorktreeRootsProvider } from "../permission/worktree-roots.js";
26+
import type { CompactionArchive } from "../session/compaction-archive.js";
2627

2728
export interface CorePosixToolPluginsArgs {
2829
cwd: string;
@@ -37,6 +38,8 @@ export interface CorePosixToolPluginsArgs {
3738
getContextDir?: () => string | undefined;
3839
// Per-project settings.env, merged into the run_shell spawn environment.
3940
shellEnv?: Record<string, string>;
41+
/** Primary-only evidence archive; workers omit this getter. */
42+
getEvidenceArchive?: () => CompactionArchive | undefined;
4043
}
4144

4245
// Middleware order matches docs/ARCHITECTURE.md: path escape through truncation,
@@ -71,6 +74,7 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP
7174
getBlobWriter,
7275
getContextDir,
7376
shellEnv,
77+
getEvidenceArchive,
7478
} = args;
7579
// Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell
7680
// cwd are not hard-denied after the gate already auto-allows. Pass a live
@@ -79,10 +83,11 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP
7983
// regardless.
8084
const allowOutside = (): boolean => permissionGate.getSkipPermissions();
8185
const truncationOptions =
82-
getBlobWriter !== undefined || getContextDir !== undefined
86+
getBlobWriter !== undefined || getContextDir !== undefined || getEvidenceArchive !== undefined
8387
? {
8488
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
8589
...(getContextDir !== undefined ? { getContextDir } : {}),
90+
...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}),
8691
}
8792
: {};
8893
return [

src/agent/tools.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { buildCorePosixToolPlugins } from "./posix-tool-plugins.js";
2222
import { createLazyBlobReader } from "./lazy-blob-reader.js";
2323
import type { BlobReader } from "@intx/types/runtime";
2424
import type { SpillBlobWriter } from "../plugins/result-truncation-plugin.js";
25+
import type { CompactionArchive } from "../session/compaction-archive.js";
2526
import {
2627
connectMCPServer as connectMCPClient,
2728
type MCPClient,
@@ -157,6 +158,8 @@ export interface AgentToolsetArgs {
157158
getContextDir?: () => string | undefined;
158159
// Per-project settings.env, merged into the run_shell tool's spawn environment.
159160
shellEnv?: Record<string, string>;
161+
/** Primary-only evidence archive; workers omit this getter. */
162+
getEvidenceArchive?: () => CompactionArchive | undefined;
160163
// Whether a workflow is currently running. submit_output rides the wire
161164
// every turn (workflow or not), so the model can call it with nothing active;
162165
// this lets its handler report an honest no-op instead of a false advance.
@@ -270,6 +273,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
270273
getBlobReader,
271274
getBlobWriter,
272275
getContextDir,
276+
getEvidenceArchive,
273277
sessionMode = "orchestrator",
274278
shellEnv,
275279
toolAvailability = { languageServerAvailable: true },
@@ -335,6 +339,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
335339
: {}),
336340
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
337341
...(getContextDir !== undefined ? { getContextDir } : {}),
342+
...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}),
338343
...(shellEnv !== undefined ? { shellEnv } : {}),
339344
}),
340345
});
@@ -801,6 +806,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
801806
const mcpTools = mcpClientToAgentTools(result.client, permissionGate, {
802807
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
803808
...(getContextDir !== undefined ? { getContextDir } : {}),
809+
...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}),
804810
...(isBuiltinExaMCPServer(config) ? { excludeToolNames: ["web_fetch_exa"] } : {}),
805811
});
806812
dynamicRunner.addTools(mcpTools);

src/exec/runner.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
loadSessionLocalSettings,
6767
resolveLiveSessionSources,
6868
} from "../session/assemble-runtime.js";
69+
import type { CompactionArchive } from "../session/compaction-archive.js";
6970
import { emitPluginWarningSummary } from "../plugins/diagnostics.js";
7071
import { createModelSummarizer } from "../session/summarizer.js";
7172
import { ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js";
@@ -427,6 +428,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
427428

428429
let currentAgent: Agent | null = null;
429430
let currentStorage: ContextStore | null = null;
431+
const evidenceArchiveHolder: { current?: CompactionArchive } = {};
430432

431433
const overlay = resolveExecDirectorOverlay(config.director);
432434

@@ -440,6 +442,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
440442
...(toolWatchdog !== undefined ? { toolWatchdog } : {}),
441443
...(localSettingsForMode?.env !== undefined ? { shellEnv: localSettingsForMode.env } : {}),
442444
getBlobWriter: () => currentStorage?.writeBlob,
445+
getEvidenceArchive: () => evidenceArchiveHolder.current,
443446
getContextDir: () => workdir,
444447
getBlobReader: () => {
445448
if (currentAgent === null) {
@@ -587,6 +590,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
587590
currentAgent = agent;
588591
currentStorage = storage;
589592
},
593+
evidenceArchiveHolder,
590594
});
591595

592596
const emitter = new EventEmitter();

src/mcp/client.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,25 @@ export interface MCPTool {
1818
inputSchema: Record<string, unknown>;
1919
annotations?: McpToolAnnotations;
2020
}
21+
export interface MCPContentBlock {
22+
type: string;
23+
text?: string;
24+
[key: string]: unknown;
25+
}
26+
2127
export interface MCPClient {
2228
serverName: string;
2329
tools: MCPTool[];
2430
call(toolName: string, args: Record<string, unknown>, signal: AbortSignal): Promise<string>;
31+
/** Validated content blocks before flattening — for post-policy archive capture. */
32+
callBlocks?(
33+
toolName: string,
34+
args: Record<string, unknown>,
35+
signal: AbortSignal,
36+
): Promise<MCPContentBlock[]>;
2537
close(): Promise<void>;
2638
}
39+
2740
export type MCPConnectResult =
2841
{ ok: true; client: MCPClient } | { ok: false; serverName: string; error: string };
2942
export interface MCPConnectOptions {
@@ -58,6 +71,23 @@ export function unwrapToolContent(content: unknown): string {
5871
.join("\n");
5972
}
6073

74+
export function validateMcpContentBlocks(content: unknown): MCPContentBlock[] {
75+
if (!Array.isArray(content)) return [];
76+
const out: MCPContentBlock[] = [];
77+
for (const block of content) {
78+
if (block === null || typeof block !== "object") continue;
79+
const type = (block as { type?: unknown }).type;
80+
if (typeof type !== "string") continue;
81+
const copy: MCPContentBlock = { type };
82+
for (const [key, value] of Object.entries(block)) {
83+
if (key === "type") continue;
84+
copy[key] = value;
85+
}
86+
out.push(copy);
87+
}
88+
return out;
89+
}
90+
6191
interface HTTPAuthContext {
6292
url: URL;
6393
authProvider: CorbitsOAuthProvider;
@@ -191,12 +221,19 @@ async function finishClient(
191221
return {
192222
serverName,
193223
tools,
224+
async callBlocks(toolName, args, signal) {
225+
const context = authContext === undefined ? undefined : { ...authContext, signal };
226+
const result = await withHTTPAuthorizationRecovery(context, () =>
227+
client.callTool({ name: toolName, arguments: args }, undefined, { signal }),
228+
);
229+
return validateMcpContentBlocks(result.content);
230+
},
194231
async call(toolName, args, signal) {
195232
const context = authContext === undefined ? undefined : { ...authContext, signal };
196233
const result = await withHTTPAuthorizationRecovery(context, () =>
197234
client.callTool({ name: toolName, arguments: args }, undefined, { signal }),
198235
);
199-
return unwrapToolContent(result.content);
236+
return unwrapToolContent(validateMcpContentBlocks(result.content));
200237
},
201238
async close() {
202239
authContext?.callback.close();

src/mcp/plugin.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ function fakeClient(reply: string): MCPClient {
1717
},
1818
],
1919
call: async () => reply,
20+
callBlocks: async () => [{ type: "text", text: reply }],
2021
close: async () => undefined,
2122
};
2223
}

src/mcp/plugin.ts

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,43 @@ import type { AgentTool } from "@intx/agent";
22
import type { ToolCall, ToolResult } from "@intx/types/runtime";
33
import type { PermissionGate } from "../permission/gate.js";
44
import { gateToolCall } from "../plugins/permission-plugin.js";
5-
import { scrubSecretShapedContent } from "../plugins/tool-result-secret-scrub.js";
5+
import {
6+
scrubSecretShapedContent,
7+
scrubSecretShapedValue,
8+
} from "../plugins/tool-result-secret-scrub.js";
69
import {
710
truncateToolResultContent,
811
type SpillBlobWriter,
912
} from "../plugins/result-truncation-plugin.js";
10-
import type { MCPClient } from "./client.js";
13+
import type { CompactionArchive } from "../session/compaction-archive.js";
14+
import type { MCPClient, MCPContentBlock } from "./client.js";
1115
import { mcpToolName } from "./tool-name.js";
16+
import { unwrapToolContent } from "./client.js";
1217

1318
export interface McpSpillOptions {
1419
getBlobWriter?: () => SpillBlobWriter | undefined;
1520
getContextDir?: () => string | undefined;
1621
excludeToolNames?: readonly string[];
22+
/** Primary-only evidence archive; workers omit this getter. */
23+
getEvidenceArchive?: () => CompactionArchive | undefined;
24+
}
25+
26+
function applyPolicyToBlocks(blocks: MCPContentBlock[]): MCPContentBlock[] {
27+
return blocks.map((block) => {
28+
const next = { ...block };
29+
if (typeof next.text === "string") {
30+
next.text = scrubSecretShapedContent(next.text);
31+
}
32+
for (const [key, value] of Object.entries(next)) {
33+
if (key === "type" || key === "text") continue;
34+
if (typeof value === "string") {
35+
next[key] = scrubSecretShapedContent(value);
36+
} else if (value !== null && typeof value === "object") {
37+
next[key] = scrubSecretShapedValue(value);
38+
}
39+
}
40+
return next;
41+
});
1742
}
1843

1944
// MCP results never reach the posix runner, so the secret-scrub and truncation
@@ -35,7 +60,7 @@ export function mcpClientToAgentTools(
3560
gate: PermissionGate,
3661
spillOptions: McpSpillOptions = {},
3762
): AgentTool[] {
38-
const { getBlobWriter, getContextDir, excludeToolNames = [] } = spillOptions;
63+
const { getBlobWriter, getContextDir, excludeToolNames = [], getEvidenceArchive } = spillOptions;
3964
const excluded = new Set(excludeToolNames);
4065

4166
return client.tools
@@ -50,7 +75,26 @@ export function mcpClientToAgentTools(
5075
handler: (call: ToolCall, signal: AbortSignal): Promise<ToolResult> =>
5176
gateToolCall(gate, call, signal, async () => {
5277
try {
53-
const content = await client.call(tool.name, call.arguments, signal);
78+
const rawBlocks =
79+
typeof client.callBlocks === "function"
80+
? await client.callBlocks(tool.name, call.arguments, signal)
81+
: [
82+
{
83+
type: "text",
84+
text: await client.call(tool.name, call.arguments, signal),
85+
} satisfies MCPContentBlock,
86+
];
87+
const authorizedBlocks = applyPolicyToBlocks(rawBlocks);
88+
const archive = getEvidenceArchive?.();
89+
if (archive !== undefined) {
90+
await archive.recordAuthorizedPayload({
91+
kind: "tool_result",
92+
payload: { blocks: authorizedBlocks },
93+
callId: call.id,
94+
provenance: "mcp:post-policy-pre-flatten",
95+
});
96+
}
97+
const flattened = unwrapToolContent(authorizedBlocks);
5498
const writeBlob = getBlobWriter?.();
5599
const contextDir = getContextDir?.();
56100
const spill =
@@ -61,14 +105,30 @@ export function mcpClientToAgentTools(
61105
...(contextDir !== undefined ? { contextDir } : {}),
62106
}
63107
: undefined;
64-
return { callId: call.id, content: await sanitizeMcpResultContent(content, spill) };
108+
// Content is already scrubbed; truncate only (avoid double-scrub).
109+
const content = await truncateToolResultContent(flattened, undefined, spill);
110+
return { callId: call.id, content };
65111
} catch (err) {
112+
const message = err instanceof Error ? err.message : String(err);
113+
const scrubbed = scrubSecretShapedContent(message);
114+
const archive = getEvidenceArchive?.();
115+
if (archive !== undefined) {
116+
await archive.recordAuthorizedPayload({
117+
kind: "tool_result",
118+
payload: scrubbed,
119+
callId: call.id,
120+
provenance: "mcp:error",
121+
});
122+
}
66123
return {
67124
callId: call.id,
68-
content: err instanceof Error ? err.message : String(err),
125+
content: scrubbed,
69126
isError: true,
70127
};
71128
}
72129
}),
73130
}));
74131
}
132+
133+
// Keep sanitize helper exported for tests that still call the string path.
134+
export { sanitizeMcpResultContent };

src/plugins/result-truncation-plugin.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type MaterializedToolResult,
77
} from "./tool-result-materialize.js";
88
import { scrubSecretShapedContent } from "./tool-result-secret-scrub.js";
9+
import { hashAuthorizedBytes, type CompactionArchive } from "../session/compaction-archive.js";
910

1011
const TRUNCATABLE_TOOLS = new Set(["read_file", "grep", "run_shell", "search_files", "web_fetch"]);
1112

@@ -206,13 +207,40 @@ export interface ResultTruncationPluginOptions {
206207
// Live getter for the absolute session context dir, re-read like
207208
// getBlobWriter so rotation picks up the new path for the notice.
208209
getContextDir?: () => string | undefined;
210+
/** Primary-only evidence archive; workers omit this getter. */
211+
getEvidenceArchive?: () => CompactionArchive | undefined;
212+
}
213+
214+
async function archiveAuthorizedResult(
215+
archive: CompactionArchive | undefined,
216+
callId: string,
217+
content: string | Record<string, unknown>,
218+
isError: boolean | undefined,
219+
): Promise<void> {
220+
if (archive === undefined) return;
221+
await archive.recordAuthorizedPayload({
222+
kind: "tool_result",
223+
payload: content,
224+
callId,
225+
provenance: isError === true ? "posix:error" : "posix:post-policy-pre-truncation",
226+
});
209227
}
210228

211229
export function resultTruncationPlugin(options: ResultTruncationPluginOptions = {}): ToolPlugin {
212-
const { getBlobWriter, getContextDir } = options;
230+
const { getBlobWriter, getContextDir, getEvidenceArchive } = options;
213231
return {
214232
middleware: (next) => async (call, signal) => {
215233
const result = await next(call, signal);
234+
const archive = getEvidenceArchive?.();
235+
236+
if (TRUNCATABLE_TOOLS.has(call.name)) {
237+
if (typeof result.content === "string") {
238+
await archiveAuthorizedResult(archive, call.id, result.content, result.isError);
239+
} else if (result.content !== null && typeof result.content === "object") {
240+
await archiveAuthorizedResult(archive, call.id, result.content, result.isError);
241+
}
242+
}
243+
216244
if (!TRUNCATABLE_TOOLS.has(call.name) || result.isError) return result;
217245

218246
const writeBlob = getBlobWriter?.();
@@ -230,11 +258,20 @@ export function resultTruncationPlugin(options: ResultTruncationPluginOptions =
230258
if (typeof content === "string") {
231259
const truncated = await truncateToolResultContent(content, MAX_RESULT_CHARS, spill);
232260
if (truncated === content) return result;
261+
if (archive !== undefined && spill !== undefined && content.length > MAX_RESULT_CHARS) {
262+
await archive.recordExistingBlobReference({
263+
kind: "overflow_blob",
264+
blobKey: spillBlobKey(call.id),
265+
contentHash: hashAuthorizedBytes(new TextEncoder().encode(content)),
266+
callId: call.id,
267+
provenance: "result-truncation:full",
268+
});
269+
}
233270
return { ...result, content: truncated };
234271
}
235272

236273
if (content !== null && typeof content === "object") {
237-
const record = content as Record<string, unknown>;
274+
const record = content;
238275
const compact = JSON.stringify(record);
239276
if (compact.length <= MAX_RESULT_CHARS) return result;
240277
const truncated = await truncateToolResultRecord(record, MAX_RESULT_CHARS, spill);

0 commit comments

Comments
 (0)