Skip to content

Commit 4bbc4cf

Browse files
sync(sanitization): hardened scrubber, identifier-free capture discovery, and the absence scan — match fork 687cbc5/eb4f844
The slice ships the harvester, so it ships the FIXED harvester: scrubBlock recurses into source (the payload one level below where the old scrubber looked — the measured five-PNG leak class) and fails closed on any long string there. Capture discovery in the two real-pair tests recovers the file by hashing candidates against the fixture's own token instead of hardcoding a capture id (a capture UUID plus a home path is a live identifier in a public tree). tools/absence-scan.mjs + its test make sanitization CHECKED rather than claimed, per the cnighswonger#272 fixture-strategy thread; one allowlist entry added with provenance (upstream's own org_id example in docs/directives/proxy-cache-warmer-v3.7.0.md). The grafted nesting tests pin the source.data class red-first at unit level. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016y33RMV399iYMXFEbAfQCk
1 parent 3f42811 commit 4bbc4cf

7 files changed

Lines changed: 1044 additions & 41 deletions

test/absence-scan.test.mjs

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
// absence-scan — the scanner's own bite.
2+
//
3+
// The classes it carries were extracted out of harvest-scrub-relations.test.mjs
4+
// §6, where they assert the ABSENCE of a defect over a corpus that is clean.
5+
// That shape cannot bite itself: a neutered predicate over a clean corpus still
6+
// passes, so "the suite is green" says nothing about whether the extraction
7+
// kept the classes alive. What proves a class alive is a SEEDED defect — one
8+
// synthetic document per class, each of which must produce exactly its own
9+
// finding. That is what the first section does, and it is the guarantee the
10+
// extraction needed.
11+
//
12+
// The rest exercises the CLI contract the pre-push hook in the dotfiles repo
13+
// depends on: exit 2 on findings, 0 on clean, the git-range mode over a real
14+
// scratch repository, the allowlist, and the degraded (unparseable) path.
15+
//
16+
// Every identifier here is synthetic — this repo is public.
17+
18+
import { test } from "node:test";
19+
import assert from "node:assert/strict";
20+
import { spawnSync } from "node:child_process";
21+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readdirSync, readFileSync } from "node:fs";
22+
import { tmpdir } from "node:os";
23+
import { join, dirname, sep } from "node:path";
24+
import { fileURLToPath } from "node:url";
25+
26+
import { scanDocument, scanContent, isAllowlisted, CLASSES } from "../tools/absence-scan.mjs";
27+
28+
const TOOL = join(dirname(fileURLToPath(import.meta.url)), "..", "tools", "absence-scan.mjs");
29+
const CORPUS = "test/fixtures/harvested";
30+
31+
// Synthetic, and shaped like the thing each class is defined against.
32+
const FAKE_UUID = "0123abcd-4567-89ef-0123-456789abcdef";
33+
const LONG_B64 = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0".repeat(4);
34+
const TOKEN_TEXT = "t_0123456789ab_42";
35+
36+
// A document with nothing for any class to say anything about.
37+
const CLEAN = {
38+
key: "s-0123456789ab",
39+
ts: "2000-01-01T00:00:03.000Z",
40+
messages: [
41+
{ role: "user", content: [{ type: "text", text: TOKEN_TEXT }] },
42+
{
43+
role: "user",
44+
content: [{ type: "image", source: { type: "base64", media_type: "image/png", data: "data_0123456789" } }],
45+
},
46+
],
47+
};
48+
49+
// One seeded defect per class. Each entry is the MINIMAL deviation from CLEAN
50+
// that its class is defined to catch.
51+
const SEEDED = {
52+
// On a `signature`, not on a `text`: a long base64 run inside a content
53+
// field is legitimately BOTH an unsanitized payload and untokenized content,
54+
// and a seed that trips two classes cannot show which one caught it.
55+
"b64-run": {
56+
...CLEAN,
57+
messages: [
58+
{ role: "assistant", content: [{ type: "thinking", thinking: TOKEN_TEXT, signature: LONG_B64 }] },
59+
],
60+
},
61+
"nested-payload": {
62+
...CLEAN,
63+
messages: [
64+
{ role: "user", content: [{ type: "image", source: { type: "base64", data: "iVBORw0KGgoAAAA" } }] },
65+
],
66+
},
67+
"live-timestamp": { ...CLEAN, ts: "2026-08-01T09:15:00.000Z" },
68+
"capture-uuid": { ...CLEAN, key: FAKE_UUID },
69+
"raw-content": {
70+
...CLEAN,
71+
messages: [{ role: "user", content: [{ type: "text", text: "plain prose that never went through the scrub" }] }],
72+
},
73+
};
74+
75+
test("every class goes RED on its own seeded defect, and only that class", () => {
76+
for (const cls of CLASSES) {
77+
const doc = SEEDED[cls.name];
78+
assert.ok(doc, `no seeded defect for class ${cls.name} — a class without a bite is an orphan`);
79+
const fired = new Set(scanDocument(doc).findings.map((f) => f.class));
80+
assert.ok(fired.has(cls.name), `${cls.name} did not fire on its own seeded defect`);
81+
assert.deepEqual([...fired], [cls.name], `${cls.name}'s seeded defect must not trip a second class`);
82+
}
83+
});
84+
85+
test("the clean document produces no finding at all", () => {
86+
assert.deepEqual(scanDocument(CLEAN).findings, []);
87+
});
88+
89+
test("a finding never carries the matched bytes", () => {
90+
// A leak reporter that prints the leak has moved it, not found it.
91+
const findings = scanDocument(SEEDED["capture-uuid"]).findings;
92+
assert.equal(findings.length, 1);
93+
assert.deepEqual(Object.keys(findings[0]).sort(), ["class", "file", "length", "path"]);
94+
assert.ok(!JSON.stringify(findings).includes(FAKE_UUID));
95+
});
96+
97+
test("the filename class fires on a UUID name and on an 8-hex s- prefix, not on the real token shape", () => {
98+
const names = (n) => scanContent(JSON.stringify(CLEAN), `${CORPUS}/${n}`).findings.map((f) => f.class);
99+
assert.deepEqual(names(`pinned-${FAKE_UUID}-26-28.json`), ["capture-uuid-filename"]);
100+
assert.deepEqual(names("pinned-s-4b6a4352-26-28.json"), ["capture-uuid-filename"]);
101+
assert.deepEqual(names("pinned-s-4b6a435234bf-26-28.json"), [], "12 hex after s- is the sanitized shape");
102+
});
103+
104+
test("classes defined over the harvested corpus do not fire outside it; byte-level classes do", () => {
105+
// Measured basis (report absence-guard-report.md): the corpus-shape classes
106+
// fired ~205 times on hand-authored synthetic proxy fixtures, none of which
107+
// is a defect. The byte-level classes fired only on real leaks.
108+
const outside = scanContent(JSON.stringify(SEEDED["raw-content"]), "test/fixtures/hand-written.json");
109+
assert.deepEqual(outside.findings, [], "prose in a hand-authored fixture is not a sanitization defect");
110+
assert.equal(outside.partial, true, "and the run must SAY it only half-checked");
111+
112+
const uuidOutside = scanContent(JSON.stringify(SEEDED["capture-uuid"]), "test/fixtures/hand-written.json");
113+
assert.deepEqual(uuidOutside.findings.map((f) => f.class), ["capture-uuid"],
114+
"a live capture identifier needs no corpus to be one");
115+
});
116+
117+
test("an unparseable file is scanned as raw bytes and reported degraded, never skipped", () => {
118+
const r = scanContent(`{ not json at all ${FAKE_UUID}`, `${CORPUS}/broken.json`);
119+
assert.deepEqual(r.degraded, ["does not parse"]);
120+
assert.deepEqual(r.findings.map((f) => f.class), ["capture-uuid"]);
121+
});
122+
123+
test("the allowlist covers the LEDGER watermark file and nothing else in the corpus", () => {
124+
assert.equal(isAllowlisted(`${CORPUS}/LEDGER-Siren.json`), true);
125+
assert.equal(isAllowlisted(`${CORPUS}/pinned-s-4b6a435234bf-26-28.json`), false);
126+
});
127+
128+
// --- CLI ---------------------------------------------------------------------
129+
130+
const run = (args, cwd) => spawnSync(process.execPath, [TOOL, ...args], { cwd, encoding: "utf-8" });
131+
132+
function withTemp(fn) {
133+
const dir = mkdtempSync(join(tmpdir(), "absence-scan-"));
134+
try {
135+
return fn(dir);
136+
} finally {
137+
rmSync(dir, { recursive: true, force: true });
138+
}
139+
}
140+
141+
function seedCorpusFile(dir, name, doc) {
142+
mkdirSync(join(dir, CORPUS), { recursive: true });
143+
const rel = `${CORPUS}/${name}`;
144+
writeFileSync(join(dir, rel), JSON.stringify(doc, null, 2));
145+
return rel;
146+
}
147+
148+
test("CLI: exit 2 on a file carrying a synthetic UUID, exit 0 on a clean one", () => {
149+
withTemp((dir) => {
150+
const dirty = seedCorpusFile(dir, "dirty.json", SEEDED["capture-uuid"]);
151+
const bad = run([dirty], dir);
152+
assert.equal(bad.status, 2, bad.stdout + bad.stderr);
153+
assert.match(bad.stdout, /FINDING capture-uuid/);
154+
assert.ok(!bad.stdout.includes(FAKE_UUID), "the CLI must not echo the matched bytes either");
155+
156+
const clean = seedCorpusFile(dir, "clean.json", CLEAN);
157+
const ok = run([clean], dir);
158+
assert.equal(ok.status, 0, ok.stdout + ok.stderr);
159+
assert.match(ok.stdout, /absence-scan: clean/);
160+
});
161+
});
162+
163+
test("CLI: an allowlisted path is reported, not scanned", () => {
164+
withTemp((dir) => {
165+
const led = seedCorpusFile(dir, "LEDGER-Testhost.json", SEEDED["capture-uuid"]);
166+
const r = run([led], dir);
167+
assert.equal(r.status, 0, r.stdout + r.stderr);
168+
assert.match(r.stdout, /^allowlisted: /m);
169+
assert.ok(!r.stdout.includes("FINDING"));
170+
});
171+
});
172+
173+
test("CLI: no arguments is an internal-error exit, not a silent pass", () => {
174+
const r = run([]);
175+
assert.equal(r.status, 1);
176+
});
177+
178+
// --- git range ---------------------------------------------------------------
179+
180+
function gitRepo(dir) {
181+
const g = (...args) => {
182+
const r = spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
183+
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
184+
return r.stdout.trim();
185+
};
186+
g("init", "-q", "-b", "main");
187+
g("config", "user.email", "t@t");
188+
g("config", "user.name", "t");
189+
return g;
190+
}
191+
192+
test("git-range: red on a defect added in the range, green on the range before it", () => {
193+
withTemp((dir) => {
194+
const g = gitRepo(dir);
195+
const cleanRel = seedCorpusFile(dir, "clean.json", CLEAN);
196+
g("add", cleanRel);
197+
g("commit", "-qm", "clean");
198+
const first = g("rev-parse", "HEAD");
199+
200+
const dirtyRel = seedCorpusFile(dir, "dirty.json", SEEDED["capture-uuid"]);
201+
g("add", dirtyRel);
202+
g("commit", "-qm", "dirty");
203+
const second = g("rev-parse", "HEAD");
204+
205+
const red = run(["--git-range", `${first}..${second}`], dir);
206+
assert.equal(red.status, 2, red.stdout + red.stderr);
207+
assert.match(red.stdout, /FINDING capture-uuid {2}test\/fixtures\/harvested\/dirty\.json/);
208+
assert.ok(!red.stdout.includes("clean.json"), "an unchanged file is outside the range");
209+
210+
const green = run(["--git-range", `EMPTY..${first}`], dir);
211+
assert.equal(green.status, 0, green.stdout + green.stderr);
212+
});
213+
});
214+
215+
test("git-range: EMPTY scans every file reachable at the new ref (the new-branch push)", () => {
216+
withTemp((dir) => {
217+
const g = gitRepo(dir);
218+
const dirtyRel = seedCorpusFile(dir, "dirty.json", SEEDED["capture-uuid"]);
219+
g("add", dirtyRel);
220+
g("commit", "-qm", "dirty");
221+
const head = g("rev-parse", "HEAD");
222+
const r = run(["--git-range", `EMPTY..${head}`], dir);
223+
assert.equal(r.status, 2, r.stdout + r.stderr);
224+
});
225+
});
226+
227+
test("git-range: a deleted file is not scanned, and a non-JSON file is ignored", () => {
228+
withTemp((dir) => {
229+
const g = gitRepo(dir);
230+
const dirtyRel = seedCorpusFile(dir, "dirty.json", SEEDED["capture-uuid"]);
231+
writeFileSync(join(dir, "notes.md"), `not scanned ${FAKE_UUID}\n`);
232+
g("add", dirtyRel, "notes.md");
233+
g("commit", "-qm", "dirty");
234+
const first = g("rev-parse", "HEAD");
235+
236+
rmSync(join(dir, dirtyRel));
237+
g("add", "-A");
238+
g("commit", "-qm", "removed");
239+
const second = g("rev-parse", "HEAD");
240+
241+
const r = run(["--git-range", `${first}..${second}`], dir);
242+
assert.equal(r.status, 0, `${r.stdout}${r.stderr}`);
243+
});
244+
});
245+
246+
test("git-range: an unresolvable base ref degrades to a full scan rather than erroring", () => {
247+
withTemp((dir) => {
248+
const g = gitRepo(dir);
249+
const dirtyRel = seedCorpusFile(dir, "dirty.json", SEEDED["capture-uuid"]);
250+
g("add", dirtyRel);
251+
g("commit", "-qm", "dirty");
252+
const head = g("rev-parse", "HEAD");
253+
// A sha this clone has never seen — the shape of a remote ref that was
254+
// never fetched.
255+
const r = run(["--git-range", `0000000000000000000000000000000000000001..${head}`], dir);
256+
assert.equal(r.status, 2, r.stdout + r.stderr);
257+
assert.match(r.stdout, /^degraded: base ref /m);
258+
});
259+
});
260+
261+
// ── Source files: a capture UUID may exist only on the allowlist ──────────────
262+
//
263+
// Fixtures are covered by the classes above; SOURCE leaks ride in comments and
264+
// string literals instead (found live 2026-08-01: the same capture UUID in a
265+
// test file's evidence comment and in tools/replay.mjs — public repo,
266+
// unscrubbable history). A bare "no UUIDs in source" rule would fire on the
267+
// synthetic ones, so the rule is: every UUID in test/, tools/, and proxy/
268+
// source is on the explicit synthetic allowlist below, or this test fails. A
269+
// new legitimate synthetic is added HERE, deliberately, in the same diff a
270+
// reviewer sees — never waved through.
271+
//
272+
// docs/ IS THE SAME SURFACE (widened 2026-08-01, BACKLOG "docs/ UUID triage"):
273+
// a directive, a review or a release-test log is as public as a source file,
274+
// and the same sweep found real capture keys and a session id sitting in four
275+
// of them. Prose carries more legitimate synthetics than code does — hence the
276+
// provenance line on each entry below.
277+
const SOURCE_UUID_ALLOWLIST = new Set([
278+
FAKE_UUID, // this suite's seeded defect
279+
"b16c607d-d484-4935-840e-e3f7ee78eb08", // proxy suites' synthetic session id
280+
"00000000-0000-4000-8000-c4f1efb22220", // session-mirror synthetic
281+
"9d1c250a-e61b-44d9-88ed-5944d1962f5e", // Anthropic's PUBLIC OAuth client_id
282+
"1a6869d5-283e-43a3-9ba3-4495ceaa239a", // upstream docs/directives/proxy-cache-warmer-v3.7.0.md org_id example (upstream's own pre-existing content)
283+
// docs/ synthetics, each a placeholder by construction:
284+
"00000000-0000-4000-8000-c4f1efb22221", // release-test harness's pinned --session-id, sibling of ...22220
285+
"abcd1234-5678-90ab-cdef-1234567890ab", // the "e.g." 8-4-4-4-12 format sample in proxy-jsonl-session-mirror.md
286+
]);
287+
288+
test("source: every UUID in test/, tools/, proxy/ and docs/ is on the synthetic allowlist", () => {
289+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
290+
const files = [];
291+
const collect = (dir, ext) => {
292+
for (const e of readdirSync(join(root, dir), { withFileTypes: true })) {
293+
const rel = join(dir, e.name);
294+
if (e.isDirectory()) {
295+
// test/ and tools/ are flat; proxy/ and docs/ are not.
296+
if (dir.startsWith("proxy") || dir.startsWith("docs")) collect(rel, ext);
297+
continue;
298+
}
299+
if (e.name.endsWith(ext)) files.push(rel);
300+
}
301+
};
302+
collect("test", ".mjs");
303+
collect("tools", ".mjs");
304+
collect("proxy", ".mjs");
305+
collect("docs", ".md");
306+
// Guard the guard: a walk that collected nothing from a root would pass
307+
// this test while checking that root not at all — the silent scope collapse
308+
// a rename or a moved directory causes.
309+
for (const root_ of ["test", "tools", "proxy", "docs"]) {
310+
assert.ok(files.some((f) => f.startsWith(root_ + sep)), `the walk collected no file under ${root_}/`);
311+
}
312+
const uuidRe = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;
313+
const offenders = [];
314+
for (const rel of files) {
315+
const text = readFileSync(join(root, rel), "utf8");
316+
for (const hit of text.match(uuidRe) ?? []) {
317+
if (!SOURCE_UUID_ALLOWLIST.has(hit)) offenders.push(`${rel}: ${hit}`);
318+
}
319+
}
320+
assert.deepEqual(
321+
offenders, [],
322+
`unlisted UUID(s) in source — a capture identifier in a public tree, or a new synthetic missing from the allowlist:\n${offenders.join("\n")}`,
323+
);
324+
});

