@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
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;
|