diff --git a/CHANGELOG.md b/CHANGELOG.md index c468eaf5e..9c263e8c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [Unreleased] + +**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 successful `bin/gstack-capy-install` run, `--check` included. 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` variable in the scanning process's environment (eight characters or more, not a placeholder) 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. `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 and every shell helper under `bin/` carry no `export $(...)`, no `env` or `printenv`, and no bare `set`, `export`, `declare -x`, `typeset -x` or `declare -p` in a fenced command (`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,274 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` drops `GSTACK_SESSION_KIND`, `GSTACK_CAPY`, `GSTACK_EPHEMERAL` and `GSTACK_INSTALL_VERDICT` from every shard's environment, so a lane 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 +- No gate, threshold, budget, timeout, trial count or case kind changed. The redaction scan only gained findings. Size pins are unchanged: the edited skills are under the limit. + ## [1.91.226.0] - 2026-10-26 **/review can re-review just its own fixes after a fixing pass, under the same rules as /ship (#3339).** Before, every /review fix cycle reran the whole review over the whole branch, so a large branch could hit the three-cycle stop on findings in code the earlier passes had already read. Now the pass after an edited pass starts with `gstack-review-log --start review --fix-range` and its checklist, specialists and QA read only what changed since that pass. The Step 4.8 adversarial review still reads the whole branch on every pass, because its rows carry their own freshness. A narrowed pass is never the final review: when it makes no edits, /review runs one full pass before writing the final record, and a fix-range record is never CURRENT. Garry's "do it" on 2026-10-24 for a fix-range re-review with a final full pass covers it. diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index a464c2fdb..5a7d6f81f 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -7,7 +7,9 @@ unattended /autoplan run through `gstack-autoplan`: the reviewers are subagents 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 +assignments your installer printed: apply them with `. ` in the +shell that runs the command, never with `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..1f0530e2e 100644 --- a/autoplan/DRIVER.md.tmpl +++ b/autoplan/DRIVER.md.tmpl @@ -5,7 +5,9 @@ unattended /autoplan run through `gstack-autoplan`: the reviewers are subagents 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 +assignments your installer printed: apply them with `. ` in the +shell that runs the command, never with `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..b309b1b88 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -580,7 +580,32 @@ record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)" [ "$CHECK_ONLY" -eq 1 ] || [ "$PROFILE" -eq 1 ] || write_shim printf '%s\n' "$OUT" | grep -v '^gstack: ' say "total: $(( $(now) - T0 ))s" +# The launch file: the same assignments as a sourceable script, mode 0600, +# beside the verdict. `. ` 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). +LAUNCH_FILE="${VERDICT%.json}.env" +_sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } +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: . $(_sq "$LAUNCH_FILE")" + 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 *:$(_sq "$LAUNCH_DIR"):*) ;; *) PATH=$(_sq "$LAUNCH_DIR"):\"\$PATH\" ;; esac; export PATH" + echo "export GSTACK_SESSION_KIND=unattended" + echo "export GSTACK_STATE_ROOT=$(_sq "$GSTACK_STATE_ROOT")" + echo "export GSTACK_EPHEMERAL=$(_sq "${GSTACK_EPHEMERAL:-1}")" + echo "export GSTACK_CAPY=1" + echo "export GSTACK_INSTALL_VERDICT=$(_sq "$VERDICT")" + } > "$tmp" && chmod 0600 "$tmp" && mv -f "$tmp" "$LAUNCH_FILE" +} +write_launch_file || { rm -f "$LAUNCH_FILE".tmp.* 2>/dev/null; say "could not write the launch file $LAUNCH_FILE"; LAUNCH_FILE=""; } 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-skill-start b/bin/gstack-skill-start index 81ccda14d..5c287374a 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -665,7 +665,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..02e7312d5 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), or prefix the command with the assignments from the line. 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/capy-install.md b/docs/capy-install.md index c2d0ce97b..0adac0eb6 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -330,8 +330,23 @@ 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). +`. ` 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` or +`*_PASSWORD` variable in the writer's environment (`env.value`, HIGH), so a +leaked value cannot reach a PR body, receipt, report or Drive file. + `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`). diff --git a/document-release/SKILL.md b/document-release/SKILL.md index 8c1690ca4..64d7e7690 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..43f490f41 100644 --- a/lib/redact-engine.ts +++ b/lib/redact-engine.ts @@ -75,6 +75,15 @@ export interface ScanOptions { * literal: in a TypeScript/JSX file `session: SessionState,` is a type. */ sourcePath?: string; + /** + * The environment whose credential-named variables (`*_KEY`, `*_TOKEN`, + * `*_SECRET`, `*_PASSWORD`) 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 +469,40 @@ 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 +512,25 @@ function scanInternal(input: string, opts: ScanOptions, spans?: Map): Array<{ name: string; value: string }> { + const out: Array<{ name: string; value: string }> = []; + for (const [name, value] of Object.entries(env)) { + if (!CREDENTIAL_ENV_NAME.test(name)) continue; + if (!value || value.trim().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/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..058c79253 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. > +> Launch env (Capy's `GSTACK_LAUNCH_FILE`): `. ` in the running shell; never `export $(...)`, `env`, `printenv` or bare `set` in a tool 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..8897d3da3 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. > +> Launch env (Capy's `GSTACK_LAUNCH_FILE`): `. ` in the running shell; never `export $(...)`, `env`, `printenv` or bare `set` in a tool 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/fixtures/launch-env-echo/negative-control.md b/test/fixtures/launch-env-echo/negative-control.md new file mode 100644 index 000000000..8cef4e6e4 --- /dev/null +++ b/test/fixtures/launch-env-echo/negative-control.md @@ -0,0 +1,41 @@ +# 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 +``` + +Allowed: + +```bash +set -euo pipefail +set -u +env FOO=1 ./tool --flag +export FOO=bar +export GSTACK_SESSION_KIND=spawned +. "$GSTACK_LAUNCH_FILE" +set | grep '^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..29b2585fa 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -340,6 +340,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`); diff --git a/test/launch-env-echo-ratchet.test.ts b/test/launch-env-echo-ratchet.test.ts new file mode 100644 index 000000000..9b47fe8f5 --- /dev/null +++ b/test/launch-env-echo-ratchet.test.ts @@ -0,0 +1,163 @@ +/** + * 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 $(...)` or `export "$(...)"` anywhere in the segment + * env `env` with no arguments, 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` + * set bare `set` as the whole line; `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'; + +const ROOT = path.resolve(import.meta.dir, '..'); + +export type Violation = { file: string; line: number; rule: string; text: string }; + +const EXPORT_SUBST = /\bexport\s+"?\$\(/; +const BARE = { + env: /^env$/, + printenv: /^printenv(\s|$)/, + export: /^(export|export -p|declare -x|typeset -x|declare -p)$/, +} as const; + +/** Strip single- and double-quoted strings so a quoted word is never read as a command. */ +function unquoted(line: string): string { + return line.replace(/'[^']*'/g, "''").replace(/"(?:[^"\\]|\\.)*"/g, '""'); +} + +export function rulesFor(line: string): string[] { + const trimmed = line.trim(); + if (trimmed === '' || trimmed.startsWith('#')) return []; + const rules: string[] = []; + const uncommented = trimmed.replace(/\s+#.*$/, ''); + if (EXPORT_SUBST.test(uncommented)) rules.push('export-subst'); + const bare = unquoted(uncommented); + if (/^set$/.test(bare)) rules.push('set'); + for (const segment of bare.split(/\s*(?:;|&&|\|\||\|)\s*/)) { + const cmd = segment.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'); + } + 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)); +} + +function shellTargets(): string[] { + return fs.readdirSync(path.join(ROOT, 'bin')).map(name => path.join('bin', name)).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'], + ]); + 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']) 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/ 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"'); + 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..5ce3ea3cf --- /dev/null +++ b/test/redact-env-value.test.ts @@ -0,0 +1,85 @@ +/** + * 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 { credentialEnvValues, redactFindingSpans, scan } from "../lib/redact-engine"; + +const env = { + ANTHROPIC_API_KEY: "zq7Vm2pL9xR4tW8yB3nK6hJ1", + UBICLOUD_API_KEY: "ubi-plain-looking-value-0042", + GH_TOKEN: "f0a9c2e1d7b6", + DB_PASSWORD: "hunter2hunter2", + 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"]); + }); + + test("a dumped environment line is HIGH by value, whatever the value's shape", () => { + const text = [ + "Tool output:", + "ANTHROPIC_API_KEY=zq7Vm2pL9xR4tW8yB3nK6hJ1", + "GSTACK_STATE_ROOT=/home/user/.gstack", + "the cloud key is ubi-plain-looking-value-0042 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("zq7Vm2pL9xR4tW8yB3nK6hJ1"); + expect(f.preview).not.toContain("ubi-plain-looking-value-0042"); + } + 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 f0a9c2e1d7b6 here"; + expect(scan(text, { processEnv: env }).findings.map(f => f.id)).toContain("env.value"); + expect(scan(text, { processEnv: env, allowlist: ["f0a9c2e1d7b6"] }).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=zq7Vm2pL9xR4tW8yB3nK6hJ1; password hunter2hunter2 twice: hunter2hunter2"; + const redacted = redactFindingSpans(text, { processEnv: env }); + expect(redacted).not.toBeNull(); + expect(redacted).not.toContain("zq7Vm2pL9xR4tW8yB3nK6hJ1"); + expect(redacted).not.toContain("hunter2hunter2"); + expect(redacted).toContain(""); + }); + + 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/shard-engine.test.ts b/test/shard-engine.test.ts index 9714a4e17..36395f891 100644 --- a/test/shard-engine.test.ts +++ b/test/shard-engine.test.ts @@ -237,7 +237,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 +252,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 ['GSTACK_SESSION_KIND', 'GSTACK_CAPY', 'GSTACK_EPHEMERAL', 'GSTACK_INSTALL_VERDICT']) 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);