Zion Boggan zionboggan.com ↗

Redact agent action bodies and capture truncated tool input

The redaction gate scanned only prompt text, so a secret an agent placed
in a Bash command (or any tool input) was captured into the action and
quoted into failures.json evidence, tree.json, and the report unredacted.

Capture a truncated tool-input summary onto each action and run the same
scanText plus applyDecisions pass over every string field in node.actions
(command, file, input) before analysis, feeding those matches into the
findings set so the fail-closed shadow scan also accounts for them. No
secret in an action body can now reach any rendered output.
ff49f57   Zion Boggan committed on Jun 13, 2026 (1 week ago)
src/cli.js +16 -1
@@ -115,8 +115,16 @@ export async function main(argv) {
}
}
+ const ACTION_FIELDS = ['command', 'file', 'input'];
const findings = [];
- for (const node of tree.nodes) findings.push(...scanText(node.text));
+ for (const node of tree.nodes) {
+ findings.push(...scanText(node.text));
+ for (const action of node.actions || []) {
+ for (const field of ACTION_FIELDS) {
+ if (typeof action[field] === 'string') findings.push(...scanText(action[field]));
+ }
+ }
+ }
const interactive = process.stdin.isTTY && process.stderr.isTTY && !opts.redactAuto;
const { decisions, asked, autoRedacted } = await resolveFindings(findings, priorDecisions, {
@@ -135,6 +143,13 @@ export async function main(argv) {
const before = node.text;
node.text = applyDecisions(node.text, findings, decisions);
if (node.text !== before) node.title = makeTitle(node.text);
+ for (const action of node.actions || []) {
+ for (const field of ACTION_FIELDS) {
+ if (typeof action[field] === 'string') {
+ action[field] = applyDecisions(action[field], findings, decisions);
+ }
+ }
+ }
}
analyzeTree(tree);
src/parse.js +36 -0
@@ -210,6 +210,7 @@ function ingestAssistant(session, rec) {
tool: block.name || null,
file: typeof file === 'string' ? file : null,
command: block.name === 'Bash' && typeof input.command === 'string' ? input.command : null,
+ input: summarizeToolInput(block.name, input),
model: synthetic ? null : msg.model || null,
});
}
@@ -219,6 +220,41 @@ function ingestAssistant(session, rec) {
}
}
+const INPUT_CAP = 300;
+
+function summarizeToolInput(tool, input) {
+ if (!input || typeof input !== 'object') return null;
+ let raw;
+ switch (tool) {
+ case 'Bash':
+ raw = typeof input.command === 'string' ? input.command : compactJson(input);
+ break;
+ case 'Edit':
+ raw = typeof input.new_string === 'string' ? input.new_string : compactJson(input);
+ break;
+ case 'Write':
+ raw = typeof input.content === 'string' ? input.content : compactJson(input);
+ break;
+ case 'WebFetch':
+ raw = [input.url, input.prompt].filter((v) => typeof v === 'string').join(' ') || compactJson(input);
+ break;
+ default:
+ raw = compactJson(input);
+ }
+ if (!raw) return null;
+ raw = raw.replace(/\s+/g, ' ').trim();
+ if (!raw) return null;
+ return raw.length > INPUT_CAP ? `${raw.slice(0, INPUT_CAP)}...` : raw;
+}
+
+function compactJson(value) {
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return null;
+ }
+}
+
function flattenUserContent(content) {
if (typeof content === 'string') {
return { text: content, hasImage: false, hasToolResult: false, hasOnlyToolResult: false };
test/treetrace.test.js +58 -1
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
@@ -313,6 +313,63 @@ test('cli: creates the output directory and .treetrace subdirectory when missing
}
});
+test('redaction: the literal phrase "security-risk" is not a false-positive secret', () => {
+ for (const phrase of ['security-risk', 'skip the security-risk step']) {
+ const hard = scanText(phrase).filter((f) => f.severity !== 'soft');
+ assert.deepEqual(hard, [], `"${phrase}" should not match any secret rule (got ${JSON.stringify(hard)})`);
+ }
+});
+
+test('redaction: a real-format GitHub token is caught', () => {
+ const token = 'ghp_0123456789abcdefghijklmnopqrstuvwxyzAB';
+ const hits = scanText(`set the remote with ${token} now`).map((f) => f.ruleId);
+ assert.ok(hits.includes('github-token'), `github-token missed (got ${hits})`);
+});
+
+test('redaction: a token inside a Bash action body is redacted end to end', async () => {
+ const token = 'ghp_0123456789abcdefghijklmnopqrstuvwxyzAB';
+ const lines = [
+ { type: 'summary', summary: 'wire up the remote', leafUuid: 'b3' },
+ {
+ parentUuid: null, isSidechain: false, type: 'user', userType: 'external', uuid: 'b1',
+ sessionId: 'leak-001', timestamp: '2026-06-01T10:00:00.000Z', cwd: '/tmp/demo', gitBranch: 'main', version: '2.1.0',
+ message: { role: 'user', content: 'Point the git remote at my fork.' },
+ },
+ {
+ parentUuid: 'b1', isSidechain: false, type: 'assistant', uuid: 'b2', sessionId: 'leak-001',
+ timestamp: '2026-06-01T10:00:30.000Z',
+ message: {
+ role: 'assistant', model: 'assistant-model', usage: { input_tokens: 100, output_tokens: 50 },
+ content: [
+ { type: 'text', text: 'Setting the remote.' },
+ { type: 'tool_use', id: 'g1', name: 'Bash', input: { command: `git push --force origin main && git remote set-url origin https://x:${token}@github.com/me/fork.git` } },
+ ],
+ },
+ },
+ ];
+ const dir = mkdtempSync(join(tmpdir(), 'treetrace-leak-'));
+ const session = join(dir, 'session.jsonl');
+ writeFileSync(session, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
+ try {
+ const parsed = await parseSessionFile(session, { sessionId: 'leak-001' });
+ const action = parsed.prompts[0].actions.find((a) => a.tool === 'Bash');
+ assert.ok(action, 'expected a captured Bash action');
+ assert.ok(action.command.includes(token), 'fixture should carry the raw token before redaction');
+ assert.ok(typeof action.input === 'string' && action.input.includes(token), 'input summary should carry the command');
+
+ await main(['--file', session, '--dir', dir, '--redact-auto', '--quiet']);
+ const exported = [
+ 'PROMPT_TREE.md', 'TREETRACE_REPORT.md', '.treetrace/tree.json',
+ '.treetrace/failures.json', '.treetrace/lessons.md', '.treetrace/evals.jsonl', '.treetrace/agent-memory.md',
+ ].map((f) => readFileSync(join(dir, f), 'utf8')).join('\n');
+ assert.ok(!exported.includes(token), 'GitHub token leaked from an action body into output');
+ assert.ok(!/ghp_[0-9A-Za-z]/.test(exported), 'a partial GitHub token prefix leaked from an action body into output');
+ assert.ok(exported.includes('[REDACTED:'), 'expected a redaction marker where the action-body token was');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
test('plain transcript fallback parses User:/Assistant: markers', () => {
const session = parsePlainTranscript(
'User: build me a snake game in python\nAssistant: sure, here is the code...\nUser: make the snake blue\nAssistant: done',