Merge origin/main into #3409 and stamp v1.91.215.0 (train slot 4)

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-25 16:10:14 +00:00
commit 7866caac97
40 files changed
+1582 -156

No files matched your search

+61 -1
View File
@@ -4,7 +4,7 @@ import * as path from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { runPublicationHook } from '../autoplan/bin/phase-publication-hook.ts';
import { linkedWorktrees, runPublicationHook } from '../autoplan/bin/phase-publication-hook.ts';
import { OWNED_READ_APPROVAL } from '../autoplan/bin/owned-read';
const ROOT = path.join(import.meta.dir, '..');
@@ -192,3 +192,63 @@ describe('Autoplan hook journal-root verdicts', () => {
for (const detail of ['unrecognized_shape:cwd_spelling', 'analytics/autoplan-guard.jsonl']) expect(guide).toContain(detail);
});
});
describe('linkedWorktrees from a project directory below the repository root (#3296)', () => {
const gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_COUNT: '0' };
const git = (cwd: string, ...args: string[]) => {
const child = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000, env: gitEnv });
expect(child.status).toBe(0);
};
function repo(): string {
const dir = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-'))); dirs.push(dir);
git(dir, 'init', '-q', '-b', 'main');
git(dir, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'seed');
return dir;
}
const real = (dir: string) => fs.realpathSync(dir);
test.skipIf(process.platform === 'win32')('the reporter layout: a subfolder project directory owns its worktree under that subfolder', () => {
const root = repo(), sub = path.join(root, 'sub'); fs.mkdirSync(path.join(sub, '.claude', 'worktrees'), { recursive: true });
const worktree = path.join(sub, '.claude', 'worktrees', 'w');
git(root, 'worktree', 'add', '-q', worktree);
expect(linkedWorktrees(sub)).toEqual([real(worktree)]);
expect(linkedWorktrees(path.join(sub, 'deeper', 'still'))).toEqual([real(worktree)]);
expect(linkedWorktrees(root)).toEqual([real(worktree)]);
});
test.skipIf(process.platform === 'win32')('a nested independent repository stops the walk at its own .git', () => {
const outer = repo(), inner = path.join(outer, 'vendor', 'lib');
fs.mkdirSync(inner, { recursive: true });
git(inner, 'init', '-q', '-b', 'main');
git(inner, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'seed');
git(outer, 'worktree', 'add', '-q', path.join(outer, 'outer-wt'));
expect(linkedWorktrees(inner)).toEqual([]);
expect(linkedWorktrees(path.join(inner, 'src'))).toEqual([]);
});
test.skipIf(process.platform === 'win32')('a project directory inside a linked worktree has no registry of its own', () => {
const root = repo(), worktree = path.join(root, 'wt');
git(root, 'worktree', 'add', '-q', worktree);
fs.mkdirSync(path.join(worktree, 'pkg'));
expect(linkedWorktrees(worktree)).toEqual([]);
expect(linkedWorktrees(path.join(worktree, 'pkg'))).toEqual([]);
});
test('a registry entry whose worktree names another repository is still refused from a subfolder', () => {
const root = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-'))); dirs.push(root);
const other = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-other-'))); dirs.push(other);
const worktree = path.join(root, 'sub', 'wt'); fs.mkdirSync(worktree, { recursive: true });
const entry = path.join(root, '.git', 'worktrees', 'wt'); fs.mkdirSync(entry, { recursive: true });
const foreign = path.join(other, '.git', 'worktrees', 'wt'); fs.mkdirSync(foreign, { recursive: true });
fs.writeFileSync(path.join(entry, 'gitdir'), path.join(worktree, '.git') + '\n');
fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${foreign}\n`);
expect(linkedWorktrees(path.join(root, 'sub'))).toEqual([]);
fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${entry}\n`);
expect(linkedWorktrees(path.join(root, 'sub'))).toEqual([worktree]);
});
test('a directory with no .git anywhere above it has no worktrees', () => {
expect(linkedWorktrees(path.parse(tmpdir()).root)).toEqual([]);
});
});
+20 -2
View File
@@ -16,9 +16,9 @@ const ROOT = path.resolve(import.meta.dir, '..');
let home: string;
let remote: string;
function run(argv: string[]) {
function run(argv: string[], extraEnv: Record<string, string> = {}) {
const r = spawnSync(path.join(ROOT, 'bin', argv[0]), argv.slice(1), {
env: { ...process.env, HOME: home, GSTACK_HOME: home },
env: { ...process.env, HOME: home, GSTACK_HOME: home, ...extraEnv },
encoding: 'utf-8',
timeout: 60_000,
});
@@ -145,6 +145,24 @@ describe('artifacts_sync_removals', () => {
expect(run(['gstack-brain-sync', '--publish-removals', '--yes']).stdout).toContain('no removals to publish');
});
test('--publish-removals cleans up its temp files and lock on every exit path', () => {
syncAll(['projects/p/designs/a.md', 'projects/p/designs/b.md']);
fs.rmSync(path.join(home, 'projects/p/designs/a.md'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-removals-tmp-'));
try {
for (const argv of [['--publish-removals'], ['--publish-removals', '--yes'], ['--publish-removals', '--yes']]) {
const r = run(['gstack-brain-sync', ...argv], { TMPDIR: tmp });
expect(r.stderr).not.toContain('unbound variable');
expect(r.status).toBe(0);
expect(fs.readdirSync(tmp)).toEqual([]);
expect(fs.existsSync(path.join(home, '.brain-sync.lock.d'))).toBe(false);
}
expect(remoteFiles()).toEqual(['projects/p/designs/b.md']);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('config rejects values other than on and off', () => {
const r = run(['gstack-config', 'set', 'artifacts_sync_removals', 'yes']);
expect(r.stderr).toContain("artifacts_sync_removals 'yes' not recognized");
+42 -2
View File
@@ -24,7 +24,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
const ROOT = path.resolve(__dirname, '..');
const DOCKERFILE = path.join(ROOT, '.github', 'docker', 'Dockerfile.ci');
const DOCKERFILE = path.join(__dirname, '..', '.github', 'docker', 'Dockerfile.ci');
/** Package specs from every `npm i -g` / `npm install -g` in the Dockerfile. */
export function globalNpmInstallSpecs(source: string): string[] {
@@ -193,7 +193,7 @@ describe('other CI Bun installs match the image (#1713, #1706)', () => {
test(`${rel} downloads the verified linux-x64 archive instead of piping an installer into a shell`, () => {
expect(curlPipedToShell(source)).toEqual([]);
const download = source.indexOf('/releases/download/bun-v$BUN_VERSION/bun-linux-x64.zip');
const verify = source.indexOf('sha256sum -c');
const verify = source.indexOf('sha256sum -c', download);
const unpack = source.indexOf('unzip -q');
expect(download).toBeGreaterThan(-1);
expect(verify).toBeGreaterThan(download);
@@ -216,3 +216,43 @@ describe('other CI Bun installs match the image (#1713, #1706)', () => {
});
}
});
describe('CI Node.js installs match the image and are verified (#3354)', () => {
const docker = fs.readFileSync(DOCKERFILE, 'utf-8');
const imageVersion = docker.match(/^ENV NODE_VERSION=(\S+)$/m)?.[1];
const imageSha = docker.match(/^ARG NODE_SHA256=(\S+)$/m)?.[1];
const nodeRun = logicalLines(docker).find((line) => line.includes('nodejs.org/dist/v${NODE_VERSION}/')) ?? '';
test('Dockerfile.ci pins an exact Node.js version and the SHA-256 of its linux-x64 archive', () => {
expect(imageVersion).toMatch(/^\d+\.\d+\.\d+$/);
expect(imageSha).toMatch(/^[0-9a-f]{64}$/);
});
test('Dockerfile.ci checks the Node.js archive before it is unpacked', () => {
expect(nodeRun, 'Node.js must come from the nodejs.org release archive').not.toBe('');
const download = nodeRun.indexOf('-o /tmp/node.tar.xz');
const verify = nodeRun.indexOf('echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c -');
const unpack = nodeRun.indexOf('tar -xJ');
expect(download).toBeGreaterThan(-1);
expect(verify).toBeGreaterThan(download);
expect(unpack).toBeGreaterThan(verify);
});
const rel = 'scripts/ubicloud/setup-free-suite.sh';
const source = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
test(`${rel} pins the same Node.js version and SHA-256 as Dockerfile.ci`, () => {
expect(imageSha).toMatch(/^[0-9a-f]{64}$/);
expect(source.match(/^NODE_VERSION=(\S+)$/m)?.[1]).toBe(imageVersion!);
expect(source.match(/^NODE_SHA256=(\S+)$/m)?.[1]).toBe(imageSha!);
});
test(`${rel} checks the Node.js archive before it is unpacked`, () => {
const download = source.indexOf('/node-v$NODE_VERSION-linux-x64.tar.xz" -o "$node_tmp/node.tar.xz"');
const verify = source.indexOf('echo "$NODE_SHA256 $node_tmp/node.tar.xz" | sha256sum -c -');
const unpack = source.indexOf('tar -xJ');
expect(download).toBeGreaterThan(-1);
expect(verify).toBeGreaterThan(download);
expect(unpack).toBeGreaterThan(verify);
});
});
+27 -1
View File
@@ -5,7 +5,7 @@
* the correct SCOPE_* variables are output.
*/
import { describe, test, expect, afterAll } from 'bun:test';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { spawnSync } from 'child_process';
@@ -245,6 +245,20 @@ describe('glob classes (table-driven, #2526 + #2455)', () => {
{ name: 'plain backend', file: 'server.go', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_FRONTEND'] },
{ name: 'docs', file: 'docs/guide.md', expects: ['SCOPE_DOCS'] },
{ name: 'prompts', file: 'app/services/prompt_builder.rb', expects: ['SCOPE_PROMPTS', 'SCOPE_BACKEND'] },
// Python tests and nested test dirs (#3335): the issue's three paths.
{ name: 'nested tests/test_*.py (#3335)', file: 'components/orch/services/shared/tests/test_native_store_custody.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'second nested tests/test_*.py (#3335)', file: 'components/orch/services/shared/tests/test_infra_promoted_first_apply.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'hyphenated service tests/test_*.py (#3335)', file: 'components/orch/services/api-gateway/tests/test_native_chat_checkpoint_store.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'test_*.py outside a tests dir', file: 'pkg/test_store.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'Django tests.py', file: 'shop/tests.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'pytest conftest.py', file: 'services/api/conftest.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'nested __tests__ dir', file: 'packages/ui/__tests__/render.ts', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'nested spec dir', file: 'engines/billing/spec/invoice_checker.rb', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
// Controls: a name that only contains "test" is not a test file.
{ name: 'latest.py is not a test', file: 'pkg/latest.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] },
{ name: 'contest_rules.py is not a test', file: 'pkg/contest_rules.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] },
{ name: 'testing/ dir is not a test dir', file: 'pkg/testing/fixtures_loader.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] },
{ name: 'attest_*.py is not a test', file: 'pkg/attest_signer.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] },
];
for (const row of TABLE) {
@@ -262,6 +276,18 @@ describe('glob classes (table-driven, #2526 + #2455)', () => {
}
});
describe('one test-path rule (#3335)', () => {
test('TEST_PATH_RE is byte-identical to the test-bootstrap TESTFILES regex', () => {
const script = readFileSync(SCRIPT, 'utf8');
const scriptRe = script.match(/^TEST_PATH_RE='([^']+)'$/m)?.[1];
const resolver = readFileSync(join(import.meta.dir, '..', 'scripts', 'resolvers', 'testing.ts'), 'utf8');
const resolverRe = resolver.match(/git ls-files \| grep -cE '([^']+)'/)?.[1]?.replace(/\\\\/g, '\\');
expect(scriptRe).toBeDefined();
expect(resolverRe).toBeDefined();
expect(scriptRe).toBe(resolverRe);
});
});
describe('exit-code contract (#2526)', () => {
test('clean tree, no changes → all false, exit 0, no SCOPE_ERROR', () => {
const dir = createRepo([]);
+3 -1
View File
@@ -14,6 +14,7 @@ import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { spawnSync } from "child_process";
import { gitEnv } from "./helpers/git-env";
import { mkdirpSync } from "../lib/fs-utils";
const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact");
@@ -93,7 +94,7 @@ describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", (
const base = tmpdir();
try {
const repo = path.join(base, "repo");
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
spawnSync("git", ["init", "-q", repo], { timeout: 30_000, env: gitEnv() });
const hookDir = path.join(repo, ".git", "hooks");
fs.mkdirSync(hookDir, { recursive: true });
const hookPath = path.join(hookDir, "pre-push");
@@ -106,6 +107,7 @@ describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", (
cwd: repo,
encoding: "utf8",
timeout: 30_000,
env: gitEnv(),
});
expect(r.status).toBe(0);
expect(r.stderr ?? "").not.toContain("EEXIST");
+99
View File
@@ -644,5 +644,104 @@ describe("gstack-global-discover", () => {
expect(json.repos).toHaveLength(1);
expect(json.repos[0].sessions.claude_code).toBe(1);
});
// Claude Code worktrees live at <repo>/.claude/worktrees/<name> and are removed once
// their branch merges; the session history under ~/.claude/projects stays behind.
function removedWorktree(main: string, name = "feat-x"): string {
const wt = join(main, ".claude", "worktrees", name);
git(main, "worktree", "add", "-q", wt);
return wt;
}
test("sessions of a removed Claude worktree count with the repository it was created from", () => {
const main = repo("work/ai-blogger-lab", "https://github.com/acme/ai-blogger-lab.git");
const wt = removedWorktree(main);
session(main, "main-1");
session(main, "main-2");
session(wt, "wt-1");
session(wt, "wt-2");
session(wt, "wt-3");
git(main, "worktree", "remove", "--force", wt);
expect(existsSync(wt)).toBe(false);
const json = discover();
expect(json.tools.claude_code.total_sessions).toBe(5);
expect(json.repos).toHaveLength(1);
expect(json.repos[0].sessions.claude_code).toBe(5);
expect(json.repos[0].paths).toEqual([main]);
});
test("a session started in a subdirectory of a removed Claude worktree counts too", () => {
const main = repo("work/app", "git@github.com:acme/app.git");
const wt = removedWorktree(main);
mkdirSync(join(wt, "packages", "web"), { recursive: true });
session(join(wt, "packages", "web"), "sub-1");
git(main, "worktree", "remove", "--force", wt);
const r = discover().repos.find((x) => x.remote === "https://github.com/acme/app")!;
expect(r.sessions.claude_code).toBe(1);
expect(r.paths).toEqual([main]);
});
test("a removed Claude worktree of a repository with no remote counts with its repository", () => {
const main = repo("work/local-only");
const wt = removedWorktree(main);
session(main, "l-1");
session(wt, "l-2");
git(main, "worktree", "remove", "--force", wt);
const local = discover().repos.filter((x) => x.remote.startsWith("local:"));
expect(local).toHaveLength(1);
expect(local[0].sessions.claude_code).toBe(2);
});
test("a live Claude worktree keeps its own path next to its repository's", () => {
const main = repo("work/live", "https://github.com/acme/live.git");
const wt = removedWorktree(main);
session(main, "m-1");
session(wt, "w-1");
const r = discover().repos.find((x) => x.remote === "https://github.com/acme/live")!;
expect(r.sessions.claude_code).toBe(2);
expect(r.paths.sort()).toEqual([main, wt].sort());
});
test("a removed Claude worktree whose repository is also gone is not counted as a repository", () => {
const gone = join(home, "work", "gone-repo");
session(join(gone, ".claude", "worktrees", "feat-x"), "g-1");
const json = discover();
expect(json.tools.claude_code.total_sessions).toBe(1);
expect(json.repos).toHaveLength(0);
});
test("a removed directory that is not a Claude worktree is not counted as a repository", () => {
const main = repo("work/real", "https://github.com/acme/real.git");
session(join(main, "some", "deleted-dir"), "d-1");
session(join(main, ".claude", "worktrees"), "d-2");
session(main, "r-1");
const json = discover();
expect(json.tools.claude_code.total_sessions).toBe(3);
expect(json.repos).toHaveLength(1);
expect(json.repos[0].sessions.claude_code).toBe(1);
});
describe("parentRepoOfClaudeWorktree", () => {
let parentRepoOfClaudeWorktree: (cwd: string) => string | null;
beforeEach(async () => {
parentRepoOfClaudeWorktree = (await import("../bin/gstack-global-discover.ts")).parentRepoOfClaudeWorktree;
});
test("returns the repository root for a worktree path, with or without a subdirectory", () => {
expect(parentRepoOfClaudeWorktree("/Users/me/code/app/.claude/worktrees/feat-x")).toBe("/Users/me/code/app");
expect(parentRepoOfClaudeWorktree("/Users/me/code/app/.claude/worktrees/feat-x/src/lib")).toBe("/Users/me/code/app");
expect(parentRepoOfClaudeWorktree("C:\\code\\app\\.claude\\worktrees\\feat-x")).toBe("C:\\code\\app");
});
test("returns null for any other path", () => {
for (const p of [
"/Users/me/code/app",
"/Users/me/code/app/.claude/worktrees",
"/Users/me/code/app/.claude/worktrees/",
"/Users/me/code/not.claude/worktrees/x",
"/.claude/worktrees/x",
]) expect(parentRepoOfClaudeWorktree(p)).toBeNull();
});
});
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* Environment for fixture git commands and `gstack-redact install-prepush-hook`.
*
* With the parent environment, a developer's global or system core.hooksPath
* (or the same key via GIT_CONFIG_COUNT / GIT_CONFIG_PARAMETERS) reaches the
* fixture: `git rev-parse --git-path hooks` resolves to that directory, so the
* install rewrites the developer's own pre-push (#3353), and their hook runs
* on fixture pushes and rejects the local remote (#2967). /dev/null drops the
* global file on every platform: Git for Windows rejects NUL (see
* EMPTY_GLOBAL_GIT_CONFIG in lib/cso/process.ts). Config written inside the
* fixture repo still applies.
*/
export function gitEnv(extra: Record<string, string | undefined> = {}): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env, ...extra };
env.GIT_CONFIG_GLOBAL = "/dev/null";
env.GIT_CONFIG_NOSYSTEM = "1";
env.GIT_CONFIG_COUNT = "0";
delete env.GIT_CONFIG_PARAMETERS;
return env;
}
+209
View File
@@ -342,6 +342,215 @@ describe('check-careful.sh', () => {
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
});
// --- Commit message false positives (#1060) ---
describe('commit message false positives (#1060)', () => {
// Quoted -m content describes destructive patterns but does not run them.
test.each([
'git commit -m "fix: prevent accidental drop table in migration"',
'git commit -m "docs: explain truncate vs delete semantics"',
'git commit -m "feat: add guard for recursive delete patterns"',
'git add . && git commit -m "fix: handle drop database edge case"',
])('does not warn on descriptive text in commit message: %s', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
// Real destructive commands chained with a commit still warn.
test('warns when a real DROP TABLE follows git commit in a chain', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -m "release" && psql -c "DROP TABLE staging"'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL DROP');
});
test('warns when a real TRUNCATE follows git commit in a chain', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -m "cleanup" && psql -c "TRUNCATE audit_log"'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL TRUNCATE');
});
// Quote-aware awk scanner: a single-quoted message whose content looks like
// a flag boundary must not swallow real commands that follow via &&.
test('warns when rm follows a single-quoted message that embeds a double-quote', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput(`git commit -m 'note -m "' && rm -rf build && echo '"'`),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
});
// A double-quoted message containing $(...) is not masked — the substitution
// content remains visible to the MEDIUM checks.
test('warns when a commit message contains a command substitution with rm', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -m "$(rm -rf build)"'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
});
// A force-push chained after a commit must still warn even after the message is masked.
test('warns on force-push chained after git commit', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -m "x" && git push --force'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('force-push');
});
// -am shorthand: the message is masked, so "drop table" in the message text
// does not trigger the SQL DROP warning.
test('does not warn on drop-table text in -am commit message', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -am "drop table notes"'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
// bash 3.2 (macOS /bin/bash) mis-parses quote characters in a heredoc
// inside $( ), so the awk program inline made the whole hook a syntax error
// there. It is a file beside the hook, read with awk -f.
test('the masking program is a file beside the hook, not a heredoc inside $( )', () => {
const source = fs.readFileSync(CAREFUL_SCRIPT, 'utf8');
expect(source).not.toMatch(/\$\(cat <</);
expect(source).toContain('awk -f "$_MASK_AWK"');
expect(fs.existsSync(path.join(path.dirname(CAREFUL_SCRIPT), 'mask-commit-message.awk'))).toBe(true);
});
// Newlines separate commands, and quote state carries across them. The
// per-line scanner joined lines (`-m xtruncate`) and restarted quote
// state on every line, so a quoted line break could hide a real command.
test.each([
['git commit -m x\ntruncate -s 0 f', 'SQL TRUNCATE'],
['git commit -m wip\nrm -rf build', 'recursive delete'],
["git commit -m \"a\n-m 'x\" && rm -rf build && echo '", 'recursive delete'],
])('a command on the next line still asks: %j', (command, reason) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
});
test('a multi-line quoted message is masked as one message', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git commit -m "fix: guard migrations\n\nNo more drop table in the seed script."'));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
// Only git commit's own message is masked: a -m argument of another
// command in the chain, a commit whose output is piped into a shell, and
// an escaped quote inside the message all stay visible.
test.each([
["git commit -m a && ls | parallel -m 'rm -rf build'", 'recursive delete'],
["git commit -m 'rm -rf build' | sh", 'recursive delete'],
["echo git commit -m 'drop table users'", 'SQL DROP'],
['git commit -m "a\\"b" && rm -rf build', 'recursive delete'],
["git commit -m wip \\\n&& rm -rf build", 'recursive delete'],
])('text outside commit\'s own message still asks: %j', (command, reason) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
});
test.each([
'git -C repo commit --message="docs: truncate the changelog intro"',
"git commit --message 'chore: drop database fixtures from the seed'",
"git commit --amend -m 'fix: git reset --hard in the docs'",
])('commit\'s own message is masked in every flag form: %s', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
// The form agents use most: a quoted-delimiter heredoc inside "$( )". Its
// body is never expanded, so it is masked whole. Any other substitution,
// backtick, $'...' or heredoc leaves the command unmasked.
const heredocMessage = (body: string, delimiter = "'EOF'", end = 'EOF') =>
`git commit -m "$(cat <<${delimiter}\n${body}\n${end}\n)"`;
test.each([
heredocMessage('fix: guard the migration that could drop table users\n\nCo-Authored-By: A <a@example.com>'),
heredocMessage('docs: why we never truncate "audit" rows (see #12)'),
heredocMessage('chore: git reset --hard is documented', '"MSG"', 'MSG'),
heredocMessage('chore: rm -rf build in the clean script', "-'EOF'", '\tEOF'),
])('a quoted-delimiter heredoc message is masked: %j', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
test.each([
[heredocMessage('docs: drop table notes') + ' && git push --force', 'force-push'],
[heredocMessage('drop table $(rm -rf build)', 'EOF'), 'recursive delete'],
[`git commit -m "$(echo " -m 'x" ; rm -rf build ; echo ')"`, 'recursive delete'],
['git commit -m "\`rm -rf build\`"', 'recursive delete'],
["git commit -F - <<'EOF'\ndocs: drop table notes\nEOF", 'SQL DROP'],
["git commit -m $'drop table notes\\n'", 'SQL DROP'],
])('other substitutions and heredocs stay unmasked: %j', (command, reason) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
});
// git prints the message on stdout, so any pipe after a masked message,
// a process substitution or a parameter expansion leaves it unmasked; a
// redirection's & is not a separator, and a comment's quote is not a quote.
test.each([
"git commit -m 'rm -rf build' 2>&1 | sh",
"(git commit -m 'rm -rf build') | sh",
"git commit -m 'rm -rf build' > >(sh)",
'git commit -m "${MSG:-rm -rf build}"',
])('a message that can reach a shell stays unmasked: %j', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
});
test.each([
"git commit -m 'drop table notes' 2>&1 && git log -1 --oneline",
"git commit -m 'drop table notes' || echo failed",
"git commit -m 'drop table notes' # it's the docs commit",
])('redirections, || and comments keep the message masked: %j', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
test('a partial install without the masking program still asks on the message text', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-nomask-'));
try {
for (const file of ['check-careful.sh', 'hook-extract.sh']) {
fs.copyFileSync(path.join(path.dirname(CAREFUL_SCRIPT), file), path.join(dir, file));
}
const { exitCode, output } = runHook(path.join(dir, 'check-careful.sh'), carefulInput('git commit -m "drop table notes"'));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL DROP');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
// --- Shell obfuscation ---
describe('shell obfuscation', () => {
+2
View File
@@ -301,6 +301,7 @@ describe('gstack-pregate on a Rust workspace', () => {
// Value: protects=the reported case end to end: a touched src file of a workspace crate is not PREGATE_NO_LANE, its crate and the
// dependent crates run with `cargo test -p` from the workspace root, and the summed count is reported; fails_when=any link of
// graph, plan or summary regresses; why_new=no Rust run existed; seam=none; no_claim=a real cargo (PATH shim reports cwd and argv)
// TODO(#3380): Windows gap. This case is skipped on win32 until the cargo stand-in runs under the lane's Git Bash there; https://github.com/garrytan/gstack/pull/3380
posixShim('a touched src file runs its crate and the crates depending on it from the workspace root', () => {
const repo = rustRepo();
commit(repo, { 'engine/crates/alpha/src/util.rs': 'pub fn two() -> u32 { 1 + 1 }\n' });
@@ -315,6 +316,7 @@ describe('gstack-pregate on a Rust workspace', () => {
// Value: protects=a crate whose test binaries all report 0 tests is ZERO-RUN (exit 0 is not a pass), and a failing cargo run is
// PREGATE_LANE_FAILED with its counts; fails_when=cargo's summary is unparsed (count unavailable passes) or the exit is ignored;
// why_new=the pure parser test does not run the lane; seam=none; no_claim=a real cargo (PATH shim)
// TODO(#3380): Windows gap. This case is skipped on win32 until the cargo stand-in runs under the lane's Git Bash there; https://github.com/garrytan/gstack/pull/3380
posixShim('zero tests is ZERO-RUN; a failing cargo run is PREGATE_LANE_FAILED', () => {
const repo = rustRepo();
commit(repo, { 'engine/crates/gamma/src/lib.rs': 'pub fn g() { }\n' });
+407
View File
@@ -0,0 +1,407 @@
/**
* The user-owned pregate policy and `local` lanes. A repo that keeps `.gstack/`
* out of git declares its policy at `<GSTACK_HOME>/projects/<slug>/pregate.json`;
* the base branch's copy always wins, and `--policy-from head` never reads the
* user file. A platform lane named in `local` runs here instead of becoming
* requires-remote. Every assertion holds on any host platform: each lane the
* fixture selects is either named in `local` or asserted only by label.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { loadConfigFromUser, validateConfig } from '../lib/pregate/config';
import { buildContext, sha256, userConfigRejection } from '../lib/pregate/context';
import { runPregate, verifyPregate } from '../lib/pregate/index';
import { BASE_PREGATE, BIN, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
import { runScriptSync } from '../lib/script-invocation';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const SLUG = 'userpolicy';
const LOCAL_ALL = ['free-tests.yml/unit', 'windows.yml/windows-shard'];
/** A fixture whose base carries no `.gstack/pregate.json`, a feature commit touching lib/util.js, and the user-file path for its slug. */
function setup(): { repo: FixtureRepo; userPolicy: string; run: (extra?: string[]) => ReturnType<typeof runBin> } {
const repo = makeFixtureRepo({ pregate: null });
repos.push(repo);
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' });
const userPolicy = path.join(repo.home, 'projects', SLUG, 'pregate.json');
fs.mkdirSync(path.dirname(userPolicy), { recursive: true });
const run = (extra: string[] = []) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', ...extra], { GSTACK_PROJECT_SLUG: SLUG });
return { repo, userPolicy, run };
}
/** setup() with a base that commits `.gstack/pregate.json` (raw text, or a policy object). */
function setupWithBase(base: string | Record<string, unknown>): { repo: FixtureRepo; userPolicy: string } {
const repo = makeFixtureRepo({ pregate: null, baseFiles: { '.gstack/pregate.json': typeof base === 'string' ? base : JSON.stringify(base) } });
repos.push(repo);
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' });
const userPolicy = path.join(repo.home, 'projects', SLUG, 'pregate.json');
fs.mkdirSync(path.dirname(userPolicy), { recursive: true });
return { repo, userPolicy };
}
const writePolicy = (file: string, policy: Record<string, unknown>) => fs.writeFileSync(file, JSON.stringify({ runner: 'node --test {files}', ...policy }));
describe('pregate.json local', () => {
// Value: protects=`local` accepts only <workflow file>/<job id> lane ids, shared with selection keys; fails_when=a bare job name or a
// string is accepted and silently matches no lane; why_new=the key is new; seam=none; no_claim=none beyond protects
test('takes <workflow>/<job> ids only', () => {
expect(validateConfig({ local: ['test.yml/unit'] })).toEqual([]);
expect(validateConfig({ local: ['unit'] }).map(e => e.path)).toEqual(['local[0]']);
expect(validateConfig({ local: 'test.yml/unit' }).map(e => e.path)).toEqual(['local']);
expect(validateConfig({ local: [7] }).map(e => e.path)).toEqual(['local']);
expect(validateConfig({ selection: { unit: 'true' } }).map(e => e.path)).toEqual(['selection["unit"]']);
});
});
describe('loadConfigFromUser', () => {
// Value: protects=a missing user file falls back (null) while any other read error refuses at the file's path, never defaults;
// fails_when=every read error is treated as absent and the user's guards silently vanish; why_new=the loader is new; seam=none;
// no_claim=EACCES specifically (a directory, and a dangling symlink, stand in for an unreadable file)
test('ENOENT is null; an unreadable file refuses at its path', () => {
const { userPolicy } = setup();
expect(loadConfigFromUser(userPolicy)).toBeNull();
fs.mkdirSync(userPolicy);
const load = loadConfigFromUser(userPolicy);
expect(load && 'errors' in load ? load.errors.map(e => [e.code, e.path]) : null).toEqual([['PREGATE_CONFIG_INVALID', userPolicy]]);
expect(load && 'kind' in load ? [load.kind, load.label] : null).toEqual(['user', `user ${userPolicy}`]);
fs.rmSync(userPolicy, { recursive: true });
fs.symlinkSync(path.join(path.dirname(userPolicy), 'not-yet-cloned.json'), userPolicy);
const dangling = loadConfigFromUser(userPolicy);
expect(dangling && 'errors' in dangling ? dangling.errors.map(e => [e.code, e.path]) : null).toEqual([['PREGATE_CONFIG_INVALID', userPolicy]]);
fs.unlinkSync(userPolicy);
// A dangling link one level up (the project directory) is the same intent.
const dir = path.dirname(userPolicy);
fs.rmSync(dir, { recursive: true });
fs.symlinkSync(path.join(path.dirname(dir), 'not-yet-cloned'), dir);
expect(loadConfigFromUser(userPolicy)).not.toBeNull();
fs.unlinkSync(dir);
fs.mkdirSync(dir);
// Malformed JSON reaches the sealed/price_file probe too; it must still come back as a refusal, never a throw.
fs.writeFileSync(userPolicy, '{"local": [');
const bad = loadConfigFromUser(userPolicy);
expect(bad && 'errors' in bad ? bad.errors.map(e => e.message.startsWith('not valid JSON')) : null).toEqual([true]);
// The JSON error names this file, not the repo copy parseConfig assumes.
expect(bad && 'errors' in bad ? bad.errors.map(e => e.path) : null).toEqual([userPolicy]);
});
});
describe('gstack-pregate with a user-owned policy', () => {
// Value: protects=with no base copy the user file is the policy and its `local` lanes run here, while an unnamed platform lane stays
// requires-remote; fails_when=the user fallback or the `local` branch in selectLanes is dropped; why_new=both are new;
// seam=none; no_claim=a real CI host (lanes are judged by the fixture's workflows)
test('the user file is read when the base has none, and a local lane runs here instead of requires-remote', () => {
const { repo, userPolicy, run } = setup();
expect(run().out).toContain('(no .gstack/pregate.json)');
// The lane left off `local` must be foreign to this host: on Windows the windows lane is native and runs anyway.
const [named, foreign] = process.platform === 'win32'
? [['windows.yml/windows-shard', 'windows'], ['free-tests.yml/unit', 'linux']]
: [['free-tests.yml/unit', 'linux'], ['windows.yml/windows-shard', 'windows']];
writePolicy(userPolicy, { local: [named[0]] });
const partial = run();
expect(partial.out).toContain(`policy user ${userPolicy}`);
expect(partial.out).toContain(`requires-remote ${foreign[0]} (${foreign[1]})`);
expect(partial.out).not.toContain(`requires-remote ${named[0]}`);
writePolicy(userPolicy, { local: LOCAL_ALL });
const all = run(['--explain']);
expect(all.status).toBe(EXIT.ok);
// The record pins the user file's bytes, since no commit does.
const lanes = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes');
expect(lanes.inputs.policy).toBe(sha256(fs.readFileSync(userPolicy, 'utf8')));
expect(all.out).toContain('Tiers ran: unit; requires-remote: none');
expect(all.out).toContain(`lane ${foreign[0]}: ${foreign[1]} lane satisfied locally (pregate.json local)`);
expect(all.out).toMatch(/^tests {6}lanes {5}pass /m);
}, 60_000);
// Value: protects=a fix that says where to declare policy names the user file when it is the active policy, since an edit to the
// repo's .gstack/pregate.json is never read on a base that carries none; fails_when=the fix hardcodes .gstack/pregate.json;
// why_new=the user source is new; seam=none; no_claim=every fix string (strays, runner, remote_gate share ctx.configFile)
test('the no-lane fix names the user file when it is the active policy', () => {
const { repo, userPolicy, run } = setup();
writePolicy(userPolicy, { local: LOCAL_ALL });
commit(repo, { 'lib/orphan.js': 'exports.lonely = () => 2;\n' });
expect(run().status).not.toBe(EXIT.ok);
const lanes = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes');
expect(lanes.code).toBe('PREGATE_NO_LANE');
expect(lanes.fix).toContain(`under dependencies — or empty_lanes for other documentation — in ${userPolicy}`);
}, 60_000);
// Value: protects=an invalid, sealed-bearing or unreadable user file refuses with PREGATE_CONFIG_INVALID naming the file, with a fix
// that points at the file rather than the base branch; fails_when=user-file errors run on defaults, or sealed/price_file are accepted
// and look active while nothing reads them; why_new=the user source is new; seam=none; no_claim=the sealed guard itself
test('an invalid, sealed or unreadable user file refuses and names the file', () => {
const { userPolicy, run } = setup();
writePolicy(userPolicy, { local: ['not-a-lane'] });
const invalid = run();
expect(invalid.status).toBe(EXIT.refused);
expect(invalid.err).toContain('PREGATE_CONFIG_INVALID');
expect(invalid.err).toContain(`user ${userPolicy}`);
expect(invalid.err).toContain(`fix: edit ${userPolicy} directly`);
expect(invalid.err).not.toContain('.gstack/pregate.json has');
for (const [key, value] of [['sealed', { paths: ['evals/**'] }], ['price_file', 'evals/calls.json']] as const) {
writePolicy(userPolicy, { [key]: value });
const r = run();
expect(r.status).toBe(EXIT.refused);
expect(r.err).toContain(`covers gstack-pregate run only; ${key}`);
}
// A null price_file is the documented default, not a policy, so it is accepted.
writePolicy(userPolicy, { price_file: null, local: LOCAL_ALL });
expect(run().err).not.toContain('PREGATE_CONFIG_INVALID');
fs.rmSync(userPolicy);
fs.mkdirSync(userPolicy);
const unreadable = run();
expect(unreadable.status).toBe(EXIT.refused);
expect(unreadable.err).toContain('cannot read the user-owned policy');
}, 60_000);
// Value: protects=the user file is consulted only for --policy-from base with no base copy: head mode and a committed base copy never
// read it; fails_when=either guard on the fallback is dropped and an unreviewed user file overrides reviewed policy; why_new=the
// fallback is new; seam=none; no_claim=none beyond protects
test('--policy-from head and a base copy never read the user file', () => {
const { repo, userPolicy, run } = setup();
writePolicy(userPolicy, { local: ['not-a-lane'] });
const head = run(['--policy-from', 'head']);
expect(head.err).not.toContain('PREGATE_CONFIG_INVALID');
expect(head.out).not.toContain('policy user');
commit(repo, { '.gstack/pregate.json': JSON.stringify(BASE_PREGATE) + '\n' });
repo.git('push', '-q', 'origin', 'HEAD:main');
repo.git('fetch', '-q', 'origin');
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' });
const based = run();
expect(based.err).not.toContain('PREGATE_CONFIG_INVALID');
expect(based.out).toContain('origin/main@');
expect(based.out).not.toContain('policy user');
// A base copy is pinned by inputs.base; no user digest is recorded.
const lanes = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes');
expect(lanes.inputs.base).toContain('origin/main@');
expect('policy' in lanes.inputs).toBe(false);
}, 60_000);
// Value: protects=the --remote refusal for a policy with no remote_gate tells the user where to declare it: the repo copy "on the
// base branch", the user file by its path (never "on the base branch", which the user file is not on); fails_when=the
// configFile ternary is dropped or inverted and the user is sent to edit a file nothing reads; why_new=no test reaches the
// no-remote_gate branch and the user source is new; seam=none (userConfigPath and remote are existing buildContext options);
// no_claim=the remote gate run itself
test('--remote with no remote_gate names the file to declare it in', async () => {
const lanesFix = async (repo: FixtureRepo, userConfigPath?: string) => {
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', remote: { spendCapUsd: 1 }, userConfigPath });
if (!('ctx' in loaded)) throw new Error('fixture context refused');
const lanes = (await runPregate(loaded.ctx, { stage: 'tests' })).checks.find(c => c.id === 'lanes')!;
expect([lanes.status, lanes.code]).toEqual(['incomplete', 'PREGATE_INCOMPLETE']);
return lanes.fix!;
};
const { repo, userPolicy } = setup();
writePolicy(userPolicy, { local: LOCAL_ALL });
const user = await lanesFix(repo, userPolicy);
expect(user).toContain(`in ${userPolicy}, or run the tests locally`);
expect(user).not.toContain('on the base branch');
const based = makeFixtureRepo({ pregate: { ...BASE_PREGATE, local: LOCAL_ALL } });
repos.push(based);
commit(based, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' });
expect(await lanesFix(based)).toContain('in .gstack/pregate.json on the base branch, or run the tests locally');
}, 60_000);
// Value: protects=the strays fail fix, the strays warn fix and the PREGATE_STALLED fix each name the user file when it is the active
// policy, so the user edits the file that is read; fails_when=any of the three reverts to a hardcoded .gstack/pregate.json, which a
// base carrying none never reads; why_new=existing strays and stall tests assert detail and lines, never the fix, and only with a
// repo copy; seam=none; no_claim=the stall timing itself (pregate-supervise covers it)
test('strays (fail and warn) and stall fixes name the user file', () => {
const { repo, userPolicy, run } = setup();
writePolicy(userPolicy, { local: LOCAL_ALL });
const check = (id: string) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === id);
const strays = (files: Record<string, string | null>) => {
commit(repo, files);
runBin(repo, 'gstack-pregate', ['--base', 'main', '--stage', 'preflight', '--only', 'strays'], { GSTACK_PROJECT_SLUG: SLUG });
return check('strays');
};
const fail = strays({ 'notes.txt': 'x\n', 'newtop/thing.js': 'module.exports = 1;\n' });
expect([fail.status, fail.code]).toEqual(['fail', 'PREGATE_STRAYS']);
expect(fail.fix).toContain(`goes in ${userPolicy} strays.allow`);
const warn = strays({ 'notes.txt': null });
expect([warn.status, warn.code]).toEqual(['warn', 'PREGATE_STRAYS']);
expect(warn.fix).toContain(`allow it in ${userPolicy} strays.allow`);
commit(repo, { 'newtop/thing.js': null, 'test/hang.test.js': 'setInterval(() => {}, 1000);\n' });
run(['--stall-timeout', '2']);
const stalled = check('lanes');
expect(stalled.code).toBe('PREGATE_STALLED');
expect(stalled.fix).toContain(`stall_timeout_s in ${userPolicy}; 0 disables`);
}, 60_000);
// Value: protects=a user path inside the checkout (GSTACK_HOME under the repo, or a relative state root when HOME is unset) is
// never read, so a PR cannot commit its own "user" policy; fails_when=userConfigRejection is dropped and the in-repo file
// becomes the policy; why_new=the trust check is new; seam=none (userConfigPath is an existing buildContext option);
// no_claim=a symlinked state root (the next test)
test('a user path inside the repository or relative is ignored, with an explain line', () => {
const { repo } = setup();
const inside = path.join(repo.work, '.gstack', 'projects', 'x', 'pregate.json');
fs.mkdirSync(path.dirname(inside), { recursive: true });
fs.writeFileSync(inside, JSON.stringify({ local: ['not-a-lane'] }));
for (const [file, why] of [[inside, 'inside the repository'], [path.join('.gstack', 'projects', 'x', 'pregate.json'), 'not an absolute path']] as const) {
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: false, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', userConfigPath: file });
if (!('ctx' in loaded)) throw new Error(`refused: ${loaded.refused.message}`);
expect(loaded.ctx.configLabel).not.toStartWith('user ');
expect(loaded.ctx.explain).toContain(`user policy: ${file} ignored (${why}${why === 'inside the repository' ? '; a PR could supply it' : ''})`);
}
}, 60_000);
// Value: protects=a base copy that exists but git show cannot read (over spawnSync's 1 MB buffer, or its blob missing)
// refuses instead of letting the user file replace it; fails_when=a git show failure is read as "no base copy" and the
// unreviewed user file governs a repo that commits its policy; why_new=the user-file fallback is new; seam=none;
// no_claim=timeouts (they take the same failure branch)
test('a base copy git show cannot read refuses rather than reading the user file', () => {
const { repo, userPolicy } = setupWithBase(`{"runner": "node --test {files}"${' '.repeat(1_200_000)}}\n`);
writePolicy(userPolicy, { local: LOCAL_ALL });
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: false, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', userConfigPath: userPolicy });
expect('refused' in loaded ? [loaded.refused.code, loaded.refused.message] : null).toEqual(['POLICY_SOURCE_UNAVAILABLE', 'origin/main:.gstack/pregate.json could not be read and git cannot confirm it is absent']);
}, 60_000);
// Value: protects=absence is decided from the tree, not the blob; fails_when=cat-file -e (a blob check) stands in for absence
// and a missing or corrupt blob lets the user file govern; why_new=the fallback is new; seam=none; no_claim=partial clones
test('a base copy whose blob is missing refuses rather than reading the user file', () => {
const { repo, userPolicy } = setupWithBase({ runner: 'node --test {files}' });
const blob = repo.git('rev-parse', 'origin/main:.gstack/pregate.json');
fs.rmSync(path.join(repo.work, '.git', 'objects', blob.slice(0, 2), blob.slice(2)));
writePolicy(userPolicy, { local: LOCAL_ALL });
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: false, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', userConfigPath: userPolicy });
expect('refused' in loaded ? loaded.refused.code : null).toBe('POLICY_SOURCE_UNAVAILABLE');
}, 60_000);
// Value: protects=the containment check sees through a symlinked state root and does not mistake an in-repo `..name`
// directory for a parent; fails_when=the realpath half or the `..`-segment test regresses and a file a PR committed is read
// as the user policy; why_new=both edges are new; seam=none; no_claim=case-only path differences (realpathSync.native)
test('a state root symlinked into the checkout, or under an in-repo ..name directory, is inside', () => {
const { repo } = setup();
fs.mkdirSync(path.join(repo.work, '.st'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'pregate-sr-'));
try {
const link = path.join(outside, 'root');
fs.symlinkSync(path.join(repo.work, '.st'), link);
expect(userConfigRejection(repo.work, path.join(link, 'projects', 'x', 'pregate.json'))).toContain('inside the repository');
expect(userConfigRejection(repo.work, path.join(repo.work, '..st', 'projects', 'x', 'pregate.json'))).toContain('inside the repository');
expect(userConfigRejection(repo.work, path.join(outside, 'projects', 'x', 'pregate.json'))).toBeNull();
} finally { fs.rmSync(outside, { recursive: true, force: true }); }
});
// Value: protects=a policy reached through the checkout is refused even when the last link points back outside it, so a
// branch cannot steer which existing out-of-repo policy governs it; fails_when=containment checks only the lexical path and
// the final target, and a state root or a link hop inside the checkout picks the file; why_new=security review of #3286;
// seam=none; no_claim=case-only path differences
test('a policy whose link chain passes through the checkout is inside, even when its final target is outside', () => {
const { repo } = setup();
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'pregate-chain-'));
try {
const permissive = path.join(outside, 'permissive.json');
fs.writeFileSync(permissive, '{}');
fs.mkdirSync(path.join(repo.work, '.st', 'projects', 'x'), { recursive: true });
fs.symlinkSync(permissive, path.join(repo.work, '.st', 'projects', 'x', 'pregate.json'));
fs.symlinkSync(path.join(repo.work, '.st'), path.join(outside, 'root'));
expect(userConfigRejection(repo.work, path.join(outside, 'root', 'projects', 'x', 'pregate.json'))).toContain('inside the repository');
fs.symlinkSync(permissive, path.join(repo.work, '.st', 'hop.json'));
fs.symlinkSync(path.join(repo.work, '.st', 'hop.json'), path.join(outside, 'leaf.json'));
expect(userConfigRejection(repo.work, path.join(outside, 'leaf.json'))).toContain('inside the repository');
fs.symlinkSync(permissive, path.join(outside, 'direct.json'));
expect(userConfigRejection(repo.work, path.join(outside, 'direct.json'))).toBeNull();
} finally { fs.rmSync(outside, { recursive: true, force: true }); }
});
// Value: protects=a defaults pass stops verifying once a link on the way to the absent user path dangles, the state a fresh
// run refuses; fails_when=verify checks only the leaf and reads the broken parent as continued absence; why_new=security
// review of #3286; seam=verifyPregate with the recorded tree passed in, so only the policy check differs; no_claim=the
// bin's verify wiring (the stale-policy test covers it)
test('verify goes stale when a parent of the absent user path becomes a dangling link', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pregate-dangle-'));
try {
const userPolicy = path.join(dir, 'projects', 's', 'pregate.json');
const record = { schema_version: 1, tree: 'T', checks: [], policy: 'defaults', policy_user_path: userPolicy, verdict: 'pass' } as any;
expect(verifyPregate(dir, record, { tree: 'T' }).ok).toBe(true);
fs.symlinkSync(path.join(dir, 'not-yet-cloned'), path.join(dir, 'projects'));
expect((loadConfigFromUser(userPolicy) as { errors: { code: string }[] } | null)?.errors.map(e => e.code)).toEqual(['PREGATE_CONFIG_INVALID']);
const v = verifyPregate(dir, record, { tree: 'T' });
expect([v.ok, v.code]).toEqual([false, 'PREGATE_STALE']);
expect(v.message).toContain(`cannot confirm ${userPolicy} is still absent`);
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
// Value: protects=--explain says when the user file was looked for and absent, and names a `local` id that matches no workflow lane;
// fails_when=either line is dropped and a typo'd slug or lane id silently does nothing; why_new=both lines are new; seam=none;
// no_claim=the line wording beyond its path and id
test('--explain names an absent user file and a local id that matches no workflow lane', () => {
const { userPolicy, run } = setup();
expect(run(['--explain']).out).toContain(`user policy: ${userPolicy} (absent)`);
writePolicy(userPolicy, { local: [...LOCAL_ALL, 'ci.yml/nope'] });
const r = run(['--explain']);
expect(r.out).toContain('local: ci.yml/nope matches no workflow lane; ignored');
expect(r.out).not.toContain('local: free-tests.yml/unit matches');
}, 60_000);
// Value: protects=with no GSTACK_PROJECT_SLUG the bin finds the user file under the slug gstack-slug resolves for the repo;
// fails_when=the bin computes the slug some other way (a different cwd or state root) and the documented path is never read;
// why_new=every other test pins GSTACK_PROJECT_SLUG; seam=none; no_claim=slug rules themselves (state-root tests cover them)
test('the default slug path is the one gstack-slug prints', () => {
const { repo } = setup();
const noSlug = { GSTACK_PROJECT_SLUG: '' };
const slug = /^SLUG=(.+)$/m.exec(runBin(repo, 'gstack-slug', [], noSlug).out)?.[1];
expect(slug).toBeTruthy();
const userPolicy = path.join(repo.home, 'projects', slug!, 'pregate.json');
fs.mkdirSync(path.dirname(userPolicy), { recursive: true });
writePolicy(userPolicy, { local: LOCAL_ALL });
expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests'], noSlug).out).toContain(`policy user ${userPolicy}`);
}, 60_000);
// Value: protects=a branch's committed .env cannot choose the slug or state root behind the user policy, whether pregate is
// launched through scriptInvocation, by its shebang, or as a bare `bun <bin>` (which refuses on any loaded dotenv file,
// since Bun's syntax admits `KEY: value` and bare-CR lines a key scan misses); fails_when=Bun's dotenv
// loading reaches slugFromEnvironment and a branch picks which pregate.json governs it; why_new=maintainer review of #3286;
// seam=none; no_claim=a .env in a parent of the working directory (Bun reads the launch directory only)
test('a committed .env cannot redirect the user policy', () => {
const { repo } = setup();
const env: Record<string, string | undefined> = { ...process.env, ...repo.env };
delete env.GSTACK_PROJECT_SLUG;
const slug = /^SLUG=(.+)$/m.exec(runScriptSync(BIN('gstack-slug'), [], { cwd: repo.work, encoding: 'utf8', env }).stdout)?.[1];
expect(slug).toBeTruthy();
const userPolicy = path.join(repo.home, 'projects', slug!, 'pregate.json');
fs.mkdirSync(path.dirname(userPolicy), { recursive: true });
writePolicy(userPolicy, { local: LOCAL_ALL });
commit(repo, { '.env': 'GSTACK_PROJECT_SLUG=elsewhere\n' });
const args = ['--base', 'main', '--allow-repo-commands', '--stage', 'tests'];
expect(runScriptSync(BIN('gstack-pregate'), args, { cwd: repo.work, encoding: 'utf8', env, timeout: 120_000 }).stdout).toContain(`policy user ${userPolicy}`);
if (process.platform !== 'win32') expect(spawnSync(BIN('gstack-pregate'), args, { cwd: repo.work, encoding: 'utf8', env, timeout: 120_000 }).stdout).toContain(`policy user ${userPolicy}`);
const bare = spawnSync(process.execPath, [BIN('gstack-pregate'), ...args], { cwd: repo.work, encoding: 'utf8', env, timeout: 120_000 });
expect(bare.status).toBe(EXIT.refused);
expect(bare.stderr).toContain('POLICY_SOURCE_UNAVAILABLE');
expect(bare.stderr).toContain('bun loaded .env from the working directory');
}, 120_000);
// Value: protects=a recorded pass stops verifying once the user-owned policy behind it is edited or deleted, or once a user
// file appears where the run used the defaults; fails_when=verify checks only the tree and a weakened or removed policy
// keeps an old pass publishable; why_new=maintainer review of #3286; seam=none; no_claim=a base-branch copy that changes
// (pinned by the recorded base, not re-read); both paths come from the record, so the verifier's environment is not read
test('verify goes stale when the user-owned policy changes, disappears, or appears after a defaults run', () => {
const { repo, userPolicy, run } = setup();
const verify = () => JSON.parse(runBin(repo, 'gstack-pregate', ['verify', '--json'], { GSTACK_PROJECT_SLUG: SLUG }).out);
writePolicy(userPolicy, { local: LOCAL_ALL });
expect(run().status).toBe(EXIT.ok);
expect(verify().ok).toBe(true);
writePolicy(userPolicy, { local: ['free-tests.yml/unit'] });
expect([verify().code, verify().message]).toEqual(['PREGATE_STALE', `the user-owned policy ${userPolicy} changed after pregate.json was recorded; rerun gstack-pregate`]);
fs.rmSync(userPolicy);
expect(verify().message).toContain('is gone or unreadable');
run();
expect(verify().code).toBe('PREGATE_REMOTE_UNCLEARED');
writePolicy(userPolicy, { local: LOCAL_ALL });
expect([verify().code, verify().message]).toEqual(['PREGATE_STALE', `${userPolicy} now holds a user-owned policy, and pregate.json was recorded under the defaults; rerun gstack-pregate`]);
}, 120_000);
});
+17 -14
View File
@@ -13,6 +13,7 @@ import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { spawnSync } from "child_process";
import { gitEnv } from "./helpers/git-env";
const PREPUSH = path.resolve(import.meta.dir, "..", "bin", "gstack-redact-prepush");
const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact");
@@ -21,7 +22,7 @@ let repo: string;
let stateHome: string;
function git(args: string[], cwd = repo): string {
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000 });
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() });
return r.stdout?.trim() ?? "";
}
@@ -40,7 +41,7 @@ function runHook(
cwd: repo,
input: Buffer.from(stdinLines),
encoding: "utf8",
env: { ...process.env, ...env },
env: gitEnv(env),
timeout: 30_000,
});
return { code: r.status ?? 0, stderr: r.stderr ?? "" };
@@ -439,7 +440,7 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => {
const r = spawnSync("bash", ["-c", script], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"], // stdin not a TTY
env: { ...process.env, GSTACK_HOME: home },
env: gitEnv({ GSTACK_HOME: home }),
timeout: 15_000,
});
expect(r.status).toBe(0);
@@ -467,7 +468,7 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => {
const r = spawnSync("bash", ["-c", script], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, GSTACK_HOME: home },
env: gitEnv({ GSTACK_HOME: home }),
timeout: 15_000,
});
expect(r.status).toBe(0);
@@ -502,7 +503,7 @@ describe("install / chaining", () => {
const existing = path.join(hookDir, "pre-push");
fs.writeFileSync(existing, "#!/usr/bin/env bash\necho mine\n", { mode: 0o755 });
const r = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const r = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(r.status).toBe(0);
const installed = fs.readFileSync(existing, "utf8");
expect(installed).toContain("gstack-redact pre-push (managed)");
@@ -516,7 +517,7 @@ describe("install / chaining", () => {
test("chained pre-push.local receives the final ref line (trailing newline preserved)", () => {
const hookDir = path.join(repo, ".git", "hooks");
fs.mkdirSync(hookDir, { recursive: true });
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 });
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() });
const seen = path.join(repo, "seen.txt");
fs.writeFileSync(
@@ -531,7 +532,7 @@ describe("install / chaining", () => {
cwd: repo,
input: Buffer.from(line),
encoding: "utf8",
env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome },
env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }),
timeout: 30_000,
});
expect(r.status).toBe(0);
@@ -545,7 +546,7 @@ describe("install / chaining", () => {
test("a blocking pre-push.local still short-circuits the push", () => {
const hookDir = path.join(repo, ".git", "hooks");
fs.mkdirSync(hookDir, { recursive: true });
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 });
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() });
fs.writeFileSync(
path.join(hookDir, "pre-push.local"),
"#!/usr/bin/env bash\nwhile read -r _a _b _c _d || [ -n \"${_a:-}\" ]; do exit 1; done\nexit 0\n",
@@ -555,7 +556,7 @@ describe("install / chaining", () => {
cwd: repo,
input: Buffer.from(`refs/heads/main ${"b".repeat(40)} refs/heads/main ${ZERO}\n`),
encoding: "utf8",
env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome },
env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }),
timeout: 30_000,
});
expect(r.status).toBe(1);
@@ -603,6 +604,7 @@ describe("install / chaining", () => {
timeout: 30_000,
cwd: repo,
encoding: "utf8",
env: gitEnv(),
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("refreshed stale managed pre-push hook");
@@ -618,7 +620,7 @@ describe("install / chaining", () => {
cwd: repo,
input: Buffer.from(`refs/heads/main ${sha} refs/heads/main ${ZERO}\n`),
encoding: "utf8",
env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome },
env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }),
});
expect(run.status).toBe(0);
expect(fs.readFileSync(path.join(stateHome, "security", "prepush-skip.jsonl"), "utf8")).toContain("env-skip");
@@ -630,13 +632,14 @@ describe("install / chaining", () => {
fs.mkdirSync(hookDir, { recursive: true });
const hook = path.join(hookDir, "pre-push");
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo });
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, env: gitEnv() });
const first = fs.readFileSync(hook, "utf8");
const stamp = fs.statSync(hook).mtimeMs;
const again = spawnSync("bun", [REDACT, "install-prepush-hook"], {
cwd: repo,
encoding: "utf8",
env: gitEnv(),
});
expect(again.status).toBe(0);
expect(again.stdout).toContain("already installed");
@@ -651,8 +654,8 @@ describe("install / chaining", () => {
fs.writeFileSync(path.join(hookDir, "pre-push"), "#!/usr/bin/env bash\necho mine\n", {
mode: 0o755,
});
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 });
spawnSync("bun", [REDACT, "uninstall-prepush-hook"], { cwd: repo, timeout: 30_000 });
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() });
spawnSync("bun", [REDACT, "uninstall-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() });
const restored = fs.readFileSync(path.join(hookDir, "pre-push"), "utf8");
expect(restored).toContain("echo mine");
expect(restored).not.toContain("managed");
@@ -671,7 +674,7 @@ describe("base resolution when the default branch is neither main nor master", (
// blocks the push having scanned NOTHING — the "scans more, never less"
// fallback inverting into "scans nothing".
const bare = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-remote-"));
spawnSync("git", ["init", "-q", "--bare", "-b", "trunk", bare], { timeout: 30_000 });
spawnSync("git", ["init", "-q", "--bare", "-b", "trunk", bare], { timeout: 30_000, env: gitEnv() });
git(["branch", "-M", "trunk"]);
const old = commit("legacy.txt", FAKE_AWS_KEY + "\n", "secret already on the remote");
+1 -11
View File
@@ -31,23 +31,13 @@ import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { spawnSync } from "child_process";
import { gitEnv } from "./helpers/git-env";
const PREPUSH = path.resolve(import.meta.dir, "..", "bin", "gstack-redact-prepush");
let repo: string;
let remote: string;
// Same blanking as test/redact-prepush-scan-range.test.ts (#2967). A
// hooksPath written into the fixture repo still runs.
function gitEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env };
env.GIT_CONFIG_GLOBAL = "/dev/null";
env.GIT_CONFIG_NOSYSTEM = "1";
env.GIT_CONFIG_COUNT = "0";
delete env.GIT_CONFIG_PARAMETERS;
return env;
}
function git(args: string[], cwd = repo): string {
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() });
if (r.status !== 0) throw new Error(`git ${args.join(" ")}\n${r.stderr}`);
+1 -15
View File
@@ -17,25 +17,11 @@
*/
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { spawnSync } from "child_process";
import { gitEnv } from "./helpers/git-env";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { dirname, join } from "path";
// Fixture git was spawned with the parent environment. A global or system
// core.hooksPath, or the same key via GIT_CONFIG_COUNT /
// GIT_CONFIG_PARAMETERS, runs on these pushes and rejects the local remote
// (#2967). /dev/null drops the global file on every platform: Git for
// Windows rejects NUL (see EMPTY_GLOBAL_GIT_CONFIG in lib/cso/process.ts).
// Config written inside the fixture repo still applies.
function gitEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env };
env.GIT_CONFIG_GLOBAL = "/dev/null";
env.GIT_CONFIG_NOSYSTEM = "1";
env.GIT_CONFIG_COUNT = "0";
delete env.GIT_CONFIG_PARAMETERS;
return env;
}
let dir: string;
const run = (args: string[], cwd = dir): string => {
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() });
+70 -31
View File
@@ -4,13 +4,14 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { scan } from "../lib/redact-engine";
import { gitEnv } from "./helpers/git-env";
const REDACT = path.resolve(import.meta.dir, "../bin/gstack-redact");
const roots: string[] = [];
const key = ["AKIA", "1234567890ABCDEF"].join("");
function git(cwd: string, ...args: string[]): string {
const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000 });
const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() });
if (result.status !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr}`);
return result.stdout.trim();
}
@@ -35,7 +36,7 @@ function fixture(initialSecret = true, sha256 = false): { repo: string; origin:
git(repo, "remote", "add", "origin", origin);
git(repo, "push", "-q", "-u", "origin", "main");
git(repo, "remote", "add", "publish", publish);
const install = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const install = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(install.status).toBe(0);
return { repo, origin, publish, head };
}
@@ -58,11 +59,49 @@ afterEach(() => {
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});
describe("fixture installs ignore the developer's hook config (#3353)", () => {
test("a global, system or environment core.hooksPath is neither rewritten nor run", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-target-global-"));
roots.push(root);
const hooks = path.join(root, "global-hooks");
fs.mkdirSync(hooks);
const original = "#!/bin/sh\necho developer-hook >&2\nexit 1\n";
fs.writeFileSync(path.join(hooks, "pre-push"), original, { mode: 0o755 });
const config = path.join(root, "gitconfig");
fs.writeFileSync(config, `[core]\n\thooksPath = ${JSON.stringify(hooks)}\n`);
const keys = ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM", "GIT_CONFIG_COUNT",
"GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0", "GIT_CONFIG_PARAMETERS"] as const;
const prev = Object.fromEntries(keys.map(key => [key, process.env[key]]));
try {
process.env.GIT_CONFIG_GLOBAL = config;
process.env.GIT_CONFIG_SYSTEM = config;
delete process.env.GIT_CONFIG_NOSYSTEM;
process.env.GIT_CONFIG_COUNT = "1";
process.env.GIT_CONFIG_KEY_0 = "core.hooksPath";
process.env.GIT_CONFIG_VALUE_0 = hooks;
process.env.GIT_CONFIG_PARAMETERS = `'core.hooksPath=${hooks}'`;
const { repo } = fixture(false);
expect(fs.readdirSync(hooks)).toEqual(["pre-push"]);
expect(fs.readFileSync(path.join(hooks, "pre-push"), "utf8")).toBe(original);
const installed = git(repo, "rev-parse", "--path-format=absolute", "--git-path", "hooks/pre-push");
expect(fs.readFileSync(installed, "utf8")).toContain("gstack-redact pre-push (managed)");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status, push.stderr).toBe(0);
expect(push.stderr).not.toContain("developer-hook");
} finally {
for (const [key, value] of Object.entries(prev)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
});
describe("installed pre-push guard uses the actual destination", () => {
test("origin tracking at HEAD cannot excuse the same credential on first push to publish", () => {
const { repo, publish, head } = fixture();
expect(git(repo, "rev-parse", "origin/main")).toBe(head);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
@@ -71,7 +110,7 @@ describe("installed pre-push guard uses the actual destination", () => {
test("a configured remote's different push URL cannot reuse its fetch tracking tip", () => {
const { repo, publish } = fixture();
git(repo, "remote", "set-url", "--push", "origin", publish);
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
@@ -92,7 +131,7 @@ describe("installed pre-push guard uses the actual destination", () => {
git(repo, "update-ref", "refs/heads/publish/main", tip);
expect(git(repo, "rev-parse", "publish/main")).toBe(tip);
expect(git(repo, "rev-parse", "refs/remotes/publish/main")).toBe(base);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
@@ -108,7 +147,7 @@ describe("installed pre-push guard uses the actual destination", () => {
expect(git(repo, "rev-parse", "refs/remotes/publish/main")).toBe(git(repo, "rev-parse", "HEAD"));
git(repo, "remote", "set-url", "--add", "publish", second);
expect(git(repo, "remote", "get-url", "publish")).toBe(publish);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(second, "for-each-ref", "--format=%(refname)")).toBe("");
@@ -116,7 +155,7 @@ describe("installed pre-push guard uses the actual destination", () => {
test("direct URL push cannot borrow origin's tracking history", () => {
const { repo, publish } = fixture();
const push = spawnSync("git", ["push", publish, "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", publish, "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
@@ -125,8 +164,8 @@ describe("installed pre-push guard uses the actual destination", () => {
test("an advertised remote tip absent from local objects cannot justify a guessed base", () => {
const { repo, publish } = fixture();
const remoteTip = seedIndependentRemote(repo, publish);
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000 }).status).not.toBe(0);
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000, env: gitEnv() }).status).not.toBe(0);
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "rev-parse", "refs/heads/main")).toBe(remoteTip);
@@ -135,21 +174,21 @@ describe("installed pre-push guard uses the actual destination", () => {
test("an absent advertised tip permits a clean forced update after conservative scanning", () => {
const { repo, publish, head } = fixture(false);
const remoteTip = seedIndependentRemote(repo, publish);
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000 }).status).not.toBe(0);
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000, env: gitEnv() }).status).not.toBe(0);
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(git(publish, "rev-parse", "refs/heads/main")).toBe(head);
});
test("new SHA-256 branch scans the correct empty tree and allows a clean push", () => {
const { repo, publish } = fixture(false, true);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(git(publish, "rev-parse", "refs/heads/main")).toHaveLength(64);
fs.writeFileSync(path.join(repo, "leak.txt"), `key ${key}\n`);
git(repo, "add", "leak.txt");
git(repo, "commit", "-qm", "new secret");
const blocked = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const blocked = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(blocked.status).toBe(1);
expect(blocked.stderr).toContain("aws.access_key");
});
@@ -164,7 +203,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "clean.txt"), "safe update\n");
git(repo, "add", "clean.txt");
git(repo, "commit", "-qm", "clean branch");
const push = spawnSync("git", ["push", "origin", "main", "leak"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main", "leak"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
@@ -183,7 +222,7 @@ describe("installed pre-push guard uses the actual destination", () => {
git(repo, "add", "clean.txt");
git(repo, "commit", "-qm", "clean update");
const head = git(repo, "rev-parse", "HEAD");
const push = spawnSync("git", ["push", "origin", "main", ":disposable"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main", ":disposable"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
expect(git(origin, "for-each-ref", "--format=%(refname)", "refs/heads/disposable")).toBe("");
@@ -193,7 +232,7 @@ describe("installed pre-push guard uses the actual destination", () => {
const { repo, origin, head } = fixture(false);
const hook = git(repo, "rev-parse", "--git-path", "hooks/pre-push");
const result = spawnSync("bash", [hook, "origin", origin], {
cwd: repo, input: "refs/heads/main malformed\n", encoding: "utf8", timeout: 30_000,
cwd: repo, input: "refs/heads/main malformed\n", encoding: "utf8", timeout: 30_000, env: gitEnv(),
});
expect(result.status, result.stderr || result.error?.message).toBe(1);
expect(result.stderr).toContain("could not parse a pre-push ref line");
@@ -208,7 +247,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "near seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.secret_key");
});
@@ -221,7 +260,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "unlabeled seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("aws.secret_key");
});
@@ -232,7 +271,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "one finding at seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr.match(/HIGH aws\.access_key/g)).toHaveLength(1);
});
@@ -247,7 +286,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "supplementary normalization seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr.match(/HIGH aws\.access_key/g)).toHaveLength(1);
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
@@ -264,7 +303,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "mid-line control");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("MEDIUM finding");
});
@@ -277,7 +316,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "embedded key control");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("aws.access_key");
expect(git(origin, "rev-parse", "refs/heads/main")).not.toBe(head);
@@ -294,7 +333,7 @@ describe("installed pre-push guard uses the actual destination", () => {
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "long bearer advisory");
const tip = git(repo, "rev-parse", "HEAD");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(push.stderr).toContain("MEDIUM finding");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(tip);
@@ -308,7 +347,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "invisible seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.secret_key");
});
@@ -320,7 +359,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "unscannable context");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("engine.input_too_large");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
@@ -339,7 +378,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "normalization seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(expected ? 1 : 0);
expect(push.stderr.includes("aws.secret_key")).toBe(expected);
});
@@ -350,19 +389,19 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "large.txt"), "ordinary content\n".repeat(160_000));
git(repo, "add", "large.txt");
git(repo, "commit", "-qm", "large clean diff");
let push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
let push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() });
expect(push.status).toBe(0);
fs.writeFileSync(path.join(repo, "long.txt"), "x".repeat(900_000) + "\n");
git(repo, "add", "long.txt");
git(repo, "commit", "-qm", "long clean line");
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() });
expect(push.status).toBe(0);
const priorTip = git(origin, "rev-parse", "refs/heads/main");
expect(priorTip).not.toBe(head);
fs.writeFileSync(path.join(repo, "too-long.txt"), "x".repeat(1_100_000) + "\n");
git(repo, "add", "too-long.txt");
git(repo, "commit", "-qm", "over cap line");
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("engine.input_too_large");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(priorTip);
@@ -373,7 +412,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "long.txt"), `${"x".repeat(900_000)} key ${key}\n`);
git(repo, "add", "long.txt");
git(repo, "commit", "-qm", "long line with key");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
@@ -385,7 +424,7 @@ describe("installed pre-push guard uses the actual destination", () => {
git(repo, "add", "long.txt");
git(repo, "commit", "-qm", "neighboring long lines");
const tip = git(repo, "rev-parse", "HEAD");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("engine.input_too_large");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(tip);