@bookedsolid/rea 0.9.4 → 0.10.1

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.
@@ -20,6 +20,7 @@
20
20
  * entirely.
21
21
  */
22
22
  import { type CacheResult } from '../cache/review-cache.js';
23
+ import type { CodexVerdict } from '../audit/codex-event.js';
23
24
  export interface CacheCheckOptions {
24
25
  sha: string;
25
26
  branch: string;
@@ -48,5 +49,36 @@ export declare function runCacheCheck(options: CacheCheckOptions): Promise<void>
48
49
  export declare function runCacheSet(options: CacheSetOptions): Promise<void>;
49
50
  export declare function runCacheClear(options: CacheClearOptions): Promise<void>;
50
51
  export declare function runCacheList(options: CacheListOptions): Promise<void>;
51
- /** Parse-and-validate helper for `set` — surfaces a clean error on bad input. */
52
+ /** Parse-and-validate helper for `set` — surfaces a clean error on bad input.
53
+ *
54
+ * Accepts the two historical cache values (`pass`, `fail`) AND the four
55
+ * canonical Codex verdicts (`pass`, `concerns`, `blocking`, `error`) per
56
+ * Defect D (rea#77). Codex verdicts are mapped to cache semantics at the CLI
57
+ * boundary: `pass|concerns` → gate-satisfying `pass`; `blocking|error` →
58
+ * gate-failing `fail`. The cache internal vocabulary stays binary
59
+ * (`pass`/`fail` = "gate-satisfying?") while the CLI accepts the full Codex
60
+ * vocabulary so agents can copy the `/codex-review` verdict verbatim.
61
+ */
52
62
  export declare function parseCacheResult(raw: string): CacheResult;
63
+ /** Shape returned by {@link codexVerdictToCacheResult}: the binary cache result
64
+ * plus an optional machine-readable `reason` string that records the source
65
+ * Codex verdict. `reason` is populated for non-`pass` verdicts so downstream
66
+ * listings expose WHY a cache fail was recorded. */
67
+ export interface CodexVerdictCacheEffect {
68
+ result: CacheResult;
69
+ reason?: string | undefined;
70
+ }
71
+ /** Map a Codex verdict to the binary cache result the gate compares against.
72
+ *
73
+ * Mapping rationale:
74
+ * - `pass` → cache `pass` (clean review, gate should pass)
75
+ * - `concerns` → cache `pass` (non-blocking findings, gate should pass;
76
+ * reviewer captured concerns in the audit record `metadata.summary`)
77
+ * - `blocking` → cache `fail` (must address findings before merge)
78
+ * - `error` → cache `fail` (Codex itself errored; no clean-bill-of-health)
79
+ *
80
+ * Kept separate from `parseCacheResult` so callers that already have a typed
81
+ * `CodexVerdict` (e.g. `rea audit record codex-review --also-set-cache`) don't
82
+ * round-trip through string parsing.
83
+ */
84
+ export declare function codexVerdictToCacheResult(verdict: CodexVerdict): CodexVerdictCacheEffect;
package/dist/cli/cache.js CHANGED
@@ -103,10 +103,48 @@ export async function runCacheList(options) {
103
103
  console.log(`${e.recorded_at} ${e.result.padEnd(4)} ${shortSha} ${e.branch} → ${e.base}${reason}`);
104
104
  }
105
105
  }
106
- /** Parse-and-validate helper for `set` — surfaces a clean error on bad input. */
106
+ /** Parse-and-validate helper for `set` — surfaces a clean error on bad input.
107
+ *
108
+ * Accepts the two historical cache values (`pass`, `fail`) AND the four
109
+ * canonical Codex verdicts (`pass`, `concerns`, `blocking`, `error`) per
110
+ * Defect D (rea#77). Codex verdicts are mapped to cache semantics at the CLI
111
+ * boundary: `pass|concerns` → gate-satisfying `pass`; `blocking|error` →
112
+ * gate-failing `fail`. The cache internal vocabulary stays binary
113
+ * (`pass`/`fail` = "gate-satisfying?") while the CLI accepts the full Codex
114
+ * vocabulary so agents can copy the `/codex-review` verdict verbatim.
115
+ */
107
116
  export function parseCacheResult(raw) {
108
117
  if (raw === 'pass' || raw === 'fail')
109
118
  return raw;
110
- err(`result must be 'pass' or 'fail'; got ${JSON.stringify(raw)}`);
119
+ if (raw === 'concerns')
120
+ return 'pass';
121
+ if (raw === 'blocking' || raw === 'error')
122
+ return 'fail';
123
+ err(`result must be 'pass', 'fail', 'concerns', 'blocking', or 'error'; got ${JSON.stringify(raw)}`);
111
124
  process.exit(1);
112
125
  }
