fix: reply, issue and spec text travel as agent-written files, never through a heredoc

Replaces the quoted-heredoc bodies with CEO-12's file mechanism: a bash
block creates a private mktemp file in the project's .gstack/tmp/ (kept out
of git through .git/info/exclude), the agent writes the text with its
file-write tool, and the next block posts with -F body=@file, --body-file
or "$(cat file)". A heredoc ends early at any body line equal to its
delimiter; a file has no such line. When the file was never written the
block refuses with the path and the command to send it by hand, and it
deletes the file after a successful post.

- review/greptile-triage.md: replies; the fetched JSON moves from fixed
  /tmp/greptile_*.json paths to a mktemp directory; REPO and PR are
  re-derived per block instead of read from a previous shell.
- Test-failure issues: title and body files, platform case on
  github/gitlab, assignee only when it is a valid login.
- /spec: one draft file feeds the audit hash, every redaction scan, the
  issue and the archive; the archive no longer expands <title>/<body>
  inside an unquoted heredoc; the decision-log JSON is built by jq from
  files.

Location verified against Claude Code 2.1.284 (Dockerfile.ci pin):
project-local .gstack/tmp is writable under acceptEdits with no prompt and
keeps mode 0600; $TMPDIR and the gstack state root prompt even under
acceptEdits.

