Skip to content

Commit b936a4b

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 4f5be60 commit b936a4b

17 files changed

Lines changed: 1440 additions & 58 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
@@ -27,6 +27,7 @@ import {
2727
} from "../plugins/read-file-guard-plugin.js";
2828
import type { PermissionGate } from "../permission/gate.js";
2929
import { createWorktreeRootsProvider } from "../permission/worktree-roots.js";
30+
import type { CompactionArchive } from "../session/compaction-archive.js";
3031

3132
export interface CorePosixToolPluginsArgs {
3233
cwd: string;
@@ -44,6 +45,8 @@ export interface CorePosixToolPluginsArgs {
4445
// Live getter for the background-shell registry (run_shell background:true).
4546
// Omitted makes background runs fail closed in shell-guard.
4647
getBackgroundShellRegistry?: () => BackgroundShellRegistry | undefined;
48+
/** Primary-only evidence archive; workers omit this getter. */
49+
getEvidenceArchive?: () => CompactionArchive | undefined;
4750
}
4851

4952
// Middleware order matches docs/ARCHITECTURE.md: path escape through truncation,
@@ -81,6 +84,7 @@ export function buildCorePosixToolPlugins(
8184
getContextDir,
8285
shellEnv,
8386
getBackgroundShellRegistry,
87+
getEvidenceArchive,
8488
} = args;
8589
// Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell
8690
// cwd are not hard-denied after the gate already auto-allows. Pass a live
@@ -89,10 +93,11 @@ export function buildCorePosixToolPlugins(
8993
// regardless.
9094
const allowOutside = (): boolean => permissionGate.getSkipPermissions();
9195
const truncationOptions =
92-
getBlobWriter !== undefined || getContextDir !== undefined
96+
getBlobWriter !== undefined || getContextDir !== undefined || getEvidenceArchive !== undefined
9397
? {
9498
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
9599
...(getContextDir !== undefined ? { getContextDir } : {}),
100+
...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}),
96101
}
97102
: {};
98103
return [

‎src/agent/tools.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
wrapAgentToolsWithResultTruncation,
2727
type SpillBlobWriter,
2828
} from "../plugins/result-truncation-plugin.js";
29+
import type { CompactionArchive } from "../session/compaction-archive.js";
2930
import {
3031
connectMCPServer as connectMCPClient,
3132
type MCPClient,
@@ -199,6 +200,8 @@ export interface AgentToolsetArgs {
199200
// deliver the exit as a system message so the reactor re-enters on a later
200201
// turn; omit it and background runs still start/collect but never notify.
201202
onBackgroundShellExit?: (exit: BackgroundShellExit) => void;
203+
/** Primary-only evidence archive; workers omit this getter. */
204+
getEvidenceArchive?: () => CompactionArchive | undefined;
202205
// Whether a workflow is currently running. submit_output rides the wire
203206
// every turn (workflow or not), so the model can call it with nothing active;
204207
// this lets its handler report an honest no-op instead of a false advance.
@@ -334,6 +337,7 @@ export async function createAgentToolset(
334337
getBlobReader,
335338
getBlobWriter,
336339
getContextDir,
340+
getEvidenceArchive,
337341
sessionMode = "orchestrator",
338342
shellEnv,
339343
toolAvailability = { languageServerAvailable: true },
@@ -420,6 +424,7 @@ export async function createAgentToolset(
420424
const truncationOptions = {
421425
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
422426
...(getContextDir !== undefined ? { getContextDir } : {}),
427+
...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}),
423428
};
424429
const posixTools = createPosixTools({
425430
cwd,
@@ -435,6 +440,7 @@ export async function createAgentToolset(
435440
? { readFileGuard: { blobReader: sessionBlobReader } }
436441
: {}),
437442
...truncationOptions,
443+
...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}),
438444
...(shellEnv !== undefined ? { shellEnv } : {}),
439445
getBackgroundShellRegistry: () => backgroundShells,
440446
}),
@@ -977,6 +983,7 @@ export async function createAgentToolset(
977983
const mcpTools = mcpClientTools(result.client, {
978984
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
979985
...(getContextDir !== undefined ? { getContextDir } : {}),
986+
...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}),
980987
...(isBuiltinExaMCPServer(config)
981988
? { excludeToolNames: ["web_fetch_exa"] }
982989
: {}),

‎src/exec/runner.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ import {
102102
loadSessionLocalSettings,
103103
resolveLiveSessionSources,
104104
} from "../session/assemble-runtime.js";
105+
import type { CompactionArchive } from "../session/compaction-archive.js";
105106
import { emitPluginWarningSummary } from "../plugins/diagnostics.js";
106107
import { createModelSummarizer } from "../session/summarizer.js";
107108
import { ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js";
@@ -524,6 +525,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
524525

525526
let currentAgent: Agent | null = null;
526527
let currentStorage: ContextStore | null = null;
528+
const evidenceArchiveHolder: { current?: CompactionArchive } = {};
527529

528530
const overlay = resolveExecDirectorOverlay(config.director);
529531
const workflowHostHolder: { instance?: WorkflowHost } = {};
@@ -541,6 +543,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
541543
? { shellEnv: localSettingsForMode.env }
542544
: {}),
543545
getBlobWriter: () => currentStorage?.writeBlob,
546+
getEvidenceArchive: () => evidenceArchiveHolder.current,
544547
getContextDir: () => workdir,
545548
// Background run_shell completions re-enter the reactor on a later turn.
546549
onBackgroundShellExit: (exit) => {
@@ -706,6 +709,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
706709
currentAgent = agent;
707710
currentStorage = storage;
708711
},
712+
evidenceArchiveHolder,
709713
});
710714