126
+ /** Map a Codex verdict to the binary cache result the gate compares against.
127
+ *
128
+ * Mapping rationale:
129
+ * - `pass` → cache `pass` (clean review, gate should pass)
130
+ * - `concerns` → cache `pass` (non-blocking findings, gate should pass;
131
+ * reviewer captured concerns in the audit record `metadata.summary`)
132
+ * - `blocking` → cache `fail` (must address findings before merge)
133
+ * - `error` → cache `fail` (Codex itself errored; no clean-bill-of-health)
134
+ *
135
+ * Kept separate from `parseCacheResult` so callers that already have a typed
136
+ * `CodexVerdict` (e.g. `rea audit record codex-review --also-set-cache`) don't
137
+ * round-trip through string parsing.
138
+ */
139
+ export function codexVerdictToCacheResult(verdict) {
140
+ switch (verdict) {
141
+ case 'pass':
142
+ return { result: 'pass' };
143
+ case 'concerns':
144
+ return { result: 'pass', reason: 'codex:concerns' };
145
+ case 'blocking':
146
+ return { result: 'fail', reason: 'codex:blocking' };
147
+ case 'error':
148
+ return { result: 'fail', reason: 'codex:error' };
149
+ }
150
+ }
@@ -103,7 +103,7 @@ export async function checkFingerprintStore(baseDir) {
103
103
  return {
104
104
  label,
105
105
  status: 'warn',
106
- detail: `${parts.join(', ')} — next \`rea serve\` will block drift (set REA_ACCEPT_DRIFT=<name> to accept)`,
106
+ detail: `${parts.join(', ')} — next \`rea serve\` will block drift (run \`rea tofu list\` for detail, \`rea tofu accept <name>\` to rebase after a legitimate registry edit)`,
107
107
  };
108
108
  }
