@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.
- package/README.md +105 -0
- package/THREAT_MODEL.md +19 -1
- package/dist/audit/append.d.ts +35 -1
- package/dist/audit/append.js +79 -11
- package/dist/cli/audit.d.ts +31 -0
- package/dist/cli/audit.js +197 -30
- package/dist/cli/cache.d.ts +33 -1
- package/dist/cli/cache.js +40 -2
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/index.js +58 -2
- package/dist/cli/tofu.d.ts +57 -0
- package/dist/cli/tofu.js +134 -0
- package/dist/config/tier-map.d.ts +1 -0
- package/dist/config/tier-map.js +210 -0
- package/dist/gateway/audit/rotator.js +4 -0
- package/dist/gateway/middleware/audit-types.d.ts +35 -0
- package/dist/gateway/middleware/audit.js +6 -0
- package/dist/gateway/middleware/blocked-paths.js +38 -0
- package/dist/gateway/middleware/policy.js +68 -3
- package/dist/registry/tofu-gate.js +4 -1
- package/hooks/_lib/push-review-core.sh +159 -26
- package/hooks/commit-review-gate.sh +25 -1
- package/hooks/settings-protection.sh +297 -64
- package/package.json +1 -1
package/dist/cli/cache.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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 (
|
|
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>
|
|
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>;
|
package/dist/cli/tofu.js
ADDED
|
@@ -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;
|
package/dist/config/tier-map.js
CHANGED
|
@@ -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(),
|