fix(capy-install): the launch file is part of the ok verdict

Third adversarial pass: a passing run whose launch file could not be
written still said gstack: ok with exit 0 and printed only the GSTACK_LAUNCH
line, leaving a shell-evaluating caller with the re-typing path the branch
exists to remove. The file is now written before the verdict is recorded;
a write failure on a passing run turns the run into
"gstack: fail launch-file — could not write the launch file <path>; fix: …
(LAUNCH_FILE_UNWRITABLE)" with exit 1 and an ok:false verdict, and every
failing run removes the launch file an earlier passing run left beside the
verdict. The two failure tests assert the new contract, the doctor-FAIL
tests assert no launch file is announced or left, and the docs, Capy skill
and CHANGELOG say the same.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 05:07:27 +00:00
1 parent 6aab86b2f7
commit d4f942492d
5 files changed
+54 -32

No files matched your search

+2 -2
View File
@@ -5,8 +5,8 @@
**The launch environment reaches every gstack command and child through a sourceable file, and nothing in a skill fence may print the environment (GSTA-104).** During GSTA-96's unattended /ship on #3425 (2026-10-26), the Step 14.5 docs-audit child was told to export the `GSTACK_LAUNCH` assignments and re-typed them as `export $(...)`. The substitution came back empty, the shell ran a bare `export`, and the values of ANTHROPIC_API_KEY, OPENAI_API_KEY and UBICLOUD_API_KEY landed in its tool output. The line itself carries no secret (PATH, GSTACK_SESSION_KIND, GSTACK_STATE_ROOT, GSTACK_EPHEMERAL, GSTACK_CAPY, GSTACK_INSTALL_VERDICT); the hazard was re-typing it. Now the installer writes the assignments as a file callers source, every fence that hands the launch environment to a child says so in one line, a free ratchet keeps environment dumps out of every skill fence and shell helper, and the redaction engine refuses a credential value from the writer's own environment, whatever its shape.
### Added
- **`GSTACK_LAUNCH_FILE: <verdict file without .json>.env`** after the `GSTACK_LAUNCH` and `GSTACK_LAUNCH_PATH` lines of every passing `bin/gstack-capy-install` run, `--check` included (a `gstack: fail` run writes and announces no launch file). The file holds the same assignments as `export` statements (the PATH prepend is idempotent), is written atomically with mode 0600 beside the install verdict under the state root, and is applied with `. <file>` at the start of the shell that runs a gstack command. A caller hands a child the path, never the values.
- **`env.value` (HIGH) in `lib/redact-engine.ts`**: the value of every `*_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_PASSWD`, `*_PASS`, `*_PAT`, `*_APIKEY` or `*_PWD` variable in the scanning process's environment (any case; eight characters or more after trimming, not a placeholder; names carrying a `PUBLIC` word and gstack's own `GSTACK_BRAIN_LOCK_TOKEN` are exempt, and there is no path exemption) is a finding wherever it appears in the scanned text, so a leaked value cannot reach a PR body (`gstack-post`), a ship receipt, a report, an approval row or a Drive mirror through any writer that scans with the engine. The value is matched in the same normalized form as the text, so a value NFKC rewrites still finds its leaked bytes; when one value is a prefix of another, the longer value is redacted whole; the finding's preview never shows a byte of the value. `redactFindingSpans` removes it on the machine-egress path; `ScanOptions.processEnv` names the environment (`{}` scans without one). `test/redact-env-value.test.ts`.
- **`GSTACK_LAUNCH_FILE: <verdict file without .json>.env`** after the `GSTACK_LAUNCH` and `GSTACK_LAUNCH_PATH` lines of every passing `bin/gstack-capy-install` run, `--check` included . The file is part of the ok verdict: a passing run that cannot write it exits 1 with `gstack: fail launch-file — could not write the launch file <path>; fix: … (LAUNCH_FILE_UNWRITABLE)`, and any failing run announces no launch file and removes the one an earlier passing run left beside the verdict, so no caller is left with only the line and the re-typing path. The file holds the same assignments as `export` statements (the PATH prepend is idempotent), is written atomically with mode 0600 beside the install verdict under the state root, and is applied with `. <file>` at the start of the shell that runs a gstack command. A caller hands a child the path, never the values.
- **`env.value` (HIGH) in `lib/redact-engine.ts`**: the value of every `*_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_PASSWD`, `*_PASS`, `*_PAT`, `*_APIKEY` or `*_PWD` variable in the scanning process's environment (any case; eight characters or more after trimming, not a placeholder; names carrying a `PUBLIC` word and gstack's own `GSTACK_BRAIN_LOCK_TOKEN` are exempt, and there is no path exemption: a path-valued `SSL_KEY` that must appear in a text goes through `gstack-redact --allowlist` or the pre-push hook's `.gstack-redact-allowlist`, and for the writers that take no allowlist the variable is set empty for that one command) is a finding wherever it appears in the scanned text, so a leaked value cannot reach a PR body (`gstack-post`), a ship receipt, a report, an approval row or a Drive mirror through any writer that scans with the engine. The value is matched in the same normalized form as the text, so a value NFKC rewrites still finds its leaked bytes; when one value is a prefix of another, the longer value is redacted whole; the finding's preview never shows a byte of the value. `redactFindingSpans` removes it on the machine-egress path; `ScanOptions.processEnv` names the environment (`{}` scans without one). `test/redact-env-value.test.ts`.
- **`test/launch-env-echo-ratchet.test.ts`**: every generated SKILL.md and section file, their templates, `autoplan/DRIVER.md`, the Capy skill, every shell helper under `bin/`, `setup` and the `scripts/*.sh` install scripts carry no `export $(...)`, `export \`...\`` or unquoted `export $VAR`, no `env` or `printenv` (flags alone included), and no bare `set`, `export`, `declare -x`, `typeset -x`, `declare -p` or `typeset -p` in any command segment of a fence (`set | grep '^NAME_'` stays allowed: the anchored filter bounds what prints). `test/fixtures/launch-env-echo/` holds the negative controls the scanner must trip on.
### Changed
+12 -6
View File
@@ -579,15 +579,13 @@ case "$TRAILER" in "gstack: ok "*) if [ -n "$BLOCKED" ]; then
TRAILER="gstack: fail behind — installed $INSTALLED is ${GSTACK_CAPY_BEHIND:-?} commits behind origin (latest ${GSTACK_CAPY_BEHIND_TO:-unknown}) and the session-start upgrade was skipped: $BLOCKED"
RC=1
elif [ -n "$NOTES" ]; then TRAILER="$TRAILER ($NOTES)"; fi ;; esac
record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)"
[ "$CHECK_ONLY" -eq 1 ] || [ "$PROFILE" -eq 1 ] || write_shim
printf '%s\n' "$OUT" | grep -v '^gstack: '
say "total: $(( $(now) - T0 ))s"
# The launch file: the same assignments as a sourceable script, mode 0600,
# beside the verdict. `. <file>` applies them without ever printing them; the
# line carries no secret, but a caller that re-typed it as `export $(...)`
# ran a bare `export` when the substitution came back empty and dumped the
# whole environment, API keys included, into its tool output (GSTA-96).
# whole environment, API keys included, into its tool output (GSTA-96). The
# file is part of the ok verdict: a passing run that cannot write it fails,
# so no caller is left with only the line and the re-typing path.
LAUNCH_FILE="${VERDICT%.json}.env"
write_launch_file() {
local tmp
@@ -605,7 +603,15 @@ write_launch_file() {
echo "export GSTACK_INSTALL_VERDICT=$(shq "$VERDICT")"
} > "$tmp" && chmod 0600 "$tmp" && mv -f "$tmp" "$LAUNCH_FILE" || { rm -f "$tmp"; return 1; }
}
if [ "$RC" -eq 0 ]; then write_launch_file || { say "could not write the launch file $LAUNCH_FILE"; LAUNCH_FILE=""; }; else LAUNCH_FILE=""; fi
if [ "$RC" -eq 0 ] && ! write_launch_file; then
TRAILER="gstack: fail launch-file — could not write the launch file $LAUNCH_FILE; fix: make $(dirname "$LAUNCH_FILE") writable, or set GSTACK_STATE_ROOT to a writable directory (LAUNCH_FILE_UNWRITABLE)"
RC=1
fi
[ "$RC" -eq 0 ] || { rm -f "$LAUNCH_FILE"; LAUNCH_FILE=""; }
record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)"
[ "$CHECK_ONLY" -eq 1 ] || [ "$PROFILE" -eq 1 ] || write_shim
printf '%s\n' "$OUT" | grep -v '^gstack: '
say "total: $(( $(now) - T0 ))s"
echo "GSTACK_LAUNCH: ${LAUNCH_DIR:+PATH=$LAUNCH_DIR:\$PATH }GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=$GSTACK_STATE_ROOT GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-1} GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=$VERDICT"
[ -n "$LAUNCH_DIR" ] && echo "GSTACK_LAUNCH_PATH: $LAUNCH_DIR"
[ -n "$LAUNCH_FILE" ] && echo "GSTACK_LAUNCH_FILE: $LAUNCH_FILE"
+1 -1
View File
@@ -22,7 +22,7 @@ Resolve `scripts/install.sh` relative to this SKILL.md, not the working reposito
## Launch every gstack command with the GSTACK_LAUNCH line
Every successful run, `--check` included, prints `GSTACK_LAUNCH: PATH=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=<file>` (the `PATH=` part only when the verified Bun is not first on PATH), then `GSTACK_LAUNCH_PATH: <dir>` for callers that do not evaluate shell syntax, then `GSTACK_LAUNCH_FILE: <file>`: the same assignments as a sourceable script, mode 0600, under the state root beside the install verdict. Apply them with `. <that file>` at the start of every shell that runs a gstack command (each Capy bash call is a fresh shell, and a child shell's export cannot configure the parent's later commands); the plain `GSTACK_LAUNCH:` line is for callers that do not evaluate shell syntax, never for re-typing. The file is written only on a passing run. Never re-type them as `export $(...)`, and never run `env`, `printenv`, a bare `set` or a bare `export` in a tool fence: when the substitution comes back empty, `export $(...)` is a bare `export`, which prints every exported variable, API keys included, into the tool output (that is how GSTA-96's docs-audit child leaked three keys). Hand a child agent the file path, never the values. The PATH part is what makes gstack's `#!/usr/bin/env bun` commands run the Bun the trailer certified; the Capy image's `/usr/local/bin/bun` is 1.3.14, below gstack's minimum.
Every successful run, `--check` included, prints `GSTACK_LAUNCH: PATH=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=<file>` (the `PATH=` part only when the verified Bun is not first on PATH), then `GSTACK_LAUNCH_PATH: <dir>` for callers that do not evaluate shell syntax, then `GSTACK_LAUNCH_FILE: <file>`: the same assignments as a sourceable script, mode 0600, under the state root beside the install verdict. Apply them with `. <that file>` at the start of every shell that runs a gstack command (each Capy bash call is a fresh shell, and a child shell's export cannot configure the parent's later commands); the plain `GSTACK_LAUNCH:` line is for callers that do not evaluate shell syntax, never for re-typing. The file is part of the ok verdict: a passing run that cannot write it fails with `gstack: fail launch-file …`, and a failing run removes it. Never re-type them as `export $(...)`, and never run `env`, `printenv`, a bare `set` or a bare `export` in a tool fence: when the substitution comes back empty, `export $(...)` is a bare `export`, which prints every exported variable, API keys included, into the tool output (that is how GSTA-96's docs-audit child leaked three keys). Hand a child agent the file path, never the values. The PATH part is what makes gstack's `#!/usr/bin/env bun` commands run the Bun the trailer certified; the Capy image's `/usr/local/bin/bun` is 1.3.14, below gstack's minimum.
With `GSTACK_CAPY=1`, a workflow started in an unattended or spawned session checks the install verdict the last run wrote for this checkout, HEAD and workflow, and the Bun on PATH. When either fails it prints an `INSTALL: fail …` row with its fix and `GSTACK_RESULT: skill=<s> status=refused run=-` and exits 3: stop, run the fix, start again. An ok whose pins row was `SKIP pins (planning-only; …)` does not admit a workflow that runs the repo's tests (`review`, `ship`, `qa`), and a verdict that did not check a component the workflow needs does not admit it either; run `--for <that skill>` first.
+10 -1
View File
@@ -335,6 +335,11 @@ GSTACK_LAUNCH_FILE: <verdict file without .json>.env
`GSTACK_LAUNCH_FILE` names the same assignments as a sourceable script (mode
0600, beside the verdict under the state root; the PATH prepend is idempotent).
The file is part of the ok verdict: a passing run that cannot write it exits 1
with `gstack: fail launch-file — could not write the launch file <path>; fix:
make <dir> writable, or set GSTACK_STATE_ROOT to a writable directory
(LAUNCH_FILE_UNWRITABLE)`, and a failing run prints no `GSTACK_LAUNCH_FILE` line
and removes the file an earlier passing run left.
`. <that file>` at the start of a shell applies them without ever printing them,
which is the only way to hand them to a child agent: pass the path, never the
values. None of the assignments is a secret, but during GSTA-96's unattended
@@ -349,7 +354,11 @@ in the writer's environment (any case; names carrying a `PUBLIC` word are
framework-public and exempt, gstack's own `GSTACK_BRAIN_LOCK_TOKEN` is not a
secret) (`env.value`, HIGH), so a leaked value cannot reach a PR body, receipt,
report or Drive file. There is no path exemption: a path-valued `SSL_KEY` that
must appear in a text goes through the allowlist.
must appear in a text goes through `bin/gstack-redact --allowlist` or the
pre-push hook's committed `.gstack-redact-allowlist`; the other writers
(`gstack-post`, receipts, reports) take no allowlist, so for one of those
commands set the variable empty for that command alone
(`SSL_KEY= gstack-post …`): an empty value is below the length floor.
`PATH=<dir>:$PATH` and `GSTACK_LAUNCH_PATH` appear only when the certified Bun,
or the project's resolved Node, is not first on PATH (older Capy images ship
+29 -22
View File
@@ -142,9 +142,11 @@ describe('gstack-capy-install', () => {
expect(g.calls().filter(l => l.startsWith('doctor '))).toEqual(['doctor --check --require claude,codex,patch,browse-bundle,browser GSTACK_EPHEMERAL=1']);
});
test('a FAIL from the doctor is exit 1 with its trailer last; usage errors exit 2', () => {
test('a FAIL from the doctor is exit 1 with its trailer last and no launch file; usage errors exit 2', () => {
const f = fixture();
const r = run(f, [], { STUB_DOCTOR_FAIL: '1' });
expect(r.stdout).not.toMatch(/^GSTACK_LAUNCH_FILE:/m);
expect(fs.readdirSync(path.join(f.home, '.gstack-state/install-verdicts'), { recursive: true }).some(n => String(n).endsWith('.env'))).toBe(false);
expect(r.status).toBe(1);
expect(lastLine(r.stdout)).toBe('gstack: fail codex');
expect(r.stdout).toContain('FAIL codex');
@@ -448,15 +450,21 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
const failed = run(f, ['--check', '--for', 'autoplan'], { STUB_DOCTOR_FAIL: '1' });
expect(failed.status).toBe(1);
expect(JSON.parse(fs.readFileSync(files[0], 'utf8'))).toMatchObject({ ok: false, trailer: 'gstack: fail codex' });
// A failing run announces no launch file and removes the one the passing run left, so nothing
// sourceable points at a verdict that now says fail.
expect(failed.stdout).not.toMatch(/^GSTACK_LAUNCH_FILE:/m);
expect(fs.existsSync(files[0].replace(/\.json$/, '.env'))).toBe(false);
});
// /ship Step 7 value card. protects: when the launch file cannot be written, the run says so,
// prints no GSTACK_LAUNCH_FILE line (a caller must never be handed an empty path to source),
// leaves no temp file behind, and keeps its exit code and trailer; the earlier launch file is
// untouched. fails_when: the `[ -n "$LAUNCH_FILE" ]` guard goes, the write failure changes RC or
// ordering, or the temp file is created outside the umask/cleanup path. why_existing_misses:
// every fixture's state root is writable. seam=none (filesystem permissions). no_claim: root.
test.skipIf(process.platform !== 'linux' || process.getuid?.() === 0)('a launch file that cannot be written is reported, prints no GSTACK_LAUNCH_FILE line, leaves no temp file and keeps the exit code and trailer', () => {
// /ship Step 7 value card. protects: the launch file is part of the ok verdict. When it cannot be
// written on a passing run, the run FAILS (exit 1, `gstack: fail launch-file — …` trailer naming
// the fix), prints no GSTACK_LAUNCH_FILE line (a caller must never be handed an empty path to
// source, and never be left with only the line and the re-typing path) and leaves no temp file
// behind. The directory is unwritable here, so the earlier launch file and the earlier ok verdict
// stay as they were. fails_when: the write failure stops setting RC, the trailer loses its fix, or
// the temp file is created outside the umask/cleanup path. why_existing_misses: every fixture's
// state root is writable. seam=none (filesystem permissions). no_claim: root.
test.skipIf(process.platform !== 'linux' || process.getuid?.() === 0)('a launch file that cannot be written fails the run with a launch-file trailer, prints no GSTACK_LAUNCH_FILE line and leaves no temp file', () => {
const f = fixture();
const first = run(f, ['--for', 'autoplan']);
expect(first.status, first.stdout + first.stderr).toBe(0);
@@ -466,11 +474,10 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
fs.chmodSync(dir, 0o555);
try {
const c = run(f, ['--check', '--for', 'autoplan']);
expect(c.status, c.stdout + c.stderr).toBe(0);
expect(c.stdout).toContain(`gstack-capy-install: could not write the launch file ${launchFile}`);
expect(c.status, c.stdout + c.stderr).toBe(1);
expect(c.stdout).toContain('GSTACK_LAUNCH: ');
expect(c.stdout).not.toMatch(/^GSTACK_LAUNCH_FILE:/m);
expect(lastLine(c.stdout)).toBe('gstack: ok 1.2.3 for=autoplan');
expect(lastLine(c.stdout)).toBe(`gstack: fail launch-file — could not write the launch file ${launchFile}; fix: make ${dir} writable, or set GSTACK_STATE_ROOT to a writable directory (LAUNCH_FILE_UNWRITABLE)`);
expect(fs.readdirSync(dir).filter(n => n.includes('.tmp.'))).toEqual([]);
expect(fs.readFileSync(launchFile, 'utf8')).toBe(before);
} finally {
@@ -480,27 +487,27 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
// protects: when the launch file's temp file IS created and the final mv fails, write_launch_file
// removes its own temp file and nothing else (a sibling temp file from a concurrent run survives),
// the run says so, prints no GSTACK_LAUNCH_FILE line and keeps the earlier launch file, exit code
// and trailer. fails_when: the `|| { rm -f "$tmp"; return 1; }` cleanup goes (the temp file stays),
// or a glob cleanup returns (the sibling disappears). why_existing_misses: the unwritable-directory
// case above fails at mktemp, before any temp file exists. seam: an mv shim first on PATH that
// refuses only the launch-file target.
test('a launch-file move that fails after the temp file exists removes only that temp file', () => {
// the run fails with the launch-file trailer, prints no GSTACK_LAUNCH_FILE line, removes the
// earlier launch file (its verdict now says fail) and records ok:false. fails_when: the
// `|| { rm -f "$tmp"; return 1; }` cleanup goes (the temp file stays), a glob cleanup returns (the
// sibling disappears), or the fail branch stops removing the stale file. why_existing_misses: the
// unwritable-directory case above fails at mktemp, before any temp file exists, and cannot delete.
// seam: an mv shim first on PATH that refuses only the launch-file target.
test('a launch-file move that fails after the temp file exists removes only that temp file and the stale launch file, and fails the run', () => {
const f = fixture();
const first = run(f, ['--for', 'autoplan']);
expect(first.status, first.stdout + first.stderr).toBe(0);
const launchFile = first.stdout.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)?.[1] ?? '';
const before = fs.readFileSync(launchFile, 'utf8');
const sibling = `${launchFile}.tmp.OTHER1`;
fs.writeFileSync(sibling, '');
script(path.join(f.stub, 'mv'), [`for a in "$@"; do [ "$a" = "${launchFile}" ] && exit 1; done`, 'exec /bin/mv "$@"']);
const c = run(f, ['--check', '--for', 'autoplan']);
expect(c.status, c.stdout + c.stderr).toBe(0);
expect(c.stdout).toContain(`gstack-capy-install: could not write the launch file ${launchFile}`);
expect(c.status, c.stdout + c.stderr).toBe(1);
expect(c.stdout).not.toMatch(/^GSTACK_LAUNCH_FILE:/m);
expect(lastLine(c.stdout)).toBe('gstack: ok 1.2.3 for=autoplan');
expect(lastLine(c.stdout)).toMatch(/^gstack: fail launch-file — could not write the launch file .*\(LAUNCH_FILE_UNWRITABLE\)$/);
expect(fs.readdirSync(path.dirname(launchFile)).filter(n => n.includes('.tmp.'))).toEqual([path.basename(sibling)]);
expect(fs.readFileSync(launchFile, 'utf8')).toBe(before);
expect(fs.existsSync(launchFile)).toBe(false);
expect(JSON.parse(fs.readFileSync(launchFile.replace(/\.env$/, '.json'), 'utf8'))).toMatchObject({ ok: false });
});
});