@bookedsolid/rea 0.10.3 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/pre-push +48 -162
- package/README.md +834 -552
- package/agents/codex-adversarial.md +5 -3
- package/commands/codex-review.md +3 -5
- package/dist/audit/append.d.ts +7 -32
- package/dist/audit/append.js +7 -35
- package/dist/cli/audit.d.ts +0 -31
- package/dist/cli/audit.js +5 -74
- package/dist/cli/doctor.d.ts +12 -0
- package/dist/cli/doctor.js +96 -17
- package/dist/cli/hook.d.ts +55 -0
- package/dist/cli/hook.js +138 -0
- package/dist/cli/index.js +5 -80
- package/dist/cli/init.js +1 -1
- package/dist/cli/install/gitignore.d.ts +2 -2
- package/dist/cli/install/gitignore.js +3 -3
- package/dist/cli/install/pre-push.d.ts +158 -272
- package/dist/cli/install/pre-push.js +491 -2633
- package/dist/cli/install/settings-merge.d.ts +17 -0
- package/dist/cli/install/settings-merge.js +48 -1
- package/dist/cli/upgrade.js +131 -3
- package/dist/config/tier-map.js +18 -25
- package/dist/hooks/push-gate/base.d.ts +104 -0
- package/dist/hooks/push-gate/base.js +198 -0
- package/dist/hooks/push-gate/codex-runner.d.ts +126 -0
- package/dist/hooks/push-gate/codex-runner.js +223 -0
- package/dist/hooks/push-gate/findings.d.ts +68 -0
- package/dist/hooks/push-gate/findings.js +142 -0
- package/dist/hooks/push-gate/halt.d.ts +28 -0
- package/dist/hooks/push-gate/halt.js +49 -0
- package/dist/hooks/push-gate/index.d.ts +98 -0
- package/dist/hooks/push-gate/index.js +416 -0
- package/dist/hooks/push-gate/policy.d.ts +55 -0
- package/dist/hooks/push-gate/policy.js +64 -0
- package/dist/hooks/push-gate/report.d.ts +89 -0
- package/dist/hooks/push-gate/report.js +140 -0
- package/dist/policy/loader.d.ts +15 -10
- package/dist/policy/loader.js +8 -6
- package/dist/policy/types.d.ts +73 -22
- package/package.json +1 -1
- package/scripts/tarball-smoke.sh +7 -2
- package/dist/cache/review-cache.d.ts +0 -115
- package/dist/cache/review-cache.js +0 -200
- package/dist/cli/cache.d.ts +0 -84
- package/dist/cli/cache.js +0 -150
- package/dist/hooks/review-gate/args.d.ts +0 -126
- package/dist/hooks/review-gate/args.js +0 -315
- package/dist/hooks/review-gate/audit.d.ts +0 -131
- package/dist/hooks/review-gate/audit.js +0 -181
- package/dist/hooks/review-gate/banner.d.ts +0 -97
- package/dist/hooks/review-gate/banner.js +0 -172
- package/dist/hooks/review-gate/base-resolve.d.ts +0 -155
- package/dist/hooks/review-gate/base-resolve.js +0 -247
- package/dist/hooks/review-gate/cache-key.d.ts +0 -55
- package/dist/hooks/review-gate/cache-key.js +0 -41
- package/dist/hooks/review-gate/cache.d.ts +0 -108
- package/dist/hooks/review-gate/cache.js +0 -120
- package/dist/hooks/review-gate/constants.d.ts +0 -26
- package/dist/hooks/review-gate/constants.js +0 -34
- package/dist/hooks/review-gate/diff.d.ts +0 -181
- package/dist/hooks/review-gate/diff.js +0 -232
- package/dist/hooks/review-gate/errors.d.ts +0 -72
- package/dist/hooks/review-gate/errors.js +0 -100
- package/dist/hooks/review-gate/hash.d.ts +0 -43
- package/dist/hooks/review-gate/hash.js +0 -46
- package/dist/hooks/review-gate/index.d.ts +0 -31
- package/dist/hooks/review-gate/index.js +0 -35
- package/dist/hooks/review-gate/metadata.d.ts +0 -98
- package/dist/hooks/review-gate/metadata.js +0 -158
- package/dist/hooks/review-gate/policy.d.ts +0 -55
- package/dist/hooks/review-gate/policy.js +0 -71
- package/dist/hooks/review-gate/protected-paths.d.ts +0 -46
- package/dist/hooks/review-gate/protected-paths.js +0 -76
- package/hooks/_lib/push-review-core.sh +0 -1250
- package/hooks/commit-review-gate.sh +0 -330
- package/hooks/push-review-gate-git.sh +0 -94
- package/hooks/push-review-gate.sh +0 -92
|
@@ -41,6 +41,23 @@ export interface MergeResult {
|
|
|
41
41
|
* hooks and returns the merged settings plus a list of warnings. Does NOT
|
|
42
42
|
* touch disk.
|
|
43
43
|
*/
|
|
44
|
+
/**
|
|
45
|
+
* Remove hook entries whose `command` contains any of the listed
|
|
46
|
+
* substrings. Returns a deep-cloned settings object plus the count of
|
|
47
|
+
* entries removed. Empty hook arrays and empty groups are pruned too so
|
|
48
|
+
* the resulting file doesn't accumulate empty sentinel objects after
|
|
49
|
+
* repeated upgrade runs.
|
|
50
|
+
*
|
|
51
|
+
* Used by `rea upgrade` to purge references to hooks we delete in a
|
|
52
|
+
* release (0.11.0 removed `push-review-gate.sh` and `commit-review-gate.sh`).
|
|
53
|
+
* Without this, `mergeSettings()` is additive-only and consumer
|
|
54
|
+
* `.claude/settings.json` would keep executing stale commands that
|
|
55
|
+
* point at files we just deleted from `.claude/hooks/`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function pruneHookCommands(existing: Record<string, unknown>, staleSubstrings: readonly string[]): {
|
|
58
|
+
merged: Record<string, unknown>;
|
|
59
|
+
removedCount: number;
|
|
60
|
+
};
|
|
44
61
|
export declare function mergeSettings(existing: Record<string, unknown>, desired: DesiredHookGroup[]): MergeResult;
|
|
45
62
|
/**
|
|
46
63
|
* Atomic write via tmp-file + rename.
|
|
@@ -39,6 +39,54 @@ function keyFor(matcher, command) {
|
|
|
39
39
|
* hooks and returns the merged settings plus a list of warnings. Does NOT
|
|
40
40
|
* touch disk.
|
|
41
41
|
*/
|
|
42
|
+
/**
|
|
43
|
+
* Remove hook entries whose `command` contains any of the listed
|
|
44
|
+
* substrings. Returns a deep-cloned settings object plus the count of
|
|
45
|
+
* entries removed. Empty hook arrays and empty groups are pruned too so
|
|
46
|
+
* the resulting file doesn't accumulate empty sentinel objects after
|
|
47
|
+
* repeated upgrade runs.
|
|
48
|
+
*
|
|
49
|
+
* Used by `rea upgrade` to purge references to hooks we delete in a
|
|
50
|
+
* release (0.11.0 removed `push-review-gate.sh` and `commit-review-gate.sh`).
|
|
51
|
+
* Without this, `mergeSettings()` is additive-only and consumer
|
|
52
|
+
* `.claude/settings.json` would keep executing stale commands that
|
|
53
|
+
* point at files we just deleted from `.claude/hooks/`.
|
|
54
|
+
*/
|
|
55
|
+
export function pruneHookCommands(existing, staleSubstrings) {
|
|
56
|
+
const merged = deepClone(existing);
|
|
57
|
+
const hooks = ensureHooksShape(merged);
|
|
58
|
+
let removedCount = 0;
|
|
59
|
+
for (const event of Object.keys(hooks)) {
|
|
60
|
+
const groups = hooks[event];
|
|
61
|
+
if (!Array.isArray(groups))
|
|
62
|
+
continue;
|
|
63
|
+
for (const group of groups) {
|
|
64
|
+
const entries = group.hooks ?? [];
|
|
65
|
+
if (!Array.isArray(entries))
|
|
66
|
+
continue;
|
|
67
|
+
const kept = [];
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const cmd = typeof entry.command === 'string' ? entry.command : '';
|
|
70
|
+
if (staleSubstrings.some((s) => cmd.includes(s))) {
|
|
71
|
+
removedCount += 1;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
kept.push(entry);
|
|
75
|
+
}
|
|
76
|
+
group.hooks = kept;
|
|
77
|
+
}
|
|
78
|
+
// Drop groups whose hooks list is now empty.
|
|
79
|
+
hooks[event] = groups.filter((g) => Array.isArray(g.hooks) && g.hooks.length > 0);
|
|
80
|
+
}
|
|
81
|
+
// Drop events whose group list is now empty.
|
|
82
|
+
for (const event of Object.keys(hooks)) {
|
|
83
|
+
const groups = hooks[event];
|
|
84
|
+
if (Array.isArray(groups) && groups.length === 0) {
|
|
85
|
+
delete hooks[event];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { merged, removedCount };
|
|
89
|
+
}
|
|
42
90
|
export function mergeSettings(existing, desired) {
|
|
43
91
|
const merged = deepClone(existing);
|
|
44
92
|
const hooks = ensureHooksShape(merged);
|
|
@@ -215,7 +263,6 @@ export function defaultDesiredHooks() {
|
|
|
215
263
|
{ type: 'command', command: `${base}/security-disclosure-gate.sh`, timeout: 5000, statusMessage: 'Checking disclosure policy...' },
|
|
216
264
|
{ type: 'command', command: `${base}/pr-issue-link-gate.sh`, timeout: 5000, statusMessage: 'Checking PR for issue reference...' },
|
|
217
265
|
{ type: 'command', command: `${base}/attribution-advisory.sh`, timeout: 5000, statusMessage: 'Checking for AI attribution...' },
|
|
218
|
-
{ type: 'command', command: `${base}/push-review-gate.sh`, timeout: 30000, statusMessage: 'Running push review gate...' },
|
|
219
266
|
],
|
|
220
267
|
},
|
|
221
268
|
{
|
package/dist/cli/upgrade.js
CHANGED
|
@@ -44,11 +44,103 @@ import { loadPolicy } from '../policy/loader.js';
|
|
|
44
44
|
import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
|
|
45
45
|
import { buildFragment, extractFragment, } from './install/claude-md.js';
|
|
46
46
|
import { atomicReplaceFile, safeDeleteFile, safeInstallFile, safeReadFile, } from './install/fs-safe.js';
|
|
47
|
-
import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
|
|
47
|
+
import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, pruneHookCommands, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
|
|
48
48
|
import { ensureReaGitignore } from './install/gitignore.js';
|
|
49
49
|
import { manifestExists, readManifest, writeManifestAtomic, } from './install/manifest-io.js';
|
|
50
50
|
import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
|
|
51
51
|
import { err, getPkgVersion, log, warn } from './utils.js';
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// 0.11.0 policy migration
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
/**
|
|
56
|
+
* Strip 0.10.x review.* fields that 0.11.0 rejects at schema load, and
|
|
57
|
+
* backfill `concerns_blocks: true` if the `review:` block exists without
|
|
58
|
+
* it. Writes a timestamped backup to `.rea/policy.yaml.bak-<ts>` before
|
|
59
|
+
* mutating.
|
|
60
|
+
*
|
|
61
|
+
* Surgical text rewrite, not YAML round-trip. YAML round-tripping loses
|
|
62
|
+
* comments and reformats quoting; operators care about their comments.
|
|
63
|
+
* Line-by-line deletion of the two removed keys keeps every other line
|
|
64
|
+
* byte-identical. This is safe because the keys we strip are simple
|
|
65
|
+
* scalars (`cache_max_age_seconds: 3600`, `allow_skip_in_ci: false`) that
|
|
66
|
+
* cannot span multiple lines in any reasonable policy file.
|
|
67
|
+
*/
|
|
68
|
+
async function migrateReviewPolicyFor0110(resolvedRoot, opts) {
|
|
69
|
+
const policyPath = path.join(resolvedRoot, '.rea', 'policy.yaml');
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = await fsPromises.readFile(policyPath, 'utf8');
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return; // no policy to migrate
|
|
76
|
+
}
|
|
77
|
+
const lines = raw.split(/\r?\n/);
|
|
78
|
+
const REVIEW_RE = /^review:\s*(?:#.*)?$/;
|
|
79
|
+
const REMOVED_KEY_RE = /^(\s+)(cache_max_age_seconds|allow_skip_in_ci):\s*\S.*$/;
|
|
80
|
+
const INDENT_RE = /^(\s+)\S/;
|
|
81
|
+
let reviewStart = -1;
|
|
82
|
+
let reviewIndent = '';
|
|
83
|
+
const linesToDrop = new Set();
|
|
84
|
+
let hasConcernsBlocks = false;
|
|
85
|
+
let reviewChildIndent = '';
|
|
86
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
87
|
+
const line = lines[i] ?? '';
|
|
88
|
+
if (reviewStart < 0 && REVIEW_RE.test(line.trimEnd())) {
|
|
89
|
+
reviewStart = i;
|
|
90
|
+
const indentMatch = INDENT_RE.exec(line);
|
|
91
|
+
reviewIndent = indentMatch?.[1] ?? '';
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (reviewStart < 0)
|
|
95
|
+
continue;
|
|
96
|
+
// Inside the review: block as long as indent is deeper than reviewIndent.
|
|
97
|
+
if (line.trim() === '')
|
|
98
|
+
continue;
|
|
99
|
+
const indentMatch = INDENT_RE.exec(line);
|
|
100
|
+
const lineIndent = indentMatch?.[1] ?? '';
|
|
101
|
+
if (lineIndent.length <= reviewIndent.length) {
|
|
102
|
+
// Left the block.
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
if (reviewChildIndent.length === 0)
|
|
106
|
+
reviewChildIndent = lineIndent;
|
|
107
|
+
if (/^\s+concerns_blocks:/.test(line))
|
|
108
|
+
hasConcernsBlocks = true;
|
|
109
|
+
if (REMOVED_KEY_RE.test(line)) {
|
|
110
|
+
linesToDrop.add(i);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const addConcernsBlocks = reviewStart >= 0 && !hasConcernsBlocks;
|
|
114
|
+
if (linesToDrop.size === 0 && !addConcernsBlocks)
|
|
115
|
+
return;
|
|
116
|
+
const newLines = [];
|
|
117
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
118
|
+
if (linesToDrop.has(i))
|
|
119
|
+
continue;
|
|
120
|
+
newLines.push(lines[i] ?? '');
|
|
121
|
+
// Append `concerns_blocks: true` right after the `review:` header line
|
|
122
|
+
// when the block exists but lacks the key.
|
|
123
|
+
if (i === reviewStart && addConcernsBlocks) {
|
|
124
|
+
const indent = reviewChildIndent.length > 0 ? reviewChildIndent : reviewIndent + ' ';
|
|
125
|
+
newLines.push(`${indent}concerns_blocks: true`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const updated = newLines.join('\n');
|
|
129
|
+
const droppedSummary = [];
|
|
130
|
+
if (linesToDrop.size > 0)
|
|
131
|
+
droppedSummary.push(`dropped ${linesToDrop.size} removed-field line(s)`);
|
|
132
|
+
if (addConcernsBlocks)
|
|
133
|
+
droppedSummary.push('added concerns_blocks: true');
|
|
134
|
+
if (opts.dryRun) {
|
|
135
|
+
log(`[dry-run] would migrate .rea/policy.yaml: ${droppedSummary.join(', ')}`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
139
|
+
const backupPath = `${policyPath}.bak-${ts}`;
|
|
140
|
+
await fsPromises.copyFile(policyPath, backupPath);
|
|
141
|
+
await atomicReplaceFile(policyPath, updated);
|
|
142
|
+
warn(`migrated .rea/policy.yaml for 0.11.0 (${droppedSummary.join(', ')}); backup at ${path.basename(backupPath)}`);
|
|
143
|
+
}
|
|
52
144
|
/**
|
|
53
145
|
* Hard cap for `showDiff` reads. Canonical files are all tiny (<64KB) but a
|
|
54
146
|
* consumer could have replaced a hook with a 500MB log; refuse to slurp the
|
|
@@ -282,19 +374,50 @@ async function upgradeClaudeMdFragment(resolvedRoot, opts) {
|
|
|
282
374
|
await atomicReplaceFile(claudeMdPath, next);
|
|
283
375
|
return { sha: newSha, action: 'written' };
|
|
284
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* Hook commands deleted in 0.11.0. `upgradeSettings` prunes any entries
|
|
379
|
+
* whose `command` string contains one of these tokens so consumer
|
|
380
|
+
* `.claude/settings.json` files don't keep invoking missing scripts
|
|
381
|
+
* (which would fail every matched tool call until the operator edited
|
|
382
|
+
* settings by hand).
|
|
383
|
+
*
|
|
384
|
+
* 0.11.0 removals:
|
|
385
|
+
* - push-review-gate.sh (replaced by husky stub → rea hook push-gate)
|
|
386
|
+
* - commit-review-gate.sh (intentionally unregistered; source-of-truth deleted)
|
|
387
|
+
* - push-review-gate-git.sh (native-git adapter; deleted)
|
|
388
|
+
*
|
|
389
|
+
* Add future removals here rather than baking the list into
|
|
390
|
+
* `pruneHookCommands` itself — the removal list is release history,
|
|
391
|
+
* not a static setting.
|
|
392
|
+
*/
|
|
393
|
+
const STALE_HOOK_COMMAND_TOKENS = [
|
|
394
|
+
'push-review-gate.sh',
|
|
395
|
+
'commit-review-gate.sh',
|
|
396
|
+
'push-review-gate-git.sh',
|
|
397
|
+
];
|
|
285
398
|
async function upgradeSettings(baseDir, opts) {
|
|
286
399
|
const desired = defaultDesiredHooks();
|
|
287
400
|
const sha = canonicalSettingsSubsetHash(desired);
|
|
288
401
|
const { settings, settingsPath } = readSettings(baseDir);
|
|
289
|
-
|
|
402
|
+
// PRUNE FIRST (remove 0.11.0-deleted hook references), THEN MERGE
|
|
403
|
+
// (add any new hooks). Order matters: merging first would re-add the
|
|
404
|
+
// stale entry only to have the prune re-delete it on the next line —
|
|
405
|
+
// pointless work. Pruning first means the merge sees a clean baseline.
|
|
406
|
+
const pruned = pruneHookCommands(settings, STALE_HOOK_COMMAND_TOKENS);
|
|
407
|
+
const mergeResult = mergeSettings(pruned.merged, desired);
|
|
290
408
|
if (opts.dryRun !== true) {
|
|
291
409
|
await writeSettingsAtomic(settingsPath, mergeResult.merged);
|
|
292
410
|
}
|
|
411
|
+
const warnings = [...mergeResult.warnings];
|
|
412
|
+
if (pruned.removedCount > 0) {
|
|
413
|
+
warnings.push(`pruned ${pruned.removedCount} stale 0.10.x hook entr${pruned.removedCount === 1 ? 'y' : 'ies'} from .claude/settings.json (removed in 0.11.0)`);
|
|
414
|
+
}
|
|
293
415
|
return {
|
|
294
416
|
sha,
|
|
295
417
|
addedCount: mergeResult.addedCount,
|
|
296
418
|
skippedCount: mergeResult.skippedCount,
|
|
297
|
-
|
|
419
|
+
removedCount: pruned.removedCount,
|
|
420
|
+
warnings,
|
|
298
421
|
};
|
|
299
422
|
}
|
|
300
423
|
/** Re-hash a file we just wrote. Source and on-disk bytes should match, but
|
|
@@ -321,6 +444,11 @@ export async function runUpgrade(options = {}) {
|
|
|
321
444
|
if (options.force === true && !dryRun) {
|
|
322
445
|
warn('--force: overwriting locally-modified files and deleting removed-upstream entries without prompt.');
|
|
323
446
|
}
|
|
447
|
+
// 0.11.0 migration — strip removed review.* fields and backfill the new
|
|
448
|
+
// concerns_blocks default. Runs before canonical file reconciliation so a
|
|
449
|
+
// policy that fails strict schema load (which happens on upgrade from
|
|
450
|
+
// 0.10.x the moment we re-read `.rea/policy.yaml`) is cleaned up first.
|
|
451
|
+
await migrateReviewPolicyFor0110(resolvedRoot, { dryRun });
|
|
324
452
|
const canonicalFiles = await enumerateCanonicalFiles();
|
|
325
453
|
if (canonicalFiles.length === 0) {
|
|
326
454
|
err('no canonical files found in package — is the build complete?');
|
package/dist/config/tier-map.js
CHANGED
|
@@ -110,28 +110,20 @@ export function isToolBlocked(toolName, serverName, gatewayConfig) {
|
|
|
110
110
|
* Classify a `rea <subcommand>` Bash invocation by its own semantics rather
|
|
111
111
|
* than the generic Bash default.
|
|
112
112
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* see that the Write is just appending a line to `.rea/review-cache.jsonl`.
|
|
113
|
+
* REA's own governance CLI must not be denied by REA's own middleware. This
|
|
114
|
+
* helper returns the tier appropriate to the rea subcommand when the command
|
|
115
|
+
* parses as `rea <sub>` or `npx rea <sub>`. Returns `null` if the command is
|
|
116
|
+
* not a rea invocation — callers then fall back to the generic Bash tier.
|
|
118
117
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* Tier mapping:
|
|
125
|
-
* - Read: `cache check|list|get`, `audit verify`,
|
|
126
|
-
* `audit record codex-review`, `check`, `doctor`, `status`
|
|
127
|
-
* - Write: `cache set|clear`, `audit rotate`, `init`,
|
|
128
|
-
* `serve`, `upgrade`, `unfreeze`
|
|
118
|
+
* Tier mapping (0.11.0):
|
|
119
|
+
* - Read: `audit verify`, `check`, `doctor`, `status`,
|
|
120
|
+
* `hook push-gate` (exec-only — no state mutation)
|
|
121
|
+
* - Write: `audit rotate`, `init`, `serve`, `upgrade`, `unfreeze`
|
|
129
122
|
* - Destructive: `freeze` (writes `.rea/HALT`, suspends the session)
|
|
130
123
|
*
|
|
131
|
-
* `audit record codex-review`
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* reintroduce exactly the deadlock Defect D/E close.
|
|
124
|
+
* `rea cache` and `rea audit record codex-review` were removed in 0.11.0 —
|
|
125
|
+
* the stateless push-gate needs neither. Any lingering reference to those
|
|
126
|
+
* subcommands falls through to `Tier.Write` (safe default).
|
|
135
127
|
*
|
|
136
128
|
* SECURITY: returns `null` for any command containing shell metacharacters
|
|
137
129
|
* that would let an attacker piggyback arbitrary commands onto an allowed
|
|
@@ -275,18 +267,19 @@ export function reaCommandTier(command) {
|
|
|
275
267
|
case 'doctor':
|
|
276
268
|
case 'status':
|
|
277
269
|
return Tier.Read;
|
|
278
|
-
case '
|
|
279
|
-
|
|
270
|
+
case 'hook': {
|
|
271
|
+
// `rea hook push-gate` is execution-only — it runs codex exec review
|
|
272
|
+
// and writes `.rea/last-review.json` + an audit record, but the
|
|
273
|
+
// user-visible effect is an exit code. Classify Read so the pre-push
|
|
274
|
+
// hook is not blocked at L1 (symmetric to the 0.10.x reasoning for
|
|
275
|
+
// `audit record codex-review`).
|
|
276
|
+
if (sub2 === 'push-gate')
|
|
280
277
|
return Tier.Read;
|
|
281
|
-
if (sub2 === 'set' || sub2 === 'clear')
|
|
282
|
-
return Tier.Write;
|
|
283
278
|
return Tier.Write;
|
|
284
279
|
}
|
|
285
280
|
case 'audit': {
|
|
286
281
|
if (sub2 === 'verify')
|
|
287
282
|
return Tier.Read;
|
|
288
|
-
if (sub2 === 'record')
|
|
289
|
-
return Tier.Read;
|
|
290
283
|
if (sub2 === 'rotate')
|
|
291
284
|
return Tier.Write;
|
|
292
285
|
return Tier.Write;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base-branch resolution for the push-gate.
|
|
3
|
+
*
|
|
4
|
+
* Codex `exec review --base <ref>` needs a git ref to diff against. The
|
|
5
|
+
* caller may provide one via `rea hook push-gate --base <ref>`; if they
|
|
6
|
+
* don't, we resolve it in priority order:
|
|
7
|
+
*
|
|
8
|
+
* 1. Current branch's upstream (`@{upstream}`) — the branch the operator
|
|
9
|
+
* configured as their tracking ref. Most accurate since it matches
|
|
10
|
+
* what `git push` is about to compare against.
|
|
11
|
+
* 2. `origin/HEAD` symbolic ref (e.g. `refs/remotes/origin/main`). Set
|
|
12
|
+
* automatically by `git clone` and `git remote set-head`.
|
|
13
|
+
* 3. Explicit probes: `origin/main` → `origin/master` via rev-parse.
|
|
14
|
+
* 4. Local `main` / `master` — last resort when the clone has no remote
|
|
15
|
+
* tracking refs yet (freshly initialized sibling project, mirror
|
|
16
|
+
* clone).
|
|
17
|
+
*
|
|
18
|
+
* On total failure we surface the sentinel `empty-tree` SHA
|
|
19
|
+
* (`4b825dc642cb6eb9a060e54bf8d69288fbee4904`) so the diff covers every
|
|
20
|
+
* file in HEAD. First pushes to a fresh remote still get reviewed — the
|
|
21
|
+
* 0.10.x fail-open at this resolution step was defect J.
|
|
22
|
+
*
|
|
23
|
+
* All git commands are invoked via the injected `GitExecutor` — tests
|
|
24
|
+
* replace it with a fake to avoid shelling out.
|
|
25
|
+
*/
|
|
26
|
+
import type { GitExecutor } from './codex-runner.js';
|
|
27
|
+
export interface BaseResolution {
|
|
28
|
+
/**
|
|
29
|
+
* Resolved ref (branch, remote-tracking ref, or 40-char SHA). Always
|
|
30
|
+
* usable as the `--base` argument to `codex exec review`.
|
|
31
|
+
*/
|
|
32
|
+
ref: string;
|
|
33
|
+
/** Where the ref came from — surfaces in audit records and stderr. */
|
|
34
|
+
source: 'explicit' | 'last-n-commits' | 'upstream' | 'origin-head' | 'origin-main' | 'origin-master' | 'local-main' | 'local-master' | 'empty-tree';
|
|
35
|
+
/**
|
|
36
|
+
* Set when `last-n-commits` was requested but `<headRef>~N` did not
|
|
37
|
+
* resolve at the requested depth (shallower-than-N clone, or N larger
|
|
38
|
+
* than the branch history). The resolver clamps to the deepest
|
|
39
|
+
* reachable commit (`<headRef>~K` for the largest `K <= N` that does
|
|
40
|
+
* resolve) and surfaces both numbers so the caller can emit a stderr
|
|
41
|
+
* warning ("requested N=50; clamped to K=12 (oldest reachable)").
|
|
42
|
+
* Present on both `last-n-commits` results (when clamped) and
|
|
43
|
+
* `empty-tree` results (when even `~1` was unreachable — orphan or
|
|
44
|
+
* single-commit branch).
|
|
45
|
+
*/
|
|
46
|
+
lastNCommitsRequested?: number;
|
|
47
|
+
/**
|
|
48
|
+
* The N value actually used. When source is `last-n-commits`, this is
|
|
49
|
+
* the depth that resolved (equals `lastNCommitsRequested` on full
|
|
50
|
+
* resolution; smaller when clamped to a shallow clone). Surfaces in
|
|
51
|
+
* audit metadata so operators can grep their audit log for narrowed
|
|
52
|
+
* reviews.
|
|
53
|
+
*/
|
|
54
|
+
lastNCommits?: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Well-known empty-tree SHA: `git hash-object -t tree /dev/null`. Every git
|
|
58
|
+
* installation carries this object implicitly. Using it as a fallback lets
|
|
59
|
+
* a review on a clone with no tracking refs still exercise the entire HEAD
|
|
60
|
+
* tree diff.
|
|
61
|
+
*/
|
|
62
|
+
export declare const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
63
|
+
export interface ResolveBaseOptions {
|
|
64
|
+
/**
|
|
65
|
+
* Explicit ref from `--base <ref>` or equivalent. When set, the resolver
|
|
66
|
+
* returns it unchanged with `source: 'explicit'` — the caller is
|
|
67
|
+
* responsible for its correctness. We do NOT validate that it exists;
|
|
68
|
+
* that's Codex's job (it'll error clearly if the ref is bad).
|
|
69
|
+
*/
|
|
70
|
+
explicit?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the base to `HEAD~N` instead of running the upstream ladder.
|
|
73
|
+
* `--last-n-commits N` flag and `policy.review.last_n_commits` map
|
|
74
|
+
* here. Ignored when `explicit` is set (explicit ref wins). When
|
|
75
|
+
* `<headRef>~N` does not resolve (shallower-than-N clone, or branch
|
|
76
|
+
* history shorter than N), the resolver CLAMPS to the deepest
|
|
77
|
+
* reachable commit (`<headRef>~K` for the largest `K <= N` that does
|
|
78
|
+
* resolve) — i.e. it diffs against the oldest commit on the branch.
|
|
79
|
+
* It does NOT fall back to the empty-tree sentinel; that would
|
|
80
|
+
* silently expand "the last N commits" to "the entire repository
|
|
81
|
+
* snapshot" on a normal repo with a short feature branch, flooding
|
|
82
|
+
* Codex with unchanged base-branch files. The resolver only emits
|
|
83
|
+
* `source: 'empty-tree'` when even `<headRef>~1` cannot be resolved
|
|
84
|
+
* (orphan branch, single-commit history); in that case
|
|
85
|
+
* `lastNCommitsRequested: N` is set so the caller can warn.
|
|
86
|
+
*/
|
|
87
|
+
lastNCommits?: number;
|
|
88
|
+
/**
|
|
89
|
+
* The head ref the gate is reviewing. Defaults to literal "HEAD" — i.e.
|
|
90
|
+
* the local checkout's tip. When the gate is invoked via pre-push and
|
|
91
|
+
* the pushed ref is not the current branch (e.g.
|
|
92
|
+
* `git push origin some-other-branch`), the caller passes the pushed
|
|
93
|
+
* `<sha>` here so `last-n-commits` resolves `<sha>~N` rather than
|
|
94
|
+
* `HEAD~N`. Without this thread-through the review walks back N commits
|
|
95
|
+
* from the local checkout, which can be a different branch entirely.
|
|
96
|
+
*/
|
|
97
|
+
headRef?: string;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the base ref using the configured priority order. Never throws —
|
|
101
|
+
* every failure mode degrades to the next step, and the worst case (no
|
|
102
|
+
* tracking refs, no local main/master) is `empty-tree`.
|
|
103
|
+
*/
|
|
104
|
+
export declare function resolveBaseRef(git: GitExecutor, options?: ResolveBaseOptions): BaseResolution;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base-branch resolution for the push-gate.
|
|
3
|
+
*
|
|
4
|
+
* Codex `exec review --base <ref>` needs a git ref to diff against. The
|
|
5
|
+
* caller may provide one via `rea hook push-gate --base <ref>`; if they
|
|
6
|
+
* don't, we resolve it in priority order:
|
|
7
|
+
*
|
|
8
|
+
* 1. Current branch's upstream (`@{upstream}`) — the branch the operator
|
|
9
|
+
* configured as their tracking ref. Most accurate since it matches
|
|
10
|
+
* what `git push` is about to compare against.
|
|
11
|
+
* 2. `origin/HEAD` symbolic ref (e.g. `refs/remotes/origin/main`). Set
|
|
12
|
+
* automatically by `git clone` and `git remote set-head`.
|
|
13
|
+
* 3. Explicit probes: `origin/main` → `origin/master` via rev-parse.
|
|
14
|
+
* 4. Local `main` / `master` — last resort when the clone has no remote
|
|
15
|
+
* tracking refs yet (freshly initialized sibling project, mirror
|
|
16
|
+
* clone).
|
|
17
|
+
*
|
|
18
|
+
* On total failure we surface the sentinel `empty-tree` SHA
|
|
19
|
+
* (`4b825dc642cb6eb9a060e54bf8d69288fbee4904`) so the diff covers every
|
|
20
|
+
* file in HEAD. First pushes to a fresh remote still get reviewed — the
|
|
21
|
+
* 0.10.x fail-open at this resolution step was defect J.
|
|
22
|
+
*
|
|
23
|
+
* All git commands are invoked via the injected `GitExecutor` — tests
|
|
24
|
+
* replace it with a fake to avoid shelling out.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Well-known empty-tree SHA: `git hash-object -t tree /dev/null`. Every git
|
|
28
|
+
* installation carries this object implicitly. Using it as a fallback lets
|
|
29
|
+
* a review on a clone with no tracking refs still exercise the entire HEAD
|
|
30
|
+
* tree diff.
|
|
31
|
+
*/
|
|
32
|
+
export const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the base ref using the configured priority order. Never throws —
|
|
35
|
+
* every failure mode degrades to the next step, and the worst case (no
|
|
36
|
+
* tracking refs, no local main/master) is `empty-tree`.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveBaseRef(git, options = {}) {
|
|
39
|
+
if (options.explicit !== undefined && options.explicit.length > 0) {
|
|
40
|
+
return { ref: options.explicit, source: 'explicit' };
|
|
41
|
+
}
|
|
42
|
+
// 0. Last-N-commits override. Caller (CLI flag or policy key) requested
|
|
43
|
+
// diffing against `HEAD~N` directly. Resolves to a 40-char SHA via
|
|
44
|
+
// `git rev-parse HEAD~N`; on failure (shallower-than-N clone, or N
|
|
45
|
+
// larger than the branch's history depth) we fall back to the
|
|
46
|
+
// empty-tree sentinel and surface `lastNCommitsRequested` so the
|
|
47
|
+
// caller can emit a stderr warning. We deliberately resolve to a
|
|
48
|
+
// SHA rather than passing `HEAD~N` through to Codex — Codex shells
|
|
49
|
+
// out to `git diff` itself, but a SHA is unambiguous regardless of
|
|
50
|
+
// intermediate ref churn.
|
|
51
|
+
if (options.lastNCommits !== undefined && options.lastNCommits > 0) {
|
|
52
|
+
// Walk back N commits from the actual head being reviewed (defaults to
|
|
53
|
+
// local HEAD when the caller didn't thread a pushed ref through). Using
|
|
54
|
+
// a literal "HEAD" here would be wrong for `git push origin
|
|
55
|
+
// some-other-branch` invocations, where the local checkout's HEAD is a
|
|
56
|
+
// different branch entirely and the resulting diff would compare the
|
|
57
|
+
// wrong commits.
|
|
58
|
+
const headRef = options.headRef !== undefined && options.headRef.length > 0
|
|
59
|
+
? options.headRef
|
|
60
|
+
: 'HEAD';
|
|
61
|
+
const requested = options.lastNCommits;
|
|
62
|
+
const tryDepth = (k) => git.tryRevParse(['--verify', '--quiet', `${headRef}~${k}^{commit}`]).trim();
|
|
63
|
+
// Fast path: requested depth resolves directly.
|
|
64
|
+
const direct = tryDepth(requested);
|
|
65
|
+
if (direct.length > 0) {
|
|
66
|
+
return {
|
|
67
|
+
ref: direct,
|
|
68
|
+
source: 'last-n-commits',
|
|
69
|
+
lastNCommits: requested,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// Clamp: `<headRef>~N` did not resolve. Two distinct causes need
|
|
73
|
+
// different handling — and the difference matters because the wrong
|
|
74
|
+
// choice silently inflates the review:
|
|
75
|
+
//
|
|
76
|
+
// (i) Branch is genuinely shorter than N (full clone). The
|
|
77
|
+
// deepest resolvable ancestor `<headRef>~K` IS the root
|
|
78
|
+
// commit (parent-less). Diffing against `<headRef>~K` would
|
|
79
|
+
// EXCLUDE the root commit's changes (`git diff base..head`
|
|
80
|
+
// excludes `base`), so we diff against EMPTY_TREE_SHA to
|
|
81
|
+
// include them. Report lastNCommits = K + 1 (every commit on
|
|
82
|
+
// the branch was reviewed).
|
|
83
|
+
//
|
|
84
|
+
// (ii) Repo is a shallow clone — `<headRef>~K` resolves but
|
|
85
|
+
// `<headRef>~K`'s parent simply isn't fetched locally. The
|
|
86
|
+
// commit isn't actually the root; older history exists on
|
|
87
|
+
// the remote. Diffing against EMPTY_TREE_SHA would balloon
|
|
88
|
+
// the review to "every tracked file in the checkout"
|
|
89
|
+
// (including all unchanged base-branch files), defeating
|
|
90
|
+
// the entire point of last-n-commits. So in the shallow
|
|
91
|
+
// case we diff against `<headRef>~K` itself, accepting that
|
|
92
|
+
// the K-th commit's changes are excluded — the operator
|
|
93
|
+
// chose a shallow clone and the deepest reachable commit is
|
|
94
|
+
// the best base we have. Report lastNCommits = K (the K
|
|
95
|
+
// ancestors we DID reach).
|
|
96
|
+
//
|
|
97
|
+
// `git rev-parse --is-shallow-repository` distinguishes the two
|
|
98
|
+
// cases (returns "true" / "false"). On unknown / errored output we
|
|
99
|
+
// assume FULL (the safer default for case (i): we'd rather review
|
|
100
|
+
// the root commit and risk a slightly larger diff than silently
|
|
101
|
+
// drop changes).
|
|
102
|
+
//
|
|
103
|
+
// Both Codex [P1] findings 2026-04-29 (initial empty-tree-on-clamp
|
|
104
|
+
// dropping root commit, then shallow-clone empty-tree expanding to
|
|
105
|
+
// full repo) drove this two-branch design.
|
|
106
|
+
const oneSha = tryDepth(1);
|
|
107
|
+
if (oneSha.length === 0) {
|
|
108
|
+
// Even `<headRef>~1` does not resolve — single-commit history
|
|
109
|
+
// (full clone with one commit) OR a shallow clone fetched at
|
|
110
|
+
// depth=1. In both cases the only locally-resolvable commit is
|
|
111
|
+
// headRef itself; there's no useful intermediate base. Fall back
|
|
112
|
+
// to empty-tree (matches case (i) of single commit review) and
|
|
113
|
+
// report lastNCommits = 1.
|
|
114
|
+
return {
|
|
115
|
+
ref: EMPTY_TREE_SHA,
|
|
116
|
+
source: 'empty-tree',
|
|
117
|
+
lastNCommits: 1,
|
|
118
|
+
lastNCommitsRequested: requested,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// Binary search for the deepest K < N where `<headRef>~K` resolves.
|
|
122
|
+
// Invariant: tryDepth(lo) resolves; tryDepth(hi+1) does not. We
|
|
123
|
+
// narrow until lo > hi; bestDepth carries the highest K seen.
|
|
124
|
+
let lo = 1;
|
|
125
|
+
let hi = requested - 1;
|
|
126
|
+
let bestDepth = 1;
|
|
127
|
+
while (lo <= hi) {
|
|
128
|
+
const mid = lo + Math.floor((hi - lo) / 2);
|
|
129
|
+
const sha = tryDepth(mid);
|
|
130
|
+
if (sha.length > 0) {
|
|
131
|
+
bestDepth = mid;
|
|
132
|
+
lo = mid + 1;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
hi = mid - 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const shallowFlag = git.tryRevParse(['--is-shallow-repository']).trim();
|
|
139
|
+
if (shallowFlag === 'true') {
|
|
140
|
+
// Case (ii): shallow clone. Diff against the deepest reachable
|
|
141
|
+
// ancestor SHA — its parent exists on the remote but isn't
|
|
142
|
+
// locally available, so empty-tree would over-review. Accept that
|
|
143
|
+
// the K-th commit's content is excluded; that's the cost of the
|
|
144
|
+
// shallow clone the operator chose.
|
|
145
|
+
const bestSha = tryDepth(bestDepth);
|
|
146
|
+
return {
|
|
147
|
+
ref: bestSha,
|
|
148
|
+
source: 'last-n-commits',
|
|
149
|
+
lastNCommits: bestDepth,
|
|
150
|
+
lastNCommitsRequested: requested,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// Case (i): full clone, branch genuinely shorter than N. The
|
|
154
|
+
// deepest resolvable ancestor IS the root. Diff against empty-tree
|
|
155
|
+
// to include the root commit's changes; reviewed count = K + 1.
|
|
156
|
+
return {
|
|
157
|
+
ref: EMPTY_TREE_SHA,
|
|
158
|
+
source: 'last-n-commits',
|
|
159
|
+
lastNCommits: bestDepth + 1,
|
|
160
|
+
lastNCommitsRequested: requested,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// 1. Upstream of current branch. `@{upstream}` resolves to the configured
|
|
164
|
+
// tracking ref (typically `refs/remotes/origin/<branch>`). Returns
|
|
165
|
+
// empty on branches without an upstream — which is normal for a brand
|
|
166
|
+
// new feature branch; fall through.
|
|
167
|
+
const upstream = git
|
|
168
|
+
.tryRevParse(['--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
|
|
169
|
+
.trim();
|
|
170
|
+
if (upstream.length > 0) {
|
|
171
|
+
return { ref: upstream, source: 'upstream' };
|
|
172
|
+
}
|
|
173
|
+
// 2. origin/HEAD symbolic ref. Set by `git clone` to point at the remote's
|
|
174
|
+
// default branch. `git symbolic-ref` returns the full ref path; we
|
|
175
|
+
// hand that to Codex directly (it's `refs/remotes/origin/<name>`).
|
|
176
|
+
const originHead = git.trySymbolicRef('refs/remotes/origin/HEAD').trim();
|
|
177
|
+
if (originHead.length > 0) {
|
|
178
|
+
return { ref: originHead, source: 'origin-head' };
|
|
179
|
+
}
|
|
180
|
+
// 3. Explicit probes for the two most common default-branch names.
|
|
181
|
+
if (git.tryRevParse(['--verify', '--quiet', 'refs/remotes/origin/main']).length > 0) {
|
|
182
|
+
return { ref: 'refs/remotes/origin/main', source: 'origin-main' };
|
|
183
|
+
}
|
|
184
|
+
if (git.tryRevParse(['--verify', '--quiet', 'refs/remotes/origin/master']).length > 0) {
|
|
185
|
+
return { ref: 'refs/remotes/origin/master', source: 'origin-master' };
|
|
186
|
+
}
|
|
187
|
+
// 4. Local branches. `main` and `master` without `refs/remotes/` prefix
|
|
188
|
+
// resolve via `refs/heads/`. Order matches priority 3.
|
|
189
|
+
if (git.tryRevParse(['--verify', '--quiet', 'refs/heads/main']).length > 0) {
|
|
190
|
+
return { ref: 'refs/heads/main', source: 'local-main' };
|
|
191
|
+
}
|
|
192
|
+
if (git.tryRevParse(['--verify', '--quiet', 'refs/heads/master']).length > 0) {
|
|
193
|
+
return { ref: 'refs/heads/master', source: 'local-master' };
|
|
194
|
+
}
|
|
195
|
+
// 5. Last resort: diff against the empty-tree SHA. Covers every file in
|
|
196
|
+
// HEAD; expensive for large repos but correct.
|
|
197
|
+
return { ref: EMPTY_TREE_SHA, source: 'empty-tree' };
|
|
198
|
+
}
|