@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.
package/dist/cli/hook.js CHANGED
@@ -109,6 +109,26 @@ export async function runHookPushGate(options) {
109
109
  * safety net for weird invocations (running the CLI from a script that
110
110
  * piped in nothing, a test that forgot to close the write end, etc.).
111
111
  */
112
+ /**
113
+ * Passwd-derived absolute home directory for the `~/.rea` global-root
114
+ * scanner gate. Uses `os.userInfo().homedir` (libuv `getpwuid_r`), NEVER
115
+ * `$HOME` / `$XDG_*` — an agent can set those in-process, so an
116
+ * env-derived root is exactly the redirect surface this gate closes.
117
+ * Returns undefined on a passwd-lookup failure or a non-absolute home
118
+ * (feature-absent parity, mirroring the shim-runtime's silent
119
+ * "unavailable" — see `shim_global_entry_gate`).
120
+ */
121
+ function passwdDerivedHome() {
122
+ try {
123
+ const home = os.userInfo().homedir;
124
+ if (typeof home === 'string' && home.startsWith('/'))
125
+ return home;
126
+ }
127
+ catch {
128
+ /* no passwd entry → gate disabled */
129
+ }
130
+ return undefined;
131
+ }
112
132
  async function readStdinWithTimeout(timeoutMs) {
113
133
  return new Promise((resolve) => {
114
134
  const chunks = [];
@@ -214,6 +234,11 @@ export async function runHookScanBash(options) {
214
234
  // Policy missing or invalid. Continue with defaults — the historical
215
235
  // protected list is hardcoded; blocked_paths becomes an empty no-op.
216
236
  }
237
+ // Passwd-derived home for the `~/.rea` global-root gate (safe-global-
238
+ // CLI). Never `$HOME` / `$XDG_*` — an agent can move those in-process.
239
+ // A passwd-lookup failure leaves it undefined → gate disabled
240
+ // (feature-absent parity, mirroring the shim's silent "unavailable").
241
+ const passwdHome = passwdDerivedHome();
217
242
  let verdict;
218
243
  try {
219
244
  if (options.mode === 'protected') {
@@ -223,6 +248,7 @@ export async function runHookScanBash(options) {
223
248
  ...(protectedWrites !== undefined ? { protected_writes: protectedWrites } : {}),
224
249
  protected_paths_relax: protectedRelax,
225
250
  },
251
+ ...(passwdHome !== undefined ? { passwdHome } : {}),
226
252
  stderr: (line) => process.stderr.write(line),
227
253
  }, cmd);
228
254
  }
package/dist/cli/index.js CHANGED
@@ -19,6 +19,8 @@ import { registerAuditByToolCommand } from './audit-by-tool.js';
19
19
  import { registerAuditTimelineCommand } from './audit-timeline.js';
20
20
  import { registerAuditTopBlocksCommand } from './audit-top-blocks.js';
21
21
  import { registerVerifyClaimCommand } from './verify-claim.js';
22
+ import { registerTrustCommands } from './trust.js';
23
+ import { registerInstallCommand } from './install/global.js';
22
24
  import { err, getPkgVersion } from './utils.js';
23
25
  async function main() {
24
26
  const program = new Command();
@@ -174,6 +176,14 @@ async function main() {
174
176
  // centerpiece of 0.28.0 (4th structural pivot — claims as
175
177
  // machine-verifiable artifacts).
176
178
  registerVerifyClaimCommand(program);
179
+ // Phase 3a — the opt-in global rea CLI tier's writer surface. `rea trust` /
180
+ // `rea untrust` / `rea trust --list` manage the per-user allow-list
181
+ // (<home>/.rea/trusted-projects); `rea install --global` drops a real
182
+ // per-user CLI at <home>/.rea/cli. Both derive the per-user root from the
183
+ // password database (never $HOME/$XDG_*) — an env-redirectable trust root
184
+ // would re-open the N3 surface the tier closes.
185
+ registerTrustCommands(program);
186
+ registerInstallCommand(program);
177
187
  const tofu = program
178
188
  .command('tofu')
179
189
  .description('TOFU fingerprint operations (G7) — inspect and rebase `.rea/fingerprints.json` when a legitimate registry edit has triggered drift fail-close. Emits audit records.');
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `rea install --global [--version <semver>] [--trust .] [--force]`
3
+ *
4
+ * Installs the rea CLI once, per-user, OUT of any project (design §9): a REAL
5
+ * `npm install --prefix <home>/.rea/cli @bookedsolid/rea@<ver>` (never a
6
+ * symlink), so the opt-in global shim tier can resolve a governed CLI for a
7
+ * blessed checkout without touching that checkout's shared `package.json`.
8
+ *
9
+ * SAFETY (refuse BEFORE install):
10
+ * - `<home>/.rea` must not be a symlink / foreign-owned / group-or-world
11
+ * writable (reuses `checkReaDirSafety` → exact remediation strings).
12
+ * - `<home>/.rea` must NOT resolve inside the current git checkout (a config
13
+ * root inside the repo would make the global CLI a committable artifact) —
14
+ * mirrors openrouter-key-source's `isInsideReviewedCheckout`.
15
+ * - The requested version must exist in the npm registry (`npm view`
16
+ * pre-check — CLAUDE.md rule: never install an unverified package).
17
+ *
18
+ * TEST ISOLATION: `home` + `cwd` are injectable and default to the passwd home
19
+ * + `process.cwd()`; the `npm view` / `npm install` shell-outs are injectable
20
+ * `deps` so tests never hit the network or mutate the real `~/.rea/`.
21
+ */
22
+ import type { Command } from 'commander';
23
+ import { type ProcReader } from '../global-cli.js';
24
+ /** Result of the `npm view <spec> version` pre-check. */
25
+ export interface NpmViewResult {
26
+ ok: boolean;
27
+ version?: string;
28
+ stderr?: string;
29
+ }
30
+ /** Result of the `npm install --prefix <prefix> <spec>` run. */
31
+ export interface NpmInstallResult {
32
+ ok: boolean;
33
+ stderr?: string;
34
+ }
35
+ /** Result of the post-install `hook policy-get` capability probe. */
36
+ export interface CapabilityProbeResult {
37
+ ok: boolean;
38
+ stderr?: string;
39
+ }
40
+ /** Injectable shell-outs so tests never hit the network. */
41
+ export interface InstallGlobalDeps {
42
+ npmView(spec: string): NpmViewResult;
43
+ npmInstall(prefix: string, spec: string): NpmInstallResult;
44
+ /**
45
+ * Prove the installed CLI implements `rea hook policy-get` (the subcommand
46
+ * the global-tier shim calls for the `allow_global_cli` veto). Spawns
47
+ * `node <cliPath> hook policy-get --help`; a non-zero exit means the version
48
+ * predates the floor and the global tier would silently fall back to no-CLI.
49
+ */
50
+ probeCapability(cliPath: string): CapabilityProbeResult;
51
+ }
52
+ export interface InstallGlobalOptions {
53
+ /** Semver to install; defaults to the currently-running rea version. */
54
+ version?: string;
55
+ /** `--trust` value: `true`/`'.'` → trust cwd; a string → trust that path. */
56
+ trust?: string | boolean;
57
+ /** Re-install even when already present (perms are always re-asserted). */
58
+ force?: boolean;
59
+ /** Injected home dir (tests). Defaults to the passwd home. NOT a CLI flag. */
60
+ home?: string;
61
+ /** Injected cwd (tests). Defaults to `process.cwd()`. */
62
+ cwd?: string;
63
+ /** Injected ancestry reader (tests). NOT a CLI flag. */
64
+ procReader?: ProcReader;
65
+ }
66
+ /**
67
+ * The version floor at which `rea hook policy-get` exists. The global-tier shim
68
+ * calls it for the `allow_global_cli` veto, so installing anything below this
69
+ * would leave a trusted checkout silently falling back to no-CLI while install
70
+ * reported success. The capability probe is the robust backstop; this floor is
71
+ * the cheaper fast-fail for an explicit `--version`.
72
+ */
73
+ export declare const HOOK_POLICY_GET_FLOOR = "0.26.0";
74
+ /**
75
+ * Is `dir` inside the SAME git checkout as `cwd`? Used to REFUSE installing the
76
+ * per-user CLI into the repo being worked on (a committable artifact) when
77
+ * home/`.rea` points inside it. Dotfiles-as-git is NOT flagged — it compares
78
+ * against `cwd`'s toplevel specifically, not any git repo. Never throws; a
79
+ * non-git `cwd` returns false.
80
+ */
81
+ export declare function isInsideReviewedCheckout(dir: string, cwd: string): boolean;
82
+ /**
83
+ * `rea install --global`. Returns the process exit code.
84
+ * 0 — installed (or already installed; perms re-asserted)
85
+ * 1 — unsafe root / inside-checkout / version-not-in-registry / install failed
86
+ */
87
+ export declare function runInstallGlobal(options?: InstallGlobalOptions, deps?: InstallGlobalDeps): number;
88
+ /**
89
+ * Register `rea install [--global] [--version <semver>] [--trust [path]]
90
+ * [--force]`. In this phase `--global` is the only mode; without it we refuse
91
+ * with a hint rather than silently doing a per-user install.
92
+ */
93
+ export declare function registerInstallCommand(program: Command): void;
@@ -0,0 +1,397 @@
1
+ /**
2
+ * `rea install --global [--version <semver>] [--trust .] [--force]`
3
+ *
4
+ * Installs the rea CLI once, per-user, OUT of any project (design §9): a REAL
5
+ * `npm install --prefix <home>/.rea/cli @bookedsolid/rea@<ver>` (never a
6
+ * symlink), so the opt-in global shim tier can resolve a governed CLI for a
7
+ * blessed checkout without touching that checkout's shared `package.json`.
8
+ *
9
+ * SAFETY (refuse BEFORE install):
10
+ * - `<home>/.rea` must not be a symlink / foreign-owned / group-or-world
11
+ * writable (reuses `checkReaDirSafety` → exact remediation strings).
12
+ * - `<home>/.rea` must NOT resolve inside the current git checkout (a config
13
+ * root inside the repo would make the global CLI a committable artifact) —
14
+ * mirrors openrouter-key-source's `isInsideReviewedCheckout`.
15
+ * - The requested version must exist in the npm registry (`npm view`
16
+ * pre-check — CLAUDE.md rule: never install an unverified package).
17
+ *
18
+ * TEST ISOLATION: `home` + `cwd` are injectable and default to the passwd home
19
+ * + `process.cwd()`; the `npm view` / `npm install` shell-outs are injectable
20
+ * `deps` so tests never hit the network or mutate the real `~/.rea/`.
21
+ */
22
+ import { spawnSync } from 'node:child_process';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import semver from 'semver';
26
+ import { assertNotGovernedSession, checkGlobalCandidateSafety, checkReaDirSafety, globalRoot, installedGlobalCliVersion, isProjectTrusted, passwdHome, probeGlobalCliCapability, projectPathControlCharReason, readRegistry, reaDir, resolveGlobalCli, writeRegistry, } from '../global-cli.js';
27
+ import { REA_PACKAGE_NAME } from './self-pin.js';
28
+ import { err, getPkgVersion, log, warn } from '../utils.js';
29
+ /**
30
+ * The version floor at which `rea hook policy-get` exists. The global-tier shim
31
+ * calls it for the `allow_global_cli` veto, so installing anything below this
32
+ * would leave a trusted checkout silently falling back to no-CLI while install
33
+ * reported success. The capability probe is the robust backstop; this floor is
34
+ * the cheaper fast-fail for an explicit `--version`.
35
+ */
36
+ export const HOOK_POLICY_GET_FLOOR = '0.26.0';
37
+ // ---------------------------------------------------------------------------
38
+ // Real shell-outs (default deps)
39
+ // ---------------------------------------------------------------------------
40
+ const realDeps = {
41
+ npmView(spec) {
42
+ const r = spawnSync('npm', ['view', spec, 'version'], {
43
+ encoding: 'utf8',
44
+ timeout: 60_000,
45
+ });
46
+ if (r.status === 0 && typeof r.stdout === 'string' && r.stdout.trim().length > 0) {
47
+ return { ok: true, version: r.stdout.trim() };
48
+ }
49
+ return { ok: false, stderr: (r.stderr ?? '').toString().trim() };
50
+ },
51
+ npmInstall(prefix, spec) {
52
+ const r = spawnSync('npm', ['install', '--prefix', prefix, spec], {
53
+ encoding: 'utf8',
54
+ timeout: 300_000,
55
+ });
56
+ if (r.status === 0)
57
+ return { ok: true };
58
+ return { ok: false, stderr: (r.stderr ?? '').toString().trim() };
59
+ },
60
+ // Share ONE capability-probe implementation with `rea doctor` (0.51.x): the
61
+ // spawn logic lives in global-cli.ts::probeGlobalCliCapability so the install
62
+ // backstop and the doctor global-tier floor can never drift.
63
+ probeCapability(cliPath) {
64
+ return probeGlobalCliCapability(cliPath);
65
+ },
66
+ };
67
+ // ---------------------------------------------------------------------------
68
+ // isInsideReviewedCheckout — mirror of openrouter-key-source (codex round-19 P1)
69
+ // ---------------------------------------------------------------------------
70
+ /**
71
+ * Realpath the NEAREST EXISTING ancestor of `p`, then re-append the
72
+ * not-yet-created suffix — so a first-time install (dir absent) still resolves
73
+ * symlinks like macOS `/var`→`/private/var` consistently on both sides.
74
+ */
75
+ function realResolve(p) {
76
+ let cur = path.resolve(p);
77
+ const tail = [];
78
+ for (;;) {
79
+ try {
80
+ return tail.length > 0
81
+ ? path.join(fs.realpathSync(cur), ...tail.reverse())
82
+ : fs.realpathSync(cur);
83
+ }
84
+ catch {
85
+ const parent = path.dirname(cur);
86
+ if (parent === cur)
87
+ return path.resolve(p);
88
+ tail.push(path.basename(cur));
89
+ cur = parent;
90
+ }
91
+ }
92
+ }
93
+ /**
94
+ * Is `dir` inside the SAME git checkout as `cwd`? Used to REFUSE installing the
95
+ * per-user CLI into the repo being worked on (a committable artifact) when
96
+ * home/`.rea` points inside it. Dotfiles-as-git is NOT flagged — it compares
97
+ * against `cwd`'s toplevel specifically, not any git repo. Never throws; a
98
+ * non-git `cwd` returns false.
99
+ */
100
+ export function isInsideReviewedCheckout(dir, cwd) {
101
+ const r = spawnSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
102
+ encoding: 'utf8',
103
+ timeout: 5_000,
104
+ });
105
+ if (r.status !== 0 || typeof r.stdout !== 'string')
106
+ return false;
107
+ const top = r.stdout.trim();
108
+ if (top.length === 0)
109
+ return false;
110
+ const topReal = realResolve(top);
111
+ const dirReal = realResolve(dir);
112
+ return dirReal === topReal || dirReal.startsWith(topReal + path.sep);
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // Runner
116
+ // ---------------------------------------------------------------------------
117
+ function resolveHome(injected) {
118
+ if (injected !== undefined)
119
+ return injected;
120
+ try {
121
+ return passwdHome();
122
+ }
123
+ catch {
124
+ return undefined;
125
+ }
126
+ }
127
+ function printRefusal(fail) {
128
+ err(fail.reason);
129
+ err(` fix: ${fail.remediation}`);
130
+ }
131
+ /** Best-effort chmod 0700 (some filesystems reject chmod). */
132
+ function chmod700(target) {
133
+ try {
134
+ fs.chmodSync(target, 0o700);
135
+ }
136
+ catch {
137
+ /* best-effort */
138
+ }
139
+ }
140
+ /**
141
+ * Post-install capability probe (P2): the installed CLI MUST implement
142
+ * `rea hook policy-get` (the global-tier shim calls it for the veto). A
143
+ * non-zero probe → the version is too old and the tier would silently fall back
144
+ * to no-CLI. Returns the refusal exit code (`1`) after emitting a clear message,
145
+ * or `null` when the probe passes. `cliPath` is the resolved entrypoint.
146
+ */
147
+ function assertCapableCli(cliPath, deps) {
148
+ const probe = deps.probeCapability(cliPath);
149
+ if (!probe.ok) {
150
+ err(`the installed global rea CLI at ${cliPath} does not implement \`rea hook policy-get\` ` +
151
+ `(needs ${HOOK_POLICY_GET_FLOOR}+). The global tier would silently fall back to no-CLI. ` +
152
+ `Reinstall with a newer --version (\`rea install --global --version <${HOOK_POLICY_GET_FLOOR}-or-newer> --force\`).`);
153
+ if (probe.stderr !== undefined && probe.stderr.length > 0) {
154
+ err(` probe: ${probe.stderr}`);
155
+ }
156
+ return 1;
157
+ }
158
+ return null;
159
+ }
160
+ /**
161
+ * Sandbox a resolved global CLI candidate (A1–A4, `checkGlobalCandidateSafety`)
162
+ * BEFORE anything executes it. `resolveGlobalCli` is only an EXISTENCE probe, so
163
+ * on the idempotent path a tampered / symlinked / hostile pre-existing
164
+ * `~/.rea/cli` would otherwise be spawned by the capability probe before any
165
+ * safety validation. A failure REFUSES (exit 1) with the safety reason +
166
+ * remediation — the candidate is NEVER executed. Returns `null` only for a
167
+ * sandbox-validated candidate.
168
+ */
169
+ function assertCandidateSandboxed(candidate, home) {
170
+ const gRoot = globalRoot(home);
171
+ const safety = checkGlobalCandidateSafety(candidate, gRoot, home);
172
+ if (!safety.ok) {
173
+ err(`the global rea CLI at ${candidate} failed the sandbox check (${safety.code}): ${safety.reason}. ` +
174
+ `Refusing to run it. Inspect ${gRoot}; remove or repair the tampered tree, then re-run ` +
175
+ `\`rea install --global --force\`.`);
176
+ return 1;
177
+ }
178
+ return null;
179
+ }
180
+ /** Normalize the `--trust` value into a path (or undefined = no trust). */
181
+ function trustTarget(trust, cwd) {
182
+ if (trust === undefined || trust === false)
183
+ return undefined;
184
+ if (trust === true || trust === '.')
185
+ return cwd;
186
+ return path.resolve(cwd, trust);
187
+ }
188
+ /**
189
+ * Add `projReal` to the registry when not already a member (idempotent).
190
+ * Returns a process exit code: `0` = trusted / already-trusted / skipped
191
+ * (non-fatal), `2` = the resolved path carries a control character (CR/LF/…)
192
+ * that writeRegistry would silently drop — a hard refusal BEFORE any success
193
+ * message, mirroring `rea trust`.
194
+ */
195
+ function addTrust(projPath, home, cwd) {
196
+ let real;
197
+ try {
198
+ real = fs.realpathSync(path.resolve(cwd, projPath));
199
+ }
200
+ catch {
201
+ warn(`--trust path does not exist; skipping trust: ${projPath}`);
202
+ return 0;
203
+ }
204
+ // Shared control-char rejection — refuse (exit 2) BEFORE reporting success so
205
+ // `install --global --trust <path-with-\r>` never prints a lie.
206
+ const ctrl = projectPathControlCharReason(real);
207
+ if (ctrl !== null) {
208
+ err(`invalid path: ${ctrl}: ${JSON.stringify(real)}`);
209
+ return 2;
210
+ }
211
+ const safety = checkReaDirSafety(home);
212
+ if (!safety.ok) {
213
+ // Should not happen (we validated earlier), but never write through an
214
+ // unsafe root.
215
+ printRefusal(safety);
216
+ return 0;
217
+ }
218
+ if (isProjectTrusted(real, home)) {
219
+ log(`Already trusted: ${real}`);
220
+ return 0;
221
+ }
222
+ const members = readRegistry(home);
223
+ members.push(real);
224
+ writeRegistry(members, home);
225
+ log(`Trusted: ${real}`);
226
+ return 0;
227
+ }
228
+ /**
229
+ * `rea install --global`. Returns the process exit code.
230
+ * 0 — installed (or already installed; perms re-asserted)
231
+ * 1 — unsafe root / inside-checkout / version-not-in-registry / install failed
232
+ */
233
+ export function runInstallGlobal(options = {}, deps = realDeps) {
234
+ const home = resolveHome(options.home);
235
+ if (home === undefined) {
236
+ err('cannot determine your home directory from the password database — refusing');
237
+ return 1;
238
+ }
239
+ // Refuse under a governed agent session BEFORE any path resolution / FS work
240
+ // or npm shell-out. `install --global` is a human action (dual-consent).
241
+ const governed = assertNotGovernedSession('install --global', home, options.procReader !== undefined ? { procReader: options.procReader } : {});
242
+ if (governed !== null)
243
+ return governed;
244
+ const cwd = options.cwd ?? process.cwd();
245
+ const version = options.version ?? getPkgVersion();
246
+ const spec = `${REA_PACKAGE_NAME}@${version}`;
247
+ const dir = reaDir(home);
248
+ const gRoot = globalRoot(home);
249
+ // Cheaper fast-fail (P2): an explicit `--version` below the global-tier floor
250
+ // (`rea hook policy-get`, ~0.26.0+) is refused up front. A range / dist-tag
251
+ // (`latest`, `^0.30`) is not a plain version — skip here and let the
252
+ // post-install capability probe be the authoritative backstop.
253
+ if (options.version !== undefined) {
254
+ const v = semver.valid(options.version);
255
+ if (v !== null && semver.lt(v, HOOK_POLICY_GET_FLOOR)) {
256
+ err(`refusing to install ${spec}: the global tier requires \`rea hook policy-get\` ` +
257
+ `(introduced in ${HOOK_POLICY_GET_FLOOR}); v${options.version} is too old and a ` +
258
+ `trusted checkout would silently fall back to no-CLI.`);
259
+ return 1;
260
+ }
261
+ }
262
+ // 1. Refuse BEFORE install: unsafe `<home>/.rea`.
263
+ const safety = checkReaDirSafety(home);
264
+ if (!safety.ok) {
265
+ printRefusal(safety);
266
+ return 1;
267
+ }
268
+ // 2. Refuse a config root INSIDE the current git checkout (committable CLI).
269
+ if (isInsideReviewedCheckout(dir, cwd)) {
270
+ err(`refusing to install the global rea CLI at ${dir} — it is INSIDE the current git checkout ` +
271
+ `(the CLI would be a committable artifact). Run this from OUTSIDE the repo, or use the ` +
272
+ `in-project install (\`pnpm add -D ${REA_PACKAGE_NAME}\`).`);
273
+ return 1;
274
+ }
275
+ // 3. Idempotency: already installed + no --force → re-assert perms + trust.
276
+ // EXCEPTION: an explicit `--version` that DIFFERS from the installed
277
+ // version must NOT no-op (pre-fix it silently left the old CLI in place
278
+ // and reported success). Only skip the reinstall when no `--version` was
279
+ // passed OR the requested version matches what is already on disk. When
280
+ // the installed version can't be read, we cannot prove a mismatch, so we
281
+ // preserve the idempotent no-op rather than churn.
282
+ const existing = resolveGlobalCli(home);
283
+ if (existing !== null && options.force !== true) {
284
+ const installedVersion = installedGlobalCliVersion(home);
285
+ const versionMismatch = options.version !== undefined &&
286
+ installedVersion !== null &&
287
+ installedVersion !== options.version;
288
+ if (!versionMismatch) {
289
+ // Sandbox the PRE-EXISTING candidate (A1–A4) BEFORE the capability probe
290
+ // executes it. This idempotent path is the exact remediation flow an
291
+ // operator runs against a possibly-tampered ~/.rea/cli, and
292
+ // resolveGlobalCli only proved existence — a symlinked / foreign / hostile
293
+ // tree must be refused, never spawned. checkReaDirSafety already ran at
294
+ // step 1; this is the candidate-tree half.
295
+ const sandboxed = assertCandidateSandboxed(existing, home);
296
+ if (sandboxed !== null)
297
+ return sandboxed;
298
+ // Probe the EXISTING CLI before reporting success — a pre-existing too-old
299
+ // install must fail loudly, not be silently blessed (and trusted).
300
+ const capable = assertCapableCli(existing, deps);
301
+ if (capable !== null)
302
+ return capable;
303
+ const vTag = installedVersion !== null ? ` (v${installedVersion})` : '';
304
+ log(`rea CLI already installed${vTag} at ${existing} (use --force to reinstall)`);
305
+ chmod700(dir);
306
+ chmod700(gRoot);
307
+ const trustPath = trustTarget(options.trust, cwd);
308
+ if (trustPath !== undefined) {
309
+ const t = addTrust(trustPath, home, cwd);
310
+ if (t !== 0)
311
+ return t;
312
+ }
313
+ return 0;
314
+ }
315
+ log(`rea CLI v${installedVersion ?? '?'} installed but v${options.version} requested — reinstalling`);
316
+ }
317
+ // 4. Registry pre-check (CLAUDE.md rule): the version MUST exist on npm.
318
+ const view = deps.npmView(spec);
319
+ if (!view.ok) {
320
+ err(`${spec} was not found in the npm registry — refusing to install.`);
321
+ if (view.stderr !== undefined && view.stderr.length > 0) {
322
+ err(` npm view: ${view.stderr}`);
323
+ }
324
+ return 1;
325
+ }
326
+ // 5. Create the config root (0700) + prefix, then real install.
327
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
328
+ chmod700(dir);
329
+ fs.mkdirSync(gRoot, { recursive: true, mode: 0o700 });
330
+ chmod700(gRoot);
331
+ const install = deps.npmInstall(gRoot, spec);
332
+ if (!install.ok) {
333
+ err(`\`npm install --prefix ${gRoot} ${spec}\` failed — the global CLI was not installed.`);
334
+ if (install.stderr !== undefined && install.stderr.length > 0) {
335
+ err(` npm install: ${install.stderr}`);
336
+ }
337
+ return 1;
338
+ }
339
+ // 6. Re-assert perms (npm may have relaxed the prefix dir mode).
340
+ chmod700(dir);
341
+ chmod700(gRoot);
342
+ // 6b. Capability probe (P2): prove the freshly-installed CLI implements
343
+ // `rea hook policy-get` BEFORE reporting success or trusting anything. A
344
+ // version that passed `npm view` (exists) but predates the floor would
345
+ // otherwise leave a silently-broken global CLI. Do NOT trust on failure.
346
+ const freshCli = resolveGlobalCli(home);
347
+ if (freshCli === null) {
348
+ err(`\`npm install\` reported success but no global CLI entrypoint resolved under ${gRoot}.`);
349
+ return 1;
350
+ }
351
+ // Belt-and-suspenders: sandbox the freshly-placed candidate too before the
352
+ // capability probe executes it (defends against a symlink race / hostile
353
+ // prefix that slipped a bad tree in during the install window).
354
+ const freshSandboxed = assertCandidateSandboxed(freshCli, home);
355
+ if (freshSandboxed !== null)
356
+ return freshSandboxed;
357
+ const capable = assertCapableCli(freshCli, deps);
358
+ if (capable !== null)
359
+ return capable;
360
+ // 7. Optional trust of the current (or named) project.
361
+ const trustPath = trustTarget(options.trust, cwd);
362
+ if (trustPath !== undefined) {
363
+ const t = addTrust(trustPath, home, cwd);
364
+ if (t !== 0)
365
+ return t;
366
+ }
367
+ log(`Installed ${spec} to ${gRoot}`);
368
+ log(`Global rea CLI entrypoint: ${freshCli}`);
369
+ return 0;
370
+ }
371
+ /**
372
+ * Register `rea install [--global] [--version <semver>] [--trust [path]]
373
+ * [--force]`. In this phase `--global` is the only mode; without it we refuse
374
+ * with a hint rather than silently doing a per-user install.
375
+ */
376
+ export function registerInstallCommand(program) {
377
+ program
378
+ .command('install')
379
+ .description('Install the rea CLI per-user, out-of-project (<home>/.rea/cli), so the opt-in global shim tier can govern a blessed checkout without touching its package.json.')
380
+ .option('--global', 'install to <home>/.rea/cli (currently the only supported mode)')
381
+ .option('--version <semver>', 'version to install (default: the running rea version)')
382
+ .option('--trust [path]', 'also trust a project (default: cwd) after installing')
383
+ .option('--force', 'reinstall even if a global CLI is already present')
384
+ .action((opts) => {
385
+ if (opts.global !== true) {
386
+ err('`rea install` requires `--global` (per-user install). No other mode is supported.');
387
+ process.exit(2);
388
+ }
389
+ const runOpts = {
390
+ ...(opts.version !== undefined ? { version: opts.version } : {}),
391
+ ...(opts.trust !== undefined ? { trust: opts.trust } : {}),
392
+ ...(opts.force === true ? { force: true } : {}),
393
+ };
394
+ const code = runInstallGlobal(runOpts);
395
+ process.exit(code);
396
+ });
397
+ }
@@ -0,0 +1,70 @@
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 type { Command } from 'commander';
20
+ import { type ProcReader } from './global-cli.js';
21
+ export interface TrustOptions {
22
+ /** Project path to trust; defaults to `cwd`. */
23
+ path?: string;
24
+ /** Injected home dir (tests). Defaults to the passwd home. NOT a CLI flag. */
25
+ home?: string;
26
+ /** Injected cwd (tests). Defaults to `process.cwd()`. */
27
+ cwd?: string;
28
+ /** Injected ancestry reader (tests). NOT a CLI flag. */
29
+ procReader?: ProcReader;
30
+ }
31
+ export interface UntrustOptions {
32
+ path?: string;
33
+ home?: string;
34
+ cwd?: string;
35
+ /** Injected ancestry reader (tests). NOT a CLI flag. */
36
+ procReader?: ProcReader;
37
+ }
38
+ export interface TrustListOptions {
39
+ home?: string;
40
+ }
41
+ /**
42
+ * `rea trust [<path>]`. Returns the process exit code.
43
+ * 0 — trusted (or already trusted; idempotent)
44
+ * 1 — `<home>/.rea` is unsafe (symlink/foreign/world-writable), or no passwd
45
+ * 2 — path does not exist / is not a directory / contains illegal bytes
46
+ */
47
+ export declare function runTrust(options?: TrustOptions): number;
48
+ /**
49
+ * `rea untrust [<path>]`. Removes the exact-match line for both the resolved
50
+ * absolute path AND its realpath (when resolvable), so a since-moved or
51
+ * symlinked project can still be untrusted. Empties → delete the file.
52
+ * 0 — removed, or nothing to remove (idempotent)
53
+ * 1 — `<home>/.rea` is unsafe, or no passwd
54
+ */
55
+ export declare function runUntrust(options?: UntrustOptions): number;
56
+ /**
57
+ * `rea trust --list`. Prints the registry members one per line to stdout
58
+ * (pipe-friendly). Perm-gated: a tampered `<home>/.rea` or registry file →
59
+ * exit 1 with remediation.
60
+ * 0 — printed (possibly "No trusted projects.")
61
+ * 1 — tampered root, or no passwd
62
+ */
63
+ export declare function runTrustList(options?: TrustListOptions): number;
64
+ /**
65
+ * Register `rea trust [path]` (with `--list`) and `rea untrust [path]`.
66
+ * The thin action wrappers translate the run functions' exit codes into
67
+ * `process.exit`. `registryPath` is imported only to keep the module's public
68
+ * surface discoverable for doctor (Phase 3b) — no behavior here.
69
+ */
70
+ export declare function registerTrustCommands(program: Command): void;