109
109
  function checkRegistryParses(baseDir, registryPath) {
package/dist/cli/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { runAuditRotate, runAuditVerify } from './audit.js';
3
+ import { runAuditRecordCodexReview, runAuditRotate, runAuditVerify } from './audit.js';
4
4
  import { parseCacheResult, runCacheCheck, runCacheClear, runCacheList, runCacheSet, } from './cache.js';
5
5
  import { runCheck } from './check.js';
6
6
  import { runDoctor } from './doctor.js';
@@ -8,6 +8,7 @@ import { runFreeze, runUnfreeze } from './freeze.js';
8
8
  import { runInit } from './init.js';
9
9
  import { runServe } from './serve.js';
10
10
  import { runStatus } from './status.js';
11
+ import { runTofuAccept, runTofuList } from './tofu.js';
11
12
  import { runUpgrade } from './upgrade.js';
12
13
  import { err, getPkgVersion } from './utils.js';
13
14
  async function main() {
@@ -102,6 +103,44 @@ async function main() {
102
103
  .action(async (opts) => {
103
104
  await runAuditVerify({ ...(opts.since !== undefined ? { since: opts.since } : {}) });
104
105
  });
106
+ const auditRecord = audit
107
+ .command('record')
108
+ .description('Emit a structured audit record (D).');
109
+ auditRecord
110
+ .command('codex-review')
111
+ .description('Append a codex.review audit entry the push-review cache gate recognizes. With --also-set-cache, writes the review cache in the same invocation (two sequential appends in one process — not a two-phase commit).')
112
+ .requiredOption('--head-sha <sha>', 'git HEAD SHA the review covers')
113
+ .requiredOption('--branch <branch>', 'feature branch under review')
114
+ .requiredOption('--target <target>', 'base ref or SHA diffed against (e.g. main)')
115
+ .requiredOption('--verdict <verdict>', 'one of: pass | concerns | blocking | error')
116
+ .requiredOption('--finding-count <N>', 'non-negative integer finding count', (raw) => {
117
+ const n = Number.parseInt(raw, 10);
118
+ if (!Number.isFinite(n) || n < 0) {
119
+ throw new Error(`--finding-count must be a non-negative integer; got ${JSON.stringify(raw)}`);
120
+ }
121
+ return n;
122
+ })
123
+ .option('--summary <text>', 'one-sentence review summary (optional)')
124
+ .option('--session-id <id>', 'session id to attribute (defaults to "external")')
125
+ .option('--also-set-cache', 'also update .rea/review-cache.jsonl to reflect this verdict, in the same invocation (recommended for post-review push flow)')
126
+ .action(async (opts) => {
127
+ if (opts.verdict !== 'pass' &&
128
+ opts.verdict !== 'concerns' &&
129
+ opts.verdict !== 'blocking' &&
130
+ opts.verdict !== 'error') {
131
+ throw new Error(`--verdict must be one of pass|concerns|blocking|error; got ${JSON.stringify(opts.verdict)}`);
132
+ }
133
+ await runAuditRecordCodexReview({
134
+ headSha: opts.headSha,
135
+ branch: opts.branch,
136
+ target: opts.target,
137
+ verdict: opts.verdict,
138
+ findingCount: opts.findingCount,
139
+ ...(opts.summary !== undefined ? { summary: opts.summary } : {}),
140
+ ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
141
+ ...(opts.alsoSetCache === true ? { alsoSetCache: true } : {}),
142
+ });
143
+ });
105
144
  const cache = program
106
145
  .command('cache')
107
146
  .description('Review-cache operations — check/set/clear/list .rea/review-cache.jsonl (BUG-009). Used by hooks/push-review-gate.sh to skip re-review on a previously-approved diff.');
@@ -115,7 +154,7 @@ async function main() {
115
154
  });
116
155
  cache
117
156
  .command('set <sha> <result>')
118
- .description('Record a review outcome. <result> must be "pass" or "fail". Idempotent line-per-invocation; last write wins on (sha, branch, base).')
157
+ .description('Record a review outcome. <result> accepts pass|fail (historical) or pass|concerns|blocking|error (Codex verdicts). concerns→pass, blocking|error→fail. Idempotent line-per-invocation; last write wins on (sha, branch, base).')
119
158
  .requiredOption('--branch <branch>', 'feature branch being pushed')
120
159
  .requiredOption('--base <base>', 'base branch the feature targets')
121
160
  .option('--reason <text>', 'free-text context for this entry (recommended on fail)')
@@ -142,6 +181,23 @@ async function main() {
142
181
  .action(async (opts) => {
143
182
  await runCacheList({ ...(opts.branch !== undefined ? { branch: opts.branch } : {}) });
144
183
  });
184
+ const tofu = program
185
+ .command('tofu')
186
+ .description('TOFU fingerprint operations (G7) — inspect and rebase `.rea/fingerprints.json` when a legitimate registry edit has triggered drift fail-close. Emits audit records.');
187
+ tofu
188
+ .command('list')
189
+ .description('Print every server declared in `.rea/registry.yaml` with its current-vs-stored fingerprint verdict (first-seen | unchanged | drifted).')
190
+ .option('--json', 'emit JSON instead of the human-readable table')
191
+ .action(async (opts) => {
192
+ await runTofuList({ ...(opts.json === true ? { json: true } : {}) });
193
+ });
194
+ tofu
195
+ .command('accept <name>')
196
+ .description('Rebase the stored fingerprint for <name> to match the current canonical shape in `.rea/registry.yaml`. Use after a deliberate registry edit (vault added, command path renamed, env-key set changed). Emits a `tofu.drift_accepted_by_cli` audit record; next `rea serve` will classify as unchanged.')
197
+ .option('--reason <text>', 'free-text note captured in the audit record (recommended when accepting drift — explains WHY the canonical shape changed)')
198
+ .action(async (name, opts) => {
199
+ await runTofuAccept({ name, ...(opts.reason !== undefined ? { reason: opts.reason } : {}) });
200
+ });
145
201
  program
146
202
  .command('doctor')
147
203
  .description('Validate the install: policy parses, .rea/ layout, hooks, Codex plugin.')
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `rea tofu` — operator-facing recovery surface for TOFU fingerprint drift
3
+ * (defect S).
4
+ *
5
+ * The TOFU gate in `src/registry/tofu-gate.ts` fail-closes on drift: an
6
+ * enabled downstream whose canonical fingerprint no longer matches the stored
7
+ * baseline is silently dropped from the spawn set. The only documented
8
+ * recovery path used to be `REA_ACCEPT_DRIFT=<name>` as a startup env var,
9
+ * which is useless when the gateway is spawned indirectly (e.g. by Claude
10
+ * Code via `.mcp.json`) — there is no operator-reachable env in that path.
11
+ *
12
+ * This module provides two verbs:
13
+ *
14
+ * - `list` — print every declared server's current-vs-stored
15
+ * fingerprint verdict so the operator can see drift
16
+ * before reaching for `accept`.
17
+ * - `accept <name>` — recompute the current fingerprint for `<name>` and
18
+ * write it to `.rea/fingerprints.json`. Emits a
19
+ * `tofu.drift_accepted_by_cli` audit record so the
20
+ * action is on the hash chain.
21
+ *
22
+ * Both verbs are pure CLI surface — they do NOT speak to a running `rea
23
+ * serve`. The next gateway boot re-runs `applyTofuGate` against the updated
24
+ * store and classifies the server as `unchanged` with no banner.
25
+ *
26
+ * ## Trust model
27
+ *
28
+ * `accept` updates the stored baseline to match whatever the YAML currently
29
+ * says. It is a **deliberate operator action**: anyone who can run `rea`
30
+ * could already edit `.rea/fingerprints.json` by hand. The CLI is an
31
+ * audit-recording wrapper over that capability, not a privilege expansion.
32
+ *
33
+ * The audit record captures BOTH fingerprints (stored + current) and the
34
+ * registry canonical shape at accept-time, so a forensic re-hash of the
35
+ * registry after the fact can confirm the operator accepted the shape they
36
+ * intended to accept.
37
+ */
38
+ import type { RegistryServer } from '../registry/types.js';
39
+ export type TofuVerdictLabel = 'first-seen' | 'unchanged' | 'drifted';
40
+ export interface TofuRow {
41
+ name: string;
42
+ enabled: boolean;
43
+ current: string;
44
+ stored: string | undefined;
45
+ verdict: TofuVerdictLabel;
46
+ }
47
+ /** Pure classifier used by both `list` and `accept` — keep free of I/O. */
48
+ export declare function classifyRows(servers: RegistryServer[], stored: Record<string, string>): TofuRow[];
49
+ export interface RunTofuListOptions {
50
+ json?: boolean;
51
+ }
52
+ export declare function runTofuList(options?: RunTofuListOptions): Promise<void>;
53
+ export interface RunTofuAcceptOptions {
54
+ name: string;
55
+ reason?: string;
56
+ }
57
+ export declare function runTofuAccept(options: RunTofuAcceptOptions): Promise<void>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `rea tofu` — operator-facing recovery surface for TOFU fingerprint drift
3
+ * (defect S).
4
+ *
5
+ * The TOFU gate in `src/registry/tofu-gate.ts` fail-closes on drift: an
6
+ * enabled downstream whose canonical fingerprint no longer matches the stored
7
+ * baseline is silently dropped from the spawn set. The only documented
8
+ * recovery path used to be `REA_ACCEPT_DRIFT=<name>` as a startup env var,
9
+ * which is useless when the gateway is spawned indirectly (e.g. by Claude
10
+ * Code via `.mcp.json`) — there is no operator-reachable env in that path.
11
+ *
12
+ * This module provides two verbs:
13
+ *
14
+ * - `list` — print every declared server's current-vs-stored
15
+ * fingerprint verdict so the operator can see drift
16
+ * before reaching for `accept`.
17
+ * - `accept <name>` — recompute the current fingerprint for `<name>` and
18
+ * write it to `.rea/fingerprints.json`. Emits a
19
+ * `tofu.drift_accepted_by_cli` audit record so the
20
+ * action is on the hash chain.
21
+ *
22
+ * Both verbs are pure CLI surface — they do NOT speak to a running `rea
23
+ * serve`. The next gateway boot re-runs `applyTofuGate` against the updated
24
+ * store and classifies the server as `unchanged` with no banner.
25
+ *
26
+ * ## Trust model
27
+ *
28
+ * `accept` updates the stored baseline to match whatever the YAML currently
29
+ * says. It is a **deliberate operator action**: anyone who can run `rea`
30
+ * could already edit `.rea/fingerprints.json` by hand. The CLI is an
31
+ * audit-recording wrapper over that capability, not a privilege expansion.
32
+ *
33
+ * The audit record captures BOTH fingerprints (stored + current) and the
34
+ * registry canonical shape at accept-time, so a forensic re-hash of the
35
+ * registry after the fact can confirm the operator accepted the shape they
36
+ * intended to accept.
37
+ */
38
+ import { appendAuditRecord } from '../audit/append.js';
39
+ import { InvocationStatus, Tier } from '../policy/types.js';
40
+ import { fingerprintServer } from '../registry/fingerprint.js';
41
+ import { FINGERPRINT_STORE_VERSION, loadFingerprintStore, saveFingerprintStore, } from '../registry/fingerprints-store.js';
42
+ import { loadRegistry } from '../registry/loader.js';
43
+ import { err, log } from './utils.js';
44
+ /** Pure classifier used by both `list` and `accept` — keep free of I/O. */
45
+ export function classifyRows(servers, stored) {
46
+ return servers.map((s) => {
47
+ const current = fingerprintServer(s);
48
+ const prior = stored[s.name];
49
+ let verdict;
50
+ if (prior === undefined)
51
+ verdict = 'first-seen';
52
+ else if (prior === current)
53
+ verdict = 'unchanged';
54
+ else
55
+ verdict = 'drifted';
56
+ return {
57
+ name: s.name,
58
+ enabled: s.enabled !== false,
59
+ current,
60
+ stored: prior,
61
+ verdict,
62
+ };
63
+ });
64
+ }
65
+ export async function runTofuList(options = {}) {
66
+ const baseDir = process.cwd();
67
+ const registry = loadRegistry(baseDir);
68
+ const store = await loadFingerprintStore(baseDir);
69
+ const rows = classifyRows(registry.servers, store.servers);
70
+ if (options.json === true) {
71
+ process.stdout.write(JSON.stringify({ servers: rows }, null, 2) + '\n');
72
+ return;
73
+ }
74
+ if (rows.length === 0) {
75
+ log('No servers declared in .rea/registry.yaml.');
76
+ return;
77
+ }
78
+ log('TOFU fingerprint status:');
79
+ log('');
80
+ for (const row of rows) {
81
+ const shortCur = row.current.slice(0, 12);
82
+ const shortPrior = row.stored !== undefined ? row.stored.slice(0, 12) : '—';
83
+ const flag = row.enabled ? '' : ' (disabled)';
84
+ log(` ${row.verdict.padEnd(10)} ${row.name.padEnd(20)} stored=${shortPrior} current=${shortCur}${flag}`);
85
+ }
86
+ log('');
87
+ const drifted = rows.filter((r) => r.verdict === 'drifted');
88
+ if (drifted.length > 0) {
89
+ log(` ${drifted.length} drifted — run \`rea tofu accept <name>\` to rebase the stored fingerprint (emits an audit record).`);
90
+ }
91
+ }
92
+ export async function runTofuAccept(options) {
93
+ const baseDir = process.cwd();
94
+ const registry = loadRegistry(baseDir);
95
+ const server = registry.servers.find((s) => s.name === options.name);
96
+ if (server === undefined) {
97
+ err(`Server "${options.name}" is not declared in .rea/registry.yaml. Run \`rea tofu list\` to see declared servers.`);
98
+ process.exit(1);
99
+ }
100
+ const current = fingerprintServer(server);
101
+ const store = await loadFingerprintStore(baseDir);
102
+ const stored = store.servers[server.name];
103
+ if (stored === current) {
104
+ log(`tofu: "${server.name}" already matches stored fingerprint (${current.slice(0, 12)}…) — no change written.`);
105
+ return;
106
+ }
107
+ const nextStore = {
108
+ version: FINGERPRINT_STORE_VERSION,
109
+ servers: { ...store.servers, [server.name]: current },
110
+ };
111
+ await saveFingerprintStore(baseDir, nextStore);
112
+ const event = stored === undefined ? 'tofu.first_seen_accepted_by_cli' : 'tofu.drift_accepted_by_cli';
113
+ try {
114
+ await appendAuditRecord(baseDir, {
115
+ tool_name: 'rea.tofu',
116
+ server_name: 'rea',
117
+ tier: Tier.Write,
118
+ status: InvocationStatus.Allowed,
119
+ metadata: {
120
+ event,
121
+ server: server.name,
122
+ stored_fingerprint: stored ?? null,
123
+ current_fingerprint: current,
124
+ ...(options.reason !== undefined ? { reason: options.reason } : {}),
125
+ },
126
+ });
127
+ }
128
+ catch (auditErr) {
129
+ err(`tofu: fingerprint updated, but audit append failed — operator MUST investigate: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
130
+ process.exit(2);
131
+ }
132
+ const shortPrior = stored !== undefined ? stored.slice(0, 12) : '(first-seen)';
133
+ log(`tofu: accepted "${server.name}" — stored=${shortPrior} → current=${current.slice(0, 12)}. Next \`rea serve\` will classify as unchanged.`);
134
+ }
@@ -9,3 +9,4 @@ export declare function classifyTool(toolName: string, serverName: string, gatew
9
9
  * Check if a tool is explicitly blocked in gateway config.
10
10
  */
11
11
  export declare function isToolBlocked(toolName: string, serverName: string, gatewayConfig?: GatewayConfig): boolean;
12
+ export declare function reaCommandTier(command: string): Tier | null;
@@ -106,3 +106,213 @@ export function isToolBlocked(toolName, serverName, gatewayConfig) {
106
106
  const override = serverConfig?.tool_overrides?.[toolName];
107
107
  return override?.blocked === true;
108
108
  }
109
+ /**
110
+ * Classify a `rea <subcommand>` Bash invocation by its own semantics rather
111
+ * than the generic Bash default.
112
+ *
113
+ * Defect E (rea#78): REA's own governance CLI must not be denied by REA's own
114
+ * middleware. The gate's error messages literally say "Run `rea cache set
115
+ * <sha> pass --branch <x> --base <y>`" — then the agent is denied at autonomy
116
+ * L1 because `Bash` is classified Write and the downstream middleware can't
117
+ * see that the Write is just appending a line to `.rea/review-cache.jsonl`.
118
+ *
119
+ * This helper returns the tier appropriate to the rea subcommand when the
120
+ * command parses as `rea <sub>` or `npx rea <sub>`. Returns `null` if the
121
+ * command is not a rea invocation — callers then fall back to the generic
122
+ * Bash tier.
123
+ *
124
+ * Tier mapping:
125
+ * - Read: `cache check|list|get`, `audit verify`,
126
+ * `audit record codex-review`, `check`, `doctor`, `status`
127
+ * - Write: `cache set|clear`, `audit rotate`, `init`,
128
+ * `serve`, `upgrade`, `unfreeze`
129
+ * - Destructive: `freeze` (writes `.rea/HALT`, suspends the session)
130
+ *
131
+ * `audit record codex-review` is Read-tier because it is REA's own append-only
132
+ * audit surface — the whole point of the command is to let an L1 agent satisfy
133
+ * the push-review gate without a human in the loop. Write-tier here would
134
+ * reintroduce exactly the deadlock Defect D/E close.
135
+ *
136
+ * SECURITY: returns `null` for any command containing shell metacharacters
137
+ * that would let an attacker piggyback arbitrary commands onto an allowed
138
+ * prefix (e.g. `rea check && rm -rf ~`). Bash tokenizes on whitespace, but
139
+ * the shell itself dispatches the full command string — token[0] matching
140
+ * is not a sufficient trust decision. Falling back to `null` forces the
141
+ * generic Write-tier Bash default, which is what the operator expects for
142
+ * any command they did not explicitly model here.
143
+ */
144
+ // Reject redirection and chaining operators. Bare `rea check > /etc/passwd`
145
+ // still executes a write the classifier cannot reason about; same for
146
+ // heredocs (`<<`), pipe-process-substitution (`>(`, `<(`), and the
147
+ // chain/substitute operators the prior pass already covered.
148
+ const REA_SHELL_METACHAR_RE = /[;&|`\n\r<>]|\$\(|>\(|<\(/;
149
+ /**
150
+ * Returns true iff `first` is an invocation shape we trust for Read-tier
151
+ * downgrade. Implemented as a function because the trust rules are not pure
152
+ * suffix matching — pass-3 Codex review surfaced two P1 bypasses in the old
153
+ * suffix-only model:
154
+ *
155
+ * 1. A repo-authored `./bin/rea` script satisfied `endsWith('/bin/rea')`
156
+ * and classified as Read at L0 → RCE via repo content.
157
+ * 2. A repo-authored `./dist/cli/index.js` satisfied
158
+ * `endsWith('/dist/cli/index.js')` → same.
159
+ *
160
+ * The rules now require:
161
+ * - The first token is **absolute** (starts with `/`). Relative paths are
162
+ * attacker-influenced via CWD and repo content, so they never get the
163
+ * Read-tier downgrade. Callers MAY still run relative-path rea — they
164
+ * just fall through to weak-trust (bare `rea`) semantics: Destructive
165
+ * subcommands still upgrade; Read/Write fall back to the generic Bash
166
+ * Write tier.
167
+ * - The path matches one of the two *strong* install shapes:
168
+ * (a) contains `/node_modules/.bin/rea` anywhere (unambiguous marker
169
+ * of an npm install directory tree);
170
+ * (b) starts with `/usr/` or `/opt/` AND ends with `/bin/rea`
171
+ * (classic root-write system install location). `/home/…/bin/rea`
172
+ * is intentionally NOT honored — `/home/<user>/` is writable
173
+ * without root, so an attacker with local shell access could
174
+ * pre-seed a trusted-looking path there.
175
+ *
176
+ * The old `/dist/cli/index.js` suffix is gone entirely. The legitimate
177
+ * developer invocation `node ./dist/cli/index.js` has `first === 'node'`
178
+ * which never matches; only a filesystem-marked-executable
179
+ * `./dist/cli/index.js` would have hit the old suffix, and that shape was
180
+ * always attacker-authorable inside a repo. Similarly, `/.bin/rea` (exactly
181
+ * `/.bin/rea`, at filesystem root) was an accident of suffix matching, not
182
+ * a real install location; it is gone.
183
+ */
184
+ function isTrustedReaPath(first) {
185
+ if (!first.startsWith('/'))
186
+ return false;
187
+ // npm install marker — absolute path whose tail is `/node_modules/.bin/rea`.
188
+ // This is unambiguous: an attacker can only seed this path by having already
189
+ // run a real npm install, at which point they already had execution.
190
+ if (first.endsWith('/node_modules/.bin/rea'))
191
+ return true;
192
+ // Classic global install — absolute path rooted at a system prefix that
193
+ // requires root write (so attacker-seeded files are out-of-scope for the
194
+ // repo-content threat model).
195
+ if (first.endsWith('/bin/rea')) {
196
+ if (first.startsWith('/usr/'))
197
+ return true;
198
+ if (first.startsWith('/opt/'))
199
+ return true;
200
+ }
201
+ return false;
202
+ }
203
+ export function reaCommandTier(command) {
204
+ if (typeof command !== 'string' || command.length === 0)
205
+ return null;
206
+ // Refuse to classify commands that chain/substitute/redirect — the trailing
207
+ // shell payload is arbitrary, so the prefix's read-tier status tells us
208
+ // nothing about what the shell will actually execute.
209
+ if (REA_SHELL_METACHAR_RE.test(command))
210
+ return null;
211
+ const trimmed = command.trim();
212
+ if (trimmed.length === 0)
213
+ return null;
214
+ const tokens = trimmed.split(/\s+/);
215
+ if (tokens.length === 0)
216
+ return null;
217
+ const first = tokens[0];
218
+ if (first === undefined)
219
+ return null;
220
+ // Classify the invocation's trust posture. The ONLY fully-trusted shape is
221
+ // an absolute-path invocation that `isTrustedReaPath()` recognizes as a
222
+ // strong install marker (npm `/node_modules/.bin/rea` or a root-write
223
+ // system global under `/usr/` or `/opt/`). Everything else — bare `rea`,
224
+ // `npx rea …`, relative paths — is treated as *weak trust*: we still
225
+ // recognize the subcommand for the sake of destructive-tier UPGRADES
226
+ // (e.g. `rea freeze` at L1 should be blocked whether or not we can prove
227
+ // the binary is ours), but we refuse to DOWNGRADE anything that could be
228
+ // piggybacking on a PATH-spoofable name or an `npx` network/install
229
+ // side-effect.
230
+ //
231
+ // npx note (pass-3 Codex Finding 2): `npx rea …` on a machine without the
232
+ // package locally cached downloads the tarball, writes to the npm cache,
233
+ // and executes — explicitly not Read-tier semantics. Treating npx as weak
234
+ // trust forces agents to commit to a deterministic install path (absolute
235
+ // `/usr/local/bin/rea` from `npm i -g`, or the fully-resolved
236
+ // `/…/node_modules/.bin/rea` from a project install) if they want the
237
+ // Read-tier downgrade.
238
+ let idx = 0;
239
+ let trust = 'trusted';
240
+ if (first === 'npx') {
241
+ if (tokens.length < 2)
242
+ return null;
243
+ const second = tokens[1];
244
+ if (second !== 'rea' && second !== '@bookedsolid/rea')
245
+ return null;
246
+ idx = 2;
247
+ trust = 'weak';
248
+ }
249
+ else if (isTrustedReaPath(first)) {
250
+ idx = 1;
251
+ }
252
+ else if (first === 'rea' || first.split('/').pop() === 'rea') {
253
+ // Bare `rea` OR any path (relative/absolute) whose tail is literally
254
+ // `rea`. This captures `./bin/rea`, `./node_modules/.bin/rea`,
255
+ // `/home/user/.npm-global/bin/rea`, `/tmp/fake/rea`, etc. — none of
256
+ // these are full-trust under `isTrustedReaPath()`, but we still want
257
+ // Destructive subcommands (`freeze`) to UPGRADE from Bash Write even
258
+ // here, because destructive intent is invocation-shape-independent.
259
+ idx = 1;
260
+ trust = 'weak';
261
+ }
262
+ else {
263
+ return null;
264
+ }
265
+ const sub = tokens[idx];
266
+ if (sub === undefined) {
267
+ // `rea` with no subcommand is help/version under `commander` — a read.
268
+ // Under weak trust, we refuse to downgrade; fall back to generic Write.
269
+ return trust === 'trusted' ? Tier.Read : null;
270
+ }
271
+ const sub2 = tokens[idx + 1];
272
+ const subcommandTier = (() => {
273
+ switch (sub) {
274
+ case 'check':
275
+ case 'doctor':
276
+ case 'status':
277
+ return Tier.Read;
278
+ case 'cache': {
279
+ if (sub2 === 'check' || sub2 === 'list' || sub2 === 'get')
280
+ return Tier.Read;
281
+ if (sub2 === 'set' || sub2 === 'clear')
282
+ return Tier.Write;
283
+ return Tier.Write;
284
+ }
285
+ case 'audit': {
286
+ if (sub2 === 'verify')
287
+ return Tier.Read;
288
+ if (sub2 === 'record')
289
+ return Tier.Read;
290
+ if (sub2 === 'rotate')
291
+ return Tier.Write;
292
+ return Tier.Write;
293
+ }
294
+ case 'init':
295
+ case 'serve':
296
+ case 'upgrade':
297
+ case 'unfreeze':
298
+ return Tier.Write;
299
+ case 'freeze':
300
+ return Tier.Destructive;
301
+ default:
302
+ return null;
303
+ }
304
+ })();
305
+ // Trusted path — return whatever the subcommand semantics say.
306
+ // Unknown subcommand: default Write (safer than Read).
307
+ if (trust === 'trusted') {
308
+ return subcommandTier ?? Tier.Write;
309
+ }
310
+ // Weak trust (bare `rea`) — only honor upgrades above Write.
311
+ // Read/Write subcommands: return null so the middleware applies the generic
312
+ // Bash Write default (same as the pre-helper behavior, no downgrade).
313
+ // Destructive subcommands: KEEP the upgrade — `rea freeze` at L1 must block
314
+ // even if we cannot prove the binary on PATH is ours.
315
+ if (subcommandTier === Tier.Destructive)
316
+ return Tier.Destructive;
317
+ return null;
318
+ }
@@ -237,6 +237,10 @@ export async function performRotation(auditFile, now = new Date()) {
237
237
  autonomy_level: 'system',
238
238
  duration_ms: 0,
239
239
  prev_hash: tailHash,
240
+ // Defect P: rotation markers are written by rea itself, not by an
241
+ // external caller of appendAuditRecord() — tag as rea-cli so the
242
+ // hash chain remains consistent under the post-P schema.
243
+ emission_source: 'rea-cli',
240
244
  metadata: {
241
245
  rotated_from: path.basename(rotatedPath),
242
246
  rotated_at: now.toISOString(),