From 181c6f4942984ee7469413f289711dd4ddc8c1f5 Mon Sep 17 00:00:00 2001 From: arlophoenix Date: Mon, 19 Oct 2026 09:34:30 +1300 Subject: [PATCH] fix(pregate): a base copy git cannot read, or a dangling user symlink, refuses Any git show failure read as no base policy, which now hands the run to the unreviewed user file; it is absent only when cat-file says so, and a spawn error (stdout cut at the buffer limit) counts as a failure. A user pregate.json that is a dangling symlink refuses instead of reading as absent. Co-Authored-By: Claude Opus 5.5 --- lib/pregate/config.ts | 12 +++++++++--- test/pregate-user-policy.test.ts | 23 +++++++++++++++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index e3348f252..0c188c51f 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -208,7 +208,8 @@ export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present function git(cwd: string, args: string[]): { ok: boolean; out: string } { const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }); - return { ok: r.status === 0, out: (r.stdout ?? '').trim() }; + // r.error covers ENOBUFS, where stdout is cut at the buffer limit even with status 0. + return { ok: r.status === 0 && !r.error, out: (r.stdout ?? '').trim() }; } export function loadConfigFromBase(repoRoot: string, base: string, knownChecks: readonly string[] = []): ConfigLoad { @@ -216,7 +217,11 @@ export function loadConfigFromBase(repoRoot: string, base: string, knownChecks: const sha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]); if (!sha.ok || !sha.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref} is not a resolvable commit` }; const show = git(repoRoot, ['show', `${sha.out}:${PREGATE_CONFIG_PATH}`]); - if (!show.ok) return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' }; + if (!show.ok) { + // Absent only when git says so: a timeout, an oversized blob or a corrupt object must refuse, not hand the run to the user file. + if (git(repoRoot, ['cat-file', '-e', `${sha.out}:${PREGATE_CONFIG_PATH}`]).ok) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref}:${PREGATE_CONFIG_PATH} exists but git show could not read it` }; + return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' }; + } return { ...parseConfig(show.out, knownChecks), present: true, label: `${ref}@${sha.out.slice(0, 12)}:${PREGATE_CONFIG_PATH}`, kind: 'base' }; } @@ -230,7 +235,8 @@ export function loadConfigFromHead(repoRoot: string, knownChecks: readonly strin export function loadConfigFromUser(file: string, knownChecks: readonly string[] = []): ConfigLoad | null { let text: string; try { text = fs.readFileSync(file, 'utf8'); } catch (e) { - if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; + // A dangling symlink is a policy the user meant to have, not an absent one. + if ((e as NodeJS.ErrnoException).code === 'ENOENT' && !fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink()) return null; // An unreadable policy must refuse, never fall back to defaults (its guards would silently vanish). return { ...parseConfig('{}', knownChecks), errors: [{ code: 'PREGATE_CONFIG_INVALID', path: file, message: `cannot read the user-owned policy: ${(e as Error).message}` }], present: true, label: `user ${file}`, kind: 'user' }; } diff --git a/test/pregate-user-policy.test.ts b/test/pregate-user-policy.test.ts index 461cd88c4..91bbcff7b 100644 --- a/test/pregate-user-policy.test.ts +++ b/test/pregate-user-policy.test.ts @@ -49,7 +49,7 @@ describe('pregate.json local', () => { 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 stands in for an unreadable file) + // 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(); @@ -57,8 +57,12 @@ describe('loadConfigFromUser', () => { 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}`]); - // Malformed JSON reaches the sealed/price_file probe too; it must still come back as a refusal, never a throw. 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); + // 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]); @@ -231,6 +235,21 @@ describe('gstack-pregate with a user-owned policy', () => { } }, 60_000); + // Value: protects=a base copy that exists but git show cannot read (here over spawnSync's 1 MB buffer) refuses instead of + // letting the user file replace it; fails_when=every 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 and + // corrupt objects (same branch, not separately driven) + test('a base copy git show cannot read refuses rather than reading the user file', () => { + const repo = makeFixtureRepo({ pregate: null, baseFiles: { '.gstack/pregate.json': `{"runner": "node --test {files}"${' '.repeat(1_200_000)}}\n` } }); + 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 }); + 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 exists but git show could not read it']); + }, 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)