test/harvest-pin.test.mjs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { tmpdir } from "node:os";
2727
import { join, dirname } from "node:path";
2828
import { fileURLToPath } from "node:url";
2929
import { execFileSync } from "node:child_process";
30+
import { createHash } from "node:crypto";
3031

3132
import { parsePinRange, pinRange, readPinnedFixture } from "../tools/harvest.mjs";
3233

@@ -122,7 +123,14 @@ test("pinRange: m beyond available requests throws rather than writing a truncat
122123

123124
// --- CLI end-to-end: the actual entry point, not a re-derivation of it ---
124125

125-
test("harvest --pin CLI: writes pinned-<key.slice(0,10)>-<n>-<m>.json with a header and sanitized records", async () => {
126+
// The sanitized token that names the fixture, stated from its DEFINITION
127+
// (docs/directives/fixture-sanitization-directive.md, settled design 2: a
128+
// conversation key becomes "s-" + the first 12 hex of its sha256) rather than
129+
// imported from tools/harvest.mjs — an expectation with the same parentage as
130+
// the code pins the bug it should catch.
131+
const KEY_TOKEN = `s-${createHash("sha256").update("s-tiny0000").digest("hex").slice(0, 12)}`;
132+
133+
test("harvest --pin CLI: writes pinned-<s-sha12>-<n>-<m>.json, no session key in the name, header or records", async () => {
126134
const dir = await mkdtemp(join(tmpdir(), "harvest-pin-cli-"));
127135
const capturesDir = join(dir, "captures");
128136
const outDir = join(dir, "out");
@@ -136,16 +144,27 @@ test("harvest --pin CLI: writes pinned-<key.slice(0,10)>-<n>-<m>.json with a hea
136144
);
137145
assert.match(stdout, /pinned 4 record\(s\), range 0\.\.1/);
138146

139-
const outPath = join(outDir, "pinned-s-tiny0000-0-1.json");
140-
assert.ok(existsSync(outPath), "fixture written at the expected name (key sliced to 10 chars, matching the scheduled harvest's own convention)");
147+
const outPath = join(outDir, `pinned-${KEY_TOKEN}-0-1.json`);
148+
assert.ok(existsSync(outPath), "fixture written at the expected name (the key's s-<sha12> token, never the session key)");
141149

142150
const fixture = JSON.parse(await readFile(outPath, "utf-8"));
143-
assert.equal(fixture.header.key, "s-tiny0000");
151+
assert.equal(fixture.header.key, KEY_TOKEN);
144152
assert.deepEqual(fixture.header.range, { n: 0, m: 1 });
145153
assert.equal(fixture.header.replayFrom, 0);
146154
assert.ok(fixture.header.sanitizer, "sanitizer note present");
147155
assert.ok(fixture.header.harvestedAt, "harvest date present");
148-
assert.ok(!JSON.stringify(fixture).includes(SECRET), "no raw content leaks through the CLI path either");
156+
const serialized = JSON.stringify(fixture);
157+
assert.ok(!serialized.includes(SECRET), "no raw content leaks through the CLI path either");
158+
assert.ok(!serialized.includes("s-tiny0000"), "the raw conversation key leaks nowhere — header, records or metadata");
159+
// Rebased, not stamped: the capture's own 2026-01-01 wall-clock is gone and
160+
// the deltas between records survive (boot at +0s, the two requests at +1s
161+
// and +3s, matching writeTinyCapture's spacing).
162+
assert.equal(fixture.records[0].ts, "2000-01-01T00:00:00.000Z");
163+
assert.deepEqual(
164+
fixture.records.map((r) => Date.parse(r.ts) - Date.parse(fixture.records[0].ts)),
165+
[0, 1000, 2000, 3000],
166+
);
167+
assert.ok(!serialized.includes("2026-01-01"), "no live wall-clock survives");
149168
});
150169

151170
test("harvest --pin CLI: unknown key exits non-zero with a stated reason, writes nothing", async () => {

0 commit comments

Comments
 (0)