Files
gstack/test/document-skills-redaction.test.ts
Garry Tanandcapy-ai[bot] 4c6fa89cdc v1.91.159.0 security: gstack's Bun entry points ignore the audited repo's .env and bunfig.toml (S1) (#3312)
* security: isolate every gstack Bun entry point from the audited checkout's .env and bunfig.toml

Bun auto-loads .env, .env.local and .env.<NODE_ENV> from its cwd and runs a
cwd bunfig.toml preload before the program starts. gstack runs inside the repo
it audits, so a committed .env could steer state roots, the claude binary or
spend caps, and a bunfig preload ran arbitrary code in every bin and hook.

Every bun shebang, shell and skill caller, TypeScript spawn and compiled build
now disables both (--no-env-file --config=/dev/null, bunIsolationArgs(),
--no-compile-autoload-dotenv/bunfig), with a ratchet and hostile-fixture
regressions.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: name the directly executed scripts in the isolation ratchet

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: follow the isolation flags in touchfiles, copy lists, recipe assertions and the harness pin

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: keep browse/src/bun-child-config.ts unchanged so the native cookie qualification still binds

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(windows): the empty bunfig outlives its process so the timeline hook's detached repair child can read it

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.159.0 security: gstack's Bun entry points ignore the audited repo's .env and bunfig.toml (S1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: plan-mode render pattern accepts the isolation flags

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-20 00:24:28 +00:00

69 lines
3.2 KiB
TypeScript

/**
* /document-release + /document-generate redaction wiring (T6/T7).
*/
import { describe, test, expect } from "bun:test";
import * as fs from "fs";
import * as path from "path";
import { spawnSync } from "child_process";
const ROOT = path.resolve(import.meta.dir, "..");
// document-release is carved (skeleton + sections/release-body.md). Step 9
// (commit + PR-body redaction scan) moved into the section template; check the
// union of SKILL.md.tmpl + sections/*.md.tmpl so the scan-before-edit ordering
// still verifies. document-generate is NOT carved (plain .md.tmpl).
function unionTmpl(skill: string): string {
let t = fs.readFileSync(path.join(ROOT, skill, "SKILL.md.tmpl"), "utf-8");
const dir = path.join(ROOT, skill, "sections");
if (fs.existsSync(dir)) {
for (const f of fs.readdirSync(dir).sort()) {
if (f.endsWith(".md.tmpl")) t += "\n" + fs.readFileSync(path.join(dir, f), "utf-8");
}
}
return t;
}
const RELEASE = unionTmpl("document-release");
const GENERATE = fs.readFileSync(path.join(ROOT, "document-generate", "SKILL.md.tmpl"), "utf-8");
describe("/document-release redaction", () => {
test("scans the PR-body temp file before gh pr edit", () => {
const scanIdx = RELEASE.indexOf('gstack-redact --from-file "<run-dir>/body.md"');
const editIdx = RELEASE.indexOf('gh pr edit --body-file "<run-dir>/body.md"');
expect(scanIdx).toBeGreaterThan(-1);
expect(editIdx).toBeGreaterThan(scanIdx);
});
test("HIGH blocks the edit", () => {
expect(RELEASE).toMatch(/exit 3 \(HIGH\).*do NOT edit/i);
});
test("separate shell calls share an explicit run directory and never re-read raw tracker text", () => {
expect(RELEASE).toContain('mktemp -d "${TMPDIR:-/tmp}/gstack-doc-release-XXXXXXXX"');
expect(RELEASE).not.toContain('/tmp/gstack-pr-body-$$');
expect(RELEASE).not.toContain('<paste the file contents here>');
expect(RELEASE).toContain('pathlib.Path(sys.argv[1]).read_text()');
});
test("title synchronization keeps every variable in one valid shell block", () => {
const section = RELEASE.slice(RELEASE.indexOf('**PR/MR title sync'));
const script = section.match(/```bash\n([\s\S]*?)\n```/)![1];
// G1 (#2334): the version comes from the shared classifier, not a root VERSION read.
for (const command of ['VB=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump classify', 'CURRENT_TITLE=$(gh pr view', 'NEW_TITLE=$(', 'gh pr edit --title "$NEW_TITLE"', 'glab mr update -t "$NEW_TITLE"']) {
expect(script).toContain(command);
}
const result = spawnSync('bash', ['-n'], { input: script, encoding: 'utf8', timeout: 5000 });
expect(result.status, result.stderr).toBe(0);
});
});
describe("/document-generate redaction", () => {
test("scans staged doc diff before commit", () => {
const scanIdx = GENERATE.indexOf("gstack-redact --repo-visibility");
const commitIdx = GENERATE.indexOf("git commit -m");
expect(scanIdx).toBeGreaterThan(-1);
expect(commitIdx).toBeGreaterThan(scanIdx);
});
test("scans added lines of the staged diff", () => {
expect(GENERATE).toMatch(/git diff --cached[\s\S]{0,80}gstack-redact/);
});
test("HIGH blocks the commit", () => {
expect(GENERATE).toMatch(/Do NOT commit/i);
});
});