@bookedsolid/rea 0.22.0 → 0.23.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.
Files changed (55) hide show
  1. package/README.md +15 -0
  2. package/THREAT_MODEL.md +582 -0
  3. package/dist/audit/append.js +1 -1
  4. package/dist/cli/doctor.js +11 -12
  5. package/dist/cli/hook.d.ts +37 -3
  6. package/dist/cli/hook.js +167 -5
  7. package/dist/cli/init.js +14 -26
  8. package/dist/cli/install/canonical.js +18 -3
  9. package/dist/cli/install/commit-msg.js +1 -2
  10. package/dist/cli/install/copy.js +4 -13
  11. package/dist/cli/install/fs-safe.js +5 -16
  12. package/dist/cli/install/gitignore.js +1 -5
  13. package/dist/cli/install/pre-push.js +3 -8
  14. package/dist/cli/install/settings-merge.js +79 -16
  15. package/dist/cli/upgrade.js +14 -10
  16. package/dist/gateway/downstream.js +1 -2
  17. package/dist/gateway/live-state.js +3 -1
  18. package/dist/gateway/log.js +1 -3
  19. package/dist/gateway/middleware/audit.js +1 -1
  20. package/dist/gateway/middleware/injection.js +3 -9
  21. package/dist/gateway/middleware/policy.js +3 -1
  22. package/dist/gateway/middleware/redact.js +1 -1
  23. package/dist/gateway/observability/codex-telemetry.js +1 -2
  24. package/dist/gateway/reviewers/claude-self.js +10 -6
  25. package/dist/hooks/bash-scanner/blocked-scan.d.ts +26 -0
  26. package/dist/hooks/bash-scanner/blocked-scan.js +467 -0
  27. package/dist/hooks/bash-scanner/index.d.ts +41 -0
  28. package/dist/hooks/bash-scanner/index.js +62 -0
  29. package/dist/hooks/bash-scanner/parse-fail-closed.d.ts +31 -0
  30. package/dist/hooks/bash-scanner/parse-fail-closed.js +27 -0
  31. package/dist/hooks/bash-scanner/parser.d.ts +42 -0
  32. package/dist/hooks/bash-scanner/parser.js +92 -0
  33. package/dist/hooks/bash-scanner/protected-scan.d.ts +76 -0
  34. package/dist/hooks/bash-scanner/protected-scan.js +815 -0
  35. package/dist/hooks/bash-scanner/verdict.d.ts +80 -0
  36. package/dist/hooks/bash-scanner/verdict.js +49 -0
  37. package/dist/hooks/bash-scanner/walker.d.ts +165 -0
  38. package/dist/hooks/bash-scanner/walker.js +7954 -0
  39. package/dist/hooks/push-gate/base.js +2 -6
  40. package/dist/hooks/push-gate/codex-runner.js +3 -1
  41. package/dist/hooks/push-gate/index.js +9 -10
  42. package/dist/policy/loader.js +4 -1
  43. package/dist/registry/tofu-gate.js +2 -2
  44. package/hooks/blocked-paths-bash-gate.sh +142 -272
  45. package/hooks/protected-paths-bash-gate.sh +227 -511
  46. package/package.json +3 -2
  47. package/profiles/bst-internal-no-codex.yaml +1 -1
  48. package/profiles/bst-internal.yaml +1 -1
  49. package/profiles/client-engagement.yaml +1 -1
  50. package/profiles/lit-wc.yaml +1 -1
  51. package/profiles/minimal.yaml +1 -1
  52. package/profiles/open-source-no-codex.yaml +1 -1
  53. package/profiles/open-source.yaml +1 -1
  54. package/scripts/postinstall.mjs +1 -2
  55. package/scripts/run-vitest.mjs +117 -0
@@ -4,12 +4,12 @@ import { loadPolicy } from '../policy/loader.js';
4
4
  import { loadRegistry } from '../registry/loader.js';
5
5
  import { loadFingerprintStore } from '../registry/fingerprints-store.js';
6
6
  import { fingerprintServer } from '../registry/fingerprint.js';
7
- import { CodexProbe, } from '../gateway/observability/codex-probe.js';
8
- import { inspectPrePushState, } from './install/pre-push.js';
7
+ import { CodexProbe } from '../gateway/observability/codex-probe.js';
8
+ import { inspectPrePushState } from './install/pre-push.js';
9
9
  import { summarizeTelemetry } from '../gateway/observability/codex-telemetry.js';
10
10
  import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
11
11
  import { buildFragment } from './install/claude-md.js';