711715
const workflowHost = new WorkflowHost({

‎src/mcp/client.ts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ export interface MCPTool {
2121
inputSchema: Record<string, unknown>;
2222
annotations?: McpToolAnnotations;
2323
}
24+
export interface MCPContentBlock {
25+
type: string;
26+
text?: string;
27+
[key: string]: unknown;
28+
}
29+
2430
export interface MCPClient {
2531
serverName: string;
2632
tools: MCPTool[];
@@ -29,8 +35,15 @@ export interface MCPClient {
2935
args: Record<string, unknown>,
3036
signal: AbortSignal,
3137
): Promise<string>;
38+
/** Validated content blocks before flattening — for post-policy archive capture. */
39+
callBlocks?(
40+
toolName: string,
41+
args: Record<string, unknown>,
42+
signal: AbortSignal,
43+
): Promise<MCPContentBlock[]>;
3244
close(): Promise<void>;
3345
}
46+
3447
export type MCPConnectResult =
3548
| { ok: true; client: MCPClient }
3649
| { ok: false; serverName: string; error: string };
@@ -69,6 +82,23 @@ export function unwrapToolContent(content: unknown): string {
6982
.join("\n");
7083
}
7184

85+
export function validateMcpContentBlocks(content: unknown): MCPContentBlock[] {
86+
if (!Array.isArray(content)) return [];
87+
const out: MCPContentBlock[] = [];
88+
for (const block of content) {
89+
if (block === null || typeof block !== "object") continue;
90+
const type = (block as { type?: unknown }).type;
91+
if (typeof type !== "string") continue;
92+
const copy: MCPContentBlock = { type };
93+
for (const [key, value] of Object.entries(block)) {
94+
if (key === "type") continue;
95+
copy[key] = value;
96+
}
97+
out.push(copy);
98+
}
99+
return out;
100+
}
101+
72102
interface HTTPAuthContext {
73103
url: URL;
74104
authProvider: CorbitsOAuthProvider;
@@ -515,6 +545,13 @@ async function finishClient(
515545
return {
516546
serverName,
517547
tools,
548+
async callBlocks(toolName, args, signal) {
549+
const context = authContext === undefined ? undefined : { ...authContext, signal };
550+
const result = await withHTTPAuthorizationRecovery(context, () =>
551+
client.callTool({ name: toolName, arguments: args }, undefined, { signal }),
552+
);
553+
return validateMcpContentBlocks(result.content);
554+
},
518555
async call(toolName, args, signal) {
519556
const result = await withHTTPAuthorizationRecovery(
520557
authContext,
@@ -524,7 +561,7 @@ async function finishClient(
524561
}),
525562
signal,
526563
);
527-
return unwrapToolContent(result.content);
564+
return unwrapToolContent(validateMcpContentBlocks(result.content));
528565
},
529566
async close() {
530567
closeLifecycle?.();

‎src/mcp/plugin.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ function fakeClient(reply: string): MCPClient {
2121
},
2222
],
2323
call: async () => reply,
24+
callBlocks: async () => [{ type: "text", text: reply }],
2425
close: async () => undefined,
2526
};
2627
}

‎src/mcp/plugin.ts‎

Lines changed: 74 additions & 9 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 { gateAgentTools } 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 mcpClientTools(
3560
client: MCPClient,
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
@@ -52,7 +77,30 @@ export function mcpClientTools(
5277
signal: AbortSignal,
5378
): Promise<ToolResult> => {
5479
try {
55-
const content = await client.call(tool.name, call.arguments, signal);
80+
const rawBlocks =
81+
typeof client.callBlocks === "function"
82+
? await client.callBlocks(tool.name, call.arguments, signal)
83+
: [
84+
{
85+
type: "text",
86+
text: await client.call(tool.name, call.arguments, signal),
87+
} satisfies MCPContentBlock,
88+
];
89+
const authorizedBlocks = applyPolicyToBlocks(rawBlocks);
90+
const archive = getEvidenceArchive?.();
91+
if (archive !== undefined) {
92+
try {
93+
await archive.recordAuthorizedPayload({
94+
kind: "tool_result",
95+
payload: { blocks: authorizedBlocks },
96+
callId: call.id,
97+
provenance: "mcp:post-policy-pre-flatten",
98+
});
99+
} catch {
100+
// Archive write must not fail a successful tool result.
101+
}
102+
}
103+
const flattened = unwrapToolContent(authorizedBlocks);
56104
const writeBlob = getBlobWriter?.();
57105
const contextDir = getContextDir?.();
58106
const spill =
@@ -63,14 +111,31 @@ export function mcpClientTools(
63111
...(contextDir !== undefined ? { contextDir } : {}),
64112
}
65113
: undefined;
66-
return {
67-
callId: call.id,
68-
content: await sanitizeMcpResultContent(content, spill),
69-
};
114+
const content = await truncateToolResultContent(
115+
flattened,
116+
undefined,
117+
spill,
118+
);
119+
return { callId: call.id, content };
70120
} catch (err) {
121+
const message = err instanceof Error ? err.message : String(err);
122+
const scrubbed = scrubSecretShapedContent(message);
123+
const archive = getEvidenceArchive?.();
124+
if (archive !== undefined) {
125+
try {
126+
await archive.recordAuthorizedPayload({
127+
kind: "tool_result",
128+
payload: scrubbed,
129+
callId: call.id,
130+
provenance: "mcp:error",
131+
});
132+
} catch {
133+
// Archive write must not fail a successful tool result.
134+
}
135+
}
71136
return {
72137
callId: call.id,
73-
content: err instanceof Error ? err.message : String(err),
138+
content: scrubbed,
74139
isError: true,
75140
};
76141
}

0 commit comments

Comments
 (0)