Co-authored-by: dgrant <dgrant@users.noreply.github.com>
This commit is contained in:
garrytananddgrant committed 2026-10-06 20:34:58 +00:00
1 parent 27c9843654
commit 8759ee58aa
8 files changed
+298 -148

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
/**
* Free text never enters a shell command (CEO-12).
*
* {{FREE_TEXT_FILE:BODY_FILE=body}} → bash that creates one private
* {{FREE_TEXT_FILE:TITLE_FILE=title:BODY_FILE=body}} mktemp file per variable,
* plus the write rule.
*
* Bodies, titles, messages, briefs, prompts, error output and reviewer or diff
* text go into a `mktemp` file that the agent writes with its own file-write
* tool; the next block passes the file (`--body-file`, `-F body=@file`,
* `"$(cat "$FILE")"`). There is no heredoc fallback: a host that cannot write
* the file leaves the post unsent and says so.
*
* Location (ENG-14, probed against the pinned Claude Code 2.1.284): the
* project's own `.gstack/tmp/`, kept out of git through `.git/info/exclude`.
* Claude Code's Write tool writes there under acceptEdits without a prompt and
* keeps mktemp's 0600 mode; `$TMPDIR` and the gstack state root are outside
* the project, so even acceptEdits asks for them and headless sessions are
* refused. In default mode every Write asks once, wherever the file lives.
*/
import type { TemplateContext } from './types';
export const FREE_TEXT_DIR = '"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp"';
export interface FreeTextFile { variable: string; stem: string }
function parse(args: string[] | undefined): FreeTextFile[] {
const files = (args ?? []).map(a => {
const [variable, stem] = a.split('=');
return { variable, stem: stem || 'text' };
}).filter(f => /^[A-Z_][A-Z0-9_]*$/.test(f.variable));
return files.length ? files : [{ variable: 'TEXT_FILE', stem: 'text' }];
}
/** Bash lines that create the private files and print each path and basename. */
export function freeTextFileBash(files: FreeTextFile[]): string {
return [
`_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}##*/})"`),
].join('\n');
}
export const FREE_TEXT_WRITE_RULE = '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.';
/**
* First lines of a later block: rebuild each path from the printed basename
* (`placeholder`) and refuse, with the manual command, when a write never
* happened. `manual` is echoed inside double quotes: escape `"` and `$(`.
*/
export function freeTextFileUse(files: Array<{ variable: string; placeholder: string }>, manual: string): string {
return [
...files.map(f => `${f.variable}=${FREE_TEXT_DIR.slice(0, -1)}/${f.placeholder}"`),
`${files.map(f => `[ -s "$${f.variable}" ]`).join(' && ')} || { echo "Not sent: ${files.map(f => `$${f.variable}`).join(' or ')} is missing or empty, so the text was never written. Write it, then send by hand: ${manual}" >&2; exit 1; }`,
].join('\n');
}
export function generateFreeTextFile(_ctx: TemplateContext, args?: string[]): string {
return `\`\`\`bash\n${freeTextFileBash(parse(args))}\n\`\`\`\n\n${FREE_TEXT_WRITE_RULE}`;
}
+2
View File
@@ -38,6 +38,7 @@ import { generateGBrainContextLoad, generateGBrainSaveResults, generateBrainPref
import { generateTasksSectionEmit, generateTasksSectionAggregate } from './tasks-section';
import { SECTION, SECTION_INDEX } from './sections';
import { generateRedactInvocationBlock } from './redact-doc';
import { generateFreeTextFile } from './free-text-file';
import { FOREGROUND_DISPATCH_NOTE } from './constants';
import { generateThirdPartyActions } from './third-party-actions';
import { generateAsideSetup, generateAsideCookbook, generateAsideResearch, generateUntrustedContentWarning, asideExecPrelude } from './aside';
@@ -65,6 +66,7 @@ export const RESOLVERS: Record<string, ResolverFn> = {
CODEX_REVIEW_MODEL_CONFIG_FLAG: generateCodexReviewModelConfigFlag,
CLAUDE_MODEL_FLAG: generateClaudeModelFlag,
REDACT_INVOCATION_BLOCK: generateRedactInvocationBlock,
FREE_TEXT_FILE: generateFreeTextFile,
THIRD_PARTY_ACTIONS: generateThirdPartyActions,
DESIGN_DOC_DISCOVERY: generateDesignDocDiscovery,
SHARED_LIBS_RUBRIC: generateSharedLibsRubric,
@@ -1,4 +1,5 @@
import { FREE_TEXT_WRITE_RULE, freeTextFileBash, freeTextFileUse } from '../free-text-file';
export function generateTestFailureTriage(): string {
return `## Test Failure Ownership Triage
@@ -62,34 +63,40 @@ Ask with AskUserQuestion in the AskUserQuestion Format. List each failure the sa
- Find who likely broke it. Check BOTH the test file AND the production code it tests:
\`\`\`bash
# Who last touched the failing test?
git log --format="%an (%ae)" -1 -- <failing-test-file>
git log --format="%an (%ae)" -1 -- "<failing-test-file>"
# Who last touched the production code the test covers? (often the actual breaker)
git log --format="%an (%ae)" -1 -- <source-file-under-test>
git log --format="%an (%ae)" -1 -- "<source-file-under-test>"
\`\`\`
If these are different people, prefer the production code author — they likely introduced the regression.
- Create an issue assigned to that person; run only your platform's create line. Test names and errors stay inside the quoted heredocs (column 0): in a double-quoted argument their backticks run as commands.
- Create an issue assigned to that person. Its title and body carry test names and error output, so they travel as files, never inside a command:
\`\`\`bash
ISSUE_TITLE=$(cat <<'GSTACK_ISSUE'
Pre-existing test failure: <test-name>
GSTACK_ISSUE
)
ISSUE_BODY=$(cat <<'GSTACK_ISSUE'
Failing on <current-branch>; pre-existing.
${freeTextFileBash([{ variable: 'TITLE_FILE', stem: 'issue-title' }, { variable: 'BODY_FILE', stem: 'issue-body' }])}
\`\`\`
${FREE_TEXT_WRITE_RULE} Title file: \`Pre-existing test failure: <test name>\`. Body file (Markdown; the error goes in a \`~~~\` fence so backticks in it stay literal):
\`\`\`text
Failing on <current branch>; pre-existing.
**Error:**
~~~
<first 10 lines>
<first 10 lines of the failure>
~~~
**Last modified by:** <author>
**Noticed by:** gstack /ship on <date>
GSTACK_ISSUE
)
# GitHub:
gh issue create --title "$ISSUE_TITLE" --body "$ISSUE_BODY" --assignee "<github-username>"
# GitLab:
glab issue create -t "$ISSUE_TITLE" -d "$ISSUE_BODY" -a "<gitlab-username>"
\`\`\`
Then post with your platform from Step 0 (\`github\` or \`gitlab\`). Substitute the two printed names, and an assignee only when it is a valid login for that platform (GitHub: letters, digits and single hyphens, at most 39 characters); otherwise drop the assignee flag and name the person in the body.
\`\`\`bash
${freeTextFileUse([{ variable: 'TITLE_FILE', placeholder: '<title-file-name>' }, { variable: 'BODY_FILE', placeholder: '<body-file-name>' }], 'gh issue create --title \\"\\$(cat $TITLE_FILE)\\" --body-file $BODY_FILE')}
case "<platform>" in
github) gh issue create --title "$(cat "$TITLE_FILE")" --body-file "$BODY_FILE" --assignee "<github-username>" ;;
gitlab) glab issue create -t "$(cat "$TITLE_FILE")" -d "$(cat "$BODY_FILE")" -a "<gitlab-username>" ;;
*) echo "Not sent: no GitHub or GitLab remote. Files: $TITLE_FILE $BODY_FILE" >&2; false ;;
esac && rm -f "$TITLE_FILE" "$BODY_FILE"
\`\`\`
- If neither CLI is available or \`--assignee\`/\`-a\` fails (user not in org, etc.), create the issue without assignee and note who should look at it in the body.
+9 -7
View File
@@ -14,6 +14,7 @@
* changes land here once. test/redact-doc-resolver.test.ts golden-pins the output.
*/
import { toShellPath, type TemplateContext } from './types';
import { FREE_TEXT_DIR } from './free-text-file';
interface SinkSpec {
/** What is being scanned, for the prose. */
@@ -66,7 +67,8 @@ esac` : `${scan}\nREDACT_CODE=$?`;
return `#### Redaction scan — ${sinkLabel} (${sink.noun})
Run the SAME scan-at-sink procedure shown above (resolve \`$REDACT_VIS\` once and
reuse it; write the exact bytes to \`$REDACT_FILE\`; \`${bin} --from-file "$REDACT_FILE"
reuse it; when ${sink.noun} changed since the last scan, rewrite the same \`$REDACT_FILE\`
with your file-write tool; \`${bin} --from-file "$REDACT_FILE"
--repo-visibility "$REDACT_VIS" --json\`), now on ${sink.noun}. Apply the same
exit-3/2/0 handling. On exit 3, do NOT ${sink.blockVerb}; HIGH has no skip. Pass the
same \`$REDACT_FILE\` downstream so the bytes scanned are the bytes sent.`;
@@ -74,8 +76,10 @@ same \`$REDACT_FILE\` downstream so the bytes scanned are the bytes sent.`;
return `#### Redaction scan — ${sinkLabel} (${sink.noun})
Scan-at-sink on the EXACT bytes that will be sent: write to a temp file, scan that
file, pass the SAME file downstream. Never scan a string then re-render it.
Scan-at-sink on the EXACT bytes that will be sent: they live in the private file
you wrote with your file-write tool, the scan reads that file, and the SAME file goes
downstream. Never scan a string then re-render it, and never put the text in a shell
command. Substitute the file's printed name for \`<redact-file-name>\`.
\`\`\`bash
${outsideGate ? 'command -v bun >/dev/null 2>&1 || { echo "ERROR: bun unavailable — refusing unscanned outside dispatch." >&2; exit 1; }' : 'command -v bun >/dev/null 2>&1 || echo "redaction scan skipped — bun not on PATH"'}
@@ -85,10 +89,8 @@ REDACT_VIS=$(~/.claude/skills/gstack/bin/gstack-config get redact_repo_visibilit
[ -z "$REDACT_VIS" ] && REDACT_VIS=$(gh repo view --json visibility -q .visibility 2>/dev/null | tr 'A-Z' 'a-z')
[ -z "$REDACT_VIS" ] && REDACT_VIS=$(glab repo view -F json 2>/dev/null | grep -o '"visibility":"[^"]*"' | head -1 | sed 's/.*:"//;s/"//' | tr 'A-Z' 'a-z')
REDACT_VIS="\${REDACT_VIS:-unknown}"
REDACT_FILE=$(mktemp "\${TMPDIR:-/tmp}/gstack-redact.XXXXXX") || { echo "ERROR: mktemp failed — refusing to send ${sink.noun} unscanned." >&2; exit 1; }
cat > "$REDACT_FILE" <<'REDACT_BODY_EOF'
<the exact ${sink.noun} goes here>
REDACT_BODY_EOF
REDACT_FILE=${FREE_TEXT_DIR.slice(0, -1)}/<redact-file-name>"
[ -s "$REDACT_FILE" ] || { echo "ERROR: $REDACT_FILE is missing or empty — write ${sink.noun} into it first; refusing to send it unscanned." >&2; exit 1; }
${scanAndGate}
\`\`\`