12
- import { canonicalSettingsSubsetHash, defaultDesiredHooks, } from './install/settings-merge.js';
12
+ import { canonicalSettingsSubsetHash, defaultDesiredHooks } from './install/settings-merge.js';
13
13
  import { manifestExists, readManifest } from './install/manifest-io.js';
14
14
  import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
15
15
  import { POLICY_FILE, REA_DIR, REGISTRY_FILE, getPkgVersion, log, reaPath } from './utils.js';
@@ -177,7 +177,11 @@ function checkAgentsPresent(baseDir) {
177
177
  function checkHooksInstalled(baseDir) {
178
178
  const hooksDir = path.join(baseDir, '.claude', 'hooks');
179
179
  if (!fs.existsSync(hooksDir)) {
180
- return { label: 'hooks installed + executable', status: 'fail', detail: `missing: ${hooksDir}` };
180
+ return {
181
+ label: 'hooks installed + executable',
182
+ status: 'fail',
183
+ detail: `missing: ${hooksDir}`,
184
+ };
181
185
  }
182
186
  const issues = [];
183
187
  for (const name of EXPECTED_HOOKS) {
@@ -309,9 +313,7 @@ export function isGitRepo(baseDir) {
309
313
  const targetPath = rawTarget;
310
314
  if (targetPath.length === 0)
311
315
  return false;
312
- const resolved = path.isAbsolute(targetPath)
313
- ? targetPath
314
- : path.join(baseDir, targetPath);
316
+ const resolved = path.isAbsolute(targetPath) ? targetPath : path.join(baseDir, targetPath);
315
317
  return fs.existsSync(resolved);
316
318
  }
317
319
  function checkCommitMsgHook(baseDir) {
@@ -386,9 +388,7 @@ function checkPrePushHook(state) {
386
388
  // …), surface the .d/ migration path explicitly so consumers know
387
389
  // exactly how to keep their existing chain without losing rea coverage
388
390
  // or having `rea upgrade` clobber them again.
389
- const hints = state.activePath !== null
390
- ? detectPriorToolHints(state.activePath)
391
- : [];
391
+ const hints = state.activePath !== null ? detectPriorToolHints(state.activePath) : [];
392
392
  let detail = `active pre-push at ${state.activePath} is present and executable but does NOT ` +
393
393
  'invoke `rea hook push-gate` — the 0.11.0 push-gate is silently bypassed. ' +
394
394
  'Either add `exec rea hook push-gate "$@"` to the existing hook, or ' +
@@ -820,8 +820,7 @@ export async function collectDriftReport(baseDir) {
820
820
  // Manifest entries no longer in canonical (removed upstream), excluding
821
821
  // synthetic entries handled below.
822
822
  for (const entry of manifest.files) {
823
- if (entry.path === CLAUDE_MD_MANIFEST_PATH ||
824
- entry.path === SETTINGS_MANIFEST_PATH)
823
+ if (entry.path === CLAUDE_MD_MANIFEST_PATH || entry.path === SETTINGS_MANIFEST_PATH)
825
824
  continue;
826
825
  if (!canonicalByPath.has(entry.path)) {
827
826
  rows.push({
@@ -48,8 +48,42 @@ export interface HookPushGateOptions {
48
48
  */
49
49
  export declare function runHookPushGate(options: HookPushGateOptions): Promise<void>;
50
50
  /**
51
- * Attach the `rea hook` subcommand tree to a commander Program. Single
52
- * subcommand today (`push-gate`); new hooks should land here rather than as
53
- * top-level commands so the CLI surface stays navigable.
51
+ * `rea hook scan-bash --mode protected|blocked` invoked by the bash
52
+ * shim hooks at `hooks/protected-paths-bash-gate.sh` and
53
+ * `hooks/blocked-paths-bash-gate.sh` (since 0.23.0). Reads the Claude
54
+ * Code tool-input JSON from stdin, extracts `.tool_input.command`,
55
+ * runs the parser-backed scanner, and writes a verdict JSON to stdout.
56
+ *
57
+ * Exit-code contract (parsed by the bash shim via `jq`):
58
+ * 0 — allow (verdict.verdict == "allow")
59
+ * 2 — block (verdict.verdict == "block")
60
+ * 1 — runtime error (HALT active, missing args, internal exception)
61
+ *
62
+ * The verdict shape on stdout is `Verdict` (see `verdict.ts`); the
63
+ * bash shim only reads `.verdict` and `.reason`. Other fields are for
64
+ * structured-logging consumers in tests + audit middleware.
65
+ *
66
+ * HALT is checked HERE (not in the bash shim) so we have a single
67
+ * source of truth — the shim is intentionally as dumb as possible.
68
+ */
69
+ export interface HookScanBashOptions {
70
+ mode: 'protected' | 'blocked';
71
+ /**
72
+ * Override REA_ROOT. Useful in tests; the production shim doesn't
73
+ * pass this — it relies on `process.cwd()` matching CLAUDE_PROJECT_DIR.
74
+ */
75
+ reaRoot?: string;
76
+ }
77
+ /**
78
+ * The non-async entry the commander binding hits. Reads stdin (with
79
+ * a timeout — same pattern as runHookPushGate), executes the scan,
80
+ * writes the verdict JSON, exits with the appropriate code.
81
+ */
82
+ export declare function runHookScanBash(options: HookScanBashOptions): Promise<void>;
83
+ /**
84
+ * Attach the `rea hook` subcommand tree to a commander Program. Two
85
+ * subcommands today: `push-gate` and `scan-bash`. New hooks should land
86
+ * here rather than as top-level commands so the CLI surface stays
87
+ * navigable.
54
88
  */
55
89
  export declare function registerHookCommand(program: Command): void;
package/dist/cli/hook.js CHANGED
@@ -28,7 +28,12 @@
28
28
  * `codex_required: true`, `concerns_blocks: true`. The gate still fires.
29
29
  * This matches the protective default established in 0.10.x.
30
30
  */
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
31
33
  import { parsePrePushStdin, runPushGate } from '../hooks/push-gate/index.js';
34
+ import { runBlockedScan, runProtectedScan } from '../hooks/bash-scanner/index.js';
35
+ import { loadPolicy } from '../policy/loader.js';
36
+ import { appendAuditRecord, InvocationStatus, Tier } from '../audit/append.js';
32
37
  import { err } from './utils.js';
33
38
  /**
34
39
  * Public runner, exposed so integration tests and the commander binding can
@@ -54,7 +59,9 @@ export async function runHookPushGate(options) {
54
59
  env: process.env,
55
60
  stderr,
56
61
  refspecs,
57
- ...(options.base !== undefined && options.base.length > 0 ? { explicitBase: options.base } : {}),
62
+ ...(options.base !== undefined && options.base.length > 0
63
+ ? { explicitBase: options.base }
64
+ : {}),
58
65
  ...(options.lastNCommits !== undefined ? { lastNCommits: options.lastNCommits } : {}),
59
66
  });
60
67
  process.exit(result.exitCode);
@@ -102,14 +109,169 @@ async function readStdinWithTimeout(timeoutMs) {
102
109
  });
103
110
  }
104
111
  /**
105
- * Attach the `rea hook` subcommand tree to a commander Program. Single
106
- * subcommand today (`push-gate`); new hooks should land here rather than as
107
- * top-level commands so the CLI surface stays navigable.
112
+ * The non-async entry the commander binding hits. Reads stdin (with
113
+ * a timeout same pattern as runHookPushGate), executes the scan,
114
+ * writes the verdict JSON, exits with the appropriate code.
115
+ */
116
+ export async function runHookScanBash(options) {
117
+ const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
118
+ // HALT check — uniform with the bash hooks. We exit 2 (block) so
119
+ // the shim refuses the command in the same way settings-protection
120
+ // and the bash gates do.
121
+ const haltPath = path.join(reaRoot, '.rea', 'HALT');
122
+ if (fs.existsSync(haltPath)) {
123
+ let reason = 'Reason unknown';
124
+ try {
125
+ const content = fs.readFileSync(haltPath, 'utf8');
126
+ reason = content.slice(0, 1024).trim() || reason;
127
+ }
128
+ catch {
129
+ /* leave default */
130
+ }
131
+ process.stderr.write(`REA HALT: ${reason}\nAll agent operations suspended. Run: rea unfreeze\n`);
132
+ const haltVerdict = {
133
+ verdict: 'block',
134
+ reason: 'rea HALT active',
135
+ };
136
+ process.stdout.write(JSON.stringify(haltVerdict) + '\n');
137
+ process.exit(2);
138
+ }
139
+ const stdinRaw = process.stdin.isTTY ? '' : await readStdinWithTimeout(5_000);
140
+ let cmd = '';
141
+ if (stdinRaw.length > 0) {
142
+ try {
143
+ const parsed = JSON.parse(stdinRaw);
144
+ const c = parsed.tool_input?.command;
145
+ // Codex round 1 F-31: tool_input.command MUST be a string. A
146
+ // crafted payload with `command: ["rm", "-rf"]` or `command: 42`
147
+ // would pre-fix silently fall through to "allow on empty cmd".
148
+ // Refuse on type mismatch.
149
+ if (c !== undefined && typeof c !== 'string') {
150
+ const wrong = {
151
+ verdict: 'block',
152
+ reason: 'rea: scan-bash received a non-string `tool_input.command` field; refusing on uncertainty',
153
+ };
154
+ process.stdout.write(JSON.stringify(wrong) + '\n');
155
+ process.stderr.write(wrong.reason + '\n');
156
+ process.exit(2);
157
+ }
158
+ if (typeof c === 'string')
159
+ cmd = c;
160
+ }
161
+ catch {
162
+ // Malformed JSON on stdin → fail closed. The bash shim only
163
+ // forwards what Claude Code sends, so this should never happen
164
+ // in production; treating it as block prevents a crafted payload
165
+ // from getting an allow.
166
+ const malformed = {
167
+ verdict: 'block',
168
+ reason: 'rea: scan-bash received malformed JSON on stdin; refusing on uncertainty',
169
+ };
170
+ process.stdout.write(JSON.stringify(malformed) + '\n');
171
+ process.exit(2);
172
+ }
173
+ }
174
+ // Empty command → allow. Matches the bash gates' `[[ -z "$CMD" ]] && exit 0`.
175
+ if (cmd.length === 0) {
176
+ process.stdout.write(JSON.stringify({ verdict: 'allow' }) + '\n');
177
+ process.exit(0);
178
+ }
179
+ // Load policy. A missing policy file is treated as "no governance" —
180
+ // we allow on missing-policy so dev environments without a fully-
181
+ // initialized rea directory don't hard-block. The bash shim
182
+ // pre-0.23.0 had the same posture.
183
+ let blockedPaths = [];
184
+ let protectedWrites;
185
+ let protectedRelax = [];
186
+ try {
187
+ const policy = loadPolicy(reaRoot);
188
+ blockedPaths = policy.blocked_paths;
189
+ protectedWrites = policy.protected_writes;
190
+ protectedRelax = policy.protected_paths_relax ?? [];
191
+ }
192
+ catch {
193
+ // Policy missing or invalid. Continue with defaults — the historical
194
+ // protected list is hardcoded; blocked_paths becomes an empty no-op.
195
+ }
196
+ let verdict;
197
+ try {
198
+ if (options.mode === 'protected') {
199
+ verdict = runProtectedScan({
200
+ reaRoot,
201
+ policy: {
202
+ ...(protectedWrites !== undefined ? { protected_writes: protectedWrites } : {}),
203
+ protected_paths_relax: protectedRelax,
204
+ },
205
+ stderr: (line) => process.stderr.write(line),
206
+ }, cmd);
207
+ }
208
+ else {
209
+ verdict = runBlockedScan({ reaRoot, blockedPaths }, cmd);
210
+ }
211
+ }
212
+ catch (e) {
213
+ // Any exception in the scanner is a bug; fail closed.
214
+ const reason = e instanceof Error ? e.message : String(e);
215
+ verdict = {
216
+ verdict: 'block',
217
+ reason: `rea: scan-bash internal error; refusing on uncertainty: ${reason}`,
218
+ };
219
+ }
220
+ // Codex round 1 F-26: emit an audit record so the gateway audit log
221
+ // captures every scan-bash invocation. Best-effort — failure to
222
+ // write an audit entry must NOT change the verdict.
223
+ try {
224
+ await appendAuditRecord(reaRoot, {
225
+ tool_name: 'rea.hook.scan-bash',
226
+ server_name: 'rea',
227
+ tier: Tier.Read,
228
+ status: verdict.verdict === 'allow' ? InvocationStatus.Allowed : InvocationStatus.Denied,
229
+ metadata: {
230
+ mode: options.mode,
231
+ verdict: verdict.verdict,
232
+ ...(verdict.detected_form !== undefined ? { detected_form: verdict.detected_form } : {}),
233
+ ...(verdict.hit_pattern !== undefined ? { hit_pattern: verdict.hit_pattern } : {}),
234
+ // Truncate the command to avoid blowing the audit log on very
235
+ // long inputs.
236
+ command_preview: cmd.slice(0, 256),
237
+ },
238
+ });
239
+ }
240
+ catch {
241
+ /* best-effort */
242
+ }
243
+ // Write verdict JSON to stdout.
244
+ process.stdout.write(JSON.stringify(verdict) + '\n');
245
+ if (verdict.verdict === 'block') {
246
+ if (typeof verdict.reason === 'string' && verdict.reason.length > 0) {
247
+ process.stderr.write(verdict.reason + '\n');
248
+ }
249
+ process.exit(2);
250
+ }
251
+ process.exit(0);
252
+ }
253
+ /**
254
+ * Attach the `rea hook` subcommand tree to a commander Program. Two
255
+ * subcommands today: `push-gate` and `scan-bash`. New hooks should land
256
+ * here rather than as top-level commands so the CLI surface stays
257
+ * navigable.
108
258
  */
109
259
  export function registerHookCommand(program) {
110
260
  const hook = program
111
261
  .command('hook')
112
- .description('Pre-hook entry points for git (pre-push) and Claude Code. Called by `.husky/pre-push` and the optional `.git/hooks/pre-push` fallback.');
262
+ .description('Pre-hook entry points for git (pre-push) and Claude Code. Called by `.husky/pre-push`, the optional `.git/hooks/pre-push` fallback, and the bash-shim Claude Code hooks at `.claude/hooks/{protected,blocked}-paths-bash-gate.sh`.');
263
+ hook
264
+ .command('scan-bash')
265
+ .description('Parser-backed bash-tier scanner. Reads Claude Code tool-input JSON from stdin, runs the AST walker against the protected-paths or blocked_paths policy, and writes a verdict JSON to stdout. Exit 0 on allow, 2 on block.')
266
+ .option('--mode <protected|blocked>', 'which policy to enforce: `protected` for the hardcoded + protected_writes list, `blocked` for the policy.blocked_paths list', (raw) => {
267
+ if (raw !== 'protected' && raw !== 'blocked') {
268
+ throw new Error(`--mode must be "protected" or "blocked", got ${JSON.stringify(raw)}`);
269
+ }
270
+ return raw;
271
+ }, 'protected')
272
+ .action(async (opts) => {
273
+ await runHookScanBash({ mode: opts.mode });
274
+ });
113
275
  hook
114
276
  .command('push-gate')
115
277
  // Accept (and silently ignore) positional args. Git passes the
package/dist/cli/init.js CHANGED
@@ -15,7 +15,7 @@ import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFile
15
15
  import { writeManifestAtomic } from './install/manifest-io.js';
16
16
  import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
17
17
  import { defaultReagentPath, ReagentDroppedFieldsError, translateReagentPolicy, } from './install/reagent.js';
18
- import { POLICY_FILE, REA_DIR, REGISTRY_FILE, err, getPkgVersion, log, warn, } from './utils.js';
18
+ import { POLICY_FILE, REA_DIR, REGISTRY_FILE, err, getPkgVersion, log, warn } from './utils.js';
19
19
  const PROFILE_NAMES = [
20
20
  'minimal',
21
21
  'client-engagement',
@@ -114,7 +114,11 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
114
114
  initialValue: 'minimal',
115
115
  options: [
116
116
  { value: 'minimal', label: 'minimal', hint: 'bare policy, no extras (default)' },
117
- { value: 'client-engagement', label: 'client-engagement', hint: 'zero-trust client project' },
117
+ {
118
+ value: 'client-engagement',
119
+ label: 'client-engagement',
120
+ hint: 'zero-trust client project',
121
+ },
118
122
  { value: 'bst-internal', label: 'bst-internal', hint: 'internal BST projects' },
119
123
  { value: 'lit-wc', label: 'lit-wc', hint: 'Lit / web component libraries' },
120
124
  { value: 'open-source', label: 'open-source', hint: 'public OSS repos' },
@@ -126,9 +130,7 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
126
130
  }
127
131
  // 0.21.1: prefer the existing on-disk value over the profile default
128
132
  // so re-running `rea init` doesn't reset an operator's manual edit.
129
- const autonomyDefault = existingPolicy?.autonomyLevel
130
- ?? layeredBase.autonomy_level
131
- ?? AutonomyLevel.L1;
133
+ const autonomyDefault = existingPolicy?.autonomyLevel ?? layeredBase.autonomy_level ?? AutonomyLevel.L1;
132
134
  const autonomyPick = await p.select({
133
135
  message: existingPolicy?.autonomyLevel !== undefined
134
136
  ? `Starting autonomy_level (current: ${existingPolicy.autonomyLevel})`
@@ -177,9 +179,7 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
177
179
  // G11.4: "Use Codex adversarial review?" — the default follows the
178
180
  // chosen profile (any `*-no-codex` profile defaults to No). An explicit
179
181
  // flag on the command line overrides that default for the initial value.
180
- const codexInitial = options.codex !== undefined
181
- ? options.codex
182
- : profileDefaultCodexRequired(profileName);
182
+ const codexInitial = options.codex !== undefined ? options.codex : profileDefaultCodexRequired(profileName);
183
183
  const codexPick = await p.confirm({
184
184
  message: 'Use Codex adversarial review? (requires an OpenAI account — can be added later)',
185
185
  initialValue: codexInitial,
@@ -542,9 +542,7 @@ export async function runInit(options) {
542
542
  process.exit(1);
543
543
  }
544
544
  const baseProfile = loadProfile(profileName);
545
- const profileCeiling = baseProfile?.max_autonomy_level ??
546
- HARD_DEFAULTS.max_autonomy_level ??
547
- AutonomyLevel.L2;
545
+ const profileCeiling = baseProfile?.max_autonomy_level ?? HARD_DEFAULTS.max_autonomy_level ?? AutonomyLevel.L2;
548
546
  try {
549
547
  const t = translateReagentPolicy(reagentPolicyPath, {
550
548
  profileCeiling,
@@ -586,21 +584,11 @@ export async function runInit(options) {
586
584
  : (existingPolicy?.codexRequired ?? profileDefaultCodexRequired(profileName));
587
585
  config = {
588
586
  profile: profileName,
589
- autonomyLevel: existingPolicy?.autonomyLevel
590
- ?? layeredBase.autonomy_level
591
- ?? AutonomyLevel.L1,
592
- maxAutonomyLevel: existingPolicy?.maxAutonomyLevel
593
- ?? layeredBase.max_autonomy_level
594
- ?? AutonomyLevel.L2,
595
- blockAiAttribution: existingPolicy?.blockAiAttribution
596
- ?? layeredBase.block_ai_attribution
597
- ?? true,
598
- blockedPaths: existingPolicy?.blockedPaths
599
- ?? layeredBase.blocked_paths
600
- ?? ['.env', '.env.*'],
601
- notificationChannel: existingPolicy?.notificationChannel
602
- ?? layeredBase.notification_channel
603
- ?? '',
587
+ autonomyLevel: existingPolicy?.autonomyLevel ?? layeredBase.autonomy_level ?? AutonomyLevel.L1,
588
+ maxAutonomyLevel: existingPolicy?.maxAutonomyLevel ?? layeredBase.max_autonomy_level ?? AutonomyLevel.L2,
589
+ blockAiAttribution: existingPolicy?.blockAiAttribution ?? layeredBase.block_ai_attribution ?? true,
590
+ blockedPaths: existingPolicy?.blockedPaths ?? layeredBase.blocked_paths ?? ['.env', '.env.*'],
591
+ notificationChannel: existingPolicy?.notificationChannel ?? layeredBase.notification_channel ?? '',
604
592
  codexRequired,
605
593
  fromReagent,
606
594
  reagentPolicyPath,
@@ -67,9 +67,24 @@ async function walkFiles(srcDir) {
67
67
  */
68
68
  export async function enumerateCanonicalFiles(pkgRoot = PKG_ROOT) {
69
69
  const mappings = [
70
- { srcDir: path.join(pkgRoot, 'hooks'), dstPrefix: '.claude/hooks', source: 'hook', mode: 0o755 },
71
- { srcDir: path.join(pkgRoot, 'agents'), dstPrefix: '.claude/agents', source: 'agent', mode: 0o644 },
72
- { srcDir: path.join(pkgRoot, 'commands'), dstPrefix: '.claude/commands', source: 'command', mode: 0o644 },
70
+ {
71
+ srcDir: path.join(pkgRoot, 'hooks'),
72
+ dstPrefix: '.claude/hooks',
73
+ source: 'hook',
74
+ mode: 0o755,
75
+ },
76
+ {
77
+ srcDir: path.join(pkgRoot, 'agents'),
78
+ dstPrefix: '.claude/agents',
79
+ source: 'agent',
80
+ mode: 0o644,
81
+ },
82
+ {
83
+ srcDir: path.join(pkgRoot, 'commands'),
84
+ dstPrefix: '.claude/commands',
85
+ source: 'command',
86
+ mode: 0o644,
87
+ },
73
88
  { srcDir: path.join(pkgRoot, '.husky'), dstPrefix: '.husky', source: 'husky', mode: 0o755 },
74
89
  ];
75
90
  const out = [];
@@ -78,8 +78,7 @@ export async function classifyCommitMsgHook(hookPath) {
78
78
  }
79
79
  // Pre-0.13 rea body had no marker but always contained the attribution
80
80
  // grep. Treat that shape as upgradeable rather than foreign.
81
- if (content.includes('block_ai_attribution') &&
82
- content.includes('AI attribution detected')) {
81
+ if (content.includes('block_ai_attribution') && content.includes('AI attribution detected')) {
83
82
  return { kind: 'unmarked' };
84
83
  }
85
84
  return { kind: 'foreign', reason: 'no-marker' };
@@ -131,9 +131,7 @@ async function assertSafeDestination(resolvedRoot, dstPath) {
131
131
  // Containment: resolve without following symlinks on the leaf so an attacker
132
132
  // cannot smuggle us out via a symlink in the leaf itself.
133
133
  const resolvedDst = path.resolve(dstPath);
134
- const rootWithSep = resolvedRoot.endsWith(path.sep)
135
- ? resolvedRoot
136
- : resolvedRoot + path.sep;
134
+ const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
137
135
  if (resolvedDst !== resolvedRoot && !resolvedDst.startsWith(rootWithSep)) {
138
136
  throw new UnsafeInstallPathError('escape', resolvedDst, undefined, `refusing to write outside install root: ${resolvedDst} is not under ${resolvedRoot}`);
139
137
  }
@@ -171,9 +169,7 @@ async function assertSafeDestination(resolvedRoot, dstPath) {
171
169
  */
172
170
  async function assertSafeDirectory(resolvedRoot, dirPath) {
173
171
  const resolvedDir = path.resolve(dirPath);
174
- const rootWithSep = resolvedRoot.endsWith(path.sep)
175
- ? resolvedRoot
176
- : resolvedRoot + path.sep;
172
+ const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
177
173
  if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(rootWithSep)) {
178
174
  throw new UnsafeInstallPathError('escape', resolvedDir, undefined, `refusing to operate on directory outside install root: ${resolvedDir}`);
179
175
  }
@@ -234,9 +230,7 @@ async function assertSafeDirectory(resolvedRoot, dirPath) {
234
230
  */
235
231
  async function snapshotAncestors(resolvedRoot, dstPath) {
236
232
  const snapshot = new Map();
237
- const rootWithSep = resolvedRoot.endsWith(path.sep)
238
- ? resolvedRoot
239
- : resolvedRoot + path.sep;
233
+ const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
240
234
  const leafDir = path.dirname(path.resolve(dstPath));
241
235
  let cursor = leafDir;
242
236
  let reachedRoot = false;
@@ -334,10 +328,7 @@ async function verifyAncestorsUnchanged(snapshot) {
334
328
  */
335
329
  async function writeFileExclusiveNoFollow(srcPath, dstPath) {
336
330
  const contents = await fsPromises.readFile(srcPath);
337
- const flags = fs.constants.O_WRONLY |
338
- fs.constants.O_CREAT |
339
- fs.constants.O_EXCL |
340
- fs.constants.O_NOFOLLOW;
331
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW;
341
332
  const fh = await fsPromises.open(dstPath, flags, 0o644);
342
333
  try {
343
334
  await fh.writeFile(contents);
@@ -47,9 +47,7 @@ export function resolveContained(resolvedRoot, candidate) {
47
47
  throw new UnsafeInstallPathError('escape', candidate, undefined, `refusing path with parent-directory segments: ${candidate}`);
48
48
  }
49
49
  const absolute = path.resolve(resolvedRoot, candidate);
50
- const rootWithSep = resolvedRoot.endsWith(path.sep)
51
- ? resolvedRoot
52
- : resolvedRoot + path.sep;
50
+ const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
53
51
  if (absolute !== resolvedRoot && !absolute.startsWith(rootWithSep)) {
54
52
  throw new UnsafeInstallPathError('escape', absolute, undefined, `refusing to resolve outside install root: ${absolute} is not under ${resolvedRoot}`);
55
53
  }
@@ -62,9 +60,7 @@ export function resolveContained(resolvedRoot, candidate) {
62
60
  */
63
61
  export async function assertSafeDestination(resolvedRoot, dstPath) {
64
62
  const resolvedDst = path.resolve(dstPath);
65
- const rootWithSep = resolvedRoot.endsWith(path.sep)
66
- ? resolvedRoot
67
- : resolvedRoot + path.sep;
63
+ const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
68
64
  if (resolvedDst !== resolvedRoot && !resolvedDst.startsWith(rootWithSep)) {
69
65
  throw new UnsafeInstallPathError('escape', resolvedDst, undefined, `refusing to write outside install root: ${resolvedDst} is not under ${resolvedRoot}`);
70
66
  }
@@ -95,9 +91,7 @@ export async function assertSafeDestination(resolvedRoot, dstPath) {
95
91
  }
96
92
  export async function assertSafeDirectory(resolvedRoot, dirPath) {
97
93
  const resolvedDir = path.resolve(dirPath);
98
- const rootWithSep = resolvedRoot.endsWith(path.sep)
99
- ? resolvedRoot
100
- : resolvedRoot + path.sep;
94
+ const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
101
95
  if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(rootWithSep)) {
102
96
  throw new UnsafeInstallPathError('escape', resolvedDir, undefined, `refusing to operate on directory outside install root: ${resolvedDir}`);
103
97
  }
@@ -126,9 +120,7 @@ export async function assertSafeDirectory(resolvedRoot, dirPath) {
126
120
  }
127
121
  export async function snapshotAncestors(resolvedRoot, dstPath) {
128
122
  const snapshot = new Map();
129
- const rootWithSep = resolvedRoot.endsWith(path.sep)
130
- ? resolvedRoot
131
- : resolvedRoot + path.sep;
123
+ const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
132
124
  const leafDir = path.dirname(path.resolve(dstPath));
133
125
  let cursor = leafDir;
134
126
  let reachedRoot = false;
@@ -196,10 +188,7 @@ export async function verifyAncestorsUnchanged(snapshot) {
196
188
  * `unlink`s first and then calls this.
197
189
  */
198
190
  export async function writeFileExclusiveNoFollow(dstPath, contents, mode = 0o644) {
199
- const flags = fs.constants.O_WRONLY |
200
- fs.constants.O_CREAT |
201
- fs.constants.O_EXCL |
202
- fs.constants.O_NOFOLLOW;
191
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW;
203
192
  const fh = await fsPromises.open(dstPath, flags, mode);
204
193
  try {
205
194
  await fh.writeFile(contents);
@@ -313,11 +313,7 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
313
313
  })();
314
314
  const bodyLines = lines.slice(0, trimmedTailIdx + 1);
315
315
  const separator = bodyLines.length === 0 ? [] : [''];
316
- const newLines = [
317
- ...bodyLines,
318
- ...separator,
319
- buildManagedBlock(entries, eol),
320
- ];
316
+ const newLines = [...bodyLines, ...separator, buildManagedBlock(entries, eol)];
321
317
  const content = newLines.join(eol) + eol;
322
318
  await writeAtomic(absPath, content);
323
319
  return {
@@ -442,9 +442,7 @@ export async function resolveHooksDir(targetDir) {
442
442
  if (configured === null) {
443
443
  return { dir: null, configured: false };
444
444
  }
445
- const absolute = path.isAbsolute(configured)
446
- ? configured
447
- : path.join(targetDir, configured);
445
+ const absolute = path.isAbsolute(configured) ? configured : path.join(targetDir, configured);
448
446
  return { dir: absolute, configured: true };
449
447
  }
450
448
  /**
@@ -534,8 +532,7 @@ export async function classifyPrePushInstall(targetDir) {
534
532
  if (classification.kind === 'absent') {
535
533
  return { action: 'install', hookPath };
536
534
  }
537
- if (classification.kind === 'rea-managed' ||
538
- classification.kind === 'rea-managed-legacy-v1') {
535
+ if (classification.kind === 'rea-managed' || classification.kind === 'rea-managed-legacy-v1') {
539
536
  return { action: 'refresh', hookPath };
540
537
  }
541
538
  if (classification.kind === 'rea-managed-husky' ||
@@ -631,9 +628,7 @@ async function resolveLockDir(targetDir) {
631
628
  const { stdout } = await execFileAsync('git', ['-C', targetDir, 'rev-parse', '--git-common-dir'], { encoding: 'utf8' });
632
629
  const commonDir = stdout.trim();
633
630
  if (commonDir.length > 0) {
634
- const absolute = path.isAbsolute(commonDir)
635
- ? commonDir
636
- : path.join(targetDir, commonDir);
631
+ const absolute = path.isAbsolute(commonDir) ? commonDir : path.join(targetDir, commonDir);
637
632
  return path.join(absolute, 'rea-prepush.lockdir');
638
633
  }
639
634
  }