mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 18:50:18 +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
@@ -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