@bookedsolid/rea 0.49.0 → 0.50.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.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `rea trust [<path>]` / `rea untrust [<path>]` / `rea trust --list`
3
+ *
4
+ * Operator-facing management of the per-user global-CLI allow-list
5
+ * `<home>/.rea/trusted-projects` (design §9). Blessing a project into this
6
+ * registry is the A5 CONSENT gate that lets the opt-in global rea CLI tier
7
+ * (`hooks/_lib/shim-runtime.sh`) govern that checkout without adding
8
+ * `@bookedsolid/rea` to a shared `package.json`.
9
+ *
10
+ * The registry can only ever ENABLE enforcement, never DISABLE a gate — so
11
+ * mutating it is a deliberate act of vouching for a checkout's policy, not a
12
+ * privilege expansion. (The governed-session mutation guard is Phase 4.)
13
+ *
14
+ * TEST ISOLATION: `home` + `cwd` are injectable parameters that default to the
15
+ * passwd home + `process.cwd()`. Tests inject a temp dir and never touch the
16
+ * real `~/.rea/`. NO CLI FLAG exposes `home` — an env/flag-redirectable trust
17
+ * root would re-open the N3 surface the tier closes.
18
+ */
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { assertNotGovernedSession, checkReaDirSafety, checkRegistrySafety, deleteRegistry, isProjectTrusted, passwdHome, projectPathControlCharReason, readRegistry, registryPath, writeRegistry, } from './global-cli.js';
22
+ import { err, log } from './utils.js';
23
+ /** Resolve the injected-or-passwd home; err + non-zero on a passwd failure. */
24
+ function resolveHome(injected) {
25
+ if (injected !== undefined)
26
+ return injected;
27
+ try {
28
+ return passwdHome();
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ /** Print a safety refusal (reason + exact fix) to stderr. */
35
+ function printRefusal(fail) {
36
+ err(fail.reason);
37
+ err(` fix: ${fail.remediation}`);
38
+ }
39
+ /**
40
+ * `rea trust [<path>]`. Returns the process exit code.
41
+ * 0 — trusted (or already trusted; idempotent)
42
+ * 1 — `<home>/.rea` is unsafe (symlink/foreign/world-writable), or no passwd
43
+ * 2 — path does not exist / is not a directory / contains illegal bytes
44
+ */
45
+ export function runTrust(options = {}) {
46
+ const home = resolveHome(options.home);
47
+ if (home === undefined) {
48
+ err('cannot determine your home directory from the password database — refusing');
49
+ return 1;
50
+ }
51
+ // Refuse under a governed agent session BEFORE any path resolution / FS work.
52
+ const governed = assertNotGovernedSession('trust', home, options.procReader !== undefined ? { procReader: options.procReader } : {});
53
+ if (governed !== null)
54
+ return governed;
55
+ const cwd = options.cwd ?? process.cwd();
56
+ const rawTarget = options.path ?? cwd;
57
+ const abs = path.resolve(cwd, rawTarget);
58
+ let real;
59
+ try {
60
+ real = fs.realpathSync(abs);
61
+ }
62
+ catch {
63
+ err(`path does not exist: ${abs}`);
64
+ return 2;
65
+ }
66
+ let st;
67
+ try {
68
+ st = fs.statSync(real);
69
+ }
70
+ catch {
71
+ err(`path does not exist: ${abs}`);
72
+ return 2;
73
+ }
74
+ if (!st.isDirectory()) {
75
+ err(`not a directory: ${abs}`);
76
+ return 2;
77
+ }
78
+ // Reject ANY control character (CR/LF/NUL/tab/…) BEFORE reporting success:
79
+ // writeRegistry would silently drop such a line as malformed, so a
80
+ // success message without persistence would be a lie. Shared validator so
81
+ // `rea install --global --trust` refuses identically.
82
+ const ctrl = projectPathControlCharReason(real);
83
+ if (ctrl !== null) {
84
+ err(`invalid path: ${ctrl}: ${JSON.stringify(real)}`);
85
+ return 2;
86
+ }
87
+ const safety = checkReaDirSafety(home);
88
+ if (!safety.ok) {
89
+ printRefusal(safety);
90
+ return 1;
91
+ }
92
+ if (isProjectTrusted(real, home)) {
93
+ log(`Already trusted: ${real}`);
94
+ return 0;
95
+ }
96
+ const members = readRegistry(home);
97
+ members.push(real);
98
+ writeRegistry(members, home);
99
+ log(`Trusted: ${real}`);
100
+ return 0;
101
+ }
102
+ /**
103
+ * `rea untrust [<path>]`. Removes the exact-match line for both the resolved
104
+ * absolute path AND its realpath (when resolvable), so a since-moved or
105
+ * symlinked project can still be untrusted. Empties → delete the file.
106
+ * 0 — removed, or nothing to remove (idempotent)
107
+ * 1 — `<home>/.rea` is unsafe, or no passwd
108
+ */
109
+ export function runUntrust(options = {}) {
110
+ const home = resolveHome(options.home);
111
+ if (home === undefined) {
112
+ err('cannot determine your home directory from the password database — refusing');
113
+ return 1;
114
+ }
115
+ // Refuse under a governed agent session BEFORE any path resolution / FS work.
116
+ const governed = assertNotGovernedSession('untrust', home, options.procReader !== undefined ? { procReader: options.procReader } : {});
117
+ if (governed !== null)
118
+ return governed;
119
+ const cwd = options.cwd ?? process.cwd();
120
+ const rawTarget = options.path ?? cwd;
121
+ const abs = path.resolve(cwd, rawTarget);
122
+ // Remove both the raw absolute form AND the realpath (design §6): a member was
123
+ // stored as realpath, but the operator may pass either form. Display the
124
+ // realpath when resolvable (symmetry with `trust`), else the raw abs form
125
+ // (the project may have moved/been deleted).
126
+ const candidates = new Set();
127
+ candidates.add(abs);
128
+ let display = abs;
129
+ try {
130
+ const real = fs.realpathSync(abs);
131
+ candidates.add(real);
132
+ display = real;
133
+ }
134
+ catch {
135
+ /* not resolvable (project moved/deleted) — the abs form still matches */
136
+ }
137
+ const safety = checkReaDirSafety(home);
138
+ if (!safety.ok) {
139
+ printRefusal(safety);
140
+ return 1;
141
+ }
142
+ const members = readRegistry(home);
143
+ const kept = members.filter((m) => !candidates.has(m));
144
+ const removed = members.length - kept.length;
145
+ if (removed === 0) {
146
+ log(`Not trusted; nothing to remove: ${display}`);
147
+ return 0;
148
+ }
149
+ if (kept.length === 0) {
150
+ deleteRegistry(home);
151
+ log(`Untrusted: ${display}`);
152
+ log('trusted-projects is now empty; removed the registry file');
153
+ return 0;
154
+ }
155
+ writeRegistry(kept, home);
156
+ log(`Untrusted: ${display}`);
157
+ return 0;
158
+ }
159
+ /**
160
+ * `rea trust --list`. Prints the registry members one per line to stdout
161
+ * (pipe-friendly). Perm-gated: a tampered `<home>/.rea` or registry file →
162
+ * exit 1 with remediation.
163
+ * 0 — printed (possibly "No trusted projects.")
164
+ * 1 — tampered root, or no passwd
165
+ */
166
+ export function runTrustList(options = {}) {
167
+ const home = resolveHome(options.home);
168
+ if (home === undefined) {
169
+ err('cannot determine your home directory from the password database — refusing');
170
+ return 1;
171
+ }
172
+ const dirSafety = checkReaDirSafety(home);
173
+ if (!dirSafety.ok) {
174
+ printRefusal(dirSafety);
175
+ return 1;
176
+ }
177
+ const regSafety = checkRegistrySafety(home);
178
+ if (!regSafety.ok) {
179
+ printRefusal(regSafety);
180
+ return 1;
181
+ }
182
+ // Absent registry OR present-but-empty → same friendly line.
183
+ if (regSafety.absent) {
184
+ log('No trusted projects.');
185
+ return 0;
186
+ }
187
+ const members = readRegistry(home);
188
+ if (members.length === 0) {
189
+ log('No trusted projects.');
190
+ return 0;
191
+ }
192
+ for (const m of members) {
193
+ process.stdout.write(`${m}\n`);
194
+ }
195
+ return 0;
196
+ }
197
+ /**
198
+ * Register `rea trust [path]` (with `--list`) and `rea untrust [path]`.
199
+ * The thin action wrappers translate the run functions' exit codes into
200
+ * `process.exit`. `registryPath` is imported only to keep the module's public
201
+ * surface discoverable for doctor (Phase 3b) — no behavior here.
202
+ */
203
+ export function registerTrustCommands(program) {
204
+ void registryPath; // referenced for tree-shake stability / doctor cohesion
205
+ program
206
+ .command('trust [path]')
207
+ .description('Bless a project into the per-user global-CLI allow-list (<home>/.rea/trusted-projects). Default path = cwd. `--list` prints the current registry.')
208
+ .option('--list', 'print the trusted-projects registry instead of adding a path')
209
+ .action((pathArg, opts) => {
210
+ const code = opts.list === true
211
+ ? runTrustList({})
212
+ : runTrust(pathArg !== undefined ? { path: pathArg } : {});
213
+ process.exit(code);
214
+ });
215
+ program
216
+ .command('untrust [path]')
217
+ .description('Remove a project from the per-user global-CLI allow-list. Default path = cwd. Empties the registry file when the last entry is removed.')
218
+ .action((pathArg) => {
219
+ const code = runUntrust(pathArg !== undefined ? { path: pathArg } : {});
220
+ process.exit(code);
221
+ });
222
+ }
@@ -51,6 +51,30 @@ export interface ProtectedScanContext {
51
51
  * pollute stdout.
52
52
  */
53
53
  stderr?: (line: string) => void;
54
+ /**
55
+ * The passwd-derived absolute home directory
56
+ * (`os.userInfo().homedir` — libuv `getpwuid_r`, NOT `$HOME` /
57
+ * `$XDG_*`). When set, `<passwdHome>/.rea` becomes a FIRST-CLASS
58
+ * absolute protected root: any write whose resolved target (or
59
+ * resolved parent + basename, for create-paths) lands anywhere under
60
+ * it is REFUSED. This protects the safe-global-CLI trust root
61
+ * (`~/.rea/cli`, `~/.rea/trusted-projects`) the shim-runtime derives
62
+ * the same way (`shim_global_entry_gate` → `os.userInfo().homedir`).
63
+ *
64
+ * Passwd-derived ONLY — never `$HOME` / `$XDG_*`: an agent can set
65
+ * those in-process, so an env-derived root is exactly the redirect
66
+ * surface this gate closes. A trust root an agent can move is not a
67
+ * trust root (same rule as the shim).
68
+ *
69
+ * When UNDEFINED the global-root gate is disabled entirely (the
70
+ * scanner behaves byte-identically to the pre-feature build — the
71
+ * legacy `~/`-is-dynamic fail-closed posture is preserved). The two
72
+ * production callers (`rea hook scan-bash`, `protected-paths-bash-
73
+ * gate`) populate it from `os.userInfo().homedir`; a passwd lookup
74
+ * failure leaves it undefined (feature-absent parity, mirroring the
75
+ * shim's silent "unavailable").
76
+ */
77
+ passwdHome?: string;
54
78
  }
55
79
  interface EffectivePatterns {
56
80
  /** Full effective protected set (default OR override + invariants − relax). */
@@ -270,7 +270,7 @@ function checkPathProtected(pathLc, effective, options) {
270
270
  * blocks on either match. Either may be a sentinel string for
271
271
  * outside-root / expansion-uncertainty.
272
272
  */
273
- function normalizeTarget(reaRoot, raw, form) {
273
+ function normalizeTarget(reaRoot, raw, form, passwdHome) {
274
274
  // 1. Strip surrounding matching quotes (the parser already strips
275
275
  // them for SglQuoted/DblQuoted, but a literal node can still hold
276
276
  // `'.rea/HALT'` in pathological cases). Defensive.
@@ -331,11 +331,47 @@ function normalizeTarget(reaRoot, raw, form) {
331
331
  resolvedLc: null,
332
332
  };
333
333
  }
334
- // 2c. Codex round 1 F-24: `~/` or bare `~` expands to $HOME at
335
- // runtime, which may equal reaRoot (any project rooted at the
336
- // user's home dir). Treat as dynamic to be safe — refuse on
337
- // uncertainty rather than guess at HOME.
338
- if (t === '~' || t.startsWith('~/') || t.startsWith('~')) {
334
+ // 2c. Tilde handling. `~` and `~/…` bash-expand to the home dir at
335
+ // runtime. Two postures:
336
+ //
337
+ // - passwdHome AVAILABLE (safe-global-CLI gate active): resolve
338
+ // `~`/`~/…` against the PASSWD-derived home so the downstream
339
+ // global-root + project-relative checks see a concrete absolute
340
+ // path. This is what lets `~/.rea/x` REFUSE (lands under the
341
+ // protected `<passwdHome>/.rea` root) while `~/.reafoo` /
342
+ // `~/.rea-backup` ALLOW (siblings — boundary-anchored, NOT under
343
+ // the root). We resolve against passwd-home, never `$HOME`: the
344
+ // goal is to refuse writes that WOULD land in the passwd-derived
345
+ // `~/.rea` (the common case where `$HOME` == passwd-home); if an
346
+ // agent moved `$HOME`, its `~/.rea/x` cannot reach the real root
347
+ // anyway, so approximating with passwd-home is the safe
348
+ // direction.
349
+ //
350
+ // - passwdHome UNDEFINED (feature-absent parity): keep the
351
+ // original Codex round 1 F-24 fail-closed posture — `~` may
352
+ // equal reaRoot (project rooted at home), so refuse on
353
+ // uncertainty.
354
+ //
355
+ // `~user`, `~+`, `~-` and other tilde-prefix forms are NEVER
356
+ // statically resolvable (a foreign user's home / `$PWD` / `$OLDPWD`)
357
+ // → always fail-closed, regardless of passwdHome.
358
+ if (t === '~' || t.startsWith('~/')) {
359
+ if (passwdHome !== undefined && passwdHome.startsWith('/')) {
360
+ // Drop the leading `~`, keep the `/tail` (empty for bare `~`).
361
+ t = t === '~' ? passwdHome : passwdHome + t.slice(1);
362
+ // Fall through to normalization with the resolved absolute path.
363
+ }
364
+ else {
365
+ return {
366
+ pathLc: '__rea_unresolved_expansion__',
367
+ sentinel: 'expansion',
368
+ original: raw,
369
+ resolvedLc: null,
370
+ };
371
+ }
372
+ }
373
+ else if (t.startsWith('~')) {
374
+ // ~user / ~+ / ~- — not statically resolvable → refuse on uncertainty.
339
375
  return {
340
376
  pathLc: '__rea_unresolved_expansion__',
341
377
  sentinel: 'expansion',
@@ -363,6 +399,32 @@ function normalizeTarget(reaRoot, raw, form) {
363
399
  }
364
400
  const hadDotDot = normalized.includes('..');
365
401
  const collapsed = collapseDotDot(abs);
402
+ // 4b. Passwd-derived `<passwdHome>/.rea` global-root gate. This runs
403
+ // BEFORE the isInsideRoot early-returns because the absolute
404
+ // `~/.rea` root is (in the common case) OUTSIDE the project root —
405
+ // without this, an absolute write like
406
+ // `/Users/me/.rea/trusted-projects` falls through to the
407
+ // `__outside_root_allowed` branch and is ALLOWED. This is NOT a
408
+ // naive `realpath(target)` prefix check: it reuses the same
409
+ // lexical `collapsed` form (handles non-existent create-path
410
+ // leaves + `..`-normalization with no FS access) plus the
411
+ // symlink-walk-to-nearest-existing-ancestor resolver (handles a
412
+ // symlinked parent / symlinked home) — the identical machinery the
413
+ // project-relative path logic below uses. Bare cwd-relative writes
414
+ // (`cd ~/.rea && > trusted-projects`) are covered upstream by the
415
+ // walker's cwd model, which flags the `cd` target (a
416
+ // `cwd_protected_unresolvable` detection carrying `~/.rea`) and
417
+ // routes it through THIS function.
418
+ if (passwdHome !== undefined && passwdHome.startsWith('/')) {
419
+ if (targetHitsGlobalReaRoot(collapsed, passwdHome)) {
420
+ return {
421
+ pathLc: '__rea_global_root__',
422
+ sentinel: 'global_root',
423
+ original: raw,
424
+ resolvedLc: null,
425
+ };
426
+ }
427
+ }
366
428
  // 5. Outside-root sentinel — fires ONLY for paths that contained
367
429
  // `..` segments and resolved outside REA_ROOT (helix-022 #1
368
430
  // semantic). A bare absolute path like `/tmp/foo` is just an
@@ -488,6 +550,68 @@ function realpathSafe(p) {
488
550
  return null;
489
551
  }
490
552
  }
553
+ /**
554
+ * Case-insensitive "is `p` inside directory `dir` (or equal to it)".
555
+ * Both are absolute lexical paths without trailing slashes. The `+ '/'`
556
+ * boundary anchor is load-bearing: it keeps `<home>/.rea-backup` and
557
+ * `<home>/.reafoo` OUT of the `<home>/.rea` root (siblings, not
558
+ * descendants). Case-insensitive to match the project-relative matcher
559
+ * (macOS APFS default is case-insensitive — helix-015 #2), so `~/.REA`
560
+ * is caught too.
561
+ */
562
+ function isUnderDirCI(p, dir) {
563
+ const pl = p.toLowerCase();
564
+ const dl = dir.toLowerCase();
565
+ return pl === dl || pl.startsWith(dl + '/');
566
+ }
567
+ /**
568
+ * True when the resolved write target lands under the passwd-derived
569
+ * `<passwdHome>/.rea` root. Checks TWO forms, mirroring the
570
+ * project-relative logic:
571
+ *
572
+ * 1. Lexical `collapsed` (from `collapseDotDot`, no FS access) against
573
+ * the lexical `<passwdHome>/.rea`. This is what catches non-existent
574
+ * create-path leaves (`> ~/.rea/new-registry`) and `..`-normalized
575
+ * escapes — `realpath(target)` would ENOENT-fail on those, which is
576
+ * exactly the naive-approach failure codex flagged (P1-4).
577
+ *
578
+ * 2. The symlink-resolved form (walk to the nearest existing ancestor,
579
+ * realpath it, re-attach the unresolved tail) against
580
+ * `realpath(<passwdHome>/.rea)`. This catches a symlinked parent
581
+ * (an in-project symlink whose target is under `~/.rea`) or a
582
+ * symlinked home dir (`/Users` ↔ firmlink, `/home` ↔ symlink).
583
+ *
584
+ * The `<passwdHome>/.rea` root need NOT exist on disk — the whole point
585
+ * is to refuse an agent CREATING it (seeding a fake `trusted-projects`
586
+ * registry), so form (1) fires regardless of existence.
587
+ */
588
+ function targetHitsGlobalReaRoot(collapsed, passwdHome) {
589
+ // Lexical global root, e.g. `/Users/me/.rea`. `path.join` normalizes a
590
+ // trailing slash on passwdHome; collapseDotDot strips any `..` in it.
591
+ const globalRoot = collapseDotDot(path.join(passwdHome, '.rea'));
592
+ // Form 1: lexical (create-path + `..`-safe, no FS).
593
+ if (isUnderDirCI(collapsed, globalRoot))
594
+ return true;
595
+ // Form 2: symlink-resolved parent. Best-effort — a dynamic sentinel
596
+ // (symlink cycle / depth cap) is left to the caller's step-6 handling.
597
+ let resolved = null;
598
+ try {
599
+ resolved = resolveSymlinksWalkUp(collapsed);
600
+ }
601
+ catch {
602
+ resolved = null;
603
+ }
604
+ if (typeof resolved === 'string') {
605
+ const realGlobalRoot = realpathSafe(globalRoot) ?? globalRoot;
606
+ if (isUnderDirCI(resolved, realGlobalRoot))
607
+ return true;
608
+ // Defensive: when `<passwdHome>/.rea` does not exist, realpathSafe
609
+ // returns the lexical form — still compare against it directly.
610
+ if (isUnderDirCI(resolved, globalRoot))
611
+ return true;
612
+ }
613
+ return false;
614
+ }
491
615
  /**
492
616
  * Walk to the nearest existing ancestor, realpath-it, then re-attach
493
617
  * the unresolved tail.
@@ -800,7 +924,28 @@ export function scanForProtectedViolations(ctx, detections) {
800
924
  }
801
925
  if (d.path.length === 0)
802
926
  continue;
803
- const norm = normalizeTarget(ctx.reaRoot, d.path, d.form);
927
+ const norm = normalizeTarget(ctx.reaRoot, d.path, d.form, ctx.passwdHome);
928
+ if (norm.sentinel === 'global_root') {
929
+ return blockVerdict({
930
+ reason: [
931
+ 'PROTECTED PATH (bash): write to the per-user rea trust root (~/.rea) blocked.',
932
+ '',
933
+ ` Resolved under: ${ctx.passwdHome ?? '~'}/.rea`,
934
+ ` Original token: ${norm.original}`,
935
+ ` Detected as: ${d.form}`,
936
+ '',
937
+ ' Rule: the passwd-derived ~/.rea tree (the safe-global-CLI trust root —',
938
+ " ~/.rea/cli and ~/.rea/trusted-projects) is unreachable via agent",
939
+ ' writes, including create-paths, cwd-relative writes after `cd ~/.rea`,',
940
+ ' mv/cp/install destinations, and symlinked parents. A human must edit',
941
+ ' it directly. This root is derived from the password database, never',
942
+ ' $HOME/$XDG_* — an agent-movable root would defeat the protection.',
943
+ ].join('\n'),
944
+ hitPattern: '(~/.rea global trust root)',
945
+ detectedForm: d.form,
946
+ ...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
947
+ });
948
+ }
804
949
  if (norm.sentinel === 'expansion') {
805
950
  return blockVerdict({
806
951
  reason: [
@@ -26,11 +26,29 @@
26
26
  */
27
27
  import path from 'node:path';
28
28
  import fs from 'node:fs';
29
+ import os from 'node:os';
29
30
  import { parse as parseYaml } from 'yaml';
30
31
  import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
31
32
  import { parseHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
32
33
  import { runProtectedScan } from '../bash-scanner/index.js';
33
34
  import { appendAuditRecord, InvocationStatus, Tier } from '../../audit/append.js';
35
+ /**
36
+ * Passwd-derived absolute home for the `~/.rea` global-root scanner gate.
37
+ * `os.userInfo().homedir` (libuv `getpwuid_r`), NEVER `$HOME` / `$XDG_*`.
38
+ * Undefined on passwd-lookup failure or a non-absolute home → gate
39
+ * disabled (feature-absent parity, mirroring `shim_global_entry_gate`).
40
+ */
41
+ function passwdDerivedHome() {
42
+ try {
43
+ const home = os.userInfo().homedir;
44
+ if (typeof home === 'string' && home.startsWith('/'))
45
+ return home;
46
+ }
47
+ catch {
48
+ /* no passwd entry → gate disabled */
49
+ }
50
+ return undefined;
51
+ }
34
52
  function loadPolicyPermissive(reaRoot) {
35
53
  const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
36
54
  const empty = { protectedRelax: [] };
@@ -121,7 +139,11 @@ export async function runProtectedPathsBashGate(options = {}) {
121
139
  if (patchSession.length > 0) {
122
140
  relax.push('.claude/hooks/');
123
141
  }
124
- // 7. Scan.
142
+ // 7. Scan. Passwd-derived home enables the `~/.rea` global-root gate
143
+ // (safe-global-CLI). Never `$HOME` / `$XDG_*` — an agent can move
144
+ // those in-process; a passwd-lookup failure disables the gate
145
+ // (feature-absent parity, mirroring the shim's silent "unavailable").
146
+ const passwdHome = passwdDerivedHome();
125
147
  const verdict = runProtectedScan({
126
148
  reaRoot,
127
149
  policy: {
@@ -130,6 +152,7 @@ export async function runProtectedPathsBashGate(options = {}) {
130
152
  : {}),
131
153
  protected_paths_relax: relax,
132
154
  },
155
+ ...(passwdHome !== undefined ? { passwdHome } : {}),
133
156
  stderr: (line) => writeStderr(line),
134
157
  }, cmd);
135
158
  // 8. Audit.
@@ -315,6 +315,13 @@ declare const PolicySchema: z.ZodObject<{
315
315
  }, {
316
316
  enabled?: boolean | undefined;
317
317
  }>>;
318
+ runtime: z.ZodOptional<z.ZodObject<{
319
+ allow_global_cli: z.ZodOptional<z.ZodBoolean>;
320
+ }, "strict", z.ZodTypeAny, {
321
+ allow_global_cli?: boolean | undefined;
322
+ }, {
323
+ allow_global_cli?: boolean | undefined;
324
+ }>>;
318
325
  }, "strict", z.ZodTypeAny, {
319
326
  version: string;
320
327
  profile: string;
@@ -399,6 +406,9 @@ declare const PolicySchema: z.ZodObject<{
399
406
  bootstrap_allowlist?: {
400
407
  enabled: boolean;
401
408
  } | undefined;
409
+ runtime?: {
410
+ allow_global_cli?: boolean | undefined;
411
+ } | undefined;
402
412
  }, {
403
413
  version: string;
404
414
  profile: string;
@@ -483,6 +493,9 @@ declare const PolicySchema: z.ZodObject<{
483
493
  bootstrap_allowlist?: {
484
494
  enabled?: boolean | undefined;
485
495
  } | undefined;
496
+ runtime?: {
497
+ allow_global_cli?: boolean | undefined;
498
+ } | undefined;
486
499
  }>;
487
500
  /**
488
501
  * Async policy loader with TTL cache and mtime-based invalidation.
@@ -380,6 +380,50 @@ const BootstrapAllowlistPolicySchema = z
380
380
  enabled: z.boolean().default(true),
381
381
  })
382
382
  .strict();
383
+ /**
384
+ * 0.50.0 — runtime resolver policy. Currently the sole field is
385
+ * `allow_global_cli`, the OPTIONAL project-level veto over the global rea
386
+ * CLI resolver tier in `hooks/_lib/shim-runtime.sh`.
387
+ *
388
+ * The global tier is gated PRIMARILY by a per-user registry (A5 consent
389
+ * gate). This project-level knob is a SECONDARY veto layered on top —
390
+ * registry can only ENABLE the tier; policy can only further-RESTRICT it.
391
+ * The asymmetry is deliberate: a project can refuse the global CLI even
392
+ * when the machine's registry has blessed it, but a project can NEVER
393
+ * turn the tier ON when the registry has not.
394
+ *
395
+ * Tri-state (locked):
396
+ * - absent → permitted (registry alone governs — the veto is silent)
397
+ * - `true` → permitted (affirm; identical effect to absent, but
398
+ * explicit — a project can pin "yes, the global tier is
399
+ * fine here")
400
+ * - `false` → veto (the project refuses the global tier even when the
401
+ * registry has blessed it)
402
+ *
403
+ * `.optional()` — NOT `.default()` — so absent stays distinguishable from
404
+ * an explicit `false`. The distinction is load-bearing: the shim's veto
405
+ * wiring (a later phase) must treat "field omitted" (registry governs)
406
+ * differently from "explicitly false" (project refuses), and a schema
407
+ * default would collapse the two.
408
+ *
409
+ * Deliberately OFF / absent by default — no profile and no shipped
410
+ * `.rea/policy.yaml` carries a `runtime:` block. The global CLI tier is a
411
+ * per-developer / per-machine opt-in via the per-user registry; the
412
+ * project-level veto exists only for repos that want to affirmatively
413
+ * refuse it, so its natural state is "not present."
414
+ *
415
+ * Strict mode rejects unknown keys so a typo (`allow_globcli`,
416
+ * `allow_global_clis`) fails loudly at policy load rather than silently
417
+ * omitting the veto. The `runtime` schema is REQUIRED even though it holds
418
+ * a single optional field: the top-level `PolicySchema` is `.strict()`, so
419
+ * without a `runtime` schema ANY `policy.yaml` carrying a `runtime:` block
420
+ * would fail to load entirely.
421
+ */
422
+ const RuntimePolicySchema = z
423
+ .object({
424
+ allow_global_cli: z.boolean().optional(),
425
+ })
426
+ .strict();
383
427
  const PolicySchema = z
384
428
  .object({
385
429
  version: z.string(),
@@ -459,6 +503,14 @@ const PolicySchema = z
459
503
  // `hooks/_lib/bootstrap-allowlist.sh` and
460
504
  // `THREAT_MODEL.md §5.X`.
461
505
  bootstrap_allowlist: BootstrapAllowlistPolicySchema.optional(),
506
+ // 0.50.0 runtime resolver policy — currently only the optional
507
+ // `allow_global_cli` project-level veto over the global rea CLI
508
+ // resolver tier. `.optional()` so a vanilla install with no
509
+ // `runtime:` block leaves the tier governed by the per-user registry
510
+ // alone (absent → permitted). See `RuntimePolicySchema` for the full
511
+ // tri-state contract and the registry-primary / policy-secondary
512
+ // asymmetry.
513
+ runtime: RuntimePolicySchema.optional(),
462
514
  })
463
515
  .strict();
464
516
  const DEFAULT_CACHE_TTL_MS = 30_000;
@@ -474,6 +474,37 @@ export interface GatewayHealthPolicy {
474
474
  export interface GatewayPolicy {
475
475
  health?: GatewayHealthPolicy;
476
476
  }
477
+ /**
478
+ * Runtime resolver policy (0.50.0+).
479
+ *
480
+ * Governs the global rea CLI resolver tier in
481
+ * `hooks/_lib/shim-runtime.sh`. The tier is enabled PRIMARILY by a
482
+ * per-user registry (an A5 consent gate). `allow_global_cli` is the
483
+ * OPTIONAL project-level SECONDARY veto: registry can only ENABLE the
484
+ * tier, this knob can only further-RESTRICT it. A project can refuse the
485
+ * global CLI even when the registry has blessed it, but a project can
486
+ * never turn the tier ON when the registry has not.
487
+ *
488
+ * See `RuntimePolicy.allow_global_cli` for the tri-state contract.
489
+ */
490
+ export interface RuntimePolicy {
491
+ /**
492
+ * Project-level veto over the global rea CLI resolver tier. Tri-state
493
+ * (locked):
494
+ * - `undefined` (omitted) → permitted; the per-user registry alone
495
+ * governs the tier. This is the default state — no shipped profile
496
+ * or `.rea/policy.yaml` carries a `runtime:` block.
497
+ * - `true` → permitted; explicit affirmation (same effect as absent,
498
+ * but pins "the global tier is fine in this repo").
499
+ * - `false` → veto; the project refuses the global tier even when the
500
+ * registry has blessed the machine.
501
+ *
502
+ * Modeled as an optional boolean (not defaulted) so absent stays
503
+ * distinguishable from an explicit `false` — the shim's veto wiring
504
+ * treats "registry governs" and "project refuses" as different states.
505
+ */
506
+ allow_global_cli?: boolean;
507
+ }
477
508
  export interface Policy {
478
509
  version: string;
479
510
  profile: string;
@@ -575,6 +606,15 @@ export interface Policy {
575
606
  * architect locked).
576
607
  */
577
608
  bootstrap_allowlist?: BootstrapAllowlistPolicy;
609
+ /**
610
+ * Runtime resolver policy (0.50.0+). Currently only the optional
611
+ * `allow_global_cli` project-level veto over the global rea CLI
612
+ * resolver tier in `hooks/_lib/shim-runtime.sh`. Absent → the tier is
613
+ * governed by the per-user registry alone; `false` → the project
614
+ * refuses the tier even when registry-blessed; `true` → explicit
615
+ * affirmation. See `RuntimePolicy` for the full contract.
616
+ */
617
+ runtime?: RuntimePolicy;
578
618
  }
579
619
  /**
580
620
  * Bootstrap allowlist policy (0.49.0+).