Skip to content

Commit 7cb89b2

Browse files
author
chenbo
committed
Harden sidecar evidence recording
1 parent 4717917 commit 7cb89b2

9 files changed

Lines changed: 326 additions & 53 deletions

File tree

docs/getting-started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ opencode-plusplus --pure
4848

4949
`opencode-plusplus` runs preflight, ensures `.agent-context`, writes `.opencode/plugins/opencode-plusplus.ts`, prepares OpenCode commands/agent files, prints a compact readiness summary, and launches `opencode .`.
5050

51-
The sidecar listens for `tool.execute.before`, `tool.execute.after`, `file.edited`, and `session.idle` events. Before a tool runs, it blocks dangerous commands, hallucinated package scripts / Makefile targets, and protected / secret paths. After a tool runs, it records command evidence under `.agent-context/traces/`, including exit code, timestamps, stdout/stderr hashes, working-tree hashes, and touched files. On idle, it runs dirty/debounced incremental verification.
51+
The sidecar listens for `tool.execute.before`, `tool.execute.after`, `file.edited`, and `session.idle` events. Before a tool runs, it blocks dangerous commands, hallucinated package scripts / Makefile targets, and protected / secret paths. After a tool runs, it records command evidence under `.agent-context/traces/`, including exit code when available, timestamps, stdout/stderr hashes, sanitized and truncated output previews, working-tree hashes, and touched files. Long stdout/stderr is passed through a JSON evidence file instead of CLI arguments. On idle, it runs dirty/debounced incremental verification.
5252

5353
For the full interactive flow, generated files, and mode comparison, read [OpenCode Transparent Sidecar Mode](integrations/opencode-sidecar.md).
5454

docs/integrations/opencode-sidecar.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ opencode-plusplus
6464
The generated OpenCode plugin listens for:
6565

6666
- `tool.execute.before`: blocks dangerous commands, hallucinated package scripts / Makefile targets, protected paths, and secret paths.
67-
- `tool.execute.after`: records command, exit code, timestamps, stdout/stderr hashes, working-tree hashes, and touched files.
67+
- `tool.execute.after`: records command, exit code when available, timestamps, stdout/stderr hashes, sanitized and truncated output previews, working-tree hashes, and touched files. Large output is passed to `record-tool` through a JSON evidence file instead of command-line arguments.
6868
- `file.edited` and `file.watcher.updated`: marks the repository dirty.
6969
- `session.idle`: runs dirty/debounced incremental verification.
7070

