Skill Tracer
Skills › gstack › gstack › cso-eval-output-redaction.ts

cso-eval-output-redaction.ts

69 lines · scripts/cso-eval-output-redaction.ts at 3f79d81

1/** Redaction of the producer's provider output and error text before a receipt binds them. */
2import { join } from 'node:path';
3import { readBoundedStable } from '../lib/cso/bounded-file';
4import { CsoError, MAX_OUTPUT } from '../lib/cso/contracts';
5import { redactFindingSpans, scan } from '../lib/redact-engine';
6
7/**
8 * Identifiers the CSO helper emits by schema, which the redactor's PII shapes misread: `<epoch ms>-<hex>` run and
9 * replay IDs and the 13-digit epoch in them (pii.phone.e164), `cso-eval-<runId>-<sha12>` catalog revisions,
10 * `<tool>-<hex16>-<hex16>` artifact IDs, and hex finding, review and content IDs (pii.wallet).
11 */
12const HELPER_ID = /(?<![A-Za-z0-9])(?:\d{13}-[a-f0-9]{16}|cso-eval-\d{1,20}-[a-f0-9]{12}|(?:[a-z]+-)+[a-f0-9]{16}-[a-f0-9]{16}|[a-f0-9]{16,64})(?![A-Za-z0-9])/g;
14export interface OutputRedaction {
15 /** The text as a receipt may bind it. */
16 text: string;
17 /** Matched rule IDs with their counts, e.g. `pii.phone.e164×2`; never the matched text. */
18 rules: string[];
19 /** True when a credential-class (`secret`) rule matched: the text must be withheld, not span-redacted. */
20 secret: boolean;
23const tally = (ids: string[]): string[] => {
24 const counts = new Map<string, number>();
25 for (const id of ids) counts.set(id, (counts.get(id) ?? 0) + 1);
26 return [...counts].sort(([left], [right]) => left < right ? -1 : 1).map(([id, count]) => `${id}×${count}`);
27};
29const ASSIGNMENT = /^\s*(?:export\s+)?[A-Za-z_][\w.-]*\s*[=:]\s*["']?([^\s"'#]{8,})/;
31/**
32 * Values assigned to credential-shaped names (the env.kv rule) in the cell's verified source, such as a planted
33 * canary. Quoted without its name, such a value reads as prose to every rule, so the output check matches it exactly.
34 */
35export function sourceCredentialValues(root: string, paths: string[]): string[] {
36 const values = new Set<string>();
37 for (const path of paths) {
38 if (path === '.git' || path.startsWith('.git/')) continue;
39 const contents = readBoundedStable(join(root, ...path.split('/')), 2 * 1024 * 1024, 'Producer source file');
40 if (contents.includes(0)) continue;
41 const text = contents.toString('utf8'), lines = text.split('\n');
42 for (const finding of scan(text, { maxBytes: 2 * 1024 * 1024 }).findings) {
43 const value = finding.id === 'env.kv' ? ASSIGNMENT.exec(lines[finding.line - 1] ?? '')?.[1] : undefined;
44 if (value) values.add(value);
45 }
46 }
47 return [...values];
50/**
51 * Credential classes are judged on the unmasked text, so masking can never hide one. Only when none matched are the
52 * helper's identifiers set aside and the remaining PII, internal, legal and hygiene spans replaced in place.
53 */
54export function redactProducerText(value: string, sourceCredentials: string[] = []): OutputRedaction {
55 const findings = scan(value, { maxBytes: MAX_OUTPUT });
56 if (findings.oversize) throw new CsoError('REDACTION_FAILED', 'Producer output withheld because it exceeds the redaction limit');
57 const secretIds = findings.findings.filter(finding => finding.category === 'secret').map(finding => finding.id);
58 for (const credential of sourceCredentials) secretIds.push(...Array(value.split(credential).length - 1).fill('source.env.kv'));
59 if (secretIds.length) return { text: '', rules: tally(secretIds), secret: true };
60 const identifiers: string[] = [];
61 const letters = (index: number): string => (index >= 26 ? letters(Math.floor(index / 26) - 1) : '') + String.fromCharCode(97 + (index % 26));
62 const masked = value.replace(HELPER_ID, match => `GSTACKHELPERID${letters(identifiers.push(match) - 1)}Z`);
63 const masking = scan(masked, { maxBytes: MAX_OUTPUT }).findings;
64 const redacted = redactFindingSpans(masked, { maxBytes: MAX_OUTPUT });
65 if (redacted === null) throw new CsoError('REDACTION_FAILED', 'Producer output withheld because redaction could not safely locate every span');
66 const indexOf = (code: string): number => [...code].reduce((total, char) => total * 26 + char.charCodeAt(0) - 96, 0) - 1;
67 const text = redacted.replace(/GSTACKHELPERID([a-z]+)Z/g, (whole, code: string) => identifiers[indexOf(code)] ?? whole);
68 return { text, rules: tally(masking.map(finding => finding.id)), secret: false };