Skip to content

Commit eebfac8

Browse files
fix: composite domain-answer key (#7) + transcript secret redaction (#9) (#11)
#7 — DomainAnswerStore keyed answers by question TEXT only, so two different files asking the same question (e.g. "What is the grace period?") collapsed to one answer; answering it in file A silently unblocked file B with A's answer. hashQuestion now takes (filePath, text) and hashes the composite. Same wording in a different file is a distinct question again. #9 — Captured transcripts wrote full prompts/responses to disk unredacted; they can carry pulled-in source and secrets. New redactSecrets() masks known token shapes (sk-ant-, sk-/sk-proj-, ghp_/gho_/…, github_pat_, AKIA/ASIA, AIza, xox*, Bearer values, and SECRET_NAME=value assignments). instrumentLLMCaller/CodexCaller redact by default (opt out with { redact: false }); the events file is created mode 0600. Tests: 387 → 400. New: hashQuestion file-scoping + path-normalization; redactSecrets per token shape + non-secret passthrough; instrumented callers redact by default and honor redact:false. No skips. Refs Codex review #7, #9 (private KB).
1 parent 7369563 commit eebfac8

9 files changed

Lines changed: 260 additions & 17 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { tmpdir } from 'node:os';
5+
import { instrumentLLMCaller, instrumentCodexCaller } from '../instrumented-callers.js';
6+
import { EventSink, readEvents, type LLMCallEvent } from '../transcript-writer.js';
7+
8+
function withSink<T>(fn: (sink: EventSink, file: string) => Promise<T>): Promise<T> {
9+
const dir = mkdtempSync(join(tmpdir(), 'instr-'));
10+
const file = join(dir, 'events.jsonl');
11+
const sink = new EventSink(file);
12+
return fn(sink, file).finally(() => rmSync(dir, { recursive: true, force: true }));
13+
}
14+
15+
describe('instrumented callers — redaction (Codex #9)', () => {
16+
it('masks secrets in the captured LLM transcript by default', async () => {
17+
await withSink(async (sink, file) => {
18+
const real = {
19+
async call() {
20+
return {
21+
content: 'sure, your key sk-ant-api03-' + 'z'.repeat(24) + ' works',
22+
inputTokens: 1,
23+
outputTokens: 1,
24+
};
25+
},
26+
};
27+
const llm = instrumentLLMCaller(real, sink);
28+
await llm.call('system has GITHUB_TOKEN=ghp_' + 'a'.repeat(36), 'user prompt', 'sonnet');
29+
const events = readEvents(file);
30+
const call = events.find((e) => e.kind === 'llm-call') as LLMCallEvent;
31+
expect(call.responseContent).toContain('«REDACTED»');
32+
expect(call.responseContent).not.toContain('sk-ant-api03');
33+
expect(call.systemPrompt).toContain('«REDACTED»');
34+
expect(call.systemPrompt).not.toContain('ghp_aaaa');
35+
});
36+
});
37+
38+
it('honors { redact: false } (raw capture for trusted/debug runs)', async () => {
39+
await withSink(async (sink, file) => {
40+
const secret = 'sk-ant-api03-' + 'z'.repeat(24);
41+
const real = {
42+
async call() {
43+
return { content: secret, inputTokens: 1, outputTokens: 1 };
44+
},
45+
};
46+
const llm = instrumentLLMCaller(real, sink, { redact: false });
47+
await llm.call('s', 'u', 'sonnet');
48+
const events = readEvents(file);
49+
const call = events.find((e) => e.kind === 'llm-call') as LLMCallEvent;
50+
expect(call.responseContent).toBe(secret);
51+
});
52+
});
53+
54+
it('redacts codex-call prompts too', async () => {
55+
await withSink(async (sink, file) => {
56+
const real = {
57+
async call() {
58+
return { content: 'ok', inputTokens: 0, outputTokens: 0 };
59+
},
60+
};
61+
const codex = instrumentCodexCaller(real, sink);
62+
await codex.call('please use ghp_' + 'b'.repeat(36), 'gpt-4o');
63+
const events = readEvents(file);
64+
const call = events.find((e) => e.kind === 'codex-call') as LLMCallEvent;
65+
expect(call.userPrompt).toContain('«REDACTED»');
66+
expect(call.userPrompt).not.toContain('ghp_bbbb');
67+
});
68+
});
69+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { redactSecrets } from '../redact.js';
3+
4+
describe('redactSecrets (Codex #9)', () => {
5+
it('masks an Anthropic key', () => {
6+
const { text, redactions } = redactSecrets(
7+
'key is sk-ant-api03-AbCdEf0123456789_xyz-more and done',
8+
);
9+
expect(text).not.toContain('sk-ant-api03');
10+
expect(text).toContain('«REDACTED»');
11+
expect(redactions).toBeGreaterThan(0);
12+
});
13+
14+
it('masks an OpenAI key (incl. sk-proj-)', () => {
15+
expect(redactSecrets('sk-proj-abcdefghijklmnopqrstuvwxyz0123').text).toContain(
16+
'«REDACTED»',
17+
);
18+
expect(redactSecrets('sk-abcdefghijklmnopqrstuvwxyz0123').text).not.toContain(
19+
'sk-abcdefghij',
20+
);
21+
});
22+
23+
it('masks GitHub tokens', () => {
24+
const t = redactSecrets('ghp_' + 'A'.repeat(36)).text;
25+
expect(t).toContain('«REDACTED»');
26+
expect(t).not.toContain('ghp_AAAA');
27+
});
28+
29+
it('masks AWS access key ids', () => {
30+
expect(redactSecrets('AKIAIOSFODNN7EXAMPLE').text).toContain('«REDACTED»');
31+
});
32+
33+
it('masks a Bearer header value', () => {
34+
const t = redactSecrets('Authorization: Bearer abcdef0123456789ghijklmnop').text;
35+
expect(t).toContain('«REDACTED»');
36+
expect(t).not.toContain('abcdef0123456789ghij');
37+
});
38+
39+
it('masks the VALUE of a secret-named assignment but keeps the name', () => {
40+
const { text } = redactSecrets('ANTHROPIC_API_KEY=sk-ant-supersecretvalue123');
41+
expect(text).toContain('ANTHROPIC_API_KEY=');
42+
expect(text).not.toContain('supersecretvalue123');
43+
expect(text).toContain('«REDACTED»');
44+
});
45+
46+
it('leaves non-secret text untouched', () => {
47+
const input = 'The function refactors the parser. No secrets here.';
48+
expect(redactSecrets(input).text).toBe(input);
49+
expect(redactSecrets(input).redactions).toBe(0);
50+
});
51+
52+
it('handles multiple secrets in one blob', () => {
53+
const blob = [
54+
'sk-ant-api03-' + 'x'.repeat(20),
55+
'ghp_' + 'y'.repeat(36),
56+
'just some code',
57+
].join('\n');
58+
const { text, redactions } = redactSecrets(blob);
59+
expect(redactions).toBeGreaterThanOrEqual(2);
60+
expect(text).toContain('just some code');
61+
});
62+
});

packages/asil-analyzer/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ export type {
3333
} from './transcript-writer.js';
3434

3535
export { instrumentLLMCaller, instrumentCodexCaller } from './instrumented-callers.js';
36+
export type { InstrumentOptions } from './instrumented-callers.js';
3637
export { wrapBudgetManager } from './budget-instrument.js';
38+
export { redactSecrets, DEFAULT_REDACTION_RULES } from './redact.js';
39+
export type { RedactionRule } from './redact.js';
3740

3841
export {
3942
analyze,

packages/asil-analyzer/src/instrumented-callers.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,24 @@
1212
*/
1313
import type { CodexCaller, LLMCaller } from 'asil-improvement-loop';
1414
import { classifyRole, type EventSink } from './transcript-writer.js';
15+
import { redactSecrets } from './redact.js';
1516

16-
export function instrumentLLMCaller(real: LLMCaller, sink: EventSink): LLMCaller {
17+
export interface InstrumentOptions {
18+
/** Mask secret-shaped tokens before writing to disk. Default true.
19+
* (Codex review #9 — transcripts can contain pulled-in source/secrets.) */
20+
redact?: boolean;
21+
}
22+
23+
function maybeRedact(text: string, redact: boolean): string {
24+
return redact ? redactSecrets(text).text : text;
25+
}
26+
27+
export function instrumentLLMCaller(
28+
real: LLMCaller,
29+
sink: EventSink,
30+
opts: InstrumentOptions = {},
31+
): LLMCaller {
32+
const redact = opts.redact !== false;
1733
return {
1834
async call(systemPrompt: string, userPrompt: string, model: string) {
1935
const startedAt = Date.now();
@@ -24,9 +40,9 @@ export function instrumentLLMCaller(real: LLMCaller, sink: EventSink): LLMCaller
2440
kind: 'llm-call',
2541
ts,
2642
model,
27-
systemPrompt,
28-
userPrompt,
29-
responseContent: response.content,
43+
systemPrompt: maybeRedact(systemPrompt, redact),
44+
userPrompt: maybeRedact(userPrompt, redact),
45+
responseContent: maybeRedact(response.content, redact),
3046
inputTokens: response.inputTokens,
3147
outputTokens: response.outputTokens,
3248
latencyMs: Date.now() - startedAt,
@@ -52,7 +68,12 @@ export function instrumentLLMCaller(real: LLMCaller, sink: EventSink): LLMCaller
5268
};
5369
}
5470

55-
export function instrumentCodexCaller(real: CodexCaller, sink: EventSink): CodexCaller {
71+
export function instrumentCodexCaller(
72+
real: CodexCaller,
73+
sink: EventSink,
74+
opts: InstrumentOptions = {},
75+
): CodexCaller {
76+
const redact = opts.redact !== false;
5677
return {
5778
async call(prompt: string, model: string) {
5879
const startedAt = Date.now();
@@ -67,8 +88,8 @@ export function instrumentCodexCaller(real: CodexCaller, sink: EventSink): Codex
6788
// and leave systemPrompt empty so analyzer text scans work
6889
// uniformly across both call types.
6990
systemPrompt: '',
70-
userPrompt: prompt,
71-
responseContent: response.content,
91+
userPrompt: maybeRedact(prompt, redact),
92+
responseContent: maybeRedact(response.content, redact),
7293
inputTokens: 0,
7394
outputTokens: 0,
7495
latencyMs: Date.now() - startedAt,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Secret redaction for captured transcripts (Codex review #9).
3+
*
4+
* Transcripts capture full prompts + responses, which can include
5+
* source pulled from the target repo, stack traces, and — the real
6+
* risk — secrets that happen to appear in that material. Redaction
7+
* masks well-known secret token shapes before anything is written to
8+
* disk. It is deliberately conservative (mask known shapes, don't try
9+
* to be a general-purpose DLP) and runs by default; callers can opt out
10+
* with `{ redact: false }` on the instrumented callers.
11+
*/
12+
13+
export interface RedactionRule {
14+
name: string;
15+
pattern: RegExp;
16+
}
17+
18+
/** Known secret token shapes. Ordered roughly by specificity. */
19+
export const DEFAULT_REDACTION_RULES: RedactionRule[] = [
20+
// Anthropic keys: sk-ant-… (long).
21+
{ name: 'anthropic-key', pattern: /sk-ant-[A-Za-z0-9_-]{16,}/g },
22+
// OpenAI keys: sk-…, sk-proj-…, sk-svcacct-… .
23+
{ name: 'openai-key', pattern: /sk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}/g },
24+
// GitHub tokens: ghp_, gho_, ghu_, ghs_, ghr_ + 36+ chars.
25+
{ name: 'github-token', pattern: /gh[posur]_[A-Za-z0-9]{36,}/g },
26+
// GitHub fine-grained PAT.
27+
{ name: 'github-pat', pattern: /github_pat_[A-Za-z0-9_]{22,}/g },
28+
// AWS access key id.
29+
{ name: 'aws-akid', pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
30+
// Google API key.
31+
{ name: 'google-key', pattern: /\bAIza[A-Za-z0-9_-]{35}\b/g },
32+
// Slack token.
33+
{ name: 'slack-token', pattern: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
34+
// Bearer header values.
35+
{ name: 'bearer', pattern: /[Bb]earer\s+[A-Za-z0-9._-]{20,}/g },
36+
// Generic `<SECRETY_NAME>=<value>` / `: <value>` assignments for
37+
// env vars whose NAME looks secret-y. Masks only the value.
38+
{
39+
name: 'assigned-secret',
40+
pattern:
41+
/\b([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)S?)\b(\s*[:=]\s*)(['"]?)([^\s'"]{6,})\3/gi,
42+
},
43+
];
44+
45+
const MASK = '«REDACTED»';
46+
47+
/**
48+
* Redact secret-shaped substrings from `text`. Returns the masked text
49+
* and a count of redactions made (useful for a "N secrets masked"
50+
* note). Idempotent-ish: re-running over masked text is a no-op for the
51+
* token rules.
52+
*/
53+
export function redactSecrets(
54+
text: string,
55+
rules: RedactionRule[] = DEFAULT_REDACTION_RULES,
56+
): { text: string; redactions: number } {
57+
let redactions = 0;
58+
let out = text;
59+
for (const rule of rules) {
60+
if (rule.name === 'assigned-secret') {
61+
out = out.replace(rule.pattern, (_m, name, sep, quote, _val) => {
62+
redactions += 1;
63+
return `${name}${sep}${quote}${MASK}${quote}`;
64+
});
65+
} else {
66+
out = out.replace(rule.pattern, () => {
67+
redactions += 1;
68+
return MASK;
69+
});
70+
}
71+
}
72+
return { text: out, redactions };
73+
}

packages/asil-analyzer/src/transcript-writer.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,10 @@ export class EventSink {
130130

131131
constructor(private readonly eventsFile: string) {
132132
mkdirSync(dirname(eventsFile), { recursive: true });
133-
// Truncate any prior content so re-runs start clean.
134-
writeFileSync(eventsFile, '');
133+
// Truncate any prior content so re-runs start clean, and restrict
134+
// to owner read/write — transcripts can carry pulled-in source even
135+
// after redaction. (Codex review #9.)
136+
writeFileSync(eventsFile, '', { mode: 0o600 });
135137
}
136138

137139
setCurrentTask(taskId: string | null): void {

packages/asil-improvement-loop/src/__tests__/domain-questions.test.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,37 @@ import {
1111
import { mockRunner } from './helpers.js';
1212

1313
describe('hashQuestion', () => {
14-
it('is deterministic for the same input', () => {
15-
expect(hashQuestion('How does X work?')).toBe(hashQuestion('How does X work?'));
14+
it('is deterministic for the same file + input', () => {
15+
expect(hashQuestion('a.ts', 'How does X work?')).toBe(
16+
hashQuestion('a.ts', 'How does X work?'),
17+
);
1618
});
1719

1820
it('normalizes whitespace and case (re-formatting does not invalidate the answer)', () => {
19-
expect(hashQuestion('How does X work?')).toBe(
20-
hashQuestion(' HOW DOES X work? '),
21+
expect(hashQuestion('a.ts', 'How does X work?')).toBe(
22+
hashQuestion('a.ts', ' HOW DOES X work? '),
2123
);
2224
});
2325

2426
it('returns a different hash when the question is reworded', () => {
25-
expect(hashQuestion('How does X work?')).not.toBe(
26-
hashQuestion('How does Y work?'),
27+
expect(hashQuestion('a.ts', 'How does X work?')).not.toBe(
28+
hashQuestion('a.ts', 'How does Y work?'),
29+
);
30+
});
31+
32+
it('returns a DIFFERENT hash for the same question in a different file (Codex #7)', () => {
33+
// The whole point: identical wording in two files must not collide.
34+
expect(hashQuestion('a.ts', 'How does X work?')).not.toBe(
35+
hashQuestion('b.ts', 'How does X work?'),
2736
);
2837
});
2938

39+
it('normalizes the file path (./a.ts and a.ts collide)', () => {
40+
expect(hashQuestion('./a.ts', 'q')).toBe(hashQuestion('a.ts', 'q'));
41+
});
42+
3043
it('returns a 64-char hex sha256', () => {
31-
expect(hashQuestion('test')).toMatch(/^[a-f0-9]{64}$/);
44+
expect(hashQuestion('f.ts', 'test')).toMatch(/^[a-f0-9]{64}$/);
3245
});
3346
});
3447

599 Bytes
Binary file not shown.

packages/asil-runners/src/__tests__/triage.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ describe('triageDomainQuestions', () => {
9797
// Compute what the hash will be by issuing the same call.
9898
const { hashQuestion } = await import('asil-improvement-loop');
9999
store.saveAnswer({
100-
hash: hashQuestion('How to do X?'),
100+
hash: hashQuestion('a.ts', 'How to do X?'),
101101
filePath: 'a.ts',
102102
line: 1,
103103
question: 'How to do X?',

0 commit comments

Comments
 (0)