@bookedsolid/rea 0.10.3 → 0.11.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 +22 -167
- 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.js +6 -16
- package/dist/cli/hook.d.ts +48 -0
- package/dist/cli/hook.js +127 -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 +146 -271
- package/dist/cli/install/pre-push.js +471 -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 +57 -0
- package/dist/hooks/push-gate/base.js +77 -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 +90 -0
- package/dist/hooks/push-gate/index.js +351 -0
- package/dist/hooks/push-gate/policy.d.ts +41 -0
- package/dist/hooks/push-gate/policy.js +55 -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 +10 -10
- package/dist/policy/loader.js +7 -6
- package/dist/policy/types.d.ts +31 -22
- package/package.json +1 -1
- 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,57 @@
|
|
|
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' | 'upstream' | 'origin-head' | 'origin-main' | 'origin-master' | 'local-main' | 'local-master' | 'empty-tree';
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Well-known empty-tree SHA: `git hash-object -t tree /dev/null`. Every git
|
|
38
|
+
* installation carries this object implicitly. Using it as a fallback lets
|
|
39
|
+
* a review on a clone with no tracking refs still exercise the entire HEAD
|
|
40
|
+
* tree diff.
|
|
41
|
+
*/
|
|
42
|
+
export declare const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
43
|
+
export interface ResolveBaseOptions {
|
|
44
|
+
/**
|
|
45
|
+
* Explicit ref from `--base <ref>` or equivalent. When set, the resolver
|
|
46
|
+
* returns it unchanged with `source: 'explicit'` — the caller is
|
|
47
|
+
* responsible for its correctness. We do NOT validate that it exists;
|
|
48
|
+
* that's Codex's job (it'll error clearly if the ref is bad).
|
|
49
|
+
*/
|
|
50
|
+
explicit?: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the base ref using the configured priority order. Never throws —
|
|
54
|
+
* every failure mode degrades to the next step, and the worst case (no
|
|
55
|
+
* tracking refs, no local main/master) is `empty-tree`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function resolveBaseRef(git: GitExecutor, options?: ResolveBaseOptions): BaseResolution;
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
// 1. Upstream of current branch. `@{upstream}` resolves to the configured
|
|
43
|
+
// tracking ref (typically `refs/remotes/origin/<branch>`). Returns
|
|
44
|
+
// empty on branches without an upstream — which is normal for a brand
|
|
45
|
+
// new feature branch; fall through.
|
|
46
|
+
const upstream = git
|
|
47
|
+
.tryRevParse(['--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
|
|
48
|
+
.trim();
|
|
49
|
+
if (upstream.length > 0) {
|
|
50
|
+
return { ref: upstream, source: 'upstream' };
|
|
51
|
+
}
|
|
52
|
+
// 2. origin/HEAD symbolic ref. Set by `git clone` to point at the remote's
|
|
53
|
+
// default branch. `git symbolic-ref` returns the full ref path; we
|
|
54
|
+
// hand that to Codex directly (it's `refs/remotes/origin/<name>`).
|
|
55
|
+
const originHead = git.trySymbolicRef('refs/remotes/origin/HEAD').trim();
|
|
56
|
+
if (originHead.length > 0) {
|
|
57
|
+
return { ref: originHead, source: 'origin-head' };
|
|
58
|
+
}
|
|
59
|
+
// 3. Explicit probes for the two most common default-branch names.
|
|
60
|
+
if (git.tryRevParse(['--verify', '--quiet', 'refs/remotes/origin/main']).length > 0) {
|
|
61
|
+
return { ref: 'refs/remotes/origin/main', source: 'origin-main' };
|
|
62
|
+
}
|
|
63
|
+
if (git.tryRevParse(['--verify', '--quiet', 'refs/remotes/origin/master']).length > 0) {
|
|
64
|
+
return { ref: 'refs/remotes/origin/master', source: 'origin-master' };
|
|
65
|
+
}
|
|
66
|
+
// 4. Local branches. `main` and `master` without `refs/remotes/` prefix
|
|
67
|
+
// resolve via `refs/heads/`. Order matches priority 3.
|
|
68
|
+
if (git.tryRevParse(['--verify', '--quiet', 'refs/heads/main']).length > 0) {
|
|
69
|
+
return { ref: 'refs/heads/main', source: 'local-main' };
|
|
70
|
+
}
|
|
71
|
+
if (git.tryRevParse(['--verify', '--quiet', 'refs/heads/master']).length > 0) {
|
|
72
|
+
return { ref: 'refs/heads/master', source: 'local-master' };
|
|
73
|
+
}
|
|
74
|
+
// 5. Last resort: diff against the empty-tree SHA. Covers every file in
|
|
75
|
+
// HEAD; expensive for large repos but correct.
|
|
76
|
+
return { ref: EMPTY_TREE_SHA, source: 'empty-tree' };
|
|
77
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex CLI runner for the push-gate.
|
|
3
|
+
*
|
|
4
|
+
* Shells to `codex exec review --base <ref> --json --ephemeral` and consumes
|
|
5
|
+
* the JSONL event stream. Every event is parsed; the sequence of
|
|
6
|
+
* `agent_message` items becomes the review text that `findings.ts` then
|
|
7
|
+
* parses for P1/P2/P3 markers.
|
|
8
|
+
*
|
|
9
|
+
* Errors are typed so `index.ts` can distinguish:
|
|
10
|
+
*
|
|
11
|
+
* - `CodexNotInstalledError` → clear install-Codex prompt
|
|
12
|
+
* - `CodexTimeoutError` → `review.timeout_ms` exceeded; kill signal
|
|
13
|
+
* - `CodexProtocolError` → stdout was not JSONL or lacked agent output
|
|
14
|
+
* - `CodexSubprocessError` → non-zero exit with captured stderr
|
|
15
|
+
*
|
|
16
|
+
* The `GitExecutor` interface is a narrow shim around `git` invocations the
|
|
17
|
+
* gate needs (base resolution, diff-names, HEAD resolution). Extracted so
|
|
18
|
+
* `./base.ts` and `./index.ts` can be unit-tested with deterministic fakes
|
|
19
|
+
* and so the one git dependency surface is in one place.
|
|
20
|
+
*/
|
|
21
|
+
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
22
|
+
export declare class CodexNotInstalledError extends Error {
|
|
23
|
+
readonly kind: "not-installed";
|
|
24
|
+
constructor();
|
|
25
|
+
}
|
|
26
|
+
export declare class CodexTimeoutError extends Error {
|
|
27
|
+
readonly timeoutMs: number;
|
|
28
|
+
readonly kind: "timeout";
|
|
29
|
+
constructor(timeoutMs: number);
|
|
30
|
+
}
|
|
31
|
+
export declare class CodexProtocolError extends Error {
|
|
32
|
+
readonly detail: string;
|
|
33
|
+
readonly sampleLine?: string | undefined;
|
|
34
|
+
readonly kind: "protocol";
|
|
35
|
+
constructor(detail: string, sampleLine?: string | undefined);
|
|
36
|
+
}
|
|
37
|
+
export declare class CodexSubprocessError extends Error {
|
|
38
|
+
readonly exitCode: number | null;
|
|
39
|
+
readonly signal: NodeJS.Signals | null;
|
|
40
|
+
readonly stderrTail: string;
|
|
41
|
+
readonly kind: "subprocess";
|
|
42
|
+
constructor(exitCode: number | null, signal: NodeJS.Signals | null, stderrTail: string);
|
|
43
|
+
}
|
|
44
|
+
export type CodexRunError = CodexNotInstalledError | CodexTimeoutError | CodexProtocolError | CodexSubprocessError;
|
|
45
|
+
export interface GitExecutor {
|
|
46
|
+
/** `git rev-parse <args>`. Returns stdout trimmed or '' on non-zero exit. */
|
|
47
|
+
tryRevParse(args: string[]): string;
|
|
48
|
+
/** `git symbolic-ref <ref>`. Returns stdout trimmed or '' on non-zero. */
|
|
49
|
+
trySymbolicRef(ref: string): string;
|
|
50
|
+
/** `git rev-parse HEAD`. Returns the 40-char SHA or '' on non-zero. */
|
|
51
|
+
headSha(): string;
|
|
52
|
+
/** `git diff --name-only <base> <head>`. Returns path list (possibly empty). */
|
|
53
|
+
diffNames(base: string, head: string): string[];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Real git implementation using `spawnSync`. Each call is independent (no
|
|
57
|
+
* persistent git process) — the gate runs infrequently enough that the
|
|
58
|
+
* fork overhead is inaudible.
|
|
59
|
+
*/
|
|
60
|
+
export declare function createRealGitExecutor(cwd: string): GitExecutor;
|
|
61
|
+
export interface CodexRunOptions {
|
|
62
|
+
baseRef: string;
|
|
63
|
+
cwd: string;
|
|
64
|
+
timeoutMs: number;
|
|
65
|
+
/** Optional custom review prompt; defaults to Codex's built-in. */
|
|
66
|
+
prompt?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Env passthrough. Tests inject a clean env to prevent ambient overrides.
|
|
69
|
+
* Production passes `process.env`.
|
|
70
|
+
*/
|
|
71
|
+
env?: NodeJS.ProcessEnv;
|
|
72
|
+
/**
|
|
73
|
+
* Injection seam for tests. When set, replaces `spawn` entirely. Must
|
|
74
|
+
* return an object whose `stdout`/`stderr` are async iterables of Buffer
|
|
75
|
+
* chunks and whose `on('exit')` yields `(code, signal)` like a real
|
|
76
|
+
* ChildProcess. Keeping this narrow means we don't have to fake the
|
|
77
|
+
* whole ChildProcess API.
|
|
78
|
+
*/
|
|
79
|
+
spawnImpl?: (command: string, args: readonly string[], options: {
|
|
80
|
+
cwd: string;
|
|
81
|
+
env: NodeJS.ProcessEnv;
|
|
82
|
+
}) => ChildProcessWithoutNullStreams;
|
|
83
|
+
}
|
|
84
|
+
export interface CodexRunResult {
|
|
85
|
+
/** The concatenated text of every `item.completed` agent_message item. */
|
|
86
|
+
reviewText: string;
|
|
87
|
+
/** Number of JSONL events observed — useful for debugging protocol issues. */
|
|
88
|
+
eventCount: number;
|
|
89
|
+
/** Seconds of wall time spent in the subprocess. */
|
|
90
|
+
durationSeconds: number;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Execute `codex exec review` and return the concatenated review text on
|
|
94
|
+
* success. Callers then pass the text to `summarizeReview()` to get a
|
|
95
|
+
* structured verdict.
|
|
96
|
+
*
|
|
97
|
+
* Every error case throws a typed `CodexRunError`. Callers are expected to
|
|
98
|
+
* catch and translate to an exit code + audit event.
|
|
99
|
+
*/
|
|
100
|
+
export declare function runCodexReview(options: CodexRunOptions): Promise<CodexRunResult>;
|
|
101
|
+
export interface CodexJsonlParseResult {
|
|
102
|
+
reviewText: string;
|
|
103
|
+
eventCount: number;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Parse the JSONL event stream emitted by `codex exec review --json`. We
|
|
107
|
+
* tolerate partial lines (stream chunks may split mid-object; our caller
|
|
108
|
+
* gives us the full stdout after exit, but robustness costs nothing).
|
|
109
|
+
*
|
|
110
|
+
* The only events we care about are `item.completed` where `item.type ===
|
|
111
|
+
* "agent_message"` — those carry the review text. Everything else (turn
|
|
112
|
+
* lifecycle, command_execution telemetry, thread metadata) is counted but
|
|
113
|
+
* discarded.
|
|
114
|
+
*
|
|
115
|
+
* A JSONL line that doesn't parse as JSON is tolerated: we skip it and
|
|
116
|
+
* continue. Codex occasionally emits warnings outside the JSON envelope
|
|
117
|
+
* (e.g. macOS xcrun cache errors leak into stderr but can accidentally
|
|
118
|
+
* land on stdout in misbehaving shells); we treat these as non-fatal.
|
|
119
|
+
*
|
|
120
|
+
* We throw `CodexProtocolError` only when the ENTIRE stdout contains zero
|
|
121
|
+
* parseable events AND zero `agent_message`-carrying items. An empty diff
|
|
122
|
+
* can legitimately yield zero agent messages with events (thread.started,
|
|
123
|
+
* turn.started, turn.completed), so we allow zero findings when at least
|
|
124
|
+
* one event parsed.
|
|
125
|
+
*/
|
|
126
|
+
export declare function parseCodexJsonl(stdout: string): CodexJsonlParseResult;
|