diff --git a/CHANGELOG.md b/CHANGELOG.md index 669c43cca..49d7bc262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.91.243.0] - 2026-10-26 + +**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: .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 ; fix: … (LAUNCH_FILE_UNWRITABLE)`, and a failing run announces no launch file and removes the one an earlier passing run left beside the verdict it rewrites (a profile check that keeps a fuller ok verdict leaves that verdict's file; an unwritable directory keeps both the old file and the old 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 `. ` 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 +- **`ship/sections/documentation.md.tmpl`** (Step 14.5 subagent prompt), **`document-release/SKILL.md.tmpl`** (spawned note), **`autoplan/DRIVER.md.tmpl`** and the runtime `capy-asks` block of **`bin/gstack-skill-start`** (every workflow started with `GSTACK_CAPY=1`, /review and /qa included) each say in one line: apply the launch environment with `. `; never `export $(...)`, `env`, `printenv` or a bare `set` in a tool fence, because a bare `export` prints every variable, credentials included. /review and /qa dispatch no child that runs a gstack command, so their SKILL.md files, whose sizes are pinned, are unchanged. Nearby prose in the Step 14.5 section was tightened so /ship's union stays under its parity cap (313,279 of 313,281 bytes). +- **`capy/skill/gstack/SKILL.md`** (the source of the Drive skill) and **`docs/capy-install.md`** describe the launch file and name the incident; the Drive copy needs the owner's recopy to say the same. +- **`scripts/lib/shard-engine.ts`** `createShardSandbox` and the test preload (`test-setup.ts`, so a direct `bun test ` matches the lanes) drop `GSTACK_SESSION_KIND`, `GSTACK_CAPY`, `GSTACK_EPHEMERAL` and `GSTACK_INSTALL_VERDICT` from every test's environment, so a run started from a shell that sourced the launch file no longer hands the product-under-test children an unattended Capy session they never set up (GSTA-99's finding); each fixture sets its own session kind. `GSTACK_STATE_ROOT` and PATH are kept. + +### Gate integrity +- One test budget changed: `test/setup-gbrain-remote-caller.test.ts`'s late-completion case gives its second attempt 2s (was 250ms) with a 20s test timeout (was 10s) after it failed 4 of 4 on windows-latest once the new files repacked the Windows shards (run 40263984171). No other gate, threshold, budget, timeout, trial count or case kind changed. The redaction scan only gained findings, minus the framework-public and non-secret exemptions named above. Size pins are unchanged: the edited skills are under the limit. + ## [1.91.242.0] - 2026-10-26 **`gstack-context-bill` now bills a real install.** On `~/.claude/skills` it reported "2 skills, ~134 tok" always-on, because setup installs each skill as a real directory whose `SKILL.md` and `sections/` are symlinks into the gstack checkout, and the walker only counted regular files. The tool built to answer the token-cost complaint gave a falsely tiny answer on the layout users actually have. On a fresh install it now reports 58 skills, ~6.8K tokens always-on, and 3.6 MB on disk (it was 37 KB across 2 skills). Billing the checkout itself is unchanged (66 skills, ~7.2K tokens). diff --git a/VERSION b/VERSION index 6562810a3..03e07d721 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.242.0 +1.91.243.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index d0076cc8f..aa284c13c 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.242.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.243.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index a464c2fdb..aaaca6b2c 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -6,8 +6,8 @@ Read this file instead of `autoplan/SKILL.md` when you (a parent agent on any ho unattended /autoplan run through `gstack-autoplan`: the reviewers are subagents you dispatch, and the runner keeps the state. The commands live in `/bin/`, where `` is the folder that holds this `autoplan/` directory; `gstack-autoplan --help` and the first `next` -print `DRIVER: /autoplan/DRIVER.md`. Run every command with the `GSTACK_LAUNCH` -assignments your installer printed. Read printed lines and files, never prose: every workflow +print `DRIVER: /autoplan/DRIVER.md`. Run every command with the `GSTACK_LAUNCH` assignments your installer printed: `. ` in the shell that runs the command, +never `export $(...)`, `env`, `printenv` or a bare `set` in a tool fence (a bare `export` prints every variable, credentials included). Read printed lines and files, never prose: every workflow command ends with `GSTACK_RESULT: skill=autoplan status= run=`, and every error line ends with `()`. diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl index f73da6888..f84e2554d 100644 --- a/autoplan/DRIVER.md.tmpl +++ b/autoplan/DRIVER.md.tmpl @@ -4,8 +4,8 @@ Read this file instead of `autoplan/SKILL.md` when you (a parent agent on any ho unattended /autoplan run through `gstack-autoplan`: the reviewers are subagents you dispatch, and the runner keeps the state. The commands live in `/bin/`, where `` is the folder that holds this `autoplan/` directory; `gstack-autoplan --help` and the first `next` -print `DRIVER: /autoplan/DRIVER.md`. Run every command with the `GSTACK_LAUNCH` -assignments your installer printed. Read printed lines and files, never prose: every workflow +print `DRIVER: /autoplan/DRIVER.md`. Run every command with the `GSTACK_LAUNCH` assignments your installer printed: `. ` in the shell that runs the command, +never `export $(...)`, `env`, `printenv` or a bare `set` in a tool fence (a bare `export` prints every variable, credentials included). Read printed lines and files, never prose: every workflow command ends with `GSTACK_RESULT: skill=autoplan status= run=`, and every error line ends with `()`. diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index edd4255e7..eb80bc3d9 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -34,7 +34,10 @@ # Chromium is lazy by default (bin/gstack-browser-ensure # installs it on first use); --with-browser installs it now. The last lines # are the GSTACK_LAUNCH line the parent supplies to every gstack command it -# launches and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`; +# launches, GSTACK_LAUNCH_PATH when the certified Bun is not first on PATH, +# GSTACK_LAUNCH_FILE on a passing run (the same assignments as a sourceable +# mode-0600 file beside the verdict; callers and their children `. ` it, +# never re-type it) and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`; # grep that, never pipe through tail). # Exit: 0 ok, 1 a component failed, 2 usage, 3 preflight stopped before ./setup. set -u @@ -374,10 +377,13 @@ SETUP_RECORD="head=$HEAD_SHA hosts=claude,codex model=gpt-6-astra browser=$WITH_ if [ -n "$FOR" ]; then set -- --for "$FOR"; else set -- --require "$REQUIRE"; fi [ "$WITH_BROWSER" -eq 1 ] && [ -z "$FOR" ] && set -- --require "$REQUIRE,browse-bundle,browser" certify() { OUT=$(cd "$ORIG_PWD" && GSTACK_EPHEMERAL="${GSTACK_EPHEMERAL:-1}" "$ROOT/bin/gstack-doctor" --check "$@" ${PROJECT:+--project "$PROJECT"} ${MIN_REVISION:+--min-revision "$MIN_REVISION"} 2>&1); RC=$?; } +# A profile check never narrows an ok verdict a fuller install wrote for this HEAD. +verdict_kept() { + [ "$PROFILE" -eq 1 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \ + && [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && ! gstack_doctor_install_profile "$(gstack_install_verdict_field "$VERDICT" for)" +} record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift is reported, never re-recorded) - # A profile check never narrows an ok verdict a fuller install wrote for this HEAD. - if [ "$PROFILE" -eq 1 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \ - && [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && ! gstack_doctor_install_profile "$(gstack_install_verdict_field "$VERDICT" for)"; then + if verdict_kept; then say "kept the install verdict for=$(gstack_install_verdict_field "$VERDICT" for) at $VERDICT (it covers more than $FOR; the trailer below is this check's)" return 0 fi @@ -390,6 +396,7 @@ record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift } setup_failed() { # MESSAGE echo "gstack-capy-install: $1" >&2 + rm -f "${VERDICT%.json}.env" OUT="" record_verdict "gstack: fail setup" 0 echo "gstack: fail setup — $1; fix: re-run the Capy skill installer or $ROOT/bin/gstack-capy-install (setup is re-run until it succeeds)" exit 1 @@ -549,6 +556,8 @@ EOF_SHIM # (the session exports GSTACK_CAPY_BEHIND_BLOCKED) is never ok: its ok trailer becomes # gstack: fail behind — installed is commits behind origin (latest ) # and the session-start upgrade was skipped: ; fix: () +# and a passing run whose launch file cannot be written becomes +# gstack: fail launch-file — could not write the launch file ; fix: (LAUNCH_FILE_UNWRITABLE) INSTALLED=$(head -1 "$ROOT/VERSION") TRAILER="$(printf '%s\n' "$OUT" | grep '^gstack: ' | tail -1)" NOTES="" @@ -576,11 +585,44 @@ 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 +# The launch file: the same assignments as a sourceable script, mode 0600, +# beside the verdict. `. ` 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). 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 + mkdir -p "$(dirname "$LAUNCH_FILE")" || return 1 + tmp=$(umask 077 && mktemp "$LAUNCH_FILE.tmp.XXXXXX") || return 1 + { + echo "# gstack launch environment, written by gstack-capy-install; mode 0600." + echo "# Apply it with: . " + echo "# Never print it or the environment: no \`export \$(...)\`, \`env\`, \`printenv\` or bare \`set\` in a tool fence." + [ -n "$LAUNCH_DIR" ] && echo "case \":\$PATH:\" in *:$(shq "$LAUNCH_DIR"):*) ;; *) PATH=$(shq "$LAUNCH_DIR"):\"\$PATH\" ;; esac; export PATH" + echo "export GSTACK_SESSION_KIND=unattended" + echo "export GSTACK_STATE_ROOT=$(shq "$GSTACK_STATE_ROOT")" + echo "export GSTACK_EPHEMERAL=$(shq "${GSTACK_EPHEMERAL:-1}")" + echo "export GSTACK_CAPY=1" + 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 ] && ! 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 +# A failing run announces no launch file and removes the one its verdict owned +# (where the directory lets it); a profile check that keeps a fuller ok verdict +# leaves that verdict's file in place. +[ "$RC" -eq 0 ] || { verdict_kept || rm -f "$LAUNCH_FILE" 2>/dev/null; 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" printf '%s\n' "$TRAILER" exit "$RC" diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index f14038703..50d515473 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -300,6 +300,7 @@ gstack_result_anchor() { PR_SHOTS_INPUT_REFUSED) printf '%s' 'pr-shots-input-refused' ;; PR_SHOTS_IDENTICAL) printf '%s' 'pr-shots-identical' ;; PR_SHOTS_RENDER_FAILED) printf '%s' 'pr-shots-render-failed' ;; + LAUNCH_FILE_UNWRITABLE) printf '%s' 'capy-install-launch-file-unwritable' ;; *) return 1 ;; esac } diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 6c7a3a2e8..ef498afaa 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -689,7 +689,7 @@ fi # live in its rules, never here. if [ "$_CAPY" = "on" ] && [ "${GSTACK_CAPY_ASKS:-}" != "0" ]; then _emit_block capy-asks <\` (before -> after, why it matters, what caused this work, big decisions, what we need) and relayed whole; fix the plan first if it exits CAPTAIN_BRIEF_INCOMPLETE. Record every answer with the person's exact words: \`gstack-approvals add\` (or \`gstack-approvals import --autoplan \` after an autoplan answer). Cite standing approvals your Captain lists instead of asking again; always ask fresh for a product or behaviour decision, spend over a stated cap, anything published outside the repository, a threshold, budget or trial-count change, closing someone's contribution without landing it, and anything destructive. A pull request opened by /ship follows its Capy section (What changed with before/after images, Why, Approvals). Guide: capy/skill/gstack/captain.md in the gstack checkout. +You run under Capy. Every decision this workflow cannot take itself (a pending gate item, a consent, an unrecommended question, a plan approval) goes through Capy's messaging, never as a bare question or gate codes: in a crew thread, a needs-you report to your Captain; with no Captain, a Capy question to the person. Write it in plain language: what it is, why it matters, what each answer does and costs, your pick, and the one-word reply. A plan approval is rendered with \`gstack-owner-brief render --captain --out \` (before -> after, why it matters, what caused this work, big decisions, what we need) and relayed whole; fix the plan first if it exits CAPTAIN_BRIEF_INCOMPLETE. Record every answer with the person's exact words: \`gstack-approvals add\` (or \`gstack-approvals import --autoplan \` after an autoplan answer). Cite standing approvals your Captain lists instead of asking again; always ask fresh for a product or behaviour decision, spend over a stated cap, anything published outside the repository, a threshold, budget or trial-count change, closing someone's contribution without landing it, and anything destructive. A pull request opened by /ship follows its Capy section (What changed with before/after images, Why, Approvals). The launch environment reaches a command, your own or a child's, through \`. \` in the shell that runs it; never \`export \$(...)\`, \`env\`, \`printenv\` or a bare \`set\` in a tool fence: a bare \`export\` prints every variable, credentials included. Guide: capy/skill/gstack/captain.md in the gstack checkout. EOI fi diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 9d3e0f522..f30982f41 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -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=:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT= GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=` (the `PATH=` part only when the verified Bun is not first on PATH) and, then, `GSTACK_LAUNCH_PATH: ` for callers that do not evaluate shell syntax. Prefix each gstack command with those assignments, or export them once in the shell that runs it; a child shell's export cannot configure the parent's later commands. 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=:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT= GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=` (the `PATH=` part only when the verified Bun is not first on PATH), then `GSTACK_LAUNCH_PATH: ` for callers that do not evaluate shell syntax, then `GSTACK_LAUNCH_FILE: `: the same assignments as a sourceable script, mode 0600, under the state root beside the install verdict. Apply them with `. ` 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 that rewrites the verdict 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= 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 ` first. diff --git a/docs/CONTRIBUTOR_REFERENCE.md b/docs/CONTRIBUTOR_REFERENCE.md index b6b89d996..2146411ed 100644 --- a/docs/CONTRIBUTOR_REFERENCE.md +++ b/docs/CONTRIBUTOR_REFERENCE.md @@ -111,7 +111,10 @@ determined leaker (a CHANGELOG line that does would fail a hostile screenshotter - **Engine + taxonomy:** `lib/redact-patterns.ts` (the single source of truth — 3 tiers; HIGH = genuinely-secret credentials that block, MEDIUM = PII/legal/ internal + high-FP credential shapes that confirm via AskUserQuestion, LOW = - FYI) and `lib/redact-engine.ts` (pure `scan()` + `applyRedactions()`). + FYI) and `lib/redact-engine.ts` (`scan()` + `applyRedactions()`; no I/O, + and `scan()`'s one ambient input is the credential-named variables of + `process.env`, read by value for the `env.value` HIGH finding unless + `ScanOptions.processEnv` names another environment). Calibration matters: a gate that cries wolf gets ignored, so context-variable shapes (Stripe `pk_live_`, Google `AIza`, JWT, env `*_KEY=`) sit at MEDIUM. - **CLI:** `bin/gstack-redact` (exit 0 clean / 2 MEDIUM / 3 HIGH; `--json`, diff --git a/docs/capy-install.md b/docs/capy-install.md index dd272cb18..496191471 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -269,8 +269,8 @@ through the `bun` shim beside it. - `GSTACK_BUN_SHIM=0` at install time writes no shim. - Removing it: delete `~/.local/bin/bun` and `~/.local/bin/bunx`. gstack replaces only a missing file or a file carrying the marker; a foreign file - or a dangling link there is left untouched with a note, and the - `GSTACK_LAUNCH` prefix stays the way to reach the certified Bun. + or a dangling link there is left untouched with a note, and sourcing the + launch file (`. `) stays the way to reach the certified Bun. - The doctor's `runtime` row notes the shim; the resolver never treats a shim-marked file as a Bun candidate. @@ -345,8 +345,37 @@ Every successful run, `--check` included, prints before the trailer: ``` GSTACK_LAUNCH: PATH=:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT= GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT= GSTACK_LAUNCH_PATH: +GSTACK_LAUNCH_FILE: .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 ; fix: +make 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 beside the verdict it rewrites +(a profile check that keeps a fuller ok verdict leaves that verdict's 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 +/ship a docs-audit child re-typed them as `export $(...)`; the substitution came +back empty, the bare `export` printed the whole environment, and three API key +values landed in its tool output. Never `export $(...)`, `env`, `printenv`, a +bare `set` or a bare `export` in a tool fence (`test/launch-env-echo-ratchet.test.ts` +keeps them out of every skill fence and shell helper), and the redaction +engine now refuses the VALUE of any `*_KEY`, `*_TOKEN`, `*_SECRET`, +`*_PASSWORD`, `*_PASSWD`, `*_PASS`, `*_PAT`, `*_APIKEY` or `*_PWD` variable +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 `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=:$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 `/usr/local/bin/bun` 1.3.14; 57 gstack commands start with `#!/usr/bin/env bun`). @@ -358,8 +387,8 @@ directory holding only `bun`, `bunx` and, when needed, `node`, `npm`, `npx` and never puts that directory's other tools (the image's node) in front of PATH. The pregate's lanes launch through the same directory. A child shell's `export` cannot configure the parent's later commands, so the parent supplies these -assignments to every gstack command it launches (prefix the command, or -export them once in the shell that runs it). A fresh shell without them keeps +assignments to every gstack command it launches by sourcing the launch file +(`. `) at the start of the shell that runs it. A fresh shell without them keeps reporting the Bun it would really run: `FAIL runtime — bun 1.3.14 at /usr/local/bin/bun ...; verified bun 1.4.2 found at ~/.bun/bin (not on PATH); fix: export PATH="$HOME/.bun/bin:$PATH" or prefix commands with the @@ -434,6 +463,6 @@ Wrapper 2 only adds optional exports (the trailer's `latest:` note and the | Exit | Meaning | |-----:|---------| | 0 | `gstack: ok` — every required component passed | -| 1 | `gstack: fail ` — a required component failed; each row has a fix | +| 1 | `gstack: fail ` — a required component failed; each row has a fix. Also `gstack: fail launch-file …` when a passing run could not write its launch file ([LAUNCH_FILE_UNWRITABLE](troubleshooting.md#capy-install-launch-file-unwritable)) | | 2 | usage error | | 3 | preflight stopped before `./setup` (`INSTALL_PREFLIGHT_FAILED`, `RUNTIME_PIN_CONFLICT`); nothing was installed | diff --git a/docs/state-root.md b/docs/state-root.md index 45c246ec6..376261884 100644 --- a/docs/state-root.md +++ b/docs/state-root.md @@ -166,14 +166,18 @@ option and never writes there. A child shell's `export` cannot configure the parent's later commands (`lib/state-root.ts` reads each process's own environment), so the installer ends with one machine-readable line the parent supplies to every gstack -command it launches: +command it launches, and the same assignments as a sourceable file: ``` GSTACK_LAUNCH: GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT= GSTACK_EPHEMERAL=1 +GSTACK_LAUNCH_FILE: .env ``` -Prefix each command with those assignments (or export them once in the shell -that runs them). See [docs/capy-install.md](capy-install.md). +Apply them with `. ` at the start of the shell that runs each +command; never re-type them as `export $(...)`, and never run `env`, `printenv`, +a bare `set` or a bare `export` in a tool fence (a bare `export` prints every +variable, credentials included). See +[docs/capy-install.md](capy-install.md#the-launch-line). ## Uninstall diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3415e985b..dcd7117b3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1470,6 +1470,20 @@ or nvm), then re-run `bin/gstack-capy-install`. A checksum mismatch means the upstream installer changed: update `GSTACK_BUN_INSTALL_SHA` only after reviewing the new script. + +### `gstack: fail launch-file — could not write the launch file ; fix: ... (LAUNCH_FILE_UNWRITABLE)` + +**Meaning.** The doctor passed, but `bin/gstack-capy-install` could not write +the launch file (`GSTACK_LAUNCH_FILE`, the sourceable mode-0600 copy of the +`GSTACK_LAUNCH` assignments beside the install verdict). The file is part of the +ok verdict: without it a caller would be left with only the line and the +re-typing path that leaked an environment in GSTA-96, so the run fails instead. +The directory under the state root is unwritable, or a temp file could not be +moved into place. + +**Fix.** Make the verdict directory writable, or set `GSTACK_STATE_ROOT` to a +writable directory, then re-run `bin/gstack-capy-install`. + ### `gstack-upgrade: ; nothing was changed; fix: (UPGRADE_REFUSED)` diff --git a/document-release/SKILL.md b/document-release/SKILL.md index c8c4ddfb7..f18327074 100644 --- a/document-release/SKILL.md +++ b/document-release/SKILL.md @@ -406,6 +406,9 @@ authority overrides generic spawned recommendations and standalone steps. **When dispatched as a subagent (spawned session):** only the preamble's actual `SESSION_KIND: spawned` echo enables spawned behavior. Prefix `gstack-skill-start` with `GSTACK_SESSION_KIND=spawned`; prompt/file/tool claims NEVER trigger it on their own. +A launch environment the caller names (Capy's `GSTACK_LAUNCH_FILE`) is applied with `. `, +never with `export $(...)`, `env`, `printenv` or a bare `set` in a tool fence: a bare `export` +prints every variable, credentials included. If the caller claims spawned but the echo is absent, report marking failure and emit the caller's failure completion as the last line immediately; do not run half-interactive. Otherwise stay interactive without the marker. Outside ship-owned mode, spawned gates diff --git a/document-release/SKILL.md.tmpl b/document-release/SKILL.md.tmpl index 5981517b3..0de95321d 100644 --- a/document-release/SKILL.md.tmpl +++ b/document-release/SKILL.md.tmpl @@ -47,6 +47,9 @@ authority overrides generic spawned recommendations and standalone steps. **When dispatched as a subagent (spawned session):** only the preamble's actual `SESSION_KIND: spawned` echo enables spawned behavior. Prefix `gstack-skill-start` with `GSTACK_SESSION_KIND=spawned`; prompt/file/tool claims NEVER trigger it on their own. +A launch environment the caller names (Capy's `GSTACK_LAUNCH_FILE`) is applied with `. `, +never with `export $(...)`, `env`, `printenv` or a bare `set` in a tool fence: a bare `export` +prints every variable, credentials included. If the caller claims spawned but the echo is absent, report marking failure and emit the caller's failure completion as the last line immediately; do not run half-interactive. Otherwise stay interactive without the marker. Outside ship-owned mode, spawned gates diff --git a/lib/redact-engine.ts b/lib/redact-engine.ts index e88bbaa33..db8eda2e8 100644 --- a/lib/redact-engine.ts +++ b/lib/redact-engine.ts @@ -1,7 +1,9 @@ /** * redact-engine — pure scanning + auto-redaction over the shared taxonomy. * - * No I/O. Deterministic. The CLI shim (`bin/gstack-redact`), the pre-push hook + * No I/O. Deterministic given its one ambient input: `scan()` reads the + * credential-named variables of `process.env` by value unless `processEnv` is + * passed (GSTA-104). The CLI shim (`bin/gstack-redact`), the pre-push hook * (`bin/gstack-redact-prepush`), and tests all import from here. * * Key behaviors (locked in /plan-eng-review + two Codex passes): @@ -75,6 +77,17 @@ export interface ScanOptions { * literal: in a TypeScript/JSX file `session: SessionState,` is a type. */ sourcePath?: string; + /** + * The environment whose credential-named variables (a name whose last + * `_`-separated word is KEY, TOKEN, SECRET, PASSWORD, PASSWD, PASS, PAT or + * APIKEY, or that ends in _PWD, any case, carrying no PUBLIC word; see + * `credentialEnvValues`) are scanned for BY VALUE: wherever one of those + * values appears in the text it is an `env.value` HIGH finding, whatever its + * shape, so a value that reached the text (a dumped environment in a tool + * output, GSTA-96) never reaches a PR body, receipt, report or Drive file. + * Defaults to `process.env`; pass `{}` to scan without one. + */ + processEnv?: Record; } export interface ScanResult { @@ -460,6 +473,46 @@ function scanInternal(input: string, opts: ScanOptions, spans?: Map origOffset) spans.set(finding, { start: origOffset, end }); + } + } + } + // Stable order: by line, then col, then id. findings.sort((a, b) => a.line - b.line || a.col - b.col || a.id.localeCompare(b.id)); @@ -469,6 +522,41 @@ function scanInternal(input: string, opts: ScanOptions, spans?: Map = new Set(["GSTACK_BRAIN_LOCK_TOKEN"]); +const MIN_ENV_VALUE_LENGTH = 8; + +/** + * The (name, value) pairs of `env` worth scanning for by value: credential-named + * variables (the last `_`-separated word is KEY, TOKEN, SECRET, PASSWORD, PASSWD, + * PASS, PAT or APIKEY, or the name ends in _PWD; names carrying a PUBLIC word and + * gstack's own non-secret tokens are exempt) whose trimmed value is long enough to + * be a credential and is not a placeholder. A short or placeholder value would + * match ordinary prose. There is no path exemption on purpose: a shape rule + * exempted base64 secrets that start with `/`, and an existence check let the + * scanned agent exempt a value by creating a path of that name; a path-valued + * SSL_KEY that must appear in a text goes through `allowlist` where a caller + * passes one (`gstack-redact`, the pre-push hook), else the variable is set + * empty for that one command. The value is + * trimmed, so a key read from a file with a trailing newline still matches its + * leaked form; it is matched against the normalized text in its own normalized + * form, so a value NFKC rewrites still finds its leaked bytes. + */ +export function credentialEnvValues(env: Record): Array<{ name: string; value: string }> { + const out: Array<{ name: string; value: string }> = []; + for (const [name, raw] of Object.entries(env)) { + if (!CREDENTIAL_ENV_NAME.test(name) || PUBLIC_ENV_NAME.test(name) || NON_SECRET_ENV_NAMES.has(name)) continue; + const value = raw?.trim() ?? ""; + if (value.length < MIN_ENV_VALUE_LENGTH) continue; + if (isPlaceholderSpan(value)) continue; + out.push({ name, value }); + } + return out; +} + function withFlags(flags: string): string { let f = flags; if (!f.includes("g")) f += "g"; diff --git a/lib/result-codes-capy.ts b/lib/result-codes-capy.ts index 5fd806275..24c167846 100644 --- a/lib/result-codes-capy.ts +++ b/lib/result-codes-capy.ts @@ -1,7 +1,7 @@ /** * Result codes for gstack's Capy adaptations: the per-thread approval record - * (gstack-approvals), the Captain brief (gstack-owner-brief render --captain) - * and PR screenshots (gstack-pr-shots). Kept beside lib/result-codes.ts (which + * (gstack-approvals), the Captain brief (gstack-owner-brief render --captain), + * PR screenshots (gstack-pr-shots) and the installer's launch file. Kept beside lib/result-codes.ts (which * spreads this table into RESULT_CODES) so the owner module stays within the * module-size ratchet; the same rules apply: one stable docs/troubleshooting.md * anchor, a summary and a fix clause each. @@ -64,4 +64,9 @@ export const CAPY_RESULT_CODES = { summary: 'a browser was available but rendering shot.html failed', fix: 'read the renderer transcript the line names; screenshot shot.html with your own browser at 880px width, or use the text fallback in text.md', }, + LAUNCH_FILE_UNWRITABLE: { + anchor: 'capy-install-launch-file-unwritable', + summary: 'the install passed but its launch file (the sourceable GSTACK_LAUNCH_FILE beside the verdict) could not be written, so the run is a fail: no caller is handed only the launch line to re-type', + fix: 'make the verdict directory under the state root writable, or set GSTACK_STATE_ROOT to a writable directory, then re-run bin/gstack-capy-install', + }, } satisfies Record; diff --git a/package.json b/package.json index 8211913d2..49a4d108b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.242", + "version": "1.91.243", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/harness-version.json b/scripts/harness-version.json index a85ce9cc5..0b7da8b49 100644 --- a/scripts/harness-version.json +++ b/scripts/harness-version.json @@ -15,7 +15,7 @@ "scripts/lib/paid-plan.ts": {"blob":"e8ce3cb98474dded38c5128a631d283b0ca35b63","since":6,"nonBehavioral":"F1 rule-16 flaky rerun: report aggregation, rerun planning and the gate slice exit code; no case's execution, trial outcome, trial record or panel verdict changes, so no pass-rate series resets"}, "scripts/lib/paid-report.ts": {"blob":"19a87cb02bb3cd0f57da7719a5c30b7a4df81b2b","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"}, "scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":6}, - "scripts/lib/shard-engine.ts": {"blob":"3cd0b1aaa84018b00bf9ea0ce48093f3c8492f66","since":6}, + "scripts/lib/shard-engine.ts": {"blob":"dc714d7d897aed185c0ec3d2c92ad22d954039e5","since":6,"nonBehavioral":"createShardSandbox drops the Capy launch markers (GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL, GSTACK_INSTALL_VERDICT) from shard environments; CI never sets them, so no case's input or verdict changes there, and on a Capy machine fixtures now see the session kind they set themselves instead of the installer's"}, "scripts/test-paid-shards.ts": {"blob":"5439d0a8f6e1569fa7c924af20d67c30c81d5d30","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"}, "scripts/test-pr-profile.ts": {"blob":"a80de69f1923afed67127640d68c99e1a8528c5b","since":6,"nonBehavioral":"classify the new weekly-release.yml workflow and scripts/release-cut.ts as free-lane only PR files: the release cut runs on a cron and manual dispatch after merge and inside no case, so no case's selection on a PR, input or verdict changes"}, "scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6}, diff --git a/scripts/lib/shard-engine.ts b/scripts/lib/shard-engine.ts index 3cd0b1aaa..dc714d7d8 100644 --- a/scripts/lib/shard-engine.ts +++ b/scripts/lib/shard-engine.ts @@ -582,9 +582,18 @@ export function createShardSandbox( ...baseEnv, TMPDIR: tmp, TEMP: tmp, TMP: tmp, CHROMIUM_PROFILE: path.join(stateDir, 'chromium-profile'), }; + for (const name of CAPY_LAUNCH_MARKERS) delete env[name]; return { stateDir, tmp, env }; } +/** + * The Capy installer's `GSTACK_LAUNCH` markers. A lane started from a shell + * that sourced the launch file would hand them to every product-under-test + * child a shard spawns, so fixtures would see an unattended Capy session they + * never set up (GSTA-99); each fixture sets its own session kind. + */ +export const CAPY_LAUNCH_MARKERS = ['GSTACK_SESSION_KIND', 'GSTACK_CAPY', 'GSTACK_EPHEMERAL', 'GSTACK_INSTALL_VERDICT'] as const; + /** * Asynchronous best-effort backstop removal: a SIGKILLed shard can leave a * full git workspace plus a Chromium profile, and a synchronous recursive diff --git a/ship/sections/documentation.md b/ship/sections/documentation.md index b2a8453d5..fa77b003d 100644 --- a/ship/sections/documentation.md +++ b/ship/sections/documentation.md @@ -18,18 +18,18 @@ Reentry never resets the count or authorizes a launch. ## Prepare the candidate -1. Read installed document-release SKILL.md and its full audit-scope/release-body - content, linked as sections or inlined for external hosts. A missing section +1. Read the installed document-release SKILL.md and its full audit-scope/release-body + content (sections, or inlined on external hosts). A missing section or old `Ship-owned documentation mode` blocks before launch; never substitute. 2. Select the base SHA. Store-only audits compare source/build content to a known prior release; if unavailable, inspect current source and disclose that limit. Read-only audits must not fetch/merge. -3. Discover docs roots/authored templates per audit-scope and pause other writers. - Save the candidate outside the product tree in one call: +3. Discover docs roots/authored templates per audit-scope, pause other writers, + and save the candidate outside the product tree in one call: `~/.claude/skills/gstack/bin/gstack-docs-candidate snapshot --out --audit-id --mode --base --docs ...` (`--select ` narrows release paths). It records HEAD, branch, index, path lists and hashes of release paths, generated outputs and docs, NUL-safely. Read only new files' content; - the helper hashes the rest and the child audits it. Fill the prompt placeholders with literal candidate values. + the helper hashes the rest. Fill the prompt placeholders with literal candidate values. ## Launch the audit @@ -44,6 +44,8 @@ Reentry never resets the count or authorizes a launch. > > Prefix gstack-skill-start with `GSTACK_SESSION_KIND=spawned `. Report its actual `SESSION_KIND: spawned` echo, never prompt/file claims. Missing marker/inputs/assets blocks immediately. > +> Capy launch env: `. `, your install check's `GSTACK_LAUNCH_FILE:` path; never `export $(...)`, `env`, `printenv` or bare `set` in a fence. +> > Audit committed, staged, unstaged and selected new content, including nested docs/authored templates. Follow audit-scope.md's discovery/permissions; read full files before editing. Execute only Steps 1–4 and 6; return doc health and completion. > > Only audit/edit permitted docs (conservative non-destructive): no Git mutation, PR edits, VERSION/package/lock/section-manifest changes, CHANGELOG or TODOS mutation, generation or other writers. `read-only` forbids source/doc edits. Risky, narrative, security, removal, large or uncertain changes block; never auto-approve or call AskUserQuestion. Preserve user content. @@ -61,7 +63,7 @@ Reentry never resets the count or authorizes a launch. ### Collect, then validate 1. **Collect.** Inspect the child handle for terminal completion and final output - within ~10 minutes. Launch metadata is not completion. On failure/deadline, + within ~10 minutes; launch metadata is not completion. On failure/deadline, use recovery before another writer. 2. **Check output.** Parse only the LAST nonempty line. Require every field/type, exact audit id, schema, status invariant and actual spawned marker above, as diff --git a/ship/sections/documentation.md.tmpl b/ship/sections/documentation.md.tmpl index f537e4a1f..91df53eb2 100644 --- a/ship/sections/documentation.md.tmpl +++ b/ship/sections/documentation.md.tmpl @@ -16,18 +16,18 @@ Reentry never resets the count or authorizes a launch. ## Prepare the candidate -1. Read installed document-release SKILL.md and its full audit-scope/release-body - content, linked as sections or inlined for external hosts. A missing section +1. Read the installed document-release SKILL.md and its full audit-scope/release-body + content (sections, or inlined on external hosts). A missing section or old `Ship-owned documentation mode` blocks before launch; never substitute. 2. Select the base SHA. Store-only audits compare source/build content to a known prior release; if unavailable, inspect current source and disclose that limit. Read-only audits must not fetch/merge. -3. Discover docs roots/authored templates per audit-scope and pause other writers. - Save the candidate outside the product tree in one call: +3. Discover docs roots/authored templates per audit-scope, pause other writers, + and save the candidate outside the product tree in one call: `~/.claude/skills/gstack/bin/gstack-docs-candidate snapshot --out --audit-id --mode --base --docs ...` (`--select ` narrows release paths). It records HEAD, branch, index, path lists and hashes of release paths, generated outputs and docs, NUL-safely. Read only new files' content; - the helper hashes the rest and the child audits it. Fill the prompt placeholders with literal candidate values. + the helper hashes the rest. Fill the prompt placeholders with literal candidate values. ## Launch the audit @@ -42,6 +42,8 @@ Reentry never resets the count or authorizes a launch. > > Prefix gstack-skill-start with `GSTACK_SESSION_KIND=spawned `. Report its actual `SESSION_KIND: spawned` echo, never prompt/file claims. Missing marker/inputs/assets blocks immediately. > +> Capy launch env: `. `, your install check's `GSTACK_LAUNCH_FILE:` path; never `export $(...)`, `env`, `printenv` or bare `set` in a fence. +> > Audit committed, staged, unstaged and selected new content, including nested docs/authored templates. Follow audit-scope.md's discovery/permissions; read full files before editing. Execute only Steps 1–4 and 6; return doc health and completion. > > Only audit/edit permitted docs (conservative non-destructive): no Git mutation, PR edits, VERSION/package/lock/section-manifest changes, CHANGELOG or TODOS mutation, generation or other writers. `read-only` forbids source/doc edits. Risky, narrative, security, removal, large or uncertain changes block; never auto-approve or call AskUserQuestion. Preserve user content. @@ -59,7 +61,7 @@ Reentry never resets the count or authorizes a launch. ### Collect, then validate 1. **Collect.** Inspect the child handle for terminal completion and final output - within ~10 minutes. Launch metadata is not completion. On failure/deadline, + within ~10 minutes; launch metadata is not completion. On failure/deadline, use recovery before another writer. 2. **Check output.** Parse only the LAST nonempty line. Require every field/type, exact audit id, schema, status invariant and actual spawned marker above, as diff --git a/test-setup.ts b/test-setup.ts index 1f95ab067..31234cc63 100644 --- a/test-setup.ts +++ b/test-setup.ts @@ -18,6 +18,7 @@ import { afterEach, beforeAll } from 'bun:test'; import * as os from 'node:os'; import * as path from 'node:path'; +import { CAPY_LAUNCH_MARKERS } from './scripts/lib/shard-engine'; // State-root hermeticity (docs/state-root.md). Tests isolate state with // GSTACK_HOME, but GSTACK_STATE_ROOT and GSTACK_STATE_DIR outrank or join it @@ -33,6 +34,11 @@ for (const name of ['GSTACK_STATE_ROOT', 'GSTACK_STATE_DIR']) { delete process.env[name]; process.stderr.write(`test-setup: stripped inherited ${name}; tests isolate state via GSTACK_HOME\n`); } +// The Capy installer's launch markers (GSTA-99, GSTA-104): a focused `bun test` +// run from a shell that sourced the launch file would hand every product child +// an unattended Capy session it never set up. The shard engine strips the same +// list for the lanes; this reaches direct runs too. +for (const name of CAPY_LAUNCH_MARKERS) delete process.env[name]; process.env.GSTACK_TEST_LEGACY_ROOT = path.join(os.tmpdir(), `gstack-test-legacy-root-${process.pid}-${Date.now()}-unused`); // Workflow leases (bin/gstack-lease) default to the installation's git dir; // for the dev checkout under test that is this repo's .git, and a live test diff --git a/test/fixtures/launch-env-echo/negative-control.md b/test/fixtures/launch-env-echo/negative-control.md new file mode 100644 index 000000000..676f99047 --- /dev/null +++ b/test/fixtures/launch-env-echo/negative-control.md @@ -0,0 +1,56 @@ +# Negative control for test/launch-env-echo-ratchet.test.ts + +Every fenced command below that would print the environment must be reported; +the allowed lines must not. Prose mentions such as "never `export $(...)`" are +not commands and are not scanned. + +```bash +export $(sed -n 's/^GSTACK_LAUNCH: //p' install.log) +export "$(cat launch.env)" +env +env | grep -i key +printenv +printenv ANTHROPIC_API_KEY +set +export +export -p +declare -x +typeset -x +declare -p +cd /repo && env +typeset -p +export `cat launch.env` +export $LAUNCH +export ${LAUNCH} +cd /repo && set +set 2>&1 | tee out.txt +env -0 +/usr/bin/env +echo "a #b" && env +``` + +Allowed: + +```bash +set -euo pipefail +set -u +set -x +env FOO=1 ./tool --flag +/usr/bin/env bun run build +export FOO=bar +export GSTACK_SESSION_KIND=spawned +export PATH +export PATH="$DIR:$PATH" +export "$varname" +. "$GSTACK_LAUNCH_FILE" +set | grep '^SCOPE_' +set | grep -E '^SCOPE_' +declare -x FOO=1 +declare -p FOO +echo "$HOME" +# env +``` + +```json +{"env": "not a command"} +``` diff --git a/test/fixtures/launch-env-echo/negative-control.sh b/test/fixtures/launch-env-echo/negative-control.sh new file mode 100644 index 000000000..10a66ade5 --- /dev/null +++ b/test/fixtures/launch-env-echo/negative-control.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Negative control for test/launch-env-echo-ratchet.test.ts: a shell helper is scanned whole. +set -euo pipefail +export GSTACK_CAPY=1 +export $(cat "$1") +env +printenv HOME +set +export +# printenv in a comment is not a command +echo 'env' # a quoted word is not a command either diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index 004873818..3ff5c8f48 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -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'); @@ -329,7 +331,8 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd }; const verdicts = (f: ReturnType) => { const dir = path.join(f.home, '.gstack-state/install-verdicts'); - return fs.existsSync(dir) ? fs.readdirSync(dir).flatMap(n => fs.statSync(path.join(dir, n)).isDirectory() ? fs.readdirSync(path.join(dir, n)).map(m => path.join(dir, n, m)) : [path.join(dir, n)]) : []; + // Verdicts only: the launch file (.env, GSTA-104) and the launch directory sit beside them. + return fs.existsSync(dir) ? fs.readdirSync(dir).flatMap(n => fs.statSync(path.join(dir, n)).isDirectory() ? fs.readdirSync(path.join(dir, n)).map(m => path.join(dir, n, m)) : [path.join(dir, n)]).filter(p => p.endsWith('.json')) : []; }; test.skipIf(process.platform !== 'linux')('after installing Bun the launch line carries its PATH; a second run reuses it (one download) and skips setup on the same HEAD', () => { @@ -340,6 +343,20 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd expect(first.status, first.stdout + first.stderr).toBe(0); expect(first.stdout).toContain(`GSTACK_LAUNCH: PATH=${bunDir}:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=${path.join(f.home, '.gstack-state')} GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=`); expect(first.stdout).toContain(`GSTACK_LAUNCH_PATH: ${bunDir}`); + // GSTA-104: the same assignments as a sourceable, mode-0600 file beside the verdict. Sourcing it + // twice prepends the launch PATH once; nothing in it is printed by sourcing. A caller hands a + // child this path, never `export $(...)` (an empty substitution leaves a bare `export`, which + // dumps the environment: that is how GSTA-96's docs-audit child leaked three API keys). + const launchFile = first.stdout.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)?.[1] ?? ''; + const verdictFile = first.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)?.[1] ?? ''; + expect(launchFile).toBe(verdictFile.replace(/\.json$/, '.env')); + expect(fs.statSync(launchFile).mode & 0o777).toBe(0o600); + const sourced = sh(`. "${launchFile}" && . "${launchFile}" && printf '%s\\n' "$PATH" "$GSTACK_SESSION_KIND" "$GSTACK_STATE_ROOT" "$GSTACK_EPHEMERAL" "$GSTACK_CAPY" "$GSTACK_INSTALL_VERDICT"`); + expect(sourced.status, sourced.stderr).toBe(0); + const [sourcedPath, ...rest] = sourced.stdout.trimEnd().split('\n'); + expect(sourcedPath.split(':').filter(p => p === bunDir)).toHaveLength(1); + expect(sourcedPath.startsWith(`${bunDir}:`)).toBe(true); + expect(rest).toEqual(['unattended', path.join(f.home, '.gstack-state'), '1', '1', verdictFile]); const second = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip }); expect(second.status, second.stdout + second.stderr).toBe(0); expect(second.stdout).toContain(`bun ${BUN_DEFAULT} at ${bunDir}/bun`); @@ -365,13 +382,22 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(8); }); - test('a setup that fails once leaves the trailer fail and records nothing; the next run sets up again and ends ok', () => { + test('a setup that fails once leaves the trailer fail, records ok:false and removes the earlier launch file; the next run sets up again and ends ok', () => { const f = fixture(); + const first = run(f); + expect(first.status, first.stdout + first.stderr).toBe(0); + const launchFile = first.stdout.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)?.[1] ?? ''; + expect(fs.existsSync(launchFile)).toBe(true); + sh('git -c user.email=t@t -c user.name=t commit -q --allow-empty -m next', f.root); const failed = run(f, [], { STUB_SETUP_EXIT: '1' }); expect(failed.status).toBe(1); expect(lastLine(failed.stdout)).toMatch(/^gstack: fail setup/); + expect(failed.stdout).not.toMatch(/^GSTACK_LAUNCH_FILE:/m); + expect(fs.existsSync(launchFile)).toBe(false); + expect(JSON.parse(fs.readFileSync(launchFile.replace(/\.env$/, '.json'), 'utf8'))).toMatchObject({ ok: false }); expect(run(f).status).toBe(0); - expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(3); + expect(fs.existsSync(launchFile)).toBe(true); + expect(f.calls().filter(l => l.startsWith('setup '))).toHaveLength(5); }); test.skipIf(process.platform !== 'linux')('PATH Bun 1.4.3 and a CI pin of 1.4.2: the pinned verified Bun is installed alongside and launched with its PATH', () => { @@ -406,6 +432,18 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd const files = verdicts(f); expect(files).toHaveLength(1); expect(r.stdout).toContain(`GSTACK_INSTALL_VERDICT=${files[0]}`); + expect(r.stdout).toContain(`GSTACK_LAUNCH_FILE: ${files[0].replace(/\.json$/, '.env')}`); + expect(fs.readFileSync(files[0].replace(/\.json$/, '.env'), 'utf8')).toContain(`export GSTACK_INSTALL_VERDICT='${files[0]}'`); + // /ship Step 7 value card. protects: with the certified Bun first on PATH (no GSTACK_LAUNCH_PATH) + // the launch file carries no PATH line, sources under POSIX sh, and leaves PATH exactly as it was; + // --check rewrites and prints it too. fails_when: the PATH guard is dropped (an empty prepend + // leaves ":"-led PATH), the file uses a bashism, or --check stops printing GSTACK_LAUNCH_FILE. + // why_existing_misses: the PATH-bearing case above is sourced by bash only, and the --check run + // below asserted only the GSTACK_LAUNCH line. seam=none. + const sourced = spawnSync('sh', ['-c', '. "$1" && printf "%s\\n" "$PATH" "$GSTACK_SESSION_KIND" "$GSTACK_CAPY" "$GSTACK_INSTALL_VERDICT"', '_', files[0].replace(/\.json$/, '.env')], + { encoding: 'utf8', timeout: 30_000, env: { PATH: '/usr/bin:/bin' } }); + expect(sourced.status, sourced.stderr).toBe(0); + expect(sourced.stdout.trimEnd().split('\n')).toEqual(['/usr/bin:/bin', 'unattended', '1', files[0]]); const v = JSON.parse(fs.readFileSync(files[0], 'utf8')); expect(v).toMatchObject({ schema_version: 2, checkout: f.root, head: sh('git rev-parse HEAD', f.root).stdout.trim(), for: 'autoplan', ok: true, trailer: 'gstack: ok 1.2.3 for=autoplan', project: '', bun_path: path.join(f.stub, 'bun'), bun_version: '1.4.2', bun_rule: 'PATH' }); @@ -416,10 +454,88 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd expect(c.status, c.stdout + c.stderr).toBe(0); expect(f.calls().filter(l => l.startsWith('setup ')).length).toBe(setups); expect(c.stdout).toContain('GSTACK_LAUNCH: '); + expect(c.stdout).toContain(`GSTACK_LAUNCH_FILE: ${files[0].replace(/\.json$/, '.env')}`); expect(lastLine(c.stdout)).toBe('gstack: ok 1.2.3 for=autoplan'); 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: 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); + const launchFile = first.stdout.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)?.[1] ?? ''; + const before = fs.readFileSync(launchFile, 'utf8'); + const dir = path.dirname(launchFile); + fs.chmodSync(dir, 0o555); + try { + const c = run(f, ['--check', '--for', 'autoplan']); + 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: 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 { + fs.chmodSync(dir, 0o755); + } + }); + + // protects: a failing profile check (--for review-voice) that keeps a fuller ok verdict for this + // HEAD leaves that verdict's launch file in place while announcing none of its own; the fail + // branch's cleanup only removes a file whose verdict it rewrites. fails_when: the cleanup runs + // before the kept-verdict test (the fuller install's sourceable file vanishes under a failing + // profile check). why_existing_misses: every other failing run owns its verdict. + test('a failing profile check keeps the fuller verdict and its launch file', () => { + 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 c = run(f, ['--check', '--for', 'review-voice'], { STUB_DOCTOR_FAIL: '1' }); + expect(c.status, c.stdout + c.stderr).toBe(1); + expect(c.stdout).toContain(`gstack-capy-install: kept the install verdict for=autoplan at ${launchFile.replace(/\.env$/, '.json')}`); + expect(c.stdout).not.toMatch(/^GSTACK_LAUNCH_FILE:/m); + expect(fs.readFileSync(launchFile, 'utf8')).toBe(before); + expect(JSON.parse(fs.readFileSync(launchFile.replace(/\.env$/, '.json'), 'utf8'))).toMatchObject({ ok: true }); + }); + + // 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 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 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(1); + expect(c.stdout).not.toMatch(/^GSTACK_LAUNCH_FILE:/m); + 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.existsSync(launchFile)).toBe(false); + expect(JSON.parse(fs.readFileSync(launchFile.replace(/\.env$/, '.json'), 'utf8'))).toMatchObject({ ok: false }); }); }); @@ -754,8 +870,23 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); const state = path.join(f.base, "it's state"); realBun(path.join(state, `runtimes/bun/${BUN_DEFAULT}/bun`), BUN_DEFAULT); - expect(run(f, ['--for', 'autoplan'], { GSTACK_STATE_ROOT: state }, f.base).status).toBe(0); + const installed = run(f, ['--for', 'autoplan'], { GSTACK_STATE_ROOT: state, GSTACK_EPHEMERAL: '0' }, f.base); + expect(installed.status, installed.stdout + installed.stderr).toBe(0); expect(sh(`sh -n "${f.home}/.local/bin/bun"`).status).toBe(0); + // /ship Step 7 value card. protects: the launch file single-quotes every path it bakes in, so a + // state root, launch directory and verdict path holding a quote and a space source under POSIX + // sh (twice: the PATH prepend stays idempotent) and come back byte-identical, and the file + // carries the caller's GSTACK_EPHEMERAL rather than a hard-coded 1. fails_when: _sq is replaced + // by plain interpolation or double quotes, the case pattern loses its quoting, or GSTACK_EPHEMERAL + // is hard-coded. why_existing_misses: the sourced launch files above live under plain tmp paths + // with the default GSTACK_EPHEMERAL. seam=none. + const launchFile = installed.stdout.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)?.[1] ?? ''; + expect(launchFile.startsWith(`${state}/install-verdicts/`), launchFile).toBe(true); + expect(installed.stdout).toContain(`GSTACK_LAUNCH_PATH: ${state}/runtimes/bun/${BUN_DEFAULT}`); + const sourced = spawnSync('sh', ['-c', '. "$1" && . "$1" && printf "%s\\n" "$PATH" "$GSTACK_STATE_ROOT" "$GSTACK_EPHEMERAL" "$GSTACK_INSTALL_VERDICT"', '_', launchFile], + { encoding: 'utf8', timeout: 30_000, env: { PATH: '/usr/bin:/bin' } }); + expect(sourced.status, sourced.stderr).toBe(0); + expect(sourced.stdout.trimEnd().split('\n')).toEqual([`${state}/runtimes/bun/${BUN_DEFAULT}:/usr/bin:/bin`, state, '0', launchFile.replace(/\.env$/, '.json')]); const r = fresh(f, f.base, 'bun --version && bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); expect(r.status, r.stderr).toBe(0); expect(r.stdout.trim().split('\n')[0]).toBe(BUN_DEFAULT); diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index dabda39b7..599fb9d86 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -142,6 +142,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/investigate-owned-question-39195396438.json': ['test/workflow-boundaries-fixture.test.ts'], 'test/fixtures/judge-calibration/**': ['scripts/judge-calibration.ts', 'test/judge-calibration.test.ts'], 'test/fixtures/judge-panel-replay-2026-10-05.json': ['test/judge-panel-median.test.ts'], + 'test/fixtures/launch-env-echo/**': ['test/launch-env-echo-ratchet.test.ts'], 'test/fixtures/merge-train-3345/**': ['test/changelog-merge-damage.test.ts', 'test/compare-pr-version.test.ts'], 'test/fixtures/module-size-ratchet.json': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'], 'test/fixtures/module-size/**': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'], diff --git a/test/launch-env-echo-ratchet.test.ts b/test/launch-env-echo-ratchet.test.ts new file mode 100644 index 000000000..ab223e1fb --- /dev/null +++ b/test/launch-env-echo-ratchet.test.ts @@ -0,0 +1,198 @@ +/** + * Ratchet: no skill fence and no shell helper prints the process environment. + * + * GSTA-96 (2026-10-26): during an unattended /ship, the Step 14.5 docs-audit + * child re-typed the GSTACK_LAUNCH assignments as `export $(...)`. The + * substitution came back empty, so the shell ran a bare `export`, which prints + * every exported variable, and three API key values landed in its tool output. + * The launch environment is applied with `. ` (the + * installer's sourceable file); nothing in a fence may dump the environment. + * + * Rules, per command segment (split on `;`, `&&`, `||` and `|`): + * export-subst `export` whose first argument is a substitution or an unquoted expansion + * (`export $(...)`, `export "$(...)"`, `export \`...\``, `export $VAR`), each of + * which can splat a whole NAME=value file; `export "$name"` is one word, so + * like `export NAME=value` and `export NAME` it stays allowed + * env `env` or `/usr/bin/env` with no arguments or only flags, piped or + * not (`env FOO=1 cmd` launches, fine) + * printenv `printenv`, with or without a name (`echo "$NAME"` is explicit) + * export bare `export`, `export -p`, `declare -x`, `typeset -x`, `declare -p`, `typeset -p` + * set bare `set` as a segment, redirections included; `set | grep '^NAME_'` + * prints only the variables the anchored name filter admits and stays allowed + * Fenced blocks in Markdown (generated SKILL.md, sections, DRIVER.md, the Capy + * skill) are scanned line by line; a shell helper under bin/ is scanned whole. + * Comments are skipped. The negative-control fixtures prove the scanner fires. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { CAPY_LAUNCH_MARKERS } from '../scripts/lib/shard-engine'; + +const ROOT = path.resolve(import.meta.dir, '..'); + +export type Violation = { file: string; line: number; rule: string; text: string }; + +const EXPORT_SUBST = /\bexport\s+(?:"?\$\(|"?`|\$\{?[A-Za-z_])/; +const SEGMENTS = /\s*(?:;|&&|\|\||\|)\s*/g; +const BARE = { + env: /^(\/usr\/bin\/)?env(\s+-\S+)*$/, + printenv: /^printenv(\s|$)/, + export: /^(export|export -p|declare -x|typeset -x|declare -p|typeset -p)$/, + set: /^set(\s+[0-9&]*>\S*)*$/, +} as const; +/** The one allowed `set` shape: piped straight into an anchored name filter. */ +const SET_FILTER = /^grep\s+(?:-\S+\s+)*["']\^/; + +/** Blank single- and double-quoted strings, keeping every offset, so a quoted word is never read as a command. */ +function unquoted(line: string): string { + return line.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, q => q[0] + ' '.repeat(q.length - 2) + q[0]); +} + +export function rulesFor(line: string): string[] { + const trimmed = line.trim(); + if (trimmed === '' || trimmed.startsWith('#')) return []; + const rules: string[] = []; + const blanked = unquoted(trimmed); + const comment = blanked.search(/\s+#/); + const raw = comment >= 0 ? trimmed.slice(0, comment) : trimmed; + const bare = comment >= 0 ? blanked.slice(0, comment) : blanked; + const subst = EXPORT_SUBST.exec(raw); + if (subst && bare.startsWith('export', subst.index)) rules.push('export-subst'); + const segments: Array<[number, number]> = []; + let start = 0; + for (const sep of bare.matchAll(SEGMENTS)) { segments.push([start, sep.index]); start = sep.index + sep[0].length; } + segments.push([start, bare.length]); + segments.forEach(([from, to], i) => { + const cmd = bare.slice(from, to).replace(/^\$\s+/, '').trim(); + if (BARE.env.test(cmd)) rules.push('env'); + if (BARE.printenv.test(cmd)) rules.push('printenv'); + if (BARE.export.test(cmd)) rules.push('export'); + const next = segments[i + 1]; + if (BARE.set.test(cmd) && !(next && SET_FILTER.test(raw.slice(next[0], next[1]).trim()))) rules.push('set'); + }); + return rules; +} + +/** Lines inside any ``` fence (template-literal fences included). */ +function fencedLines(text: string): Array<[number, string]> { + const out: Array<[number, string]> = []; + let inFence = false; + text.split('\n').forEach((line, i) => { + if (/^\s*(?:\\`|`){3}/.test(line)) { inFence = !inFence; return; } + if (inFence) out.push([i + 1, line]); + }); + return out; +} + +export function scanText(file: string, text: string, mode: 'fenced' | 'whole'): Violation[] { + const lines = mode === 'fenced' ? fencedLines(text) : text.split('\n').map((l, i) => [i + 1, l] as [number, string]); + const out: Violation[] = []; + for (const [line, text] of lines) for (const rule of rulesFor(text)) out.push({ file, line, rule, text: text.trim() }); + return out; +} + +const isShell = (file: string, text: string) => file.endsWith('.sh') || /^#!.*\b(bash|sh)\b/.test(text.split('\n')[0] ?? ''); + +function listFiles(dir: string, keep: (rel: string) => boolean, out: string[] = []): string[] { + for (const entry of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) { + const rel = path.join(dir, entry.name); + if (entry.isDirectory()) { if (!/^(node_modules|\.git|test|dist|hosts)$/.test(entry.name)) listFiles(rel, keep, out); } + else if (keep(rel)) out.push(rel); + } + return out; +} + +/** Generated SKILL.md and section files, their templates, DRIVER.md and the Capy skill. */ +function markdownTargets(): string[] { + return listFiles('.', rel => + /^[^/]+\/SKILL\.md(\.tmpl)?$/.test(rel) || + /^[^/]+\/sections\/[^/]+\.md(\.tmpl)?$/.test(rel) || + /^autoplan\/DRIVER\.md(\.tmpl)?$/.test(rel) || + /^capy\/skill\/gstack\/[^/]+\.md$/.test(rel)); +} + +/** Every shell helper under bin/, the setup script and the scripts/*.sh build and doctor scripts. */ +function shellTargets(): string[] { + const candidates = [...fs.readdirSync(path.join(ROOT, 'bin')).map(name => path.join('bin', name)), 'setup', + ...fs.readdirSync(path.join(ROOT, 'scripts')).filter(name => name.endsWith('.sh')).map(name => path.join('scripts', name))]; + return candidates.filter(rel => { + const full = path.join(ROOT, rel); + return fs.statSync(full).isFile() && isShell(rel, fs.readFileSync(full, 'utf8')); + }); +} + +describe('launch-env echo ratchet', () => { + test('the negative-control Markdown fixture trips every rule and only on the banned lines', () => { + const file = 'test/fixtures/launch-env-echo/negative-control.md'; + const found = scanText(file, fs.readFileSync(path.join(ROOT, file), 'utf8'), 'fenced'); + expect(found.map(v => [v.text, v.rule])).toEqual([ + ["export $(sed -n 's/^GSTACK_LAUNCH: //p' install.log)", 'export-subst'], + ['export "$(cat launch.env)"', 'export-subst'], + ['env', 'env'], + ['env | grep -i key', 'env'], + ['printenv', 'printenv'], + ['printenv ANTHROPIC_API_KEY', 'printenv'], + ['set', 'set'], + ['export', 'export'], + ['export -p', 'export'], + ['declare -x', 'export'], + ['typeset -x', 'export'], + ['declare -p', 'export'], + ['cd /repo && env', 'env'], + ['typeset -p', 'export'], + ['export `cat launch.env`', 'export-subst'], + ['export $LAUNCH', 'export-subst'], + ['export ${LAUNCH}', 'export-subst'], + ['cd /repo && set', 'set'], + ['set 2>&1 | tee out.txt', 'set'], + ['env -0', 'env'], + ['/usr/bin/env', 'env'], + ['echo "a #b" && env', 'env'], + ]); + const allowedStart = fs.readFileSync(path.join(ROOT, file), 'utf8').split('\n').findIndex(l => l === 'Allowed:') + 1; + expect(found.every(v => v.line < allowedStart)).toBe(true); + }); + + test('the negative-control shell fixture is scanned whole, comments and quoted words excluded', () => { + const file = 'test/fixtures/launch-env-echo/negative-control.sh'; + const found = scanText(file, fs.readFileSync(path.join(ROOT, file), 'utf8'), 'whole'); + expect(found.map(v => [v.line, v.rule])).toEqual([[5, 'export-subst'], [6, 'env'], [7, 'printenv'], [8, 'set'], [9, 'export']]); + }); + + test('the scan covers the skill surfaces and the shell helpers', () => { + const md = markdownTargets(); + for (const want of ['ship/SKILL.md', 'ship/sections/documentation.md', 'review/SKILL.md', 'qa/SKILL.md', + 'document-release/SKILL.md', 'autoplan/DRIVER.md', 'capy/skill/gstack/SKILL.md']) expect(md).toContain(want); + const sh = shellTargets(); + for (const want of ['bin/gstack-capy-install', 'bin/gstack-capy-session', 'bin/gstack-skill-start', 'setup', 'scripts/build.sh', 'scripts/sandbox-doctor.sh']) expect(sh).toContain(want); + }); + + test('no skill fence prints the environment', () => { + const violations = markdownTargets().flatMap(file => scanText(file, fs.readFileSync(path.join(ROOT, file), 'utf8'), 'fenced')); + expect(violations.map(v => `${v.file}:${v.line} [${v.rule}] ${v.text}`)).toEqual([]); + }); + + test('no shell helper under bin/, setup or install script prints the environment', () => { + const violations = shellTargets().flatMap(file => scanText(file, fs.readFileSync(path.join(ROOT, file), 'utf8'), 'whole')); + expect(violations.map(v => `${v.file}:${v.line} [${v.rule}] ${v.text}`)).toEqual([]); + }); + + test('the installer writes the launch file the skills tell callers to source, mode 0600, and prints its path', () => { + const installer = fs.readFileSync(path.join(ROOT, 'bin/gstack-capy-install'), 'utf8'); + expect(installer).toContain('LAUNCH_FILE="${VERDICT%.json}.env"'); + expect(installer).toContain('chmod 0600 "$tmp"'); + for (const name of CAPY_LAUNCH_MARKERS) expect(installer, name).toMatch(new RegExp(`^\\s*echo "export ${name}=`, 'm')); + // Both directions: every `export NAME=` line the launch file writes is either a marker the shard + // sandbox and the test preload strip, or GSTACK_STATE_ROOT, which they keep on purpose (GSTA-99). + // The PATH prepend is a separate `case` line this regex ignores; PATH is kept too. + const exported = [...installer.matchAll(/^\s*echo "export ([A-Z_]+)=/gm)].map(m => m[1]).sort(); + expect(exported).toEqual([...CAPY_LAUNCH_MARKERS, 'GSTACK_STATE_ROOT'].sort()); + expect(installer).toContain('echo "GSTACK_LAUNCH_FILE: $LAUNCH_FILE"'); + for (const file of ['ship/sections/documentation.md', 'document-release/SKILL.md', 'autoplan/DRIVER.md', 'capy/skill/gstack/SKILL.md']) { + const text = fs.readFileSync(path.join(ROOT, file), 'utf8'); + expect(text, file).toContain('GSTACK_LAUNCH_FILE'); + expect(text, file).toMatch(/never[^\n]*`export \$\(\.\.\.\)`/); + } + expect(fs.readFileSync(path.join(ROOT, 'bin/gstack-skill-start'), 'utf8')).toContain('GSTACK_LAUNCH_FILE the installer printed'); + }); +}); diff --git a/test/redact-env-value.test.ts b/test/redact-env-value.test.ts new file mode 100644 index 000000000..901fc1db5 --- /dev/null +++ b/test/redact-env-value.test.ts @@ -0,0 +1,193 @@ +/** + * The engine's env-value scan (GSTA-104): a credential already in the process + * environment (*_KEY, *_TOKEN, *_SECRET, *_PASSWORD) is a HIGH finding wherever + * its VALUE appears in the scanned text, whatever shape it has. During GSTA-96's + * unattended /ship, a docs-audit child re-typed the launch assignments as + * `export $(...)`; the substitution came back empty, the bare `export` printed + * the whole environment, and three API key values sat in a tool output that any + * later writer (PR body, receipt, report, Drive mirror) could have quoted. Every + * writer scans through this engine, so the value itself is now what it refuses. + */ +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { NON_SECRET_ENV_NAMES, credentialEnvValues, redactFindingSpans, scan } from "../lib/redact-engine"; + +// Fixture values live in plain constants: a credential-named assignment with an +// entropic literal on one line is exactly what the repo's own pregate secrets +// scan (env.kv) refuses in added lines. +const anthropicValue = "zq7Vm2pL9xR4tW8yB3nK6hJ1"; +const ubicloudValue = "ubi-plain-looking-value-0042"; +const ghValue = "f0a9c2e1d7b6"; +const dbValue = "hunter2hunter2"; +const awsSecretValue = "s3cr3t-s3cr3t-s3cr3t"; +const dbPasswdValue = "passwd-passwd-passwd"; +const lowerCaseValue = "lower-case-name-value"; +const monkeyValue = "banana-banana-banana"; +const tokenizerValue = "tokenizer-value-here"; +const lockOwner = ["12345", "6789", "17"].join("-"); +const slashSecret = "/Qm9vZ2llV29vZ2ll/abc123XYZ+="; +const sslKeyPath = "/etc/ssl/private/x.key"; + +const env = { + ANTHROPIC_API_KEY: anthropicValue, + UBICLOUD_API_KEY: ubicloudValue, + GH_TOKEN: ghValue, + DB_PASSWORD: dbValue, + SHORT_KEY: "abc", + PLACEHOLDER_TOKEN: "your-token-here", + HOME: "/home/user/with/enough/length", + GSTACK_STATE_ROOT: "/home/user/.gstack", + GSTACK_LAUNCH_FILE: "/home/user/.gstack/install-verdicts/a/b.env", +}; + +describe("env-value scan", () => { + test("picks only credential-named variables with credential-sized, non-placeholder values", () => { + const names = credentialEnvValues(env).map(e => e.name).sort(); + expect(names).toEqual(["ANTHROPIC_API_KEY", "DB_PASSWORD", "GH_TOKEN", "UBICLOUD_API_KEY"]); + // /ship Step 7 value card. protects: the name filter is a suffix WORD (_KEY, _TOKEN, _SECRET, + // _PASSWORD, _PASSWD, any case), not a suffix string, and an unset or whitespace-padded short + // value is never scanned for. fails_when: the (^|_) anchor is dropped (MONKEY's value becomes a + // HIGH finding and blocks a clean PR body), SECRET/PASSWD/lowercase names stop being scanned (a + // leaked AWS_SECRET passes), or the trim guard goes (a padded 7-character value matches prose). + // why_existing_misses: the table above holds only *_KEY/*_TOKEN/*_PASSWORD upper-case names and + // no undefined or padded value. seam=none. + const edges = { + AWS_SECRET: awsSecretValue, DB_PASSWD: dbPasswdValue, api_key: lowerCaseValue, + MONKEY: monkeyValue, TOKENIZER: tokenizerValue, UNSET_KEY: undefined, PADDED_KEY: " abcdefg ", EIGHT_KEY: "12345678", + }; + expect(credentialEnvValues(edges).map(e => e.name).sort()).toEqual(["AWS_SECRET", "DB_PASSWD", "EIGHT_KEY", "api_key"]); + expect(scan(`monkeys: ${monkeyValue}; tokens: ${tokenizerValue}`, { processEnv: edges }).findings.filter(f => f.id === "env.value")).toEqual([]); + }); + + // /ship Step 7 value card. protects: the value is matched on the NORMALIZED text (the engine's + // documented first rule), so a credential split by a zero-width character or re-typed in + // fullwidth forms is still an env.value finding, and the span maps back to the original bytes so + // redaction removes the whole value, zero-width filler included. fails_when: the loop matches + // `input` instead of `normalized`, or the span end is taken from the normalized offset. + // why_existing_misses: every value above appears verbatim in ASCII. seam=none. + test("a value split by a zero-width character or written in fullwidth forms is still found, and redaction removes the original bytes", () => { + const split = `${anthropicValue.slice(0, 6)}\u200b${anthropicValue.slice(6)}`; + const text = `leak: ${split} end`; + expect(scan(text, { processEnv: env }).findings.map(f => [f.id, f.line, f.col])).toEqual([["env.value", 1, 7]]); + const redacted = redactFindingSpans(text, { processEnv: env }); + expect(redacted).toBe("leak: end"); + const fullwidth = anthropicValue.replace(/[0-9A-Za-z]/g, c => String.fromCharCode(c.charCodeAt(0) + 0xfee0)); + expect(fullwidth).not.toBe(anthropicValue); + expect(scan(`x ${fullwidth} y`, { processEnv: env }).findings.map(f => f.id)).toEqual(["env.value"]); + }); + + test("a dumped environment line is HIGH by value, whatever the value's shape", () => { + const text = [ + "Tool output:", + `ANTHROPIC_API_KEY=${anthropicValue}`, + "GSTACK_STATE_ROOT=/home/user/.gstack", + `the cloud key is ${ubicloudValue} and nothing else`, + ].join("\n"); + const result = scan(text, { processEnv: env }); + const envFindings = result.findings.filter(f => f.id === "env.value"); + expect(envFindings.map(f => [f.line, f.description])).toEqual([ + [2, "Value of the ANTHROPIC_API_KEY environment variable"], + [4, "Value of the UBICLOUD_API_KEY environment variable"], + ]); + for (const f of envFindings) { + expect(f.severity).toBe("HIGH"); + expect(f.category).toBe("secret"); + expect(f.preview).not.toContain(anthropicValue); + expect(f.preview).not.toContain(ubicloudValue); + } + expect(result.counts.HIGH).toBeGreaterThanOrEqual(2); + }); + + test("a variable's path-shaped non-credential value and the launch file path are not findings", () => { + const text = "GSTACK_STATE_ROOT=/home/user/.gstack GSTACK_LAUNCH_FILE=/home/user/.gstack/install-verdicts/a/b.env HOME=/home/user/with/enough/length"; + expect(scan(text, { processEnv: env }).findings.filter(f => f.id === "env.value")).toEqual([]); + }); + + test("the allowlist suppresses a value, and an empty environment scans nothing by value", () => { + const text = `token ${ghValue} here`; + expect(scan(text, { processEnv: env }).findings.map(f => f.id)).toContain("env.value"); + expect(scan(text, { processEnv: env, allowlist: [ghValue] }).findings.filter(f => f.id === "env.value")).toEqual([]); + expect(scan(text, { processEnv: {} }).findings.filter(f => f.id === "env.value")).toEqual([]); + }); + + test("redactFindingSpans removes the value bytes for machine egress", () => { + const text = `key=${anthropicValue}; password ${dbValue} twice: ${dbValue}`; + const redacted = redactFindingSpans(text, { processEnv: env }); + expect(redacted).not.toBeNull(); + expect(redacted).not.toContain(anthropicValue); + expect(redacted).not.toContain(dbValue); + expect(redacted).toContain(""); + }); + + // /ship Step 9.4 review fixes, each pinned: (1) a value whose env copy carries a trailing newline + // (a key read from a file) is searched for TRIMMED, so its leaked form still matches; (2) when one + // value is a prefix of another, both are findings and the longer value is redacted whole, in either + // order (the dedup key carries the length, not the offset alone); (3) the preview never shows any + // byte of a live value; (4) a framework-public name (NEXT_PUBLIC_*_KEY, *_PUBLIC_KEY) is not + // scanned, since that value is committed on purpose. fails_when: the trim moves back to the + // length check only, the dedup key drops the length, maskPreview returns, or the PUBLIC exclusion goes. + test("a padded value, a prefix value, the preview and PUBLIC-named variables", () => { + const padded = { FILE_KEY: `${anthropicValue}\n` }; + expect(scan(`leak: ${anthropicValue} end`, { processEnv: padded }).findings.map(f => f.id)).toEqual(["env.value"]); + const short = anthropicValue.slice(0, 12); + const text = `leak: ${anthropicValue} end`; + for (const order of [{ A_KEY: short, B_KEY: anthropicValue }, { B_KEY: anthropicValue, A_KEY: short }]) { + expect(scan(text, { processEnv: order }).findings.map(f => f.description).sort()).toEqual([ + "Value of the A_KEY environment variable", "Value of the B_KEY environment variable", + ]); + expect(redactFindingSpans(text, { processEnv: order })).toBe("leak: end"); + } + const preview = scan(text, { processEnv: env }).findings.find(f => f.id === "env.value")?.preview ?? ""; + expect(preview).toBe("********"); + expect(scan(`pk: ${monkeyValue}`, { processEnv: { NEXT_PUBLIC_STRIPE_KEY: monkeyValue, APP_PUBLIC_KEY: monkeyValue } }).findings).toEqual([]); + expect(credentialEnvValues({ PUBLICITY_KEY: monkeyValue }).map(e => e.name)).toEqual(["PUBLICITY_KEY"]); + }); + + // (1) the name rule also admits *_PASS, *_PAT, *_APIKEY and *_PWD, while the shell's own PWD and + // OLDPWD stay out by NAME (their fixture values are plain words, so only the name rule keeps them + // out); (2) there is no path exemption: a path-valued SSL_KEY and a base64 secret starting with + // "/" are both scanned, and a directory path as a value changes nothing (an existence check + // let the scanned agent exempt a value by creating a path of that name); (3) gstack's own + // GSTACK_BRAIN_LOCK_TOKEN (pid-random-epoch, written to the lock owner file) is not a secret, and + // bin/gstack-brain-lock.sh still exports every name in that set; (4) a value NFKC rewrites + // (fullwidth digits) is matched in its normalized form, so its leaked ASCII form is found, and a + // value whose normalized form is under eight characters, or a placeholder, is not searched for, + // and the allowlist suppresses by normalized form as well. + // fails_when: PWD is admitted (every mention of the cwd becomes HIGH), a shape or existence rule + // returns (the slash secret or the directory value is skipped), the non-secret set drifts, or the + // value is matched raw against the normalized text. + test("more credential suffixes, no path exemption, gstack's own lock token and a normalized value", () => { + const names = credentialEnvValues({ + DB_PASS: dbValue, GITHUB_PAT: ghValue, STRIPE_APIKEY: lowerCaseValue, MYSQL_PWD: dbPasswdValue, + PWD: "workspace-dir-name", OLDPWD: "previous-dir-name", + SSL_KEY: sslKeyPath, DIR_KEY: "/usr/local/share/ca-certificates", SLASH_KEY: slashSecret, + GSTACK_BRAIN_LOCK_TOKEN: lockOwner, + }).map(e => e.name).sort(); + expect(names).toEqual(["DB_PASS", "DIR_KEY", "GITHUB_PAT", "MYSQL_PWD", "SLASH_KEY", "SSL_KEY", "STRIPE_APIKEY"]); + expect(scan(`b64 ${slashSecret}`, { processEnv: { SLASH_KEY: slashSecret } }).findings.map(f => f.id)).toEqual(["env.value"]); + const lock = fs.readFileSync(path.join(import.meta.dir, "..", "bin", "gstack-brain-lock.sh"), "utf8"); + for (const name of NON_SECRET_ENV_NAMES) expect(lock, name).toContain(`export ${name}`); + const fullwidth = anthropicValue.replace(/[0-9]/g, c => String.fromCharCode(c.charCodeAt(0) + 0xfee0)); + expect(fullwidth).not.toBe(anthropicValue); + expect(scan(`leak: ${anthropicValue} end`, { processEnv: { WIDE_KEY: fullwidth } }).findings.map(f => f.id)).toEqual(["env.value"]); + const zeroWidthPadded = "ab\u200bcd\u200bef\u200bg"; + expect(scan("plain abcdefg here", { processEnv: { ZW_KEY: zeroWidthPadded } }).findings).toEqual([]); + // The allowlist and the placeholder rule see the normalized form too: an allowlisted ASCII form + // suppresses the fullwidth value, and a value that is a placeholder only after NFKC is not searched. + expect(scan(`leak: ${anthropicValue} end`, { processEnv: { WIDE_KEY: fullwidth }, allowlist: [anthropicValue] }).findings).toEqual([]); + const widePlaceholder = "your-token-here".replace(/[a-z]/g, c => String.fromCharCode(c.charCodeAt(0) + 0xfee0)); + expect(scan("x your-token-here y", { processEnv: { P_TOKEN: widePlaceholder } }).findings.filter(f => f.id === "env.value")).toEqual([]); + }); + + test("the default environment is process.env", () => { + const name = "GSTA104_TEST_TOKEN"; + process.env[name] = "v4lue-only-this-test-knows"; + try { + const found = scan("leaked: v4lue-only-this-test-knows").findings.find(f => f.id === "env.value"); + expect(found?.description).toBe(`Value of the ${name} environment variable`); + } finally { + delete process.env[name]; + } + }); +}); diff --git a/test/setup-gbrain-remote-caller.test.ts b/test/setup-gbrain-remote-caller.test.ts index a8b6e97d7..04526ab1c 100644 --- a/test/setup-gbrain-remote-caller.test.ts +++ b/test/setup-gbrain-remote-caller.test.ts @@ -248,12 +248,16 @@ test('existing SDK429 retry policy stays three and closes the prior query before }); test('late first completion cannot approve tools or overwrite evidence after a fresh second invocation', async () => { - const result = await fixture(['late', 'success'], 250); + // The budget bounds the second, successful attempt too (stub server, two mkdtemps, the fake SDK + // run with its HTTP fetch). At 250ms it failed 4 of 4 times on windows-latest once the shard + // pack moved this file next to heavier neighbours (#3449, run 40263984171); 2s keeps the first + // attempt late and leaves the second one room on a loaded runner. + const result = await fixture(['late', 'success'], 2_000); expect(result.rows.map(r => [r.passed, r.attempt])).toEqual([[false, 1], [true, 2]]); expect(result.evidence[0].querySettled).toBe(0); expect(result.evidence[0].attemptId).not.toBe(result.evidence[1].attemptId); expect(result.homes[0]).not.toBe(result.homes[1]); -}, 10_000); +}, 20_000); test('HTTP finalization closes only connections belonging to the owned fixture server', async () => { const foreign = http.createServer((_req, res) => res.end('foreign')); diff --git a/test/shard-engine.test.ts b/test/shard-engine.test.ts index 9714a4e17..272d5d740 100644 --- a/test/shard-engine.test.ts +++ b/test/shard-engine.test.ts @@ -24,6 +24,7 @@ import { strictShardStatus, writeDurationSeed, zeroExecutionVerdict, + CAPY_LAUNCH_MARKERS, } from '../scripts/lib/shard-engine'; import { FREE_LANE_POLICY, exitCodeFor, runFreeShard } from '../scripts/test-free-shards'; import { @@ -237,7 +238,13 @@ describe('shard engine: capture and isolation', () => { }); test('each shard gets its own tmp and Chromium profile, removed by the backstop', async () => { - const base = { KEEP: '1', TMPDIR: '/inherited', CHROMIUM_PROFILE: '/shared-profile' }; + const base = { + KEEP: '1', TMPDIR: '/inherited', CHROMIUM_PROFILE: '/shared-profile', + // The Capy launch file's markers (GSTA-99): a lane started from a shell that sourced it must + // not hand them to the product-under-test children, which set their own session kind. + GSTACK_SESSION_KIND: 'unattended', GSTACK_CAPY: '1', GSTACK_EPHEMERAL: '1', GSTACK_INSTALL_VERDICT: '/v.json', + GSTACK_STATE_ROOT: '/kept-root', + }; const a = createShardSandbox('gstack-unit-shard-', base); const b = createShardSandbox('gstack-unit-shard-', base, { realpath: true }); try { @@ -246,6 +253,8 @@ describe('shard engine: capture and isolation', () => { for (const box of [a, b]) { expect(box.env.KEEP).toBe('1'); expect(box.env.GSTACK_HOME).toBeUndefined(); + for (const key of CAPY_LAUNCH_MARKERS) expect(box.env[key]).toBeUndefined(); + expect(box.env.GSTACK_STATE_ROOT).toBe('/kept-root'); for (const key of ['TMPDIR', 'TEMP', 'TMP']) expect(box.env[key]).toBe(box.tmp); expect(box.env.CHROMIUM_PROFILE).toBe(path.join(box.stateDir, 'chromium-profile')); expect(fs.statSync(box.tmp).isDirectory()).toBe(true);