@@ -82,10 +82,11 @@ The generated OpenCode plugin listens for:
8282
.agent-context/sidecar/hallucination.md
8383
.agent-context/sidecar/regression.md
8484
.agent-context/traces/opencode-sidecar-events.jsonl
85+
.agent-context/traces/tool-evidence/opencode-tool-*.json
8586
.agent-context/traces/opencode-session-<id>.json
8687
```
8788

88-
`.agent-context/traces/opencode-sidecar-events.jsonl` is the low-level event stream. `.agent-context/traces/opencode-session-<id>.json` is the normalized execution trace consumed by Evidence Guard and Policy Engine.
89+
`.agent-context/traces/opencode-sidecar-events.jsonl` is the low-level event stream. `.agent-context/traces/tool-evidence/opencode-tool-*.json` stores sanitized after-tool evidence payloads used by `record-tool`; it does not store raw long stdout/stderr. `.agent-context/traces/opencode-session-<id>.json` is the normalized execution trace consumed by Evidence Guard and Policy Engine.
8990

9091
## Common Commands
9192

docs/reference/cli-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ Use `--quiet` for sidecar automation; it only prints when blockers are found.
9797

9898
`opencode-plusplus sidecar check-command` is the pre-execution command guard used by the OpenCode sidecar `tool.execute.before` hook. It checks package scripts from `package.json`, Makefile targets, dangerous shell patterns, and protected / secret paths. It exits non-zero when execution should be blocked.
9999

100-
`opencode-plusplus sidecar record-tool` is an internal post-execution evidence recorder used by the OpenCode sidecar `tool.execute.after` hook. It writes `.agent-context/traces/opencode-sidecar-events.jsonl` and `.agent-context/traces/opencode-session-<id>.json` with command, exit code, timestamps, stdout/stderr hashes, working-tree hashes, and touched files. Users normally do not call it manually.
100+
`opencode-plusplus sidecar record-tool` is an internal post-execution evidence recorder used by the OpenCode sidecar `tool.execute.after` hook. The sidecar normally calls it with `--input-json <path>` so long stdout/stderr is not exposed through command-line arguments. It writes `.agent-context/traces/opencode-sidecar-events.jsonl`, `.agent-context/traces/tool-evidence/opencode-tool-*.json`, and `.agent-context/traces/opencode-session-<id>.json` with command, exit code when available, timestamps, stdout/stderr hashes, sanitized/truncated output previews, working-tree hashes, and touched files. Missing exit code is recorded as `unknown`, not success. Users normally do not call it manually.
101101

102102
## OpenCode Preset
103103

src/cli/commands/sidecar.ts

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Command } from "commander";
2+
import { readFileSync } from "node:fs";
23
import {
34
checkOpencodeSidecarCommand,
45
recordOpencodeSidecarTool,
@@ -46,7 +47,7 @@ export function registerSidecarCommands(program: Command): void {
4647
sidecar
4748
.command("record-tool")
4849
.argument("[repo]", "repository path", ".")
49-
.requiredOption("--tool <tool>", "OpenCode tool name that just executed")
50+
.option("--tool <tool>", "OpenCode tool name that just executed")
5051
.option("--command <command>", "command executed by the tool")
5152
.option("--exit-code <code>", "tool or command exit code", parseNullableInteger)
5253
.option("--started-at <iso>", "tool start timestamp")
@@ -55,10 +56,17 @@ export function registerSidecarCommands(program: Command): void {
5556
.option("--stderr <text>", "captured stderr text")
5657
.option("--stdout-hash <sha256>", "stdout content hash")
5758
.option("--stderr-hash <sha256>", "stderr content hash")
59+
.option("--stdout-preview <text>", "sanitized stdout preview")
60+
.option("--stderr-preview <text>", "sanitized stderr preview")
61+
.option("--stdout-truncated", "stdout preview was truncated")
62+
.option("--stderr-truncated", "stderr preview was truncated")
63+
.option("--stdout-redacted", "stdout preview was redacted")
64+
.option("--stderr-redacted", "stderr preview was redacted")
5865
.option("--working-tree-hash-before <sha256>", "working tree hash before tool execution")
5966
.option("--working-tree-hash-after <sha256>", "working tree hash after tool execution")
6067
.option("--session-id <id>", "OpenCode session id")
6168
.option("--path <path...>", "path(s) touched by the tool")
69+
.option("--input-json <path>", "JSON evidence payload path produced by the OpenCode++ sidecar plugin")
6270
.option("--json", "print machine-readable tool record result")
6371
.description("Record OpenCode tool.execute.after evidence into sidecar event logs and execution traces.")
6472
.action(
@@ -74,29 +82,106 @@ export function registerSidecarCommands(program: Command): void {
7482
stderr?: string;
7583
stdoutHash?: string;
7684
stderrHash?: string;
85+
stdoutPreview?: string;
86+
stderrPreview?: string;
87+
stdoutTruncated?: boolean;
88+
stderrTruncated?: boolean;
89+
stdoutRedacted?: boolean;
90+
stderrRedacted?: boolean;
7791
workingTreeHashBefore?: string;
7892
workingTreeHashAfter?: string;
7993
sessionId?: string;
8094
path?: string[];
95+
inputJson?: string;
8196
json?: boolean;
8297
}
8398
) => {
99+
const payload = readRecordToolInput(options.inputJson);
84100
const result = recordOpencodeSidecarTool(repo, {
85-
tool: options.tool,
86-
command: options.command,
87-
exitCode: options.exitCode,
88-
startedAt: options.startedAt,
89-
finishedAt: options.finishedAt,
90-
stdout: options.stdout,
91-
stderr: options.stderr,
92-
stdoutHash: options.stdoutHash,
93-
stderrHash: options.stderrHash,
94-
workingTreeHashBefore: options.workingTreeHashBefore,
95-
workingTreeHashAfter: options.workingTreeHashAfter,
96-
sessionId: options.sessionId,
97-
paths: options.path
101+
...payload,
102+
tool: options.tool ?? payload.tool,
103+
command: options.command ?? payload.command,
104+
exitCode: options.exitCode ?? payload.exitCode,
105+
startedAt: options.startedAt ?? payload.startedAt,
106+
finishedAt: options.finishedAt ?? payload.finishedAt,
107+
stdout: options.stdout ?? payload.stdout,
108+
stderr: options.stderr ?? payload.stderr,
109+
stdoutHash: options.stdoutHash ?? payload.stdoutHash,
110+
stderrHash: options.stderrHash ?? payload.stderrHash,
111+
stdoutPreview: options.stdoutPreview ?? payload.stdoutPreview,
112+
stderrPreview: options.stderrPreview ?? payload.stderrPreview,
113+
stdoutTruncated: options.stdoutTruncated ?? payload.stdoutTruncated,
114+
stderrTruncated: options.stderrTruncated ?? payload.stderrTruncated,
115+
stdoutRedacted: options.stdoutRedacted ?? payload.stdoutRedacted,
116+
stderrRedacted: options.stderrRedacted ?? payload.stderrRedacted,
117+
workingTreeHashBefore: options.workingTreeHashBefore ?? payload.workingTreeHashBefore,
118+
workingTreeHashAfter: options.workingTreeHashAfter ?? payload.workingTreeHashAfter,
119+
sessionId: options.sessionId ?? payload.sessionId,
120+
paths: options.path ?? payload.paths
98121
});
99122
console.log(options.json ? JSON.stringify(result, null, 2) : renderOpencodeSidecarToolRecord(result));
100123
}
101124
);
102125
}
126+
127+
function readRecordToolInput(inputJson?: string): RecordToolInputPayload {
128+
if (!inputJson) return {};
129+
const parsed = JSON.parse(readFileSync(inputJson, "utf8")) as Record<string, unknown>;
130+
return {
131+
tool: stringValue(parsed.tool),
132+
command: stringValue(parsed.command),
133+
exitCode: numberOrNull(parsed.exitCode),
134+
startedAt: stringValue(parsed.startedAt),
135+
finishedAt: stringValue(parsed.finishedAt),
136+
stdout: stringValue(parsed.stdout),
137+
stderr: stringValue(parsed.stderr),
138+
stdoutHash: stringValue(parsed.stdoutHash),
139+
stderrHash: stringValue(parsed.stderrHash),
140+
stdoutPreview: stringValue(parsed.stdoutPreview),
141+
stderrPreview: stringValue(parsed.stderrPreview),
142+
stdoutTruncated: booleanValue(parsed.stdoutTruncated),
143+
stderrTruncated: booleanValue(parsed.stderrTruncated),
144+
stdoutRedacted: booleanValue(parsed.stdoutRedacted),
145+
stderrRedacted: booleanValue(parsed.stderrRedacted),
146+
workingTreeHashBefore: stringValue(parsed.workingTreeHashBefore),
147+
workingTreeHashAfter: stringValue(parsed.workingTreeHashAfter),
148+
sessionId: stringValue(parsed.sessionId),
149+
paths: Array.isArray(parsed.paths) ? parsed.paths.filter((item): item is string => typeof item === "string") : undefined
150+
};
151+
}
152+
153+
interface RecordToolInputPayload {
154+
tool?: string;
155+
command?: string;
156+
exitCode?: number | null;
157+
startedAt?: string;
158+
finishedAt?: string;
159+
stdout?: string;
160+
stderr?: string;
161+
stdoutHash?: string;
162+
stderrHash?: string;
163+
stdoutPreview?: string;
164+
stderrPreview?: string;
165+
stdoutTruncated?: boolean;
166+
stderrTruncated?: boolean;
167+
stdoutRedacted?: boolean;
168+
stderrRedacted?: boolean;
169+
workingTreeHashBefore?: string;
170+
workingTreeHashAfter?: string;
171+
sessionId?: string;
172+
paths?: string[];
173+
}
174+
175+
function stringValue(value: unknown): string | undefined {
176+
return typeof value === "string" ? value : undefined;
177+
}
178+
179+
function booleanValue(value: unknown): boolean | undefined {
180+
return typeof value === "boolean" ? value : undefined;
181+
}
182+
183+
function numberOrNull(value: unknown): number | null | undefined {
184+
if (typeof value === "number") return value;
185+
if (value === null) return null;
186+
return undefined;
187+
}

src/harness/observability/execution-trace.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ export interface ExecutionTraceStep {
2828
finishedAt?: string;
2929
stdoutHash?: string;
3030
stderrHash?: string;
31+
stdoutPreview?: string;
32+
stderrPreview?: string;
33+
stdoutTruncated?: boolean;
34+
stderrTruncated?: boolean;
35+
stdoutRedacted?: boolean;
36+
stderrRedacted?: boolean;
3137
workingTreeHashBefore?: string;
3238
workingTreeHashAfter?: string;
3339
}
@@ -66,6 +72,12 @@ export interface TraceStepInput {
6672
finishedAt?: string;
6773
stdoutHash?: string;
6874
stderrHash?: string;
75+
stdoutPreview?: string;
76+
stderrPreview?: string;
77+
stdoutTruncated?: boolean;
78+
stderrTruncated?: boolean;
79+
stdoutRedacted?: boolean;
80+
stderrRedacted?: boolean;
6981
workingTreeHashBefore?: string;
7082
workingTreeHashAfter?: string;
7183
}
@@ -138,6 +150,12 @@ export function appendExecutionTraceStep(root: string, traceId: string, input: T
138150
finishedAt: input.finishedAt,
139151
stdoutHash: input.stdoutHash,
140152
stderrHash: input.stderrHash,
153+
stdoutPreview: input.stdoutPreview,
154+
stderrPreview: input.stderrPreview,
155+
stdoutTruncated: input.stdoutTruncated,
156+
stderrTruncated: input.stderrTruncated,
157+
stdoutRedacted: input.stdoutRedacted,
158+
stderrRedacted: input.stderrRedacted,
141159
workingTreeHashBefore: input.workingTreeHashBefore,
142160
workingTreeHashAfter: input.workingTreeHashAfter
143161
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { createHash } from "node:crypto";
2+
3+
const PREVIEW_EDGE_CHARS = 400;
4+
const REDACTION_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
5+
{ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gi, replacement: "[REDACTED_PRIVATE_KEY]" },
6+
{ pattern: /\bauthorization\s*:\s*(?:Bearer|Basic)?\s*[^ \r\n]+/gi, replacement: "[REDACTED_AUTH]" },
7+
{ pattern: /\b(?:api[_-]?key|token|secret|password|passwd|pwd|cookie|authorization)\s*[:=]\s*["']?[^"'\s]+/gi, replacement: "[REDACTED_SECRET]" },
8+
{ pattern: /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, replacement: "[REDACTED_AUTH]" },
9+
{ pattern: /\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/g, replacement: "[REDACTED_JWT]" },
10+
{ pattern: /\b(?:sk|pk|ghp|gho|github_pat|xox[baprs])[-_A-Za-z0-9]{16,}\b/gi, replacement: "[REDACTED_TOKEN]" }
11+
];
12+
13+
export interface SanitizedOutput {
14+
hash: string;
15+
preview: string;
16+
truncated: boolean;
17+
redacted: boolean;
18+
bytes: number;
19+
}
20+
21+
export function sanitizeToolOutput(value: unknown): SanitizedOutput {
22+
const text = String(value ?? "");
23+
const hash = hashText(text);
24+
const redacted = redactSecrets(text);
25+
const truncated = redacted.text.length > PREVIEW_EDGE_CHARS * 2;
26+
const preview = truncated
27+
? `${redacted.text.slice(0, PREVIEW_EDGE_CHARS)}\n...[truncated ${redacted.text.length - PREVIEW_EDGE_CHARS * 2} chars]...\n${redacted.text.slice(-PREVIEW_EDGE_CHARS)}`
28+
: redacted.text;
29+
return {
30+
hash,
31+
preview,
32+
truncated,
33+
redacted: redacted.redacted,
34+
bytes: Buffer.byteLength(text, "utf8")
35+
};
36+
}
37+
38+
export function hashText(text: unknown): string {
39+
return createHash("sha256")
40+
.update(String(text ?? ""))
41+
.digest("hex");
42+
}
43+
44+
function redactSecrets(text: string): { text: string; redacted: boolean } {
45+
let redacted = false;
46+
let result = text;
47+
for (const { pattern, replacement } of REDACTION_PATTERNS) {
48+
result = result.replace(pattern, () => {
49+
redacted = true;
50+
return replacement;
51+
});
52+
}
53+
return { text: result, redacted };
54+
}

src/integrations/opencode/plugin-runtime/index.ts

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { spawnSync } from "node:child_process";
2+
import { mkdirSync, writeFileSync } from "node:fs";
3+
import path from "node:path";
4+
import { sanitizeToolOutput } from "../output-sanitizer.js";
25
import { runCommandGuard } from "./command-guard.js";
36
import { createSidecarRecorder, type OpenCodeSidecarRuntimeContext } from "./events.js";
4-
import { exitCodeFromOutput, outputText, toolKey } from "./evidence.js";
7+
import { exitCodeFromOutput, hashText, outputText, stableJson, toolKey } from "./evidence.js";
58
import { createIdleVerifier } from "./idle-verify.js";
69
import { commandFromTool, pathsFromTool } from "./paths.js";
710
import { currentSidecarWorkingTreeHash } from "./worktree-hash.js";
@@ -37,32 +40,35 @@ export async function createOpenCodePlusPlusSidecar(context: OpenCodeSidecarRunt
3740
};
3841
toolStarts.delete(key);
3942

40-
const cliArgs = [
41-
"sidecar",
42-
"record-tool",
43-
context.directory,
44-
"--json",
45-
"--tool",
46-
String(tool),
47-
"--exit-code",
48-
String(exitCodeFromOutput(output) ?? 0),
49-
"--started-at",
50-
started.startedAt,
51-
"--finished-at",
52-
new Date().toISOString(),
53-
"--working-tree-hash-before",
54-
started.workingTreeHashBefore,
55-
"--working-tree-hash-after",
56-
currentSidecarWorkingTreeHash(context.directory)
57-
];
58-
if (command) cliArgs.push("--command", command);
43+
const finishedAt = new Date().toISOString();
44+
const exitCode = exitCodeFromOutput(output);
5945
const sessionId = inputRecord.sessionID ?? inputRecord.sessionId ?? outputRecord.sessionID ?? outputRecord.sessionId;
60-
if (sessionId) cliArgs.push("--session-id", String(sessionId));
6146
const stdout = outputText(output, ["stdout", "output", "text"]);
6247
const stderr = outputText(output, ["stderr", "error"]);
63-
if (stdout) cliArgs.push("--stdout", stdout);
64-
if (stderr) cliArgs.push("--stderr", stderr);
65-
for (const file of paths) cliArgs.push("--path", file);
48+
const stdoutEvidence = sanitizeToolOutput(stdout);
49+
const stderrEvidence = sanitizeToolOutput(stderr);
50+
const payload = {
51+
tool: String(tool),
52+
command,
53+
exitCode,
54+
startedAt: started.startedAt,
55+
finishedAt,
56+
workingTreeHashBefore: started.workingTreeHashBefore,
57+
workingTreeHashAfter: currentSidecarWorkingTreeHash(context.directory),
58+
sessionId: sessionId ? String(sessionId) : undefined,
59+
paths,
60+
stdoutHash: stdoutEvidence.hash,
61+
stdoutPreview: stdoutEvidence.preview,
62+
stdoutTruncated: stdoutEvidence.truncated,
63+
stdoutRedacted: stdoutEvidence.redacted,
64+
stderrHash: stderrEvidence.hash,
65+
stderrPreview: stderrEvidence.preview,
66+
stderrTruncated: stderrEvidence.truncated,
67+
stderrRedacted: stderrEvidence.redacted
68+
};
69+
const inputJson = writeToolEvidenceInput(context.directory, tool, args, payload);
70+
71+
const cliArgs = ["sidecar", "record-tool", context.directory, "--json", "--input-json", inputJson];
6672

6773
const recordResult = spawnSync("opencode-plusplus", cliArgs, {
6874
cwd: context.directory,
@@ -115,3 +121,11 @@ export async function createOpenCodePlusPlusSidecar(context: OpenCodeSidecarRunt
115121
}
116122
};
117123
}
124+
125+
function writeToolEvidenceInput(directory: string, tool: unknown, args: unknown, payload: Record<string, unknown>): string {
126+
const dir = path.join(directory, ".agent-context", "traces", "tool-evidence");
127+
mkdirSync(dir, { recursive: true });
128+
const file = path.join(dir, `opencode-tool-${Date.now()}-${hashText(`${String(tool ?? "unknown")}:${stableJson(args)}`).slice(0, 12)}.json`);
129+
writeFileSync(file, `${JSON.stringify(payload)}\n`, "utf8");
130+
return file;
131+
}

0 commit comments

Comments
 (0)