Files
gstack/test/gbrain-repo-policy.test.ts
T
01593aa67c v1.91.2.0 fix: consolidate gstack reliability wave (#2959)
* fix(memory-ingest): --scan-secrets scans the rendered page and fails closed

--scan-secrets ran gitleaks on the raw transcript .jsonl, then imported a
page rendered from it. gitleaks' assignment rules don't match across a
JSON-escaped quote (KEY=\"v\" on disk), so a secret the rendered page
shows as KEY="v" was imported unflagged. And the gate skipped a file only
on scanner "gitleaks" with findings, so a scan that errored (non-zero
exit, 16MB maxBuffer overflow on a file with many findings, unparseable
report) or could not run (gitleaks missing, slow-probe cooldown) imported
the file unscanned.

Scan the rendered page body, the exact bytes writeStaged() writes, via a
new secretScanText() helper, and skip the file whenever the scan did not
complete. Skipped files stay out of the state file, so the next run
retries them. Reword the helper warnings and setup-gbrain/memory.md,
which described the fail-open as intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(test): reconcile Bun failure markers and footer counts

* fix(sync-gbrain): verify source-scoped reads without mutation

* fix(test): recognize grounded TTHW target choices structurally

* fix(aside): make the readiness probe work under zsh and report why it failed

The probe built its deadline into `_T` and expanded it unquoted, so
`$_T aside repl …` only worked in a shell that word-splits. zsh does not: it
looked for a command literally named "gtimeout 30", the probe answered
ASIDE_NOT_RUNNING with Aside installed and ready, and every browsing skill
fell back to the bundled Chromium in silence. zsh is the macOS default and
Aside is macOS-only, so on a stock Mac the probe could never report READY.

The deadline becomes a function, `_gs_d`. It receives the command as "$@",
already split, so sh, bash and zsh all behave the same, and the gtimeout →
timeout → perl alarm chain is unchanged. A 4th arm runs the call unbounded
when none of the three is present, which is what the empty `_T` did before.
Not `eval`: it re-parses the string, so the parens and `;` of the perl arm
become syntax and that arm dies in bash *and* zsh — on a stock Mac, the arm
that actually runs.

On failure the probe now prints the CLI's reason after ASIDE_NOT_RUNNING:,
the shape gstack-render already uses: the first line that starts with a
capital letter, i.e. the CLI's own sentence or Node's `Error:` line below its
loader frame. "Not running" covers states with different fixes — no window
open for the profile, a NODE_OPTIONS preload that kills the CLI — and a bare
verdict sent all of them to "open the Aside app". The BROWSER SETUP prose
quotes that reason before asking the user to open the app.

The text pin asserted the broken invocation verbatim, so it now pins the
function and asserts neither `$_T aside repl` nor an eval form comes back. A
second test executes the rendered probe in sh, bash and zsh on each of the
four deadline arms with stubbed binaries on a narrowed PATH, plus two failing
CLIs: one that prints its own sentence, one that crashes like Node with the
useful line below the frame.

The deadline function costs zero bytes against the lines it replaces; the
reason costs 53 per copy of the probe (44 where the reworded BROWSER SETUP
line gives 9 back). That moves four guards by the measured amount:
plan-devex-review's skeleton cap to 68,550 (measured 68,544), plan-ceo-review's
skeleton cap to 80,150 (measured 80,111) and union ratio to 1.081 (measured
1.0803), and plan-eng-review's union ratio to 1.151 (measured 1.1504).

Fixes #2842, #2941.

* Clarify engineering review startup and decision flow

* Fix Windows readiness fixture PATH and command shim

* fix(test): recognize grounded TTHW target choices structurally

* Clarify engineering review startup and decision flow

* fix(test): restrict QA-only fixture tools to its no-Edit contract

* v1.90.0.0 fix(sync-gbrain): guard readiness verdicts and refresh metadata

* fix(browse): validate canonical upload targets

* fix(gbrain): classify structured PGLite busy response

* fix(browse): preserve native extension runtime APIs

* Fix displayless browser handoff ownership

* Accept unique installed autoplan methodology aliases

* fix(skills): preserve positional literals during installation

* fix(browse): checksum installer contents through stdin

* fix(test): normalize Windows checksum fixture paths

* test: emulate unavailable shasum in Windows checksum fixture

* fix(investigate): preserve owned freeze lifecycle

* fix(review): preserve N+1 retry and Red Team completion

* fix: bound Aside readiness and preserve safe fallback

* test: exercise setup and Chromium on native ARM

* fix: preserve install ownership and ARM browser selection

* Fix gbrain ingest scan boundaries and seed observation

* Refresh managed ship hooks and supervise expanded paid census

* Reject resumed gbrain pages excluded by current policy

* Recover zombie agent locks safely and enable CI Python venv

* Repair paid actor declarations and Aside pitch assertions

* Bump consolidated wave to next free minor release

* Clarify CEO review admin choices and option tradeoffs

* Preserve CEO mode handoff anchors in clarified workflow

* Make Windows portability fixtures use shell-native paths

* Restore ARM Bun alias and clarify ship review gates

* Refresh ship workflow golden snapshots

* Fix Windows DX documentation controls without piped stdin

* Decode Codex child pipes without Bun's encoded-stream stall

* Bound DX pre-review audit before product questions

* Clarify trusted review-start read in paid revalidation

* Bump consolidated wave to next free minor release

* Clarify CEO review admin choices and option tradeoffs

* Preserve CEO mode handoff anchors in clarified workflow

* Make Windows portability fixtures use shell-native paths

* Restore ARM Bun alias and clarify ship review gates

* Refresh ship workflow golden snapshots

* Fix Windows DX documentation controls without piped stdin

* Decode Codex child pipes without Bun's encoded-stream stall

* Bound DX pre-review audit before product questions

* Clarify trusted review-start read in paid revalidation

* Reconcile new main planning flow and paid judge census

* fix: reconcile rebased planning and source-bound validation

* test: pin cookie workflow judge to scored Sonnet model

* fix: keep terminal agent boot out of module imports

* fix: preserve pending-question uncertainty in engineering review

* fix: stabilize Windows reliability-wave fixtures

* fix: clarify design consultation research workflow

* fix: preserve independent design consultation inputs

* fix: resolve design taste scope and browser research guidance

* fix: make consultation opt-in preflight unambiguous

* test: await native Edge owner readiness or terminal result

---------

Co-authored-by: Bruce Krysiak <brucek@alum.mit.edu>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Antonio Vitalic <antoninte99@gmail.com>
2026-09-26 18:57:53 -04:00

421 lines
16 KiB
TypeScript

/**
* gstack-gbrain-repo-policy — per-remote trust-tier policy store.
*
* Covers the setup-gbrain D3/D2-eng decisions end-to-end:
* - D3 triad semantics (read-write / read-only / deny / unset)
* - Remote-URL normalization (ssh/https/shorthand all collapse to the same key)
* - D2-eng schema-version field (_schema_version: 2) written on new files
* - Legacy `allow` → `read-write` migration, one-shot, idempotent
* - Atomic writes (tmpfile + rename; no partial files visible)
* - Corrupt-file quarantine (file renamed to .corrupt-<ts>, fresh file created)
* - 0600 permissions on the policy file
*
* Each test uses a temp GSTACK_HOME so nothing leaks into the user's real ~/.gstack.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { canRevokeReads } from './helpers/fs-caps';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-gbrain-repo-policy');
let tmpHome: string;
function isolateGitRemote(repo: string, url: string): void {
const git = (...args: string[]) => {
const result = spawnSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 10_000 });
expect(result.status).toBe(0);
return result.stdout.trim();
};
expect(git('config', '--get', 'remote.origin.url')).toBe(url);
git('config', '--local', `url.${url}.insteadOf`, url);
expect(git('remote', 'get-url', 'origin')).toBe(url);
}
function run(args: string[], opts: { env?: Record<string, string> } = {}) {
const res = spawnSync(BIN, args, {
env: { ...process.env, GSTACK_HOME: tmpHome, ...(opts.env || {}) },
encoding: 'utf-8',
timeout: 30_000,
});
return {
stdout: (res.stdout || '').trim(),
stderr: (res.stderr || '').trim(),
status: res.status ?? -1,
};
}
function policyFile(): string {
return path.join(tmpHome, 'gbrain-repo-policy.json');
}
function readPolicy(): any {
return JSON.parse(fs.readFileSync(policyFile(), 'utf-8'));
}
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-policy-'));
});
afterEach(() => {
fs.rmSync(tmpHome, { recursive: true, force: true });
});
test('fixture origin isolation overrides a controlled URL rewrite without changing the stored remote', () => {
const repo = path.join(tmpHome, 'repo');
fs.mkdirSync(repo);
const git = (...args: string[]) => {
const result = spawnSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 10_000 });
expect(result.status).toBe(0);
return result.stdout.trim();
};
const url = 'https://fixture.invalid/org/repo.git';
git('init', '-q');
git('remote', 'add', 'origin', url);
git('config', '--local', 'url.https://mirror.invalid/.insteadOf', 'https://fixture.invalid/');
expect(git('config', '--get', 'remote.origin.url')).toBe(url);
expect(git('remote', 'get-url', 'origin')).toBe('https://mirror.invalid/org/repo.git');
isolateGitRemote(repo, url);
expect(git('config', '--get', 'remote.origin.url')).toBe(url);
expect(git('remote', 'get-url', 'origin')).toBe(url);
});
describe('normalize', () => {
test('strips https:// and .git', () => {
const r = run(['normalize', 'https://github.com/foo/bar.git']);
expect(r.status).toBe(0);
expect(r.stdout).toBe('github.com/foo/bar');
});
test('plain https without .git', () => {
const r = run(['normalize', 'https://github.com/foo/bar']);
expect(r.stdout).toBe('github.com/foo/bar');
});
test('ssh shorthand git@host:path collapses to the same key', () => {
const r = run(['normalize', 'git@github.com:foo/bar.git']);
expect(r.stdout).toBe('github.com/foo/bar');
});
test('ssh:// URL form collapses to the same key', () => {
const r = run(['normalize', 'ssh://git@github.com/foo/bar.git']);
expect(r.stdout).toBe('github.com/foo/bar');
});
test('uppercase hostname and path are lowercased', () => {
const r = run(['normalize', 'HTTPS://GITHUB.COM/FOO/BAR']);
expect(r.stdout).toBe('github.com/foo/bar');
});
test('gitlab subgroups preserved (ssh shorthand)', () => {
const r = run(['normalize', 'git@gitlab.com:group/subgroup/project.git']);
expect(r.stdout).toBe('gitlab.com/group/subgroup/project');
});
test('custom gitlab host with https', () => {
const r = run(['normalize', 'https://gitlab.example.com/group/project']);
expect(r.stdout).toBe('gitlab.example.com/group/project');
});
test('all variants collapse to a single key', () => {
const forms = [
'https://github.com/Foo/Bar.git',
'https://github.com/foo/bar',
'git@github.com:foo/bar.git',
'ssh://git@github.com/foo/bar.git',
'HTTPS://GITHUB.COM/FOO/BAR',
];
const keys = forms.map((f) => run(['normalize', f]).stdout);
expect(new Set(keys).size).toBe(1);
expect(keys[0]).toBe('github.com/foo/bar');
});
});
describe('set + get', () => {
test('set persists the tier and get returns it', () => {
const s = run(['set', 'https://github.com/foo/bar.git', 'read-write']);
expect(s.status).toBe(0);
const g = run(['get', 'https://github.com/foo/bar']);
expect(g.status).toBe(0);
expect(g.stdout).toBe('read-write');
});
test('all three tier values accepted', () => {
run(['set', 'https://github.com/a/a', 'read-write']);
run(['set', 'https://github.com/b/b', 'read-only']);
run(['set', 'https://github.com/c/c', 'deny']);
expect(run(['get', 'https://github.com/a/a']).stdout).toBe('read-write');
expect(run(['get', 'https://github.com/b/b']).stdout).toBe('read-only');
expect(run(['get', 'https://github.com/c/c']).stdout).toBe('deny');
});
test('invalid tier rejected with non-zero exit', () => {
const r = run(['set', 'https://github.com/foo/bar', 'allow']);
expect(r.status).not.toBe(0);
expect(r.stderr.toLowerCase()).toContain('invalid tier');
});
test('get for unset remote returns literal unset', () => {
run(['set', 'https://github.com/foo/bar', 'read-write']);
const r = run(['get', 'https://github.com/baz/qux']);
expect(r.stdout).toBe('unset');
});
test('ssh-set then https-get returns the same tier', () => {
run(['set', 'git@github.com:foo/bar.git', 'deny']);
const r = run(['get', 'https://github.com/foo/bar']);
expect(r.stdout).toBe('deny');
});
});
describe('file format + schema version', () => {
test('_schema_version: 2 added on fresh file creation', () => {
run(['set', 'https://github.com/foo/bar', 'read-write']);
expect(readPolicy()._schema_version).toBe(2);
});
test('policy file mode is 0600', () => {
run(['set', 'https://github.com/foo/bar', 'read-write']);
const mode = fs.statSync(policyFile()).mode & 0o777;
expect(mode).toBe(0o600);
});
test('re-running set does not duplicate schema version or entries', () => {
run(['set', 'https://github.com/foo/bar', 'read-write']);
run(['set', 'https://github.com/foo/bar', 'deny']);
const p = readPolicy();
expect(p._schema_version).toBe(2);
expect(p['github.com/foo/bar']).toBe('deny');
// Only the schema version + the one entry
expect(Object.keys(p).length).toBe(2);
});
});
describe('legacy migration (D3 allow → read-write)', () => {
test('legacy allow value is rewritten to read-write on first read', () => {
fs.writeFileSync(
policyFile(),
JSON.stringify({ 'github.com/foo/bar': 'allow' }),
{ mode: 0o600 }
);
const r = run(['get', 'https://github.com/foo/bar']);
expect(r.stdout).toBe('read-write');
expect(r.stderr).toContain('Migrated 1 legacy allow entries');
const p = readPolicy();
expect(p['github.com/foo/bar']).toBe('read-write');
expect(p._schema_version).toBe(2);
});
test('migration preserves deny entries unchanged', () => {
fs.writeFileSync(
policyFile(),
JSON.stringify({ 'github.com/foo/bar': 'allow', 'github.com/baz/qux': 'deny' }),
{ mode: 0o600 }
);
run(['get', 'https://github.com/foo/bar']);
const p = readPolicy();
expect(p['github.com/foo/bar']).toBe('read-write');
expect(p['github.com/baz/qux']).toBe('deny');
});
test('migration is idempotent — second run is a no-op', () => {
fs.writeFileSync(
policyFile(),
JSON.stringify({ 'github.com/foo/bar': 'allow' }),
{ mode: 0o600 }
);
const first = run(['get', 'https://github.com/foo/bar']);
expect(first.stderr).toContain('Migrated 1');
const second = run(['get', 'https://github.com/foo/bar']);
expect(second.stderr).not.toContain('Migrated');
expect(second.stdout).toBe('read-write');
});
test('already-v2 file is not re-migrated', () => {
fs.writeFileSync(
policyFile(),
JSON.stringify({ _schema_version: 2, 'github.com/foo/bar': 'read-write' }),
{ mode: 0o600 }
);
const r = run(['get', 'https://github.com/foo/bar']);
expect(r.stderr).not.toContain('Migrated');
expect(r.stdout).toBe('read-write');
});
});
describe('corrupt-file handling', () => {
test('unparseable JSON is quarantined and a fresh file is started', () => {
fs.writeFileSync(policyFile(), 'not valid json{', { mode: 0o600 });
const r = run(['get', 'https://github.com/foo/bar']);
expect(r.status).toBe(0);
expect(r.stdout).toBe('unset');
expect(r.stderr).toContain('corrupt policy file quarantined');
// New file exists, is valid, and has schema version
const p = readPolicy();
expect(p._schema_version).toBe(2);
// Quarantine file exists
const quarantine = fs.readdirSync(tmpHome).find((f) =>
f.startsWith('gbrain-repo-policy.json.corrupt-')
);
expect(quarantine).toBeDefined();
});
});
describe('list', () => {
test('list prints entries sorted, excludes _schema_version', () => {
run(['set', 'https://github.com/zebra/zz', 'deny']);
run(['set', 'https://github.com/apple/aa', 'read-write']);
run(['set', 'https://github.com/middle/mm', 'read-only']);
const r = run(['list']);
const lines = r.stdout.split('\n');
expect(lines.length).toBe(3);
expect(lines[0]).toBe('github.com/apple/aa\tread-write');
expect(lines[1]).toBe('github.com/middle/mm\tread-only');
expect(lines[2]).toBe('github.com/zebra/zz\tdeny');
});
test('list on missing file returns empty, no file created', () => {
const r = run(['list']);
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
expect(fs.existsSync(policyFile())).toBe(false);
});
});
describe('get without arg (auto-detect from current dir)', () => {
test('returns unset when not in a git repo', () => {
const cwdTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'no-git-'));
try {
const res = spawnSync(BIN, ['get'], {
env: { ...process.env, GSTACK_HOME: tmpHome },
cwd: cwdTmp,
encoding: 'utf-8',
timeout: 30_000,
});
expect((res.stdout || '').trim()).toBe('unset');
} finally {
fs.rmSync(cwdTmp, { recursive: true, force: true });
}
});
});
// ── #2140 sync-path chokepoint ──────────────────────────────────────────────
// The tier above is a STORE. This block pins the ENFORCEMENT: a direct
// gstack-gbrain-sync invocation (skill prose bypassed — cron, curiosity,
// automation) must honor deny/read-only at the code-import stage, and the
// egress receipt's "per-repo policy chokepoint (repoPolicyTier)" consent
// string must describe code that exists. Wave-1 shipped the receipt string
// without the function; these tests make that impossible to repeat.
describe('gstack-gbrain-sync code stage honors the repo policy (#2140 sync path)', () => {
const SYNC = path.join(ROOT, 'bin', 'gstack-gbrain-sync.ts');
const REPO_URL = 'https://github.com/acme/widget.git';
let repoDir: string;
function makeRepo(): void {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-policy-repo-'));
const git = (...args: string[]) =>
spawnSync('git', args, { cwd: repoDir, encoding: 'utf-8', timeout: 30_000 });
git('init', '-q', '.');
git('remote', 'add', 'origin', REPO_URL);
isolateGitRemote(repoDir, REPO_URL);
expect(git('config', '--local', '--unset', `url.${REPO_URL}.insteadOf`).status).toBe(0);
git('config', '--local', 'url.https://git.capy.ai/.insteadOf', 'https://github.com/');
expect(git('remote', 'get-url', 'origin').stdout.trim()).toBe('https://git.capy.ai/acme/widget.git');
expect(git('config', '--get', 'remote.origin.url').stdout.trim()).toBe(REPO_URL);
fs.writeFileSync(path.join(repoDir, 'README.md'), 'fixture\n');
git('add', '-A');
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'fixture');
}
function runSync(): { status: number; text: string; stages: any[] } {
const res = spawnSync('bun', [SYNC, '--code-only', '--incremental'], {
cwd: repoDir,
encoding: 'utf-8',
timeout: 60_000,
// HOME also redirected so engine detection can't find a real ~/.gbrain.
env: { ...process.env, GSTACK_HOME: tmpHome, HOME: tmpHome },
});
let stages: any[] = [];
try {
stages = JSON.parse(
fs.readFileSync(path.join(tmpHome, '.gbrain-sync-state.json'), 'utf-8'),
).last_stages || [];
} catch {
// state file may be absent on early refusal paths — text asserts cover it
}
return {
status: res.status ?? -1,
text: `${res.stdout || ''}\n${res.stderr || ''}`,
stages,
};
}
afterEach(() => {
if (repoDir) fs.rmSync(repoDir, { recursive: true, force: true });
});
test('implicit lookup uses the configured remote rather than its rewritten transport URL', () => {
makeRepo();
expect(run(['set', REPO_URL, 'deny']).status).toBe(0);
const r = spawnSync(BIN, ['get'], { cwd: repoDir, env: { ...process.env, GSTACK_HOME: tmpHome },
encoding: 'utf8', timeout: 30_000 });
expect(r.status).toBe(0);
expect(r.stdout.trim()).toBe('deny');
});
test('deny → code stage refuses loudly, exit 1, status refused-policy-deny', () => {
makeRepo();
expect(run(['set', REPO_URL, 'deny']).status).toBe(0);
const r = runSync();
expect(r.status).toBe(1);
expect(r.text).toContain('refused');
expect(r.text).toContain('deny');
const code = r.stages.find((s: any) => s.name === 'code');
expect(code?.detail?.status).toBe('refused-policy-deny');
});
test('read-only → clean skip (exit 0), status skipped-policy-read-only', () => {
makeRepo();
expect(run(['set', REPO_URL, 'read-only']).status).toBe(0);
const r = runSync();
expect(r.status).toBe(0);
expect(r.text).toContain('read-only');
const code = r.stages.find((s: any) => s.name === 'code');
expect(code?.detail?.status).toBe('skipped-policy-read-only');
});
test('store exists but unreadable → fail-closed refusal, never bypassed', () => {
if (!canRevokeReads()) return; // chmod is advisory here (win32, root, DAC-override containers)
makeRepo();
expect(run(['set', REPO_URL, 'deny']).status).toBe(0);
fs.chmodSync(policyFile(), 0o000);
try {
const r = runSync();
expect(r.status).toBe(1);
expect(r.text).toContain('refus');
const code = r.stages.find((s: any) => s.name === 'code');
expect(code?.detail?.status).toBe('refused-policy-unreadable');
} finally {
fs.chmodSync(policyFile(), 0o600);
}
});
test('no policy store → fail-open, stage proceeds past the gate (no policy status)', () => {
makeRepo();
const r = runSync();
const code = r.stages.find((s: any) => s.name === 'code');
// With no engine in the redirected HOME the stage skips for ENGINE
// reasons — what matters is that no policy refusal fired and the exit
// is clean, preserving pre-policy behavior for every non-policy user.
expect(r.status).toBe(0);
expect(String(code?.detail?.status || '')).not.toContain('policy');
expect(r.text).not.toContain('refused');
});
});