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