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:
garrytanandcapy-ai[bot] committed 2026-10-26 03:15:05 +00:00
1 parent 8d9501373b
commit 04dcfea0ce
19 files changed
+481 -17

No files matched your search

+41
View File
@@ -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"}
```
+11
View File
@@ -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
+14
View File
@@ -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`);
+163
View File
@@ -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');
});
});
+85
View File
@@ -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];
}
});
});
+9 -1
View File
@@ -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);