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

+1 -1
View File
@@ -102,7 +102,7 @@ Reply text quotes commit SHAs, diff lines and reviewer text, so it never goes in
_GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp"
mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; }
_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; }
REPLY_FILE=$(mktemp "$_GT/reply.XXXXXX") && echo "REPLY_FILE: $REPLY_FILE (name: ${REPLY_FILE##*/})"
REPLY_FILE=$(mktemp "$_GT/reply.XXXXXX") || { echo "Not sent: cannot create a file in $_GT." >&2; exit 1; }; echo "REPLY_FILE: $REPLY_FILE (name: ${REPLY_FILE##*/})"
```
**2. Write the reply** (a template below) into the printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand.
+1 -1
View File
@@ -38,7 +38,7 @@ export function freeTextFileBash(files: FreeTextFile[]): string {
`_GT=${FREE_TEXT_DIR}`,
'mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; }',
`_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; }`,
...files.map(f => `${f.variable}=$(mktemp "$_GT/${f.stem}.XXXXXX") && echo "${f.variable}: $${f.variable} (name: \${${f.variable}##*/})"`),
...files.map(f => `${f.variable}=$(mktemp "$_GT/${f.stem}.XXXXXX") || { echo "Not sent: cannot create a file in $_GT." >&2; exit 1; }; echo "${f.variable}: $${f.variable} (name: \${${f.variable}##*/})"`),
].join('\n');
}
+2 -2
View File
@@ -302,8 +302,8 @@ Ask with AskUserQuestion in the AskUserQuestion Format. List each failure the sa
_GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp"
mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; }
_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; }
TITLE_FILE=$(mktemp "$_GT/issue-title.XXXXXX") && echo "TITLE_FILE: $TITLE_FILE (name: ${TITLE_FILE##*/})"
BODY_FILE=$(mktemp "$_GT/issue-body.XXXXXX") && echo "BODY_FILE: $BODY_FILE (name: ${BODY_FILE##*/})"
TITLE_FILE=$(mktemp "$_GT/issue-title.XXXXXX") || { echo "Not sent: cannot create a file in $_GT." >&2; exit 1; }; echo "TITLE_FILE: $TITLE_FILE (name: ${TITLE_FILE##*/})"
BODY_FILE=$(mktemp "$_GT/issue-body.XXXXXX") || { echo "Not sent: cannot create a file in $_GT." >&2; exit 1; }; echo "BODY_FILE: $BODY_FILE (name: ${BODY_FILE##*/})"
```
Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand. Title file: `Pre-existing test failure: <test name>`. Body file (Markdown; the error goes in a `~~~` fence so backticks in it stay literal):
+3 -3
View File
@@ -35,7 +35,7 @@ one file; the draft never goes into a shell command:
_GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp"
mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; }
_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; }
REDACT_FILE=$(mktemp "$_GT/spec.XXXXXX") && echo "REDACT_FILE: $REDACT_FILE (name: ${REDACT_FILE##*/})"
REDACT_FILE=$(mktemp "$_GT/spec.XXXXXX") || { echo "Not sent: cannot create a file in $_GT." >&2; exit 1; }; echo "REDACT_FILE: $REDACT_FILE (name: ${REDACT_FILE##*/})"
```
Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand.
@@ -267,8 +267,8 @@ own private files:
_GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp"
mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; }
_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; }
TITLE_FILE=$(mktemp "$_GT/title.XXXXXX") && echo "TITLE_FILE: $TITLE_FILE (name: ${TITLE_FILE##*/})"
APPROACH_FILE=$(mktemp "$_GT/approach.XXXXXX") && echo "APPROACH_FILE: $APPROACH_FILE (name: ${APPROACH_FILE##*/})"
TITLE_FILE=$(mktemp "$_GT/title.XXXXXX") || { echo "Not sent: cannot create a file in $_GT." >&2; exit 1; }; echo "TITLE_FILE: $TITLE_FILE (name: ${TITLE_FILE##*/})"
APPROACH_FILE=$(mktemp "$_GT/approach.XXXXXX") || { echo "Not sent: cannot create a file in $_GT." >&2; exit 1; }; echo "APPROACH_FILE: $APPROACH_FILE (name: ${APPROACH_FILE##*/})"
```
Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand.
+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');