mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
fix(capy): the launch environment is sourced from a 0600 file and no skill fence may print the environment (GSTA-104)
During GSTA-96's unattended /ship on #3425, the Step 14.5 docs-audit child re-typed the GSTACK_LAUNCH assignments as `export $(...)`; the substitution came back empty, the bare `export` printed the whole environment, and three API key values landed in its tool output. - bin/gstack-capy-install writes the assignments to <verdict>.env (mode 0600, idempotent PATH prepend) and prints GSTACK_LAUNCH_FILE: <path>. - ship Step 14.5 prompt, document-release's spawned note, autoplan/DRIVER.md and gstack-skill-start's capy-asks block say: source the file; never export $(...), env, printenv or bare set in a tool fence. - test/launch-env-echo-ratchet.test.ts greps every generated SKILL.md, sections, DRIVER.md, the Capy skill and bin/ shell helpers, with negative controls under test/fixtures/launch-env-echo/. - lib/redact-engine.ts: env.value (HIGH) finds the value of any *_KEY, *_TOKEN, *_SECRET, *_PASSWORD variable in the writer's environment, so a leaked value cannot reach a PR body, receipt, report or Drive file. - scripts/lib/shard-engine.ts scrubs the Capy launch markers from shard environments (GSTA-99's finding). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
8d9501373b
commit
04dcfea0ce
19 files changed
+481
-17
No files matched your search
@@ -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: <verdict file without .json>.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 `. <file>` at the start of the shell that runs a gstack command. A caller hands a child the path, never the values.
|
||||
- **`env.value` (HIGH) in `lib/redact-engine.ts`**: the value of every `*_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` 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 `. <GSTACK_LAUNCH_FILE>`; 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.
|
||||
|
||||
+3
-1
@@ -7,7 +7,9 @@ unattended /autoplan run through `gstack-autoplan`: the reviewers are subagents
|
||||
and the runner keeps the state. The commands live in `<root>/bin/`, where `<root>` is the
|
||||
folder that holds this `autoplan/` directory; `gstack-autoplan --help` and the first `next`
|
||||
print `DRIVER: <root>/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 `. <the GSTACK_LAUNCH_FILE it printed>` 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=<s> run=<dir>`, and every error line
|
||||
ends with `(<CODE>)`.
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ unattended /autoplan run through `gstack-autoplan`: the reviewers are subagents
|
||||
and the runner keeps the state. The commands live in `<root>/bin/`, where `<root>` is the
|
||||
folder that holds this `autoplan/` directory; `gstack-autoplan --help` and the first `next`
|
||||
print `DRIVER: <root>/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 `. <the GSTACK_LAUNCH_FILE it printed>` 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=<s> run=<dir>`, and every error line
|
||||
ends with `(<CODE>)`.
|
||||
|
||||
|
||||
@@ -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. `. <file>` applies them without ever printing them; the
|
||||
# line carries no secret, but a caller that re-typed it as `export $(...)`
|
||||
# ran a bare `export` when the substitution came back empty and dumped the
|
||||
# whole environment, API keys included, into its tool output (GSTA-96).
|
||||
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"
|
||||
@@ -665,7 +665,7 @@ fi
|
||||
# live in its rules, never here.
|
||||
if [ "$_CAPY" = "on" ] && [ "${GSTACK_CAPY_ASKS:-}" != "0" ]; then
|
||||
_emit_block capy-asks <<EOI
|
||||
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 <run dir>\` (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 <run dir>\` 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 <run dir>\` (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 <run dir>\` 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 \`. <the GSTACK_LAUNCH_FILE the installer printed>\` 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
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Resolve `scripts/install.sh` relative to this SKILL.md, not the working reposito
|
||||
|
||||
## Launch every gstack command with the GSTACK_LAUNCH line
|
||||
|
||||
Every successful run, `--check` included, prints `GSTACK_LAUNCH: PATH=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=<file>` (the `PATH=` part only when the verified Bun is not first on PATH) and, then, `GSTACK_LAUNCH_PATH: <dir>` 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=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=<file>` (the `PATH=` part only when the verified Bun is not first on PATH), then `GSTACK_LAUNCH_PATH: <dir>` for callers that do not evaluate shell syntax, then `GSTACK_LAUNCH_FILE: <file>`: the same assignments as a sourceable script, mode 0600, under the state root beside the install verdict. Apply them with `. <that file>` at the start of every shell that runs a gstack command (each Capy bash call is a fresh shell, and a child shell's export cannot configure the parent's later commands), 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=<s> status=refused run=-` and exits 3: stop, run the fix, start again. An ok whose pins row was `SKIP pins (planning-only; …)` does not admit a workflow that runs the repo's tests (`review`, `ship`, `qa`), and a verdict that did not check a component the workflow needs does not admit it either; run `--for <that skill>` first.
|
||||
|
||||
|
||||
@@ -330,8 +330,23 @@ Every successful run, `--check` included, prints before the trailer:
|
||||
```
|
||||
GSTACK_LAUNCH: PATH=<dir>:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=<path> GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=<file>
|
||||
GSTACK_LAUNCH_PATH: <dir>
|
||||
GSTACK_LAUNCH_FILE: <verdict file without .json>.env
|
||||
```
|
||||
|
||||
`GSTACK_LAUNCH_FILE` names the same assignments as a sourceable script (mode
|
||||
0600, beside the verdict under the state root; the PATH prepend is idempotent).
|
||||
`. <that file>` at the start of a shell applies them without ever printing them,
|
||||
which is the only way to hand them to a child agent: pass the path, never the
|
||||
values. None of the assignments is a secret, but during GSTA-96's unattended
|
||||
/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=<dir>:$PATH` and `GSTACK_LAUNCH_PATH` appear only when the certified Bun,
|
||||
or the project's resolved Node, is not first on PATH (older Capy images ship
|
||||
`/usr/local/bin/bun` 1.3.14; 57 gstack commands start with `#!/usr/bin/env bun`).
|
||||
|
||||
@@ -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 `. <that file>`,
|
||||
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
|
||||
|
||||
@@ -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 `. <that file>`,
|
||||
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
|
||||
|
||||
@@ -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<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
@@ -460,6 +469,40 @@ function scanInternal(input: string, opts: ScanOptions, spans?: Map<Finding, Ori
|
||||
}
|
||||
}
|
||||
|
||||
// Environment values, by exact value: a credential that is already in the
|
||||
// process environment needs no shape to be recognized.
|
||||
for (const { name, value } of credentialEnvValues(opts.processEnv ?? process.env)) {
|
||||
if (allow.has(value)) continue;
|
||||
let from = 0;
|
||||
let normOffset: number;
|
||||
while ((normOffset = normalized.indexOf(value, from)) !== -1) {
|
||||
from = normOffset + value.length;
|
||||
const origOffset = map[Math.min(normOffset, map.length - 1)] ?? 0;
|
||||
const key = `env.value:${origOffset}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
starts ??= lineStarts(input);
|
||||
const { line, col } = lineColAt(starts, input, origOffset);
|
||||
const finding: Finding = {
|
||||
id: "env.value",
|
||||
tier: "HIGH",
|
||||
severity: "HIGH",
|
||||
category: "secret",
|
||||
description: `Value of the ${name} environment variable`,
|
||||
line,
|
||||
col,
|
||||
preview: maskPreview(value),
|
||||
autoRedactable: false,
|
||||
repoVisibility,
|
||||
};
|
||||
findings.push(finding);
|
||||
if (spans) {
|
||||
const end = spanEnds?.[normOffset + value.length - 1];
|
||||
if (end !== undefined && end > 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<Finding, Ori
|
||||
return { findings, counts, repoVisibility, oversize: false };
|
||||
}
|
||||
|
||||
const CREDENTIAL_ENV_NAME = /(^|_)(KEY|TOKEN|SECRET|PASSWORD|PASSWD)$/i;
|
||||
const MIN_ENV_VALUE_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* The (name, value) pairs of `env` worth scanning for by value: credential-named
|
||||
* variables whose value is long enough to be a credential and is not a
|
||||
* placeholder. A short or placeholder value would match ordinary prose.
|
||||
*/
|
||||
export function credentialEnvValues(env: Record<string, string | undefined>): 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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <private.json> --audit-id <fresh id> --mode <edit|read-only> --base <sha> --docs <root or generated output>...`
|
||||
(`--select <path>` 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`): `. <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
|
||||
|
||||
@@ -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 <private.json> --audit-id <fresh id> --mode <edit|read-only> --base <sha> --docs <root or generated output>...`
|
||||
(`--select <path>` 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`): `. <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
|
||||
|
||||
@@ -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"}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 `. <GSTACK_LAUNCH_FILE>` (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');
|
||||
});
|
||||
});
|
||||
@@ -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("<REDACTED-env.value>");
|
||||
});
|
||||
|
||||
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];
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user