fix: the free-text block exits loudly when mktemp fails; spec and redact tests follow the file mechanism

The redact resolver test, the #2679 mktemp guard test, the /spec invariant
and the spec secret-sink harness pinned the heredoc transport. They now pin
the agent-written file: the harness runs the draft-file block, writes the
draft the way the file-write tool would, then runs the real scan fence, with
every HIGH/MEDIUM/scanner-failure assertion unchanged.

Co-authored-by: dgrant <dgrant@users.noreply.github.com>
This commit is contained in:
garrytananddgrant committed 2026-10-06 20:43:08 +00:00
1 parent 7d9e37ffd6
commit e20d59be0d
8 files changed
+34 -23

No files matched your search

+5 -2
View File
@@ -21,9 +21,12 @@ const ctx = {
describe("REDACT_INVOCATION_BLOCK", () => {
test("scan-at-sink: temp file → scan that file → exact bytes", () => {
test("scan-at-sink: agent-written private file → scan that file → exact bytes", () => {
const block = generateRedactInvocationBlock(ctx, ["pre-issue"]);
expect(block).toContain("mktemp");
// CEO-12: the bytes come from the file the agent wrote (created by the
// shared mktemp free-text block), never from a heredoc.
expect(block).toContain('/.gstack/tmp/<redact-file-name>"');
expect(block).not.toContain("<<");
expect(block).toContain("--from-file");
expect(block).toMatch(/exact bytes/i);
});
@@ -64,15 +64,13 @@ describe("PR #1169 bug #4: gstack-telemetry-sync mktemp fallback", () => {
// must also restore the backup when the swap fails (same failure class:
// backup deletion after a failed mv).
describe("#2679: skill-content mktemp guards", () => {
test("redact-doc resolver guards REDACT_FILE=$(mktemp) with a loud exit", () => {
// The guard line contains a ${sink.noun} interpolation in the resolver
// source, so match to end-of-line rather than [^}]* (which stops at the
// interpolation's closing brace).
const body = readScript("scripts/resolvers/redact-doc.ts");
expect(body).toMatch(/REDACT_FILE=\$\(mktemp "\\\$\{TMPDIR:-\/tmp\}\/[^"]+"\)\s*\|\|\s*\{.*exit 1/);
// And the rendered output (interpolation resolved) carries the guard too.
test("the shared free-text block guards each mktemp with a loud exit (it creates /spec's REDACT_FILE)", () => {
// CEO-12 moved REDACT_FILE's creation from the redact-doc resolver into the
// shared free-text block (scripts/resolvers/free-text-file.ts).
const body = readScript("scripts/resolvers/free-text-file.ts");
expect(body).toMatch(/=\$\(mktemp "\$_GT\/\$\{f\.stem\}\.XXXXXX"\) \|\| \{ echo "Not sent: [^"]*" >&2; exit 1; \}/);
const rendered = readScript("spec/sections/gate-and-file.md");
expect(rendered).toMatch(/REDACT_FILE=\$\(mktemp "\$\{TMPDIR:-\/tmp\}\/[^"]+"\)\s*\|\|\s*\{[^}]*exit 1/);
expect(rendered).toMatch(/REDACT_FILE=\$\(mktemp "\$_GT\/spec\.XXXXXX"\)\s*\|\|\s*\{[^}]*exit 1/);
});
test("ship pr-body template guards PR_BODY_FILE=$(mktemp ...) with a loud exit", () => {
+11 -3
View File
@@ -42,6 +42,14 @@ exec '${realMktemp}' "$@"
if (scanner === 'broken') writeFileSync(join(bin, 'gstack-redact'), '#!/usr/bin/env bash\nexit 70\n', { mode: 0o755 });
writeFileSync(join(bin, 'fake-reviewer'), '#!/usr/bin/env bash\ncat > "$SINK_DIR/reviewer-received.txt"\n', { mode: 0o755 });
const full = content(host);
// The agent's part: run the draft-file block, then write the draft with its
// file-write tool (CEO-12: the text never passes through the shell).
const create = full.match(/```bash\n([^`]*?REDACT_FILE=\$\(mktemp[\s\S]*?)\n```/);
if (!create) throw new Error(`Missing draft-file block in ${host} spec`);
const made = Bun.spawnSync(['bash', '-c', create[1]], { cwd: scratch, env: { ...process.env, TMPDIR: temps }, stdout: 'pipe', stderr: 'pipe', timeout: 10_000 });
const draft = /^REDACT_FILE: (\S+) \(name: (\S+)\)$/m.exec(made.stdout.toString());
if (made.exitCode !== 0 || !draft) throw new Error(`draft-file block failed: ${made.stderr.toString()}`);
writeFileSync(draft[1], body + '\n');
const start = full.indexOf('#### Redaction scan — pre-codex');
if (start < 0) throw new Error(`Missing pre-codex redaction in ${host} spec`);
const match = full.slice(start).match(/```bash\n([\s\S]*?)\n```/);
@@ -49,8 +57,8 @@ exec '${realMktemp}' "$@"
const fence = match[1]
.replaceAll('~/.claude/skills/gstack', runtime)
.replaceAll('$HOME/.claude/skills/gstack', runtime)
.replace('<the exact the spec body goes here>', body);
if (fence.includes('<the exact')) throw new Error('Spec fixture bytes did not replace the scan placeholder');
.replace('<redact-file-name>', draft[2]);
if (fence.includes('<redact-file-name>')) throw new Error('Spec fixture did not substitute the draft file name');
// Deliberately put sinks directly after the real fence. A missing executable
// stop (the previous prose-only gate) sends/persists the secret and fails.
const script = `${errexit ? 'set -e\n' : ''}${fence}\n"$GSTACK_BIN/fake-reviewer" < "$REDACT_FILE"
@@ -68,7 +76,7 @@ rm -f "$REDACT_FILE"
const printed = /^REDACT_FILE: (.+)$/m.exec(stdout)?.[1];
return { code: result.exitCode, stdout, stderr: result.stderr.toString(),
sinks: readdirSync(sinks).map(name => ({ name, body: readFileSync(join(sinks, name), 'utf8') })),
pending: [temps, systemTemps].flatMap(dir => readdirSync(dir).map(name => readFileSync(join(dir, name), 'utf8'))),
pending: [temps, systemTemps, join(scratch, '.gstack', 'tmp')].flatMap(dir => readdirSync(dir).map(name => readFileSync(join(dir, name), 'utf8'))),
printed: printed && existsSync(printed) ? readFileSync(printed, 'utf8') : undefined };
} finally { rmSync(scratch, { recursive: true, force: true }); }
}
+5 -3
View File
@@ -184,9 +184,11 @@ describe('/spec redaction at every sink (scan-at-sink, carved: gate-and-file sec
expect(fileIdx).toBeGreaterThan(scanIdx);
});
test('files from the scanned temp file (exact bytes, not a re-render)', () => {
expect(SEC_GEN).toMatch(/gh issue create --title "\$ISSUE_TITLE" --body-file "\$REDACT_FILE"/);
// #3046: the title reaches the shell only through a quoted heredoc.
expect(SEC_GEN).toMatch(/ISSUE_TITLE=\$\(cat <<'GSTACK_TITLE'\n<title>\nGSTACK_TITLE\n\)/);
expect(SEC_GEN).toContain('ISSUE_URL=$(gh issue create --title "$(cat "$TITLE_FILE")" --body-file "$REDACT_FILE")');
// CEO-12: the title is an agent-written file; no heredoc or quoted placeholder carries it.
expect(SEC_GEN).toContain('TITLE_FILE=$(mktemp "$_GT/title.XXXXXX")');
expect(SEC_GEN).not.toContain('<<\'GSTACK_TITLE\'');
expect(SEC_GEN).not.toMatch(/"<title>"|<<\s*'?REDACT_BODY_EOF/);
});
test('scan precedes the archive write (pre-archive)', () => {
const scanIdx = SEC_GEN.indexOf('Re-scan before archiving');