@bookedsolid/rea 0.49.1 → 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,709 @@
1
+ /**
2
+ * Shared resolution + safety module for the opt-in GLOBAL rea CLI tier.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * Phase 1b landed the bash shim's global resolver in `hooks/_lib/shim-runtime.sh`
7
+ * (`shim_global_entry_gate`, `shim_sandbox_check_global`): it resolves a
8
+ * per-user rea CLI from `<pw_dir>/.rea/cli`, gated by a per-user allow-list
9
+ * `<pw_dir>/.rea/trusted-projects`, and derives `pw_dir` from the PASSWORD
10
+ * DATABASE (`os.userInfo().homedir`), NEVER from `$HOME`/`$XDG_*` — an agent can
11
+ * set those in-process, so an env-derived root is the N3 redirect surface the
12
+ * tier closes. A trust root an agent can move is not a trust root.
13
+ *
14
+ * This module is the TypeScript MIRROR of that bash logic, consumed by the
15
+ * writer-side CLIs (`rea trust` / `rea untrust` / `rea install --global`) and by
16
+ * `rea doctor` (Phase 3b). The bash shim is the pre-CLI authority; a parity test
17
+ * binds this mirror to it (Phase 3b F1).
18
+ *
19
+ * TEST-ISOLATION CONTRACT
20
+ * -----------------------
21
+ * `pw_dir` is `os.userInfo().homedir` — passwd-derived and therefore ENV-IMMUNE
22
+ * by design, so tests CANNOT redirect it with `$HOME`/`$XDG`. Every function that
23
+ * touches the per-user root instead takes the home dir as a parameter that
24
+ * DEFAULTS to `os.userInfo().homedir` at the call site. Production entrypoints
25
+ * pass the default; tests inject a temp dir and stay hermetic without ever
26
+ * mutating the real `~/.rea/`. NO CLI FLAG exposes this parameter — that would
27
+ * re-introduce the env-redirect threat we are closing.
28
+ */
29
+ import { spawnSync } from 'node:child_process';
30
+ import fs from 'node:fs';
31
+ import os from 'node:os';
32
+ import path from 'node:path';
33
+ import { err } from './utils.js';
34
+ /**
35
+ * The passwd-derived home directory. Isolated in one helper so every default
36
+ * parameter reads from the same source. Reads the password database via libuv
37
+ * `getpwuid_r`, NOT the environment. Throws on a squashed/absent passwd entry —
38
+ * the caller decides whether that is fatal (writer CLIs) or a silent
39
+ * tier-unavailable (the shim already handles that side in bash).
40
+ */
41
+ export function passwdHome() {
42
+ return os.userInfo().homedir;
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ // Path derivation — passwd-rooted, never env-rooted
46
+ // ---------------------------------------------------------------------------
47
+ /** `<home>/.rea` — the per-user rea config root. */
48
+ export function reaDir(home = passwdHome()) {
49
+ return path.join(home, '.rea');
50
+ }
51
+ /** `<home>/.rea/cli` — where `npm install --prefix` drops the global CLI tree. */
52
+ export function globalRoot(home = passwdHome()) {
53
+ return path.join(reaDir(home), 'cli');
54
+ }
55
+ /** `<home>/.rea/trusted-projects` — the per-user global-CLI allow-list. */
56
+ export function registryPath(home = passwdHome()) {
57
+ return path.join(reaDir(home), 'trusted-projects');
58
+ }
59
+ /**
60
+ * Default process reader: Linux `/proc/<pid>/comm` + `/proc/<pid>/status`
61
+ * (PPid), else macOS/BSD `ps -o ppid=,comm= -p <pid>`. Best-effort — any
62
+ * failure returns `null` (which stops the walk fail-safe, never throwing).
63
+ * Mirrors `hooks/_lib/shim-cache.sh`'s session-token ancestry walk.
64
+ */
65
+ export function defaultProcReader(pid) {
66
+ // Linux /proc fast path.
67
+ try {
68
+ const comm = fs.readFileSync(`/proc/${pid}/comm`, 'utf8').trim();
69
+ const status = fs.readFileSync(`/proc/${pid}/status`, 'utf8');
70
+ const m = status.match(/^PPid:\s*(\d+)/m);
71
+ if (comm.length > 0)
72
+ return { ppid: m ? Number(m[1]) : 0, comm };
73
+ }
74
+ catch {
75
+ /* not Linux, or /proc unreadable — fall through to ps */
76
+ }
77
+ // macOS / BSD via ps.
78
+ try {
79
+ const r = spawnSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], {
80
+ encoding: 'utf8',
81
+ timeout: 5_000,
82
+ });
83
+ if (r.status === 0 && typeof r.stdout === 'string') {
84
+ const line = r.stdout.trim();
85
+ const mm = line.match(/^(\d+)\s+(.*)$/);
86
+ if (mm)
87
+ return { ppid: Number(mm[1]), comm: (mm[2] ?? '').trim() };
88
+ if (/^\d+$/.test(line))
89
+ return { ppid: Number(line), comm: '' };
90
+ }
91
+ }
92
+ catch {
93
+ /* ps missing / error — undeterminable */
94
+ }
95
+ return null;
96
+ }
97
+ /**
98
+ * Walk the parent-PID chain (bounded depth) looking for a `claude` /
99
+ * `claude-code` ancestor. Returns `true` only on a POSITIVE match; `false` on
100
+ * not-found OR any undeterminable step (fail-safe — an undeterminable walk
101
+ * degrades the guard to the `CLAUDE_PROJECT_DIR` signal alone, it never weakens
102
+ * to "allow" when ancestry says claude). A plain human terminal is not a claude
103
+ * descendant → no false positive; a human using Claude Code's `!` shell IS a
104
+ * descendant → correctly detected (trust is out-of-band).
105
+ */
106
+ export function isClaudeAncestor(reader = defaultProcReader, startPid = typeof process.ppid === 'number' ? process.ppid : 0, maxHops = 12) {
107
+ let cur = startPid;
108
+ for (let hops = 0; hops < maxHops && cur > 1; hops += 1) {
109
+ let info;
110
+ try {
111
+ info = reader(cur);
112
+ }
113
+ catch {
114
+ return false; // fail-safe
115
+ }
116
+ if (info === null)
117
+ return false;
118
+ const base = path.basename(info.comm);
119
+ if (base === 'claude' || base === 'claude-code')
120
+ return true;
121
+ const next = info.ppid;
122
+ if (!Number.isInteger(next) || next <= 1 || next === cur)
123
+ break;
124
+ cur = next;
125
+ }
126
+ return false;
127
+ }
128
+ /**
129
+ * Refuse a `~/.rea`-MUTATING command when running under a Claude Code agent
130
+ * session. TWO independent signals, either of which triggers a refusal:
131
+ * 1. `CLAUDE_PROJECT_DIR` is set + non-empty (rea's established "running under
132
+ * Claude Code" signal — the reaRoot source in `src/cli/hook.ts`, set for
133
+ * every agent Bash call).
134
+ * 2. A `claude` / `claude-code` process is an ancestor (codex P1: signal (1)
135
+ * alone is bypassable — `CLAUDE_PROJECT_DIR= rea trust`,
136
+ * `env -u CLAUDE_PROJECT_DIR rea trust` clear the var in the child, which a
137
+ * subprocess CANNOT do for its own parent chain).
138
+ *
139
+ * The three mutating commands (`trust`, `untrust`, `install --global`) call this
140
+ * FIRST. Trust must be established by a HUMAN in a plain shell OUTSIDE the agent
141
+ * loop — the real dual-consent. The Write-tier scanner protects literal
142
+ * `~/.rea` writes; this closes the INDIRECT `rea trust`-writes-there path the
143
+ * scanner cannot see statically, AND which a bash command-string matcher cannot
144
+ * catch either (`node <path>/dist/cli/index.js trust`, `npx --no-install …
145
+ * trust`, wrapped shells all bypass a string matcher — the CLI is the only real
146
+ * chokepoint).
147
+ *
148
+ * NO override env — the strictness IS the dual-consent. Read-only commands
149
+ * (`trust --list`) are NOT guarded. Fail-safe: an undeterminable ancestry walk
150
+ * falls back to the `CLAUDE_PROJECT_DIR` signal alone (never weakens).
151
+ *
152
+ * Returns the refusal exit code (`1`) after emitting to stderr when governed;
153
+ * returns `null` when allowed. The `procReader` is injectable so tests stay
154
+ * hermetic + cross-platform.
155
+ */
156
+ export function assertNotGovernedSession(cmdLabel, home = passwdHome(), deps = {}) {
157
+ const cpd = process.env.CLAUDE_PROJECT_DIR;
158
+ const cpdSet = cpd !== undefined && cpd.length > 0;
159
+ const ancestor = isClaudeAncestor(deps.procReader ?? defaultProcReader);
160
+ if (cpdSet || ancestor) {
161
+ const via = cpdSet
162
+ ? 'CLAUDE_PROJECT_DIR is set'
163
+ : 'a Claude Code process is an ancestor of this one';
164
+ err(`rea ${cmdLabel} mutates your per-user trust root (${reaDir(home)}). ` +
165
+ `This is a human action — run it in a plain shell OUTSIDE the agent session ` +
166
+ `(detected an agent session: ${via}). Refusing.`);
167
+ return 1;
168
+ }
169
+ return null;
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // Registry file contract (design §6)
173
+ // ---------------------------------------------------------------------------
174
+ /**
175
+ * Fixed advisory header (design §6): the first line is bound verbatim; the two
176
+ * following `#` lines are advisory. Fixed content keeps the file byte-idempotent
177
+ * (devex F3). The header is `grep -Fxq`-immune: realpath queries start with `/`
178
+ * and never match a `#` line.
179
+ */
180
+ export const REGISTRY_HEADER_LINES = [
181
+ '# rea trusted-projects (v1) — managed by rea trust/untrust',
182
+ '# One absolute project realpath per line; membership is an exact whole-line match.',
183
+ '# Managed automatically — edit via `rea trust` / `rea untrust`, not by hand.',
184
+ ];
185
+ const REGISTRY_HEADER = `${REGISTRY_HEADER_LINES.join('\n')}\n`;
186
+ /**
187
+ * Reject a project path that would corrupt the one-realpath-per-line registry
188
+ * format or be silently dropped by {@link writeRegistry}. Any C0 control
189
+ * character (`\x00`–`\x1f`: NUL, tab, LF, **CR**, …) breaks the whole-line
190
+ * contract — a `\r` in particular is treated as malformed by
191
+ * {@link isWellFormedMemberLine}, so a writer that skipped this check would
192
+ * report success and then silently drop the entry.
193
+ *
194
+ * This is the SHARED rejection point every registry-writing entrypoint
195
+ * (`rea trust`, `rea install --global --trust`) MUST consult BEFORE reporting
196
+ * success. Returns a short human-readable reason on rejection, or `null` when
197
+ * the path is safe to store.
198
+ */
199
+ export function projectPathControlCharReason(realpath) {
200
+ // C0 control range \x00–\x1f (NUL, tab, LF, CR, …) — exactly what we reject.
201
+ if (/[\x00-\x1f]/.test(realpath))
202
+ return 'contains control characters';
203
+ return null;
204
+ }
205
+ /**
206
+ * Is `line` a well-formed member line? A member = a bare absolute path with no
207
+ * control characters (NUL / tab / LF / CR / …) and no surrounding whitespace (a
208
+ * leading-space line would never `grep -Fxq`-match a trimmed query, so we treat
209
+ * it as malformed). Comment/blank lines are NOT members (inert to the reader).
210
+ * The control-character test is the shared {@link projectPathControlCharReason}
211
+ * so the reader and the writer-side validators can never drift.
212
+ */
213
+ export function isWellFormedMemberLine(line) {
214
+ if (typeof line !== 'string')
215
+ return false;
216
+ if (line.length === 0)
217
+ return false;
218
+ if (line.charAt(0) !== '/')
219
+ return false; // absolute only; also excludes `#`
220
+ if (projectPathControlCharReason(line) !== null)
221
+ return false; // NUL/LF/CR/tab/…
222
+ if (line.trim() !== line)
223
+ return false; // no surrounding whitespace
224
+ return true;
225
+ }
226
+ /**
227
+ * Read the registry's MEMBER lines (design §6 reader). Skips `#`-prefixed and
228
+ * blank lines; returns every remaining non-blank line verbatim (no trimming —
229
+ * whole-line semantics mirror `grep -Fxq`). Absent/unreadable file → `[]`.
230
+ *
231
+ * This is a lenient read: it returns candidate lines as-stored (including any
232
+ * malformed content a hand-edit introduced) so the writer can normalize + drop
233
+ * them on the next rewrite. Use {@link isProjectTrusted} for a membership test.
234
+ */
235
+ export function readRegistry(home = passwdHome()) {
236
+ let content;
237
+ try {
238
+ content = fs.readFileSync(registryPath(home), 'utf8');
239
+ }
240
+ catch {
241
+ return [];
242
+ }
243
+ const out = [];
244
+ for (const line of content.split('\n')) {
245
+ if (line.length === 0)
246
+ continue; // blank
247
+ if (line.charAt(0) === '#')
248
+ continue; // comment
249
+ out.push(line);
250
+ }
251
+ return out;
252
+ }
253
+ /**
254
+ * Exact whole-line membership test (mirrors the shim's `grep -Fxq -- "$p"`).
255
+ * `projRealpath` is expected to be an absolute realpath. A comment line can
256
+ * never match because queries start with `/`.
257
+ */
258
+ export function isProjectTrusted(projRealpath, home = passwdHome()) {
259
+ if (typeof projRealpath !== 'string' || projRealpath.length === 0)
260
+ return false;
261
+ let content;
262
+ try {
263
+ content = fs.readFileSync(registryPath(home), 'utf8');
264
+ }
265
+ catch {
266
+ return false;
267
+ }
268
+ for (const line of content.split('\n')) {
269
+ if (line === projRealpath)
270
+ return true;
271
+ }
272
+ return false;
273
+ }
274
+ /**
275
+ * Full-rewrite the registry (design §6 writer). NEVER appends. Normalizes the
276
+ * given paths: drops malformed lines (with a single one-line stderr notice),
277
+ * then dedups + sorts + joins with a single trailing LF after the fixed header.
278
+ * Byte-idempotent: `writeRegistry(home, readRegistry(home))` reproduces the file
279
+ * byte-for-byte for well-formed input.
280
+ *
281
+ * Atomicity: ensures `<home>/.rea` exists `0700`, writes the normalized content
282
+ * to `<home>/.rea/.trusted-projects.tmp.<pid>` under `umask 077` + explicit
283
+ * `chmod 0600`, then `rename()`s over the target. On failure the tmp file is
284
+ * unlinked and the original is left intact.
285
+ *
286
+ * SAFETY: this helper does NOT gate on ownership/symlink/permissions — callers
287
+ * MUST run {@link checkReaDirSafety} first and surface the remediation string.
288
+ * It ensures the dir exists but refuses nothing.
289
+ */
290
+ export function writeRegistry(paths, home = passwdHome()) {
291
+ const dir = reaDir(home);
292
+ const target = registryPath(home);
293
+ // Normalize: drop malformed, dedup, sort. One aggregate stderr notice.
294
+ const wellFormed = [];
295
+ let dropped = 0;
296
+ for (const p of paths) {
297
+ if (isWellFormedMemberLine(p))
298
+ wellFormed.push(p);
299
+ else
300
+ dropped += 1;
301
+ }
302
+ if (dropped > 0) {
303
+ process.stderr.write(`[rea] WARN: dropped ${dropped} malformed line(s) while rewriting the trusted-projects registry\n`);
304
+ }
305
+ const normalized = [...new Set(wellFormed)].sort();
306
+ const content = normalized.length > 0 ? `${REGISTRY_HEADER}${normalized.join('\n')}\n` : REGISTRY_HEADER;
307
+ // Ensure the config root exists as a real 0700 dir. mkdir mode is masked by
308
+ // the process umask on some platforms, so chmod explicitly (best-effort).
309
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
310
+ try {
311
+ fs.chmodSync(dir, 0o700);
312
+ }
313
+ catch {
314
+ /* best-effort: some filesystems reject chmod; the 0600 file is the gate */
315
+ }
316
+ const tmp = path.join(dir, `.trusted-projects.tmp.${process.pid}`);
317
+ const prevUmask = process.umask(0o077);
318
+ try {
319
+ fs.writeFileSync(tmp, content, { mode: 0o600 });
320
+ // writeFileSync's `mode` only applies on CREATE and is umask-masked; an
321
+ // explicit chmod makes the file deterministically 0600.
322
+ try {
323
+ fs.chmodSync(tmp, 0o600);
324
+ }
325
+ catch {
326
+ /* best-effort */
327
+ }
328
+ // rename replaces the destination PATH atomically (not a symlink target).
329
+ fs.renameSync(tmp, target);
330
+ }
331
+ catch (e) {
332
+ try {
333
+ fs.rmSync(tmp, { force: true });
334
+ }
335
+ catch {
336
+ /* best-effort cleanup */
337
+ }
338
+ throw e;
339
+ }
340
+ finally {
341
+ process.umask(prevUmask);
342
+ }
343
+ }
344
+ /**
345
+ * Delete the registry file (used when `trust`/`untrust` empties it). Missing
346
+ * file is not an error. Does NOT touch the `<home>/.rea` dir.
347
+ */
348
+ export function deleteRegistry(home = passwdHome()) {
349
+ fs.rmSync(registryPath(home), { force: true });
350
+ }
351
+ /** POSIX uid check availability. On Windows `process.getuid` is undefined. */
352
+ function currentUid() {
353
+ return typeof process.getuid === 'function' ? process.getuid() : undefined;
354
+ }
355
+ /**
356
+ * Validate `<home>/.rea` (mirrors shim A5.3a). Rejects a symlink, a non-dir, a
357
+ * foreign owner, or a group/other-writable dir (`mode & 0o022`). An ABSENT dir
358
+ * is safe (`{ ok: true, absent: true }`) — the writer creates it 0700. Never
359
+ * lstats `<home>` or above (firmlinks / BSD `/home` symlinks legitimately live
360
+ * there).
361
+ */
362
+ export function checkReaDirSafety(home = passwdHome()) {
363
+ const dir = reaDir(home);
364
+ let st;
365
+ try {
366
+ st = fs.lstatSync(dir);
367
+ }
368
+ catch {
369
+ return { ok: true, absent: true };
370
+ }
371
+ if (st.isSymbolicLink()) {
372
+ return {
373
+ ok: false,
374
+ code: 'symlink',
375
+ reason: `${dir} is a symlink — refusing (a symlink could redirect the global CLI outside the private root)`,
376
+ remediation: `rm ${dir}`,
377
+ };
378
+ }
379
+ if (!st.isDirectory()) {
380
+ return {
381
+ ok: false,
382
+ code: 'not-dir',
383
+ reason: `${dir} exists but is not a directory`,
384
+ remediation: `rm ${dir}`,
385
+ };
386
+ }
387
+ const uid = currentUid();
388
+ if (uid !== undefined && st.uid !== uid) {
389
+ return {
390
+ ok: false,
391
+ code: 'foreign-owner',
392
+ reason: `${dir} is not owned by the current user (owner uid=${st.uid}) — refusing`,
393
+ remediation: `chown ${uid} ${dir}`,
394
+ };
395
+ }
396
+ if (uid !== undefined && (st.mode & 0o022) !== 0) {
397
+ return {
398
+ ok: false,
399
+ code: 'world-writable',
400
+ reason: `${dir} is group/other-writable (mode ${(st.mode & 0o777).toString(8)}) — refusing`,
401
+ remediation: `chmod 700 ${dir}`,
402
+ };
403
+ }
404
+ return { ok: true, absent: false };
405
+ }
406
+ /**
407
+ * Validate `<home>/.rea/trusted-projects` (mirrors shim A5.3b). Rejects a
408
+ * symlink, a non-regular file, a foreign owner, a group/other-ACCESSIBLE file
409
+ * (`mode & 0o077`, the strict 0600 mask — NOT the 0o022 dir mask), or a
410
+ * hardlinked file (`nlink !== 1`; a pre-existing in-project hardlink is a
411
+ * same-uid mutation primitive — codex P2-7). An ABSENT registry is safe
412
+ * (`{ ok: true, absent: true }`) — nothing is trusted yet.
413
+ */
414
+ export function checkRegistrySafety(home = passwdHome()) {
415
+ const reg = registryPath(home);
416
+ let st;
417
+ try {
418
+ st = fs.lstatSync(reg);
419
+ }
420
+ catch {
421
+ return { ok: true, absent: true };
422
+ }
423
+ if (st.isSymbolicLink()) {
424
+ return {
425
+ ok: false,
426
+ code: 'symlink',
427
+ reason: `${reg} is a symlink — refusing (a symlink could redirect the trust read)`,
428
+ remediation: `rm ${reg}`,
429
+ };
430
+ }
431
+ if (!st.isFile()) {
432
+ return {
433
+ ok: false,
434
+ code: 'not-file',
435
+ reason: `${reg} is not a regular file`,
436
+ remediation: `rm ${reg}`,
437
+ };
438
+ }
439
+ const uid = currentUid();
440
+ if (uid !== undefined && st.uid !== uid) {
441
+ return {
442
+ ok: false,
443
+ code: 'foreign-owner',
444
+ reason: `${reg} is not owned by the current user (owner uid=${st.uid}) — refusing`,
445
+ remediation: `chown ${uid} ${reg}`,
446
+ };
447
+ }
448
+ if (uid !== undefined && (st.mode & 0o077) !== 0) {
449
+ return {
450
+ ok: false,
451
+ code: 'bad-mode',
452
+ reason: `${reg} is group/other-accessible (mode ${(st.mode & 0o777).toString(8)}) — refusing`,
453
+ remediation: `chmod 600 ${reg}`,
454
+ };
455
+ }
456
+ if (st.nlink !== 1) {
457
+ return {
458
+ ok: false,
459
+ code: 'bad-nlink',
460
+ reason: `${reg} has ${st.nlink} hard links — refusing (a hardlinked registry is a same-uid mutation primitive)`,
461
+ remediation: `rm ${reg} (then re-run \`rea trust\`)`,
462
+ };
463
+ }
464
+ return { ok: true, absent: false };
465
+ }
466
+ // ---------------------------------------------------------------------------
467
+ // Resolver — probe the two global CLI shapes (design §1)
468
+ // ---------------------------------------------------------------------------
469
+ /**
470
+ * Probe the global CLI under `<home>/.rea/cli`: (1) the `npm install --prefix`
471
+ * shape `node_modules/@bookedsolid/rea/dist/cli/index.js`, then (2) the
472
+ * bare-drop fallback `dist/cli/index.js`. Returns the first existing candidate
473
+ * path, or `null` when neither exists (not installed). Pure existence probe —
474
+ * pair it with {@link checkGlobalCandidateSafety} to reproduce the shim's
475
+ * A1–A4 sandbox before treating the candidate as the active tier.
476
+ */
477
+ export function resolveGlobalCli(home = passwdHome()) {
478
+ const root = globalRoot(home);
479
+ const c1 = path.join(root, 'node_modules', '@bookedsolid', 'rea', 'dist', 'cli', 'index.js');
480
+ if (fs.existsSync(c1))
481
+ return c1;
482
+ const c2 = path.join(root, 'dist', 'cli', 'index.js');
483
+ if (fs.existsSync(c2))
484
+ return c2;
485
+ return null;
486
+ }
487
+ /**
488
+ * Read the `version` of the currently-installed global CLI from the
489
+ * `package.json` beside whichever install shape {@link resolveGlobalCli}
490
+ * matched: (1) `node_modules/@bookedsolid/rea/package.json` (npm-prefix shape),
491
+ * then (2) `<gRoot>/package.json` (bare-drop). Best-effort (BOM-tolerant); a
492
+ * leading UTF-8 BOM is stripped. Returns `null` when neither shape carries a
493
+ * readable non-empty `version`. Used by `rea install --global` to decide
494
+ * whether a requested `--version` differs from what is already on disk.
495
+ */
496
+ export function installedGlobalCliVersion(home = passwdHome()) {
497
+ const root = globalRoot(home);
498
+ const candidates = [
499
+ path.join(root, 'node_modules', '@bookedsolid', 'rea', 'package.json'),
500
+ path.join(root, 'package.json'),
501
+ ];
502
+ for (const p of candidates) {
503
+ let raw;
504
+ try {
505
+ raw = fs.readFileSync(p, 'utf8');
506
+ }
507
+ catch {
508
+ continue;
509
+ }
510
+ if (raw.charCodeAt(0) === 0xfeff)
511
+ raw = raw.slice(1); // strip UTF-8 BOM
512
+ let parsed;
513
+ try {
514
+ parsed = JSON.parse(raw);
515
+ }
516
+ catch {
517
+ continue;
518
+ }
519
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
520
+ const v = parsed['version'];
521
+ if (typeof v === 'string' && v.length > 0)
522
+ return v;
523
+ }
524
+ }
525
+ return null;
526
+ }
527
+ /**
528
+ * Prove a resolved global CLI implements `rea hook policy-get` — the subcommand
529
+ * the global-tier shim invokes for the `allow_global_cli` veto read
530
+ * (`shim_run` step 4-global-veto). Spawns `node <cliPath> hook policy-get
531
+ * --help`; a non-zero exit means the CLI predates the `hook policy-get`
532
+ * subcommand (~0.26.0) and the shim would fall back to no-CLI at the veto step.
533
+ *
534
+ * This is the ONE shared implementation consumed by both `rea install --global`
535
+ * (as the post-install backstop) and `rea doctor` (the global-tier floor), so
536
+ * the two surfaces never drift. Signature-compatible with install's injectable
537
+ * `probeCapability` dep — tests inject a fake to stay hermetic; NO CLI flag
538
+ * exposes it.
539
+ */
540
+ export function probeGlobalCliCapability(cliPath) {
541
+ const r = spawnSync('node', [cliPath, 'hook', 'policy-get', '--help'], {
542
+ encoding: 'utf8',
543
+ timeout: 30_000,
544
+ });
545
+ if (r.status === 0)
546
+ return { ok: true };
547
+ return { ok: false, stderr: (r.stderr ?? '').toString().trim() };
548
+ }
549
+ /**
550
+ * Reproduce the bash shim's A1–A4 candidate sandbox (`shim_sandbox_check_global`
551
+ * in `hooks/_lib/shim-runtime.sh`) over a resolved global CLI candidate. This is
552
+ * the missing half of {@link resolveGlobalCli}: existence alone is NOT enough to
553
+ * treat a candidate as runnable — the shim ALSO refuses a hostile/malformed tree
554
+ * (blessed-but-hostile), and `rea doctor` must refuse identically or it would
555
+ * claim `global` for a candidate the shim falls back from.
556
+ *
557
+ * Evaluation order mirrors the shim (cheapest/most-decisive first):
558
+ * - A2 per-component `lstat` walk from the candidate UP TO AND INCLUDING
559
+ * `<home>/.rea` (= `dirname(gRoot)`; STOP there — never lstat the home
560
+ * dir or above, where firmlinks / BSD `/home` symlinks legitimately
561
+ * live). Reject ANY symlink component, foreign owner, group/other-write
562
+ * (`mode & 0o022`), or a device-number change vs `gRoot` (mount/bind
563
+ * aliasing). The candidate `index.js` itself must have `nlink === 1`.
564
+ * - A1 `realpath(candidate)` contained in `realpath(gRoot)`.
565
+ * - A4 realpath ends in `dist/cli/index.js` (ALWAYS-on for the global tier).
566
+ * - A3 an ancestor `package.json` (≤20 hops) has `name === "@bookedsolid/rea"`
567
+ * AND that `package.json` has `nlink === 1`.
568
+ *
569
+ * `unavailable` (an `lstat`/`realpath` that threw — ENOENT / automount) is
570
+ * surfaced as a fail here because doctor treats "sandbox did not confirm safe"
571
+ * the same as "not the active tier": the shim likewise does NOT set the global
572
+ * CLI in that case. Callers map ANY `ok:false` to "global tier NOT active".
573
+ */
574
+ export function checkGlobalCandidateSafety(candidate, gRoot, home = passwdHome()) {
575
+ const uid = currentUid();
576
+ // Walk stops at <home>/.rea (= dirname(gRoot)); never lstat home or above.
577
+ const stopDir = reaDir(home);
578
+ const sep = path.sep;
579
+ // Capture gRoot's device number for the mount/bind aliasing check (A2).
580
+ let gRootDev;
581
+ try {
582
+ gRootDev = fs.lstatSync(gRoot).dev;
583
+ }
584
+ catch {
585
+ return { ok: false, code: 'unavailable', reason: `${gRoot} is not accessible` };
586
+ }
587
+ // A2 — per-component lstat walk: candidate UP TO AND INCLUDING stopDir.
588
+ let comp = candidate;
589
+ let first = true;
590
+ let guard = 0;
591
+ for (;;) {
592
+ guard += 1;
593
+ if (guard > 128) {
594
+ return { ok: false, code: 'perm', reason: `pathological path depth resolving ${candidate}` };
595
+ }
596
+ let st;
597
+ try {
598
+ st = fs.lstatSync(comp);
599
+ }
600
+ catch {
601
+ return { ok: false, code: 'unavailable', reason: `${comp} is not accessible` };
602
+ }
603
+ if (st.isSymbolicLink()) {
604
+ return {
605
+ ok: false,
606
+ code: 'symlink',
607
+ reason: `${comp} is a symlink — refusing (a symlink component could redirect the global CLI)`,
608
+ };
609
+ }
610
+ if (uid !== undefined && st.uid !== uid) {
611
+ return {
612
+ ok: false,
613
+ code: 'perm',
614
+ reason: `${comp} is not owned by the current user (owner uid=${st.uid})`,
615
+ };
616
+ }
617
+ if (uid !== undefined && (st.mode & 0o022) !== 0) {
618
+ return {
619
+ ok: false,
620
+ code: 'perm',
621
+ reason: `${comp} is group/other-writable (mode ${(st.mode & 0o777).toString(8)})`,
622
+ };
623
+ }
624
+ if (st.dev !== gRootDev) {
625
+ return {
626
+ ok: false,
627
+ code: 'perm',
628
+ reason: `${comp} is on a different filesystem than ${gRoot} (mount/bind aliasing)`,
629
+ };
630
+ }
631
+ if (first) {
632
+ if (st.nlink !== 1) {
633
+ return {
634
+ ok: false,
635
+ code: 'hardlink',
636
+ reason: `${candidate} has ${st.nlink} hard links — refusing`,
637
+ };
638
+ }
639
+ first = false;
640
+ }
641
+ if (comp === stopDir)
642
+ break;
643
+ const parent = path.dirname(comp);
644
+ if (parent === comp)
645
+ break; // reached fs root without hitting stopDir
646
+ comp = parent;
647
+ }
648
+ // A1 — realpath containment.
649
+ let real;
650
+ let realRoot;
651
+ try {
652
+ real = fs.realpathSync(candidate);
653
+ }
654
+ catch {
655
+ return { ok: false, code: 'unavailable', reason: `realpath failed for ${candidate}` };
656
+ }
657
+ try {
658
+ realRoot = fs.realpathSync(gRoot);
659
+ }
660
+ catch {
661
+ return { ok: false, code: 'unavailable', reason: `realpath failed for ${gRoot}` };
662
+ }
663
+ const rootSep = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
664
+ if (!(real === realRoot || real.startsWith(rootSep))) {
665
+ return { ok: false, code: 'escapes-root', reason: `${real} escapes ${realRoot}` };
666
+ }
667
+ // A4 — dist/cli/index.js shape (ALWAYS-on for the global tier).
668
+ const endWith = path.join('dist', 'cli', 'index.js');
669
+ if (!(real.endsWith(sep + endWith) || real === sep + endWith)) {
670
+ return { ok: false, code: 'shape', reason: `${real} does not end in ${endWith}` };
671
+ }
672
+ // A3 — ancestor package.json name @bookedsolid/rea + nlink === 1.
673
+ let cur = path.dirname(path.dirname(path.dirname(real)));
674
+ let pkg = '';
675
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
676
+ const pj = path.join(cur, 'package.json');
677
+ if (fs.existsSync(pj)) {
678
+ try {
679
+ const data = JSON.parse(fs.readFileSync(pj, 'utf8'));
680
+ if (data && data.name === '@bookedsolid/rea') {
681
+ pkg = pj;
682
+ break;
683
+ }
684
+ }
685
+ catch {
686
+ // keep walking — a malformed package.json on the path is not fatal
687
+ }
688
+ }
689
+ cur = path.dirname(cur);
690
+ }
691
+ if (pkg === '') {
692
+ return {
693
+ ok: false,
694
+ code: 'no-rea-pkg',
695
+ reason: `no ancestor package.json declaring @bookedsolid/rea above ${real}`,
696
+ };
697
+ }
698
+ let ps;
699
+ try {
700
+ ps = fs.lstatSync(pkg);
701
+ }
702
+ catch {
703
+ return { ok: false, code: 'no-rea-pkg', reason: `${pkg} is not accessible` };
704
+ }
705
+ if (ps.nlink !== 1) {
706
+ return { ok: false, code: 'hardlink', reason: `${pkg} has ${ps.nlink} hard links — refusing` };
707
+ }
708
+ return { ok: true, realpath: real };
709
+ }