@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/MIGRATING.md +105 -0
- package/README.md +62 -0
- package/THREAT_MODEL.md +65 -0
- package/dist/cli/doctor.d.ts +50 -0
- package/dist/cli/doctor.js +379 -0
- package/dist/cli/global-cli.d.ts +274 -0
- package/dist/cli/global-cli.js +709 -0
- package/dist/cli/hook.js +26 -0
- package/dist/cli/index.js +10 -0
- package/dist/cli/install/global.d.ts +93 -0
- package/dist/cli/install/global.js +397 -0
- package/dist/cli/trust.d.ts +70 -0
- package/dist/cli/trust.js +222 -0
- package/dist/hooks/bash-scanner/protected-scan.d.ts +24 -0
- package/dist/hooks/bash-scanner/protected-scan.js +152 -7
- package/dist/hooks/protected-paths-bash-gate/index.js +24 -1
- package/dist/policy/loader.d.ts +13 -0
- package/dist/policy/loader.js +52 -0
- package/dist/policy/types.d.ts +40 -0
- package/hooks/_lib/shim-cache.sh +42 -8
- package/hooks/_lib/shim-runtime.sh +494 -29
- package/hooks/local-review-gate.sh +51 -5
- package/package.json +1 -1
|
@@ -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
|
+
}
|