@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/doctor.js
CHANGED
|
@@ -20,6 +20,7 @@ import { computeHash } from '../audit/fs.js';
|
|
|
20
20
|
import { PREPARE_COMMIT_MSG_BODY_MARKER, PREPARE_COMMIT_MSG_MARKER, } from './install/prepare-commit-msg.js';
|
|
21
21
|
import { validateSettings } from '../config/settings-schema.js';
|
|
22
22
|
import { checkSelfPinDeclaredSync, REA_PACKAGE_NAME, stripUtf8Bom } from './install/self-pin.js';
|
|
23
|
+
import { checkGlobalCandidateSafety, checkReaDirSafety, checkRegistrySafety, globalRoot, isProjectTrusted, passwdHome, probeGlobalCliCapability, reaDir as globalReaDir, registryPath as globalRegistryPath, resolveGlobalCli, } from './global-cli.js';
|
|
23
24
|
import { POLICY_FILE, REA_DIR, REGISTRY_FILE, getPkgVersion, log, reaPath } from './utils.js';
|
|
24
25
|
function checkFileExists(label, filePath, fatal) {
|
|
25
26
|
const exists = fs.existsSync(filePath);
|
|
@@ -2443,6 +2444,377 @@ export async function checkDelegationRoundTrip(baseDir) {
|
|
|
2443
2444
|
detail: `probe via real .claude/hooks/delegation-capture.sh shell hook (hash=${storedHash.slice(0, 16)}, tag=${probeTag.slice(-8)})`,
|
|
2444
2445
|
};
|
|
2445
2446
|
}
|
|
2447
|
+
/** realpath a path, returning null on any failure (missing / ENOENT / loop). */
|
|
2448
|
+
function realpathOrNull(p) {
|
|
2449
|
+
if (p === null)
|
|
2450
|
+
return null;
|
|
2451
|
+
try {
|
|
2452
|
+
return fs.realpathSync(p);
|
|
2453
|
+
}
|
|
2454
|
+
catch {
|
|
2455
|
+
return null;
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
/**
|
|
2459
|
+
* Is the global tier vetoed by this repo's policy? Mirrors `shim_run`'s
|
|
2460
|
+
* step 4-global-veto, which shells `rea hook policy-get runtime.allow_global_cli`:
|
|
2461
|
+
* - explicit `runtime.allow_global_cli: false` → veto
|
|
2462
|
+
* - `true` / no `runtime:` block / EMPTY output → allow
|
|
2463
|
+
* - a MISSING `.rea/policy.yaml` → allow (policy-get exits
|
|
2464
|
+
* 0 with empty output for an absent file — the tier is ALLOWED)
|
|
2465
|
+
* - a PRESENT but malformed/unreadable policy → veto (policy-get exits
|
|
2466
|
+
* non-zero → the shim's fail-closed branch refuses the tier)
|
|
2467
|
+
*
|
|
2468
|
+
* The missing-vs-malformed distinction is load-bearing: turning file-absent into
|
|
2469
|
+
* a veto (the old behavior) misreported a trusted checkout with no policy.yaml as
|
|
2470
|
+
* `policy-veto` while the shim actually runs the global CLI.
|
|
2471
|
+
*/
|
|
2472
|
+
function globalTierVetoed(baseDir) {
|
|
2473
|
+
const policyPath = reaPath(baseDir, POLICY_FILE);
|
|
2474
|
+
// Absent policy ⇒ allow (parity with `policy-get` exit 0 + empty output).
|
|
2475
|
+
if (!fs.existsSync(policyPath))
|
|
2476
|
+
return false;
|
|
2477
|
+
try {
|
|
2478
|
+
return loadPolicy(baseDir).runtime?.allow_global_cli === false;
|
|
2479
|
+
}
|
|
2480
|
+
catch {
|
|
2481
|
+
// Present but malformed/unreadable ⇒ fail-closed veto (parity with the
|
|
2482
|
+
// shim's non-zero `policy-get` branch).
|
|
2483
|
+
return true;
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
/**
|
|
2487
|
+
* THE single predicate (F1). Resolve which tier a checkout runs through and
|
|
2488
|
+
* why, consuming `global-cli.ts`. Follows the authoritative Phase-3b decision
|
|
2489
|
+
* tree top-to-bottom; each branch corresponds to a `shim_run` outcome so the
|
|
2490
|
+
* parity test can assert byte-agreement on `tier` + `cliRealpath`.
|
|
2491
|
+
*
|
|
2492
|
+
* `home` defaults to the passwd-derived home (env-immune by design); tests
|
|
2493
|
+
* inject a temp home to stay hermetic. `probeCapability` defaults to the shared
|
|
2494
|
+
* `probeGlobalCliCapability` (spawns the real CLI); tests inject a fake to stay
|
|
2495
|
+
* hermetic. NO CLI flag exposes either parameter.
|
|
2496
|
+
*/
|
|
2497
|
+
export function resolveGlobalCliTier(baseDir, home = passwdHome(), probeCapability = probeGlobalCliCapability) {
|
|
2498
|
+
const inProjectReal = realpathOrNull(resolveCliDistPath(baseDir));
|
|
2499
|
+
// In-project ALWAYS wins (shim step 4). The bash shim NEVER consults
|
|
2500
|
+
// <home>/.rea when an in-project @bookedsolid/rea resolves — so we MUST
|
|
2501
|
+
// short-circuit BEFORE any global-root safety / resolvability / candidate
|
|
2502
|
+
// check. Otherwise a repo that vendors rea locally, whose user also has a
|
|
2503
|
+
// stale or permission-damaged ~/.rea/cli, would get a FALSE [fail] from
|
|
2504
|
+
// `rea doctor` even though the hooks happily run the in-project CLI. A
|
|
2505
|
+
// broken or unused global tree must never produce a warn/fail here.
|
|
2506
|
+
if (inProjectReal !== null) {
|
|
2507
|
+
// Note the global root's mere existence (a benign [info]) but do NOT probe
|
|
2508
|
+
// or sandbox it — it is unused, and the shim would never touch it.
|
|
2509
|
+
const reason = fs.existsSync(globalRoot(home)) ? 'in-project-wins' : 'global-root-absent';
|
|
2510
|
+
return { tier: 'in-project', reason, cliRealpath: inProjectReal, globalCliRealpath: null };
|
|
2511
|
+
}
|
|
2512
|
+
// Platform guard (before ANY global work). The global tier is POSIX-ONLY (v1):
|
|
2513
|
+
// the bash `shim_global_entry_gate` calls `process.geteuid()`, which is
|
|
2514
|
+
// undefined on Windows / Git Bash, so it falls back to `unavailable` and the
|
|
2515
|
+
// tier NEVER resolves there. Mirror that gate EXACTLY (`geteuid` presence, not
|
|
2516
|
+
// `platform === 'win32'`) so doctor never claims a global tier the hooks can't
|
|
2517
|
+
// resolve. In-project resolution above is unaffected; this is NOT a fail —
|
|
2518
|
+
// the feature simply isn't available on this platform.
|
|
2519
|
+
if (typeof process.geteuid !== 'function') {
|
|
2520
|
+
return {
|
|
2521
|
+
tier: 'in-project',
|
|
2522
|
+
reason: 'global-unavailable-platform',
|
|
2523
|
+
cliRealpath: null,
|
|
2524
|
+
globalCliRealpath: null,
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
// No in-project CLI — the global tier now governs.
|
|
2528
|
+
// Global root absent → feature unused; the (empty) in-project resolver governs.
|
|
2529
|
+
if (!fs.existsSync(globalRoot(home))) {
|
|
2530
|
+
return {
|
|
2531
|
+
tier: 'in-project',
|
|
2532
|
+
reason: 'global-root-absent',
|
|
2533
|
+
cliRealpath: null,
|
|
2534
|
+
globalCliRealpath: null,
|
|
2535
|
+
};
|
|
2536
|
+
}
|
|
2537
|
+
// <home>/.rea safety gate (shim A5.3a). Unsafe → shim degrades to no-CLI;
|
|
2538
|
+
// doctor hard-fails.
|
|
2539
|
+
const safety = checkReaDirSafety(home);
|
|
2540
|
+
if (!safety.ok) {
|
|
2541
|
+
return {
|
|
2542
|
+
tier: 'in-project',
|
|
2543
|
+
reason: 'global-root-unsafe',
|
|
2544
|
+
cliRealpath: null,
|
|
2545
|
+
globalCliRealpath: null,
|
|
2546
|
+
safety,
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
2549
|
+
// <home>/.rea/trusted-projects safety gate (shim A5.3b) — checked BEFORE
|
|
2550
|
+
// membership, exactly as the shim's entry gate does. A symlinked / hardlinked
|
|
2551
|
+
// / group-or-other-accessible (non-0600) registry makes the shim REFUSE the
|
|
2552
|
+
// global CLI entirely (silent no-CLI fallback); doctor must NOT then report
|
|
2553
|
+
// `trusted`/`global` — it would send the operator to the wrong remediation.
|
|
2554
|
+
// An ABSENT registry is safe here (`ok:true, absent:true`) — it just means
|
|
2555
|
+
// nothing is trusted yet, which the membership check below reports as
|
|
2556
|
+
// `untrusted`.
|
|
2557
|
+
const registrySafety = checkRegistrySafety(home);
|
|
2558
|
+
if (!registrySafety.ok) {
|
|
2559
|
+
return {
|
|
2560
|
+
tier: 'in-project',
|
|
2561
|
+
reason: 'global-registry-unsafe',
|
|
2562
|
+
cliRealpath: null,
|
|
2563
|
+
globalCliRealpath: null,
|
|
2564
|
+
safety: registrySafety,
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2567
|
+
// Root present but no resolvable CLI under it (blessed-but-not-installed).
|
|
2568
|
+
const globalCli = resolveGlobalCli(home);
|
|
2569
|
+
if (globalCli === null) {
|
|
2570
|
+
return {
|
|
2571
|
+
tier: 'in-project',
|
|
2572
|
+
reason: 'global-unresolvable',
|
|
2573
|
+
cliRealpath: null,
|
|
2574
|
+
globalCliRealpath: null,
|
|
2575
|
+
};
|
|
2576
|
+
}
|
|
2577
|
+
const globalReal = realpathOrNull(globalCli);
|
|
2578
|
+
// The remaining branches mirror the shim's GLOBAL path when the in-project
|
|
2579
|
+
// resolver missed. The shim evaluates them in this order:
|
|
2580
|
+
// entry gate (registry membership) -> A1–A4 candidate sandbox -> veto.
|
|
2581
|
+
// Reproduce that order so the tier verdict matches for every scenario.
|
|
2582
|
+
// Trust-registry membership (shim A5.5) keyed on realpath(cwd). An untrusted
|
|
2583
|
+
// checkout fails the entry gate BEFORE the candidate is ever sandboxed.
|
|
2584
|
+
const cwdReal = realpathOrNull(baseDir);
|
|
2585
|
+
if (cwdReal === null || !isProjectTrusted(cwdReal, home)) {
|
|
2586
|
+
return {
|
|
2587
|
+
tier: 'in-project',
|
|
2588
|
+
reason: 'untrusted',
|
|
2589
|
+
cliRealpath: null,
|
|
2590
|
+
globalCliRealpath: globalReal,
|
|
2591
|
+
};
|
|
2592
|
+
}
|
|
2593
|
+
// A1–A4 candidate sandbox (shim `shim_sandbox_check_global`). A
|
|
2594
|
+
// blessed-but-hostile tree (symlinked index.js, foreign owner, wrong pkg
|
|
2595
|
+
// name, …) makes the shim fall back to no-CLI — so doctor must NOT claim
|
|
2596
|
+
// global. Reported as a fail row (a hostile global tree is worth surfacing).
|
|
2597
|
+
const candSafety = checkGlobalCandidateSafety(globalCli, globalRoot(home), home);
|
|
2598
|
+
if (!candSafety.ok) {
|
|
2599
|
+
return {
|
|
2600
|
+
tier: 'in-project',
|
|
2601
|
+
reason: 'global-candidate-unsafe',
|
|
2602
|
+
cliRealpath: null,
|
|
2603
|
+
globalCliRealpath: globalReal,
|
|
2604
|
+
candidateSafety: candSafety,
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
// Capability floor (shim step 4-global-veto read). The shim's veto step runs
|
|
2608
|
+
// `<global CLI> hook policy-get runtime.allow_global_cli`; a CLI that predates
|
|
2609
|
+
// that subcommand (older/manually-seeded ~/.rea/cli that still passes A1–A4)
|
|
2610
|
+
// exits non-zero → the shim FAIL-CLOSES to no-CLI. Doctor must NOT then claim
|
|
2611
|
+
// `global`. Probe `hook policy-get --help` on the sandbox-validated realpath;
|
|
2612
|
+
// a failing probe reports NOT-global so operators get the right remediation
|
|
2613
|
+
// (re-run `rea install --global`), not a false "global active".
|
|
2614
|
+
if (!probeCapability(candSafety.realpath).ok) {
|
|
2615
|
+
return {
|
|
2616
|
+
tier: 'in-project',
|
|
2617
|
+
reason: 'global-cli-incapable',
|
|
2618
|
+
cliRealpath: null,
|
|
2619
|
+
globalCliRealpath: globalReal,
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
// Project veto over the global tier (shim step 4-global-veto), read only
|
|
2623
|
+
// AFTER the sandbox confirmed the candidate — exactly the shim's ordering.
|
|
2624
|
+
if (globalTierVetoed(baseDir)) {
|
|
2625
|
+
return {
|
|
2626
|
+
tier: 'in-project',
|
|
2627
|
+
reason: 'policy-veto',
|
|
2628
|
+
cliRealpath: null,
|
|
2629
|
+
globalCliRealpath: globalReal,
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2632
|
+
// Trusted + sandbox-clean + not vetoed → the global CLI is the active tier.
|
|
2633
|
+
// Prefer the sandbox-validated realpath (identical to `globalReal` for a
|
|
2634
|
+
// non-symlinked tree, but it is the exact path the shim would execute).
|
|
2635
|
+
return {
|
|
2636
|
+
tier: 'global',
|
|
2637
|
+
reason: 'trusted',
|
|
2638
|
+
cliRealpath: candSafety.realpath,
|
|
2639
|
+
globalCliRealpath: globalReal,
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
/**
|
|
2643
|
+
* Read the global CLI's declared version from the package.json that sits
|
|
2644
|
+
* beside whichever install shape `resolveGlobalCli` matched. Best-effort;
|
|
2645
|
+
* returns null when neither shape carries a readable version.
|
|
2646
|
+
*/
|
|
2647
|
+
function readGlobalCliVersion(home) {
|
|
2648
|
+
const root = globalRoot(home);
|
|
2649
|
+
const candidates = [
|
|
2650
|
+
path.join(root, 'node_modules', '@bookedsolid', 'rea', 'package.json'),
|
|
2651
|
+
path.join(root, 'package.json'),
|
|
2652
|
+
];
|
|
2653
|
+
for (const p of candidates) {
|
|
2654
|
+
const v = readPackageVersion(p);
|
|
2655
|
+
if (v !== null)
|
|
2656
|
+
return v;
|
|
2657
|
+
}
|
|
2658
|
+
return null;
|
|
2659
|
+
}
|
|
2660
|
+
/**
|
|
2661
|
+
* Render the global rea CLI section as flat `CheckResult` rows. Off-by-default
|
|
2662
|
+
* quiet: when the global root does NOT exist, emits exactly ONE `info` row.
|
|
2663
|
+
* When it exists, walks the authoritative decision tree via
|
|
2664
|
+
* `resolveGlobalCliTier` and always names the RESOLVED REALPATH (helix-013.1
|
|
2665
|
+
* lesson). A `global-root-unsafe` / `global-unresolvable` verdict yields a
|
|
2666
|
+
* `fail` row so `rea doctor` exits 1.
|
|
2667
|
+
*
|
|
2668
|
+
* The registry named in these rows is the per-user GLOBAL-CLI TRUST REGISTRY
|
|
2669
|
+
* (`<home>/.rea/trusted-projects`), NOT `.rea/registry.yaml` (the MCP
|
|
2670
|
+
* fingerprint store).
|
|
2671
|
+
*/
|
|
2672
|
+
export function checkGlobalCli(baseDir, homeArg, probeCapability = probeGlobalCliCapability) {
|
|
2673
|
+
// P2-2: the passwd lookup throws on an arbitrary/unmapped UID (containers,
|
|
2674
|
+
// CI, `nobody`). The feature is off-by-default quiet — a passwd failure must
|
|
2675
|
+
// degrade to ONE info row, NEVER abort `rea doctor` before any row renders.
|
|
2676
|
+
let home;
|
|
2677
|
+
if (homeArg !== undefined) {
|
|
2678
|
+
home = homeArg;
|
|
2679
|
+
}
|
|
2680
|
+
else {
|
|
2681
|
+
try {
|
|
2682
|
+
home = passwdHome();
|
|
2683
|
+
}
|
|
2684
|
+
catch {
|
|
2685
|
+
return [
|
|
2686
|
+
{
|
|
2687
|
+
label: 'global rea CLI',
|
|
2688
|
+
status: 'info',
|
|
2689
|
+
detail: 'unavailable — could not resolve home directory from the password database',
|
|
2690
|
+
},
|
|
2691
|
+
];
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
const dir = globalReaDir(home);
|
|
2695
|
+
const reg = globalRegistryPath(home);
|
|
2696
|
+
const root = globalRoot(home);
|
|
2697
|
+
if (!fs.existsSync(root)) {
|
|
2698
|
+
return [
|
|
2699
|
+
{
|
|
2700
|
+
label: 'global rea CLI',
|
|
2701
|
+
status: 'info',
|
|
2702
|
+
detail: 'not installed — in-project resolution active; see `rea install --global`',
|
|
2703
|
+
},
|
|
2704
|
+
];
|
|
2705
|
+
}
|
|
2706
|
+
const tier = resolveGlobalCliTier(baseDir, home, probeCapability);
|
|
2707
|
+
// POSIX-only tier on a platform without geteuid (Windows/Git Bash). A single
|
|
2708
|
+
// benign info row — never a fail/warn (the feature is simply unavailable, and
|
|
2709
|
+
// in-project resolution still governs).
|
|
2710
|
+
if (tier.reason === 'global-unavailable-platform') {
|
|
2711
|
+
return [
|
|
2712
|
+
{
|
|
2713
|
+
label: 'global rea CLI',
|
|
2714
|
+
status: 'info',
|
|
2715
|
+
detail: 'POSIX-only; not available on this platform',
|
|
2716
|
+
},
|
|
2717
|
+
];
|
|
2718
|
+
}
|
|
2719
|
+
if (tier.reason === 'global-root-unsafe') {
|
|
2720
|
+
const s = tier.safety;
|
|
2721
|
+
return [
|
|
2722
|
+
{
|
|
2723
|
+
label: 'global rea CLI root safety',
|
|
2724
|
+
status: 'fail',
|
|
2725
|
+
detail: `${s.reason} — fix: \`${s.remediation}\``,
|
|
2726
|
+
},
|
|
2727
|
+
];
|
|
2728
|
+
}
|
|
2729
|
+
if (tier.reason === 'global-registry-unsafe') {
|
|
2730
|
+
const s = tier.safety;
|
|
2731
|
+
return [
|
|
2732
|
+
{
|
|
2733
|
+
label: 'global-CLI trust registry safety',
|
|
2734
|
+
status: 'fail',
|
|
2735
|
+
detail: `${s.reason} — the shim refuses the global CLI while the trust registry is tampered; fix: \`${s.remediation}\``,
|
|
2736
|
+
},
|
|
2737
|
+
];
|
|
2738
|
+
}
|
|
2739
|
+
if (tier.reason === 'global-unresolvable') {
|
|
2740
|
+
return [
|
|
2741
|
+
{
|
|
2742
|
+
label: 'global rea CLI',
|
|
2743
|
+
status: 'fail',
|
|
2744
|
+
detail: `${root} present but no resolvable CLI (expected dist/cli/index.js) — re-run \`rea install --global\``,
|
|
2745
|
+
},
|
|
2746
|
+
];
|
|
2747
|
+
}
|
|
2748
|
+
if (tier.reason === 'global-candidate-unsafe') {
|
|
2749
|
+
const c = tier.candidateSafety;
|
|
2750
|
+
return [
|
|
2751
|
+
{
|
|
2752
|
+
label: 'global rea CLI candidate safety',
|
|
2753
|
+
status: 'fail',
|
|
2754
|
+
detail: `blessed global CLI tree fails the sandbox (${c.code}): ${c.reason} — the shim refuses it; re-run \`rea install --global\` to reinstall a clean tree`,
|
|
2755
|
+
},
|
|
2756
|
+
];
|
|
2757
|
+
}
|
|
2758
|
+
if (tier.reason === 'global-cli-incapable') {
|
|
2759
|
+
return [
|
|
2760
|
+
{
|
|
2761
|
+
label: 'global rea CLI capability',
|
|
2762
|
+
status: 'fail',
|
|
2763
|
+
detail: `resolved global CLI at ${tier.globalCliRealpath} does not implement \`rea hook policy-get\` (needs ~0.26.0+) — the shim falls back to no-CLI at the veto step; re-run \`rea install --global\` to reinstall a current CLI`,
|
|
2764
|
+
},
|
|
2765
|
+
];
|
|
2766
|
+
}
|
|
2767
|
+
// In-project wins: an in-project CLI resolved, so the shim never consults the
|
|
2768
|
+
// global tree. Emit exactly ONE benign info row — NEVER a warn/fail for the
|
|
2769
|
+
// unused global tree (it may legitimately be broken/stale and it does not
|
|
2770
|
+
// matter here). We deliberately do NOT read the global version or sandbox it.
|
|
2771
|
+
if (tier.reason === 'in-project-wins') {
|
|
2772
|
+
return [
|
|
2773
|
+
{
|
|
2774
|
+
label: 'global rea CLI',
|
|
2775
|
+
status: 'info',
|
|
2776
|
+
detail: `present at ${root} but unused — this checkout resolves the in-project CLI (${tier.cliRealpath})`,
|
|
2777
|
+
},
|
|
2778
|
+
];
|
|
2779
|
+
}
|
|
2780
|
+
const version = readGlobalCliVersion(home);
|
|
2781
|
+
const rows = [
|
|
2782
|
+
{
|
|
2783
|
+
label: 'global rea CLI installed',
|
|
2784
|
+
status: 'pass',
|
|
2785
|
+
detail: `${version !== null ? `v${version} ` : ''}at ${tier.globalCliRealpath}`,
|
|
2786
|
+
},
|
|
2787
|
+
];
|
|
2788
|
+
switch (tier.reason) {
|
|
2789
|
+
case 'policy-veto':
|
|
2790
|
+
rows.push({
|
|
2791
|
+
label: 'global rea CLI active tier',
|
|
2792
|
+
status: 'info',
|
|
2793
|
+
detail: 'policy.runtime.allow_global_cli: false — global tier vetoed by this repo; in-project required',
|
|
2794
|
+
});
|
|
2795
|
+
break;
|
|
2796
|
+
case 'trusted':
|
|
2797
|
+
rows.push({
|
|
2798
|
+
label: 'global rea CLI active tier',
|
|
2799
|
+
status: 'pass',
|
|
2800
|
+
detail: `global — this checkout is trusted (in the global-CLI trust registry ${reg}); hooks run ${tier.cliRealpath}`,
|
|
2801
|
+
});
|
|
2802
|
+
rows.push({
|
|
2803
|
+
label: 'global rea CLI residual risk',
|
|
2804
|
+
status: 'info',
|
|
2805
|
+
detail: `integrity relies on filesystem ownership of ${dir}; prefer in-project install for shared or CI checkouts`,
|
|
2806
|
+
});
|
|
2807
|
+
break;
|
|
2808
|
+
case 'untrusted':
|
|
2809
|
+
rows.push({
|
|
2810
|
+
label: 'global rea CLI active tier',
|
|
2811
|
+
status: 'warn',
|
|
2812
|
+
detail: `this checkout is NOT in the global-CLI trust registry ${reg} — hooks FAIL-CLOSED here until you trust it; run: rea trust`,
|
|
2813
|
+
});
|
|
2814
|
+
break;
|
|
2815
|
+
}
|
|
2816
|
+
return rows;
|
|
2817
|
+
}
|
|
2446
2818
|
/**
|
|
2447
2819
|
* Assemble the full checklist for a given baseDir. Exported so tests can
|
|
2448
2820
|
* exercise the conditional branching without capturing stdout from
|
|
@@ -2563,6 +2935,13 @@ export function collectChecks(baseDir, codexProbeState, prePushState, options =
|
|
|
2563
2935
|
detail: 'disabled via policy.review.codex_required — skipping Codex-related checks',
|
|
2564
2936
|
});
|
|
2565
2937
|
}
|
|
2938
|
+
// 0.50.0 Phase 3b — global rea CLI resolution section. Off-by-default quiet:
|
|
2939
|
+
// one `info` row when the global root is absent (the common case). When the
|
|
2940
|
+
// root exists, the section names the active tier + resolved realpath and
|
|
2941
|
+
// hard-fails on an unsafe/unresolvable global root. `globalHome` is the
|
|
2942
|
+
// injectable passwd-home seam for hermetic tests; production passes nothing
|
|
2943
|
+
// and the check reads the env-immune passwd home.
|
|
2944
|
+
checks.push(...checkGlobalCli(baseDir, options.globalHome));
|
|
2566
2945
|
return checks;
|
|
2567
2946
|
}
|
|
2568
2947
|
/**
|
|
@@ -0,0 +1,274 @@
|
|
|
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
|
+
/**
|
|
30
|
+
* The passwd-derived home directory. Isolated in one helper so every default
|
|
31
|
+
* parameter reads from the same source. Reads the password database via libuv
|
|
32
|
+
* `getpwuid_r`, NOT the environment. Throws on a squashed/absent passwd entry —
|
|
33
|
+
* the caller decides whether that is fatal (writer CLIs) or a silent
|
|
34
|
+
* tier-unavailable (the shim already handles that side in bash).
|
|
35
|
+
*/
|
|
36
|
+
export declare function passwdHome(): string;
|
|
37
|
+
/** `<home>/.rea` — the per-user rea config root. */
|
|
38
|
+
export declare function reaDir(home?: string): string;
|
|
39
|
+
/** `<home>/.rea/cli` — where `npm install --prefix` drops the global CLI tree. */
|
|
40
|
+
export declare function globalRoot(home?: string): string;
|
|
41
|
+
/** `<home>/.rea/trusted-projects` — the per-user global-CLI allow-list. */
|
|
42
|
+
export declare function registryPath(home?: string): string;
|
|
43
|
+
/** One walked process's parent PID + command name. */
|
|
44
|
+
export interface ProcInfo {
|
|
45
|
+
ppid: number;
|
|
46
|
+
comm: string;
|
|
47
|
+
}
|
|
48
|
+
/** Reads a single pid's `{ ppid, comm }`, or `null` when it can't be read. */
|
|
49
|
+
export type ProcReader = (pid: number) => ProcInfo | null;
|
|
50
|
+
/**
|
|
51
|
+
* Default process reader: Linux `/proc/<pid>/comm` + `/proc/<pid>/status`
|
|
52
|
+
* (PPid), else macOS/BSD `ps -o ppid=,comm= -p <pid>`. Best-effort — any
|
|
53
|
+
* failure returns `null` (which stops the walk fail-safe, never throwing).
|
|
54
|
+
* Mirrors `hooks/_lib/shim-cache.sh`'s session-token ancestry walk.
|
|
55
|
+
*/
|
|
56
|
+
export declare function defaultProcReader(pid: number): ProcInfo | null;
|
|
57
|
+
/**
|
|
58
|
+
* Walk the parent-PID chain (bounded depth) looking for a `claude` /
|
|
59
|
+
* `claude-code` ancestor. Returns `true` only on a POSITIVE match; `false` on
|
|
60
|
+
* not-found OR any undeterminable step (fail-safe — an undeterminable walk
|
|
61
|
+
* degrades the guard to the `CLAUDE_PROJECT_DIR` signal alone, it never weakens
|
|
62
|
+
* to "allow" when ancestry says claude). A plain human terminal is not a claude
|
|
63
|
+
* descendant → no false positive; a human using Claude Code's `!` shell IS a
|
|
64
|
+
* descendant → correctly detected (trust is out-of-band).
|
|
65
|
+
*/
|
|
66
|
+
export declare function isClaudeAncestor(reader?: ProcReader, startPid?: number, maxHops?: number): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Refuse a `~/.rea`-MUTATING command when running under a Claude Code agent
|
|
69
|
+
* session. TWO independent signals, either of which triggers a refusal:
|
|
70
|
+
* 1. `CLAUDE_PROJECT_DIR` is set + non-empty (rea's established "running under
|
|
71
|
+
* Claude Code" signal — the reaRoot source in `src/cli/hook.ts`, set for
|
|
72
|
+
* every agent Bash call).
|
|
73
|
+
* 2. A `claude` / `claude-code` process is an ancestor (codex P1: signal (1)
|
|
74
|
+
* alone is bypassable — `CLAUDE_PROJECT_DIR= rea trust`,
|
|
75
|
+
* `env -u CLAUDE_PROJECT_DIR rea trust` clear the var in the child, which a
|
|
76
|
+
* subprocess CANNOT do for its own parent chain).
|
|
77
|
+
*
|
|
78
|
+
* The three mutating commands (`trust`, `untrust`, `install --global`) call this
|
|
79
|
+
* FIRST. Trust must be established by a HUMAN in a plain shell OUTSIDE the agent
|
|
80
|
+
* loop — the real dual-consent. The Write-tier scanner protects literal
|
|
81
|
+
* `~/.rea` writes; this closes the INDIRECT `rea trust`-writes-there path the
|
|
82
|
+
* scanner cannot see statically, AND which a bash command-string matcher cannot
|
|
83
|
+
* catch either (`node <path>/dist/cli/index.js trust`, `npx --no-install …
|
|
84
|
+
* trust`, wrapped shells all bypass a string matcher — the CLI is the only real
|
|
85
|
+
* chokepoint).
|
|
86
|
+
*
|
|
87
|
+
* NO override env — the strictness IS the dual-consent. Read-only commands
|
|
88
|
+
* (`trust --list`) are NOT guarded. Fail-safe: an undeterminable ancestry walk
|
|
89
|
+
* falls back to the `CLAUDE_PROJECT_DIR` signal alone (never weakens).
|
|
90
|
+
*
|
|
91
|
+
* Returns the refusal exit code (`1`) after emitting to stderr when governed;
|
|
92
|
+
* returns `null` when allowed. The `procReader` is injectable so tests stay
|
|
93
|
+
* hermetic + cross-platform.
|
|
94
|
+
*/
|
|
95
|
+
export declare function assertNotGovernedSession(cmdLabel: string, home?: string, deps?: {
|
|
96
|
+
procReader?: ProcReader;
|
|
97
|
+
}): number | null;
|
|
98
|
+
/**
|
|
99
|
+
* Fixed advisory header (design §6): the first line is bound verbatim; the two
|
|
100
|
+
* following `#` lines are advisory. Fixed content keeps the file byte-idempotent
|
|
101
|
+
* (devex F3). The header is `grep -Fxq`-immune: realpath queries start with `/`
|
|
102
|
+
* and never match a `#` line.
|
|
103
|
+
*/
|
|
104
|
+
export declare const REGISTRY_HEADER_LINES: readonly string[];
|
|
105
|
+
/**
|
|
106
|
+
* Reject a project path that would corrupt the one-realpath-per-line registry
|
|
107
|
+
* format or be silently dropped by {@link writeRegistry}. Any C0 control
|
|
108
|
+
* character (`\x00`–`\x1f`: NUL, tab, LF, **CR**, …) breaks the whole-line
|
|
109
|
+
* contract — a `\r` in particular is treated as malformed by
|
|
110
|
+
* {@link isWellFormedMemberLine}, so a writer that skipped this check would
|
|
111
|
+
* report success and then silently drop the entry.
|
|
112
|
+
*
|
|
113
|
+
* This is the SHARED rejection point every registry-writing entrypoint
|
|
114
|
+
* (`rea trust`, `rea install --global --trust`) MUST consult BEFORE reporting
|
|
115
|
+
* success. Returns a short human-readable reason on rejection, or `null` when
|
|
116
|
+
* the path is safe to store.
|
|
117
|
+
*/
|
|
118
|
+
export declare function projectPathControlCharReason(realpath: string): string | null;
|
|
119
|
+
/**
|
|
120
|
+
* Is `line` a well-formed member line? A member = a bare absolute path with no
|
|
121
|
+
* control characters (NUL / tab / LF / CR / …) and no surrounding whitespace (a
|
|
122
|
+
* leading-space line would never `grep -Fxq`-match a trimmed query, so we treat
|
|
123
|
+
* it as malformed). Comment/blank lines are NOT members (inert to the reader).
|
|
124
|
+
* The control-character test is the shared {@link projectPathControlCharReason}
|
|
125
|
+
* so the reader and the writer-side validators can never drift.
|
|
126
|
+
*/
|
|
127
|
+
export declare function isWellFormedMemberLine(line: string): boolean;
|
|
128
|
+
/**
|
|
129
|
+
* Read the registry's MEMBER lines (design §6 reader). Skips `#`-prefixed and
|
|
130
|
+
* blank lines; returns every remaining non-blank line verbatim (no trimming —
|
|
131
|
+
* whole-line semantics mirror `grep -Fxq`). Absent/unreadable file → `[]`.
|
|
132
|
+
*
|
|
133
|
+
* This is a lenient read: it returns candidate lines as-stored (including any
|
|
134
|
+
* malformed content a hand-edit introduced) so the writer can normalize + drop
|
|
135
|
+
* them on the next rewrite. Use {@link isProjectTrusted} for a membership test.
|
|
136
|
+
*/
|
|
137
|
+
export declare function readRegistry(home?: string): string[];
|
|
138
|
+
/**
|
|
139
|
+
* Exact whole-line membership test (mirrors the shim's `grep -Fxq -- "$p"`).
|
|
140
|
+
* `projRealpath` is expected to be an absolute realpath. A comment line can
|
|
141
|
+
* never match because queries start with `/`.
|
|
142
|
+
*/
|
|
143
|
+
export declare function isProjectTrusted(projRealpath: string, home?: string): boolean;
|
|
144
|
+
/**
|
|
145
|
+
* Full-rewrite the registry (design §6 writer). NEVER appends. Normalizes the
|
|
146
|
+
* given paths: drops malformed lines (with a single one-line stderr notice),
|
|
147
|
+
* then dedups + sorts + joins with a single trailing LF after the fixed header.
|
|
148
|
+
* Byte-idempotent: `writeRegistry(home, readRegistry(home))` reproduces the file
|
|
149
|
+
* byte-for-byte for well-formed input.
|
|
150
|
+
*
|
|
151
|
+
* Atomicity: ensures `<home>/.rea` exists `0700`, writes the normalized content
|
|
152
|
+
* to `<home>/.rea/.trusted-projects.tmp.<pid>` under `umask 077` + explicit
|
|
153
|
+
* `chmod 0600`, then `rename()`s over the target. On failure the tmp file is
|
|
154
|
+
* unlinked and the original is left intact.
|
|
155
|
+
*
|
|
156
|
+
* SAFETY: this helper does NOT gate on ownership/symlink/permissions — callers
|
|
157
|
+
* MUST run {@link checkReaDirSafety} first and surface the remediation string.
|
|
158
|
+
* It ensures the dir exists but refuses nothing.
|
|
159
|
+
*/
|
|
160
|
+
export declare function writeRegistry(paths: string[], home?: string): void;
|
|
161
|
+
/**
|
|
162
|
+
* Delete the registry file (used when `trust`/`untrust` empties it). Missing
|
|
163
|
+
* file is not an error. Does NOT touch the `<home>/.rea` dir.
|
|
164
|
+
*/
|
|
165
|
+
export declare function deleteRegistry(home?: string): void;
|
|
166
|
+
export type SafetyReason = 'symlink' | 'not-dir' | 'not-file' | 'foreign-owner' | 'world-writable' | 'bad-mode' | 'bad-nlink';
|
|
167
|
+
export interface SafetyOk {
|
|
168
|
+
ok: true;
|
|
169
|
+
/** True when the path does not exist yet (a to-be-created target — safe). */
|
|
170
|
+
absent: boolean;
|
|
171
|
+
}
|
|
172
|
+
export interface SafetyFail {
|
|
173
|
+
ok: false;
|
|
174
|
+
code: SafetyReason;
|
|
175
|
+
/** Human-readable reason (no remediation verb). */
|
|
176
|
+
reason: string;
|
|
177
|
+
/** The exact shell command to fix it (design §9 remediation strings). */
|
|
178
|
+
remediation: string;
|
|
179
|
+
}
|
|
180
|
+
export type SafetyResult = SafetyOk | SafetyFail;
|
|
181
|
+
/**
|
|
182
|
+
* Validate `<home>/.rea` (mirrors shim A5.3a). Rejects a symlink, a non-dir, a
|
|
183
|
+
* foreign owner, or a group/other-writable dir (`mode & 0o022`). An ABSENT dir
|
|
184
|
+
* is safe (`{ ok: true, absent: true }`) — the writer creates it 0700. Never
|
|
185
|
+
* lstats `<home>` or above (firmlinks / BSD `/home` symlinks legitimately live
|
|
186
|
+
* there).
|
|
187
|
+
*/
|
|
188
|
+
export declare function checkReaDirSafety(home?: string): SafetyResult;
|
|
189
|
+
/**
|
|
190
|
+
* Validate `<home>/.rea/trusted-projects` (mirrors shim A5.3b). Rejects a
|
|
191
|
+
* symlink, a non-regular file, a foreign owner, a group/other-ACCESSIBLE file
|
|
192
|
+
* (`mode & 0o077`, the strict 0600 mask — NOT the 0o022 dir mask), or a
|
|
193
|
+
* hardlinked file (`nlink !== 1`; a pre-existing in-project hardlink is a
|
|
194
|
+
* same-uid mutation primitive — codex P2-7). An ABSENT registry is safe
|
|
195
|
+
* (`{ ok: true, absent: true }`) — nothing is trusted yet.
|
|
196
|
+
*/
|
|
197
|
+
export declare function checkRegistrySafety(home?: string): SafetyResult;
|
|
198
|
+
/**
|
|
199
|
+
* Probe the global CLI under `<home>/.rea/cli`: (1) the `npm install --prefix`
|
|
200
|
+
* shape `node_modules/@bookedsolid/rea/dist/cli/index.js`, then (2) the
|
|
201
|
+
* bare-drop fallback `dist/cli/index.js`. Returns the first existing candidate
|
|
202
|
+
* path, or `null` when neither exists (not installed). Pure existence probe —
|
|
203
|
+
* pair it with {@link checkGlobalCandidateSafety} to reproduce the shim's
|
|
204
|
+
* A1–A4 sandbox before treating the candidate as the active tier.
|
|
205
|
+
*/
|
|
206
|
+
export declare function resolveGlobalCli(home?: string): string | null;
|
|
207
|
+
/**
|
|
208
|
+
* Read the `version` of the currently-installed global CLI from the
|
|
209
|
+
* `package.json` beside whichever install shape {@link resolveGlobalCli}
|
|
210
|
+
* matched: (1) `node_modules/@bookedsolid/rea/package.json` (npm-prefix shape),
|
|
211
|
+
* then (2) `<gRoot>/package.json` (bare-drop). Best-effort (BOM-tolerant); a
|
|
212
|
+
* leading UTF-8 BOM is stripped. Returns `null` when neither shape carries a
|
|
213
|
+
* readable non-empty `version`. Used by `rea install --global` to decide
|
|
214
|
+
* whether a requested `--version` differs from what is already on disk.
|
|
215
|
+
*/
|
|
216
|
+
export declare function installedGlobalCliVersion(home?: string): string | null;
|
|
217
|
+
/** Result of the `rea hook policy-get` capability probe. */
|
|
218
|
+
export interface GlobalCliCapability {
|
|
219
|
+
ok: boolean;
|
|
220
|
+
stderr?: string;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Prove a resolved global CLI implements `rea hook policy-get` — the subcommand
|
|
224
|
+
* the global-tier shim invokes for the `allow_global_cli` veto read
|
|
225
|
+
* (`shim_run` step 4-global-veto). Spawns `node <cliPath> hook policy-get
|
|
226
|
+
* --help`; a non-zero exit means the CLI predates the `hook policy-get`
|
|
227
|
+
* subcommand (~0.26.0) and the shim would fall back to no-CLI at the veto step.
|
|
228
|
+
*
|
|
229
|
+
* This is the ONE shared implementation consumed by both `rea install --global`
|
|
230
|
+
* (as the post-install backstop) and `rea doctor` (the global-tier floor), so
|
|
231
|
+
* the two surfaces never drift. Signature-compatible with install's injectable
|
|
232
|
+
* `probeCapability` dep — tests inject a fake to stay hermetic; NO CLI flag
|
|
233
|
+
* exposes it.
|
|
234
|
+
*/
|
|
235
|
+
export declare function probeGlobalCliCapability(cliPath: string): GlobalCliCapability;
|
|
236
|
+
export type GlobalCandidateFailCode = 'symlink' | 'perm' | 'hardlink' | 'escapes-root' | 'shape' | 'no-rea-pkg' | 'unavailable';
|
|
237
|
+
export interface GlobalCandidateOk {
|
|
238
|
+
ok: true;
|
|
239
|
+
/** `realpath(candidate)` — the validated path the shim would execute. */
|
|
240
|
+
realpath: string;
|
|
241
|
+
}
|
|
242
|
+
export interface GlobalCandidateFail {
|
|
243
|
+
ok: false;
|
|
244
|
+
code: GlobalCandidateFailCode;
|
|
245
|
+
/** Human-readable reason (no remediation verb). */
|
|
246
|
+
reason: string;
|
|
247
|
+
}
|
|
248
|
+
export type GlobalCandidateSafety = GlobalCandidateOk | GlobalCandidateFail;
|
|
249
|
+
/**
|
|
250
|
+
* Reproduce the bash shim's A1–A4 candidate sandbox (`shim_sandbox_check_global`
|
|
251
|
+
* in `hooks/_lib/shim-runtime.sh`) over a resolved global CLI candidate. This is
|
|
252
|
+
* the missing half of {@link resolveGlobalCli}: existence alone is NOT enough to
|
|
253
|
+
* treat a candidate as runnable — the shim ALSO refuses a hostile/malformed tree
|
|
254
|
+
* (blessed-but-hostile), and `rea doctor` must refuse identically or it would
|
|
255
|
+
* claim `global` for a candidate the shim falls back from.
|
|
256
|
+
*
|
|
257
|
+
* Evaluation order mirrors the shim (cheapest/most-decisive first):
|
|
258
|
+
* - A2 per-component `lstat` walk from the candidate UP TO AND INCLUDING
|
|
259
|
+
* `<home>/.rea` (= `dirname(gRoot)`; STOP there — never lstat the home
|
|
260
|
+
* dir or above, where firmlinks / BSD `/home` symlinks legitimately
|
|
261
|
+
* live). Reject ANY symlink component, foreign owner, group/other-write
|
|
262
|
+
* (`mode & 0o022`), or a device-number change vs `gRoot` (mount/bind
|
|
263
|
+
* aliasing). The candidate `index.js` itself must have `nlink === 1`.
|
|
264
|
+
* - A1 `realpath(candidate)` contained in `realpath(gRoot)`.
|
|
265
|
+
* - A4 realpath ends in `dist/cli/index.js` (ALWAYS-on for the global tier).
|
|
266
|
+
* - A3 an ancestor `package.json` (≤20 hops) has `name === "@bookedsolid/rea"`
|
|
267
|
+
* AND that `package.json` has `nlink === 1`.
|
|
268
|
+
*
|
|
269
|
+
* `unavailable` (an `lstat`/`realpath` that threw — ENOENT / automount) is
|
|
270
|
+
* surfaced as a fail here because doctor treats "sandbox did not confirm safe"
|
|
271
|
+
* the same as "not the active tier": the shim likewise does NOT set the global
|
|
272
|
+
* CLI in that case. Callers map ANY `ok:false` to "global tier NOT active".
|
|
273
|
+
*/
|
|
274
|
+
export declare function checkGlobalCandidateSafety(candidate: string, gRoot: string, home?: string): GlobalCandidateSafety;
|