mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
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:
1 parent
7d9e37ffd6
commit
e20d59be0d
8 files changed
+34
-23
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }); }
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user