diff --git a/review/greptile-triage.md b/review/greptile-triage.md index dad456996..4050b8a6b 100644 --- a/review/greptile-triage.md +++ b/review/greptile-triage.md @@ -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. diff --git a/scripts/resolvers/free-text-file.ts b/scripts/resolvers/free-text-file.ts index b0e0dd264..911fd7df5 100644 --- a/scripts/resolvers/free-text-file.ts +++ b/scripts/resolvers/free-text-file.ts @@ -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'); } diff --git a/ship/sections/tests.md b/ship/sections/tests.md index b5030d8b6..a632a3f1d 100644 --- a/ship/sections/tests.md +++ b/ship/sections/tests.md @@ -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: `. Body file (Markdown; the error goes in a `~~~` fence so backticks in it stay literal): diff --git a/spec/sections/gate-and-file.md b/spec/sections/gate-and-file.md index 520f69cf0..bc83e8aaa 100644 --- a/spec/sections/gate-and-file.md +++ b/spec/sections/gate-and-file.md @@ -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. diff --git a/test/redact-doc-resolver.test.ts b/test/redact-doc-resolver.test.ts index c4d96affa..588b59dad 100644 --- a/test/redact-doc-resolver.test.ts +++ b/test/redact-doc-resolver.test.ts @@ -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/"'); + expect(block).not.toContain("<<"); expect(block).toContain("--from-file"); expect(block).toMatch(/exact bytes/i); }); diff --git a/test/regression-pr1169-mktemp-fallbacks.test.ts b/test/regression-pr1169-mktemp-fallbacks.test.ts index d87b9e42a..aa573b831 100644 --- a/test/regression-pr1169-mktemp-fallbacks.test.ts +++ b/test/regression-pr1169-mktemp-fallbacks.test.ts @@ -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", () => { diff --git a/test/spec-quality-gate-secret-sink.test.ts b/test/spec-quality-gate-secret-sink.test.ts index 60f6d418d..f6150bfd9 100644 --- a/test/spec-quality-gate-secret-sink.test.ts +++ b/test/spec-quality-gate-secret-sink.test.ts @@ -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('', body); - if (fence.includes('', draft[2]); + if (fence.includes('')) 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 }); } } diff --git a/test/spec-template-invariants.test.ts b/test/spec-template-invariants.test.ts index a82638f8f..c71f71a81 100644 --- a/test/spec-template-invariants.test.ts +++ b/test/spec-template-invariants.test.ts @@ -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\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');