@bookedsolid/rea 0.22.0 → 0.23.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/README.md +15 -0
- package/THREAT_MODEL.md +582 -0
- package/dist/audit/append.js +1 -1
- package/dist/cli/doctor.js +11 -12
- package/dist/cli/hook.d.ts +37 -3
- package/dist/cli/hook.js +167 -5
- package/dist/cli/init.js +14 -26
- package/dist/cli/install/canonical.js +18 -3
- package/dist/cli/install/commit-msg.js +1 -2
- package/dist/cli/install/copy.js +4 -13
- package/dist/cli/install/fs-safe.js +5 -16
- package/dist/cli/install/gitignore.js +1 -5
- package/dist/cli/install/pre-push.js +3 -8
- package/dist/cli/install/settings-merge.js +79 -16
- package/dist/cli/upgrade.js +14 -10
- package/dist/gateway/downstream.js +1 -2
- package/dist/gateway/live-state.js +3 -1
- package/dist/gateway/log.js +1 -3
- package/dist/gateway/middleware/audit.js +1 -1
- package/dist/gateway/middleware/injection.js +3 -9
- package/dist/gateway/middleware/policy.js +3 -1
- package/dist/gateway/middleware/redact.js +1 -1
- package/dist/gateway/observability/codex-telemetry.js +1 -2
- package/dist/gateway/reviewers/claude-self.js +10 -6
- package/dist/hooks/bash-scanner/blocked-scan.d.ts +26 -0
- package/dist/hooks/bash-scanner/blocked-scan.js +467 -0
- package/dist/hooks/bash-scanner/index.d.ts +41 -0
- package/dist/hooks/bash-scanner/index.js +62 -0
- package/dist/hooks/bash-scanner/parse-fail-closed.d.ts +31 -0
- package/dist/hooks/bash-scanner/parse-fail-closed.js +27 -0
- package/dist/hooks/bash-scanner/parser.d.ts +42 -0
- package/dist/hooks/bash-scanner/parser.js +92 -0
- package/dist/hooks/bash-scanner/protected-scan.d.ts +76 -0
- package/dist/hooks/bash-scanner/protected-scan.js +815 -0
- package/dist/hooks/bash-scanner/verdict.d.ts +80 -0
- package/dist/hooks/bash-scanner/verdict.js +49 -0
- package/dist/hooks/bash-scanner/walker.d.ts +165 -0
- package/dist/hooks/bash-scanner/walker.js +7954 -0
- package/dist/hooks/push-gate/base.js +2 -6
- package/dist/hooks/push-gate/codex-runner.js +3 -1
- package/dist/hooks/push-gate/index.js +9 -10
- package/dist/policy/loader.js +4 -1
- package/dist/registry/tofu-gate.js +2 -2
- package/hooks/blocked-paths-bash-gate.sh +142 -272
- package/hooks/protected-paths-bash-gate.sh +227 -511
- package/package.json +3 -2
- package/profiles/bst-internal-no-codex.yaml +1 -1
- package/profiles/bst-internal.yaml +1 -1
- package/profiles/client-engagement.yaml +1 -1
- package/profiles/lit-wc.yaml +1 -1
- package/profiles/minimal.yaml +1 -1
- package/profiles/open-source-no-codex.yaml +1 -1
- package/profiles/open-source.yaml +1 -1
- package/scripts/postinstall.mjs +1 -2
- package/scripts/run-vitest.mjs +117 -0
package/dist/cli/doctor.js
CHANGED
|
@@ -4,12 +4,12 @@ import { loadPolicy } from '../policy/loader.js';
|
|
|
4
4
|
import { loadRegistry } from '../registry/loader.js';
|
|
5
5
|
import { loadFingerprintStore } from '../registry/fingerprints-store.js';
|
|
6
6
|
import { fingerprintServer } from '../registry/fingerprint.js';
|
|
7
|
-
import { CodexProbe
|
|
8
|
-
import { inspectPrePushState
|
|
7
|
+
import { CodexProbe } from '../gateway/observability/codex-probe.js';
|
|
8
|
+
import { inspectPrePushState } from './install/pre-push.js';
|
|
9
9
|
import { summarizeTelemetry } from '../gateway/observability/codex-telemetry.js';
|
|
10
10
|
import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
|
|
11
11
|
import { buildFragment } from './install/claude-md.js';
|
|
12
|
-
import { canonicalSettingsSubsetHash, defaultDesiredHooks
|
|
12
|
+
import { canonicalSettingsSubsetHash, defaultDesiredHooks } from './install/settings-merge.js';
|
|
13
13
|
import { manifestExists, readManifest } from './install/manifest-io.js';
|
|
14
14
|
import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
|
|
15
15
|
import { POLICY_FILE, REA_DIR, REGISTRY_FILE, getPkgVersion, log, reaPath } from './utils.js';
|
|
@@ -177,7 +177,11 @@ function checkAgentsPresent(baseDir) {
|
|
|
177
177
|
function checkHooksInstalled(baseDir) {
|
|
178
178
|
const hooksDir = path.join(baseDir, '.claude', 'hooks');
|
|
179
179
|
if (!fs.existsSync(hooksDir)) {
|
|
180
|
-
return {
|
|
180
|
+
return {
|
|
181
|
+
label: 'hooks installed + executable',
|
|
182
|
+
status: 'fail',
|
|
183
|
+
detail: `missing: ${hooksDir}`,
|
|
184
|
+
};
|
|
181
185
|
}
|
|
182
186
|
const issues = [];
|
|
183
187
|
for (const name of EXPECTED_HOOKS) {
|
|
@@ -309,9 +313,7 @@ export function isGitRepo(baseDir) {
|
|
|
309
313
|
const targetPath = rawTarget;
|
|
310
314
|
if (targetPath.length === 0)
|
|
311
315
|
return false;
|
|
312
|
-
const resolved = path.isAbsolute(targetPath)
|
|
313
|
-
? targetPath
|
|
314
|
-
: path.join(baseDir, targetPath);
|
|
316
|
+
const resolved = path.isAbsolute(targetPath) ? targetPath : path.join(baseDir, targetPath);
|
|
315
317
|
return fs.existsSync(resolved);
|
|
316
318
|
}
|
|
317
319
|
function checkCommitMsgHook(baseDir) {
|
|
@@ -386,9 +388,7 @@ function checkPrePushHook(state) {
|
|
|
386
388
|
// …), surface the .d/ migration path explicitly so consumers know
|
|
387
389
|
// exactly how to keep their existing chain without losing rea coverage
|
|
388
390
|
// or having `rea upgrade` clobber them again.
|
|
389
|
-
const hints = state.activePath !== null
|
|
390
|
-
? detectPriorToolHints(state.activePath)
|
|
391
|
-
: [];
|
|
391
|
+
const hints = state.activePath !== null ? detectPriorToolHints(state.activePath) : [];
|
|
392
392
|
let detail = `active pre-push at ${state.activePath} is present and executable but does NOT ` +
|
|
393
393
|
'invoke `rea hook push-gate` — the 0.11.0 push-gate is silently bypassed. ' +
|
|
394
394
|
'Either add `exec rea hook push-gate "$@"` to the existing hook, or ' +
|
|
@@ -820,8 +820,7 @@ export async function collectDriftReport(baseDir) {
|
|
|
820
820
|
// Manifest entries no longer in canonical (removed upstream), excluding
|
|
821
821
|
// synthetic entries handled below.
|
|
822
822
|
for (const entry of manifest.files) {
|
|
823
|
-
if (entry.path === CLAUDE_MD_MANIFEST_PATH ||
|
|
824
|
-
entry.path === SETTINGS_MANIFEST_PATH)
|
|
823
|
+
if (entry.path === CLAUDE_MD_MANIFEST_PATH || entry.path === SETTINGS_MANIFEST_PATH)
|
|
825
824
|
continue;
|
|
826
825
|
if (!canonicalByPath.has(entry.path)) {
|
|
827
826
|
rows.push({
|
package/dist/cli/hook.d.ts
CHANGED
|
@@ -48,8 +48,42 @@ export interface HookPushGateOptions {
|
|
|
48
48
|
*/
|
|
49
49
|
export declare function runHookPushGate(options: HookPushGateOptions): Promise<void>;
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
51
|
+
* `rea hook scan-bash --mode protected|blocked` — invoked by the bash
|
|
52
|
+
* shim hooks at `hooks/protected-paths-bash-gate.sh` and
|
|
53
|
+
* `hooks/blocked-paths-bash-gate.sh` (since 0.23.0). Reads the Claude
|
|
54
|
+
* Code tool-input JSON from stdin, extracts `.tool_input.command`,
|
|
55
|
+
* runs the parser-backed scanner, and writes a verdict JSON to stdout.
|
|
56
|
+
*
|
|
57
|
+
* Exit-code contract (parsed by the bash shim via `jq`):
|
|
58
|
+
* 0 — allow (verdict.verdict == "allow")
|
|
59
|
+
* 2 — block (verdict.verdict == "block")
|
|
60
|
+
* 1 — runtime error (HALT active, missing args, internal exception)
|
|
61
|
+
*
|
|
62
|
+
* The verdict shape on stdout is `Verdict` (see `verdict.ts`); the
|
|
63
|
+
* bash shim only reads `.verdict` and `.reason`. Other fields are for
|
|
64
|
+
* structured-logging consumers in tests + audit middleware.
|
|
65
|
+
*
|
|
66
|
+
* HALT is checked HERE (not in the bash shim) so we have a single
|
|
67
|
+
* source of truth — the shim is intentionally as dumb as possible.
|
|
68
|
+
*/
|
|
69
|
+
export interface HookScanBashOptions {
|
|
70
|
+
mode: 'protected' | 'blocked';
|
|
71
|
+
/**
|
|
72
|
+
* Override REA_ROOT. Useful in tests; the production shim doesn't
|
|
73
|
+
* pass this — it relies on `process.cwd()` matching CLAUDE_PROJECT_DIR.
|
|
74
|
+
*/
|
|
75
|
+
reaRoot?: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The non-async entry the commander binding hits. Reads stdin (with
|
|
79
|
+
* a timeout — same pattern as runHookPushGate), executes the scan,
|
|
80
|
+
* writes the verdict JSON, exits with the appropriate code.
|
|
81
|
+
*/
|
|
82
|
+
export declare function runHookScanBash(options: HookScanBashOptions): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Attach the `rea hook` subcommand tree to a commander Program. Two
|
|
85
|
+
* subcommands today: `push-gate` and `scan-bash`. New hooks should land
|
|
86
|
+
* here rather than as top-level commands so the CLI surface stays
|
|
87
|
+
* navigable.
|
|
54
88
|
*/
|
|
55
89
|
export declare function registerHookCommand(program: Command): void;
|
package/dist/cli/hook.js
CHANGED
|
@@ -28,7 +28,12 @@
|
|
|
28
28
|
* `codex_required: true`, `concerns_blocks: true`. The gate still fires.
|
|
29
29
|
* This matches the protective default established in 0.10.x.
|
|
30
30
|
*/
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
31
33
|
import { parsePrePushStdin, runPushGate } from '../hooks/push-gate/index.js';
|
|
34
|
+
import { runBlockedScan, runProtectedScan } from '../hooks/bash-scanner/index.js';
|
|
35
|
+
import { loadPolicy } from '../policy/loader.js';
|
|
36
|
+
import { appendAuditRecord, InvocationStatus, Tier } from '../audit/append.js';
|
|
32
37
|
import { err } from './utils.js';
|
|
33
38
|
/**
|
|
34
39
|
* Public runner, exposed so integration tests and the commander binding can
|
|
@@ -54,7 +59,9 @@ export async function runHookPushGate(options) {
|
|
|
54
59
|
env: process.env,
|
|
55
60
|
stderr,
|
|
56
61
|
refspecs,
|
|
57
|
-
...(options.base !== undefined && options.base.length > 0
|
|
62
|
+
...(options.base !== undefined && options.base.length > 0
|
|
63
|
+
? { explicitBase: options.base }
|
|
64
|
+
: {}),
|
|
58
65
|
...(options.lastNCommits !== undefined ? { lastNCommits: options.lastNCommits } : {}),
|
|
59
66
|
});
|
|
60
67
|
process.exit(result.exitCode);
|
|
@@ -102,14 +109,169 @@ async function readStdinWithTimeout(timeoutMs) {
|
|
|
102
109
|
});
|
|
103
110
|
}
|
|
104
111
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
112
|
+
* The non-async entry the commander binding hits. Reads stdin (with
|
|
113
|
+
* a timeout — same pattern as runHookPushGate), executes the scan,
|
|
114
|
+
* writes the verdict JSON, exits with the appropriate code.
|
|
115
|
+
*/
|
|
116
|
+
export async function runHookScanBash(options) {
|
|
117
|
+
const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
|
|
118
|
+
// HALT check — uniform with the bash hooks. We exit 2 (block) so
|
|
119
|
+
// the shim refuses the command in the same way settings-protection
|
|
120
|
+
// and the bash gates do.
|
|
121
|
+
const haltPath = path.join(reaRoot, '.rea', 'HALT');
|
|
122
|
+
if (fs.existsSync(haltPath)) {
|
|
123
|
+
let reason = 'Reason unknown';
|
|
124
|
+
try {
|
|
125
|
+
const content = fs.readFileSync(haltPath, 'utf8');
|
|
126
|
+
reason = content.slice(0, 1024).trim() || reason;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* leave default */
|
|
130
|
+
}
|
|
131
|
+
process.stderr.write(`REA HALT: ${reason}\nAll agent operations suspended. Run: rea unfreeze\n`);
|
|
132
|
+
const haltVerdict = {
|
|
133
|
+
verdict: 'block',
|
|
134
|
+
reason: 'rea HALT active',
|
|
135
|
+
};
|
|
136
|
+
process.stdout.write(JSON.stringify(haltVerdict) + '\n');
|
|
137
|
+
process.exit(2);
|
|
138
|
+
}
|
|
139
|
+
const stdinRaw = process.stdin.isTTY ? '' : await readStdinWithTimeout(5_000);
|
|
140
|
+
let cmd = '';
|
|
141
|
+
if (stdinRaw.length > 0) {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(stdinRaw);
|
|
144
|
+
const c = parsed.tool_input?.command;
|
|
145
|
+
// Codex round 1 F-31: tool_input.command MUST be a string. A
|
|
146
|
+
// crafted payload with `command: ["rm", "-rf"]` or `command: 42`
|
|
147
|
+
// would pre-fix silently fall through to "allow on empty cmd".
|
|
148
|
+
// Refuse on type mismatch.
|
|
149
|
+
if (c !== undefined && typeof c !== 'string') {
|
|
150
|
+
const wrong = {
|
|
151
|
+
verdict: 'block',
|
|
152
|
+
reason: 'rea: scan-bash received a non-string `tool_input.command` field; refusing on uncertainty',
|
|
153
|
+
};
|
|
154
|
+
process.stdout.write(JSON.stringify(wrong) + '\n');
|
|
155
|
+
process.stderr.write(wrong.reason + '\n');
|
|
156
|
+
process.exit(2);
|
|
157
|
+
}
|
|
158
|
+
if (typeof c === 'string')
|
|
159
|
+
cmd = c;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Malformed JSON on stdin → fail closed. The bash shim only
|
|
163
|
+
// forwards what Claude Code sends, so this should never happen
|
|
164
|
+
// in production; treating it as block prevents a crafted payload
|
|
165
|
+
// from getting an allow.
|
|
166
|
+
const malformed = {
|
|
167
|
+
verdict: 'block',
|
|
168
|
+
reason: 'rea: scan-bash received malformed JSON on stdin; refusing on uncertainty',
|
|
169
|
+
};
|
|
170
|
+
process.stdout.write(JSON.stringify(malformed) + '\n');
|
|
171
|
+
process.exit(2);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Empty command → allow. Matches the bash gates' `[[ -z "$CMD" ]] && exit 0`.
|
|
175
|
+
if (cmd.length === 0) {
|
|
176
|
+
process.stdout.write(JSON.stringify({ verdict: 'allow' }) + '\n');
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
// Load policy. A missing policy file is treated as "no governance" —
|
|
180
|
+
// we allow on missing-policy so dev environments without a fully-
|
|
181
|
+
// initialized rea directory don't hard-block. The bash shim
|
|
182
|
+
// pre-0.23.0 had the same posture.
|
|
183
|
+
let blockedPaths = [];
|
|
184
|
+
let protectedWrites;
|
|
185
|
+
let protectedRelax = [];
|
|
186
|
+
try {
|
|
187
|
+
const policy = loadPolicy(reaRoot);
|
|
188
|
+
blockedPaths = policy.blocked_paths;
|
|
189
|
+
protectedWrites = policy.protected_writes;
|
|
190
|
+
protectedRelax = policy.protected_paths_relax ?? [];
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// Policy missing or invalid. Continue with defaults — the historical
|
|
194
|
+
// protected list is hardcoded; blocked_paths becomes an empty no-op.
|
|
195
|
+
}
|
|
196
|
+
let verdict;
|
|
197
|
+
try {
|
|
198
|
+
if (options.mode === 'protected') {
|
|
199
|
+
verdict = runProtectedScan({
|
|
200
|
+
reaRoot,
|
|
201
|
+
policy: {
|
|
202
|
+
...(protectedWrites !== undefined ? { protected_writes: protectedWrites } : {}),
|
|
203
|
+
protected_paths_relax: protectedRelax,
|
|
204
|
+
},
|
|
205
|
+
stderr: (line) => process.stderr.write(line),
|
|
206
|
+
}, cmd);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
verdict = runBlockedScan({ reaRoot, blockedPaths }, cmd);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
// Any exception in the scanner is a bug; fail closed.
|
|
214
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
215
|
+
verdict = {
|
|
216
|
+
verdict: 'block',
|
|
217
|
+
reason: `rea: scan-bash internal error; refusing on uncertainty: ${reason}`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// Codex round 1 F-26: emit an audit record so the gateway audit log
|
|
221
|
+
// captures every scan-bash invocation. Best-effort — failure to
|
|
222
|
+
// write an audit entry must NOT change the verdict.
|
|
223
|
+
try {
|
|
224
|
+
await appendAuditRecord(reaRoot, {
|
|
225
|
+
tool_name: 'rea.hook.scan-bash',
|
|
226
|
+
server_name: 'rea',
|
|
227
|
+
tier: Tier.Read,
|
|
228
|
+
status: verdict.verdict === 'allow' ? InvocationStatus.Allowed : InvocationStatus.Denied,
|
|
229
|
+
metadata: {
|
|
230
|
+
mode: options.mode,
|
|
231
|
+
verdict: verdict.verdict,
|
|
232
|
+
...(verdict.detected_form !== undefined ? { detected_form: verdict.detected_form } : {}),
|
|
233
|
+
...(verdict.hit_pattern !== undefined ? { hit_pattern: verdict.hit_pattern } : {}),
|
|
234
|
+
// Truncate the command to avoid blowing the audit log on very
|
|
235
|
+
// long inputs.
|
|
236
|
+
command_preview: cmd.slice(0, 256),
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
/* best-effort */
|
|
242
|
+
}
|
|
243
|
+
// Write verdict JSON to stdout.
|
|
244
|
+
process.stdout.write(JSON.stringify(verdict) + '\n');
|
|
245
|
+
if (verdict.verdict === 'block') {
|
|
246
|
+
if (typeof verdict.reason === 'string' && verdict.reason.length > 0) {
|
|
247
|
+
process.stderr.write(verdict.reason + '\n');
|
|
248
|
+
}
|
|
249
|
+
process.exit(2);
|
|
250
|
+
}
|
|
251
|
+
process.exit(0);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Attach the `rea hook` subcommand tree to a commander Program. Two
|
|
255
|
+
* subcommands today: `push-gate` and `scan-bash`. New hooks should land
|
|
256
|
+
* here rather than as top-level commands so the CLI surface stays
|
|
257
|
+
* navigable.
|
|
108
258
|
*/
|
|
109
259
|
export function registerHookCommand(program) {
|
|
110
260
|
const hook = program
|
|
111
261
|
.command('hook')
|
|
112
|
-
.description('Pre-hook entry points for git (pre-push) and Claude Code. Called by `.husky/pre-push
|
|
262
|
+
.description('Pre-hook entry points for git (pre-push) and Claude Code. Called by `.husky/pre-push`, the optional `.git/hooks/pre-push` fallback, and the bash-shim Claude Code hooks at `.claude/hooks/{protected,blocked}-paths-bash-gate.sh`.');
|
|
263
|
+
hook
|
|
264
|
+
.command('scan-bash')
|
|
265
|
+
.description('Parser-backed bash-tier scanner. Reads Claude Code tool-input JSON from stdin, runs the AST walker against the protected-paths or blocked_paths policy, and writes a verdict JSON to stdout. Exit 0 on allow, 2 on block.')
|
|
266
|
+
.option('--mode <protected|blocked>', 'which policy to enforce: `protected` for the hardcoded + protected_writes list, `blocked` for the policy.blocked_paths list', (raw) => {
|
|
267
|
+
if (raw !== 'protected' && raw !== 'blocked') {
|
|
268
|
+
throw new Error(`--mode must be "protected" or "blocked", got ${JSON.stringify(raw)}`);
|
|
269
|
+
}
|
|
270
|
+
return raw;
|
|
271
|
+
}, 'protected')
|
|
272
|
+
.action(async (opts) => {
|
|
273
|
+
await runHookScanBash({ mode: opts.mode });
|
|
274
|
+
});
|
|
113
275
|
hook
|
|
114
276
|
.command('push-gate')
|
|
115
277
|
// Accept (and silently ignore) positional args. Git passes the
|
package/dist/cli/init.js
CHANGED
|
@@ -15,7 +15,7 @@ import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFile
|
|
|
15
15
|
import { writeManifestAtomic } from './install/manifest-io.js';
|
|
16
16
|
import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
|
|
17
17
|
import { defaultReagentPath, ReagentDroppedFieldsError, translateReagentPolicy, } from './install/reagent.js';
|
|
18
|
-
import { POLICY_FILE, REA_DIR, REGISTRY_FILE, err, getPkgVersion, log, warn
|
|
18
|
+
import { POLICY_FILE, REA_DIR, REGISTRY_FILE, err, getPkgVersion, log, warn } from './utils.js';
|
|
19
19
|
const PROFILE_NAMES = [
|
|
20
20
|
'minimal',
|
|
21
21
|
'client-engagement',
|
|
@@ -114,7 +114,11 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
|
|
|
114
114
|
initialValue: 'minimal',
|
|
115
115
|
options: [
|
|
116
116
|
{ value: 'minimal', label: 'minimal', hint: 'bare policy, no extras (default)' },
|
|
117
|
-
{
|
|
117
|
+
{
|
|
118
|
+
value: 'client-engagement',
|
|
119
|
+
label: 'client-engagement',
|
|
120
|
+
hint: 'zero-trust client project',
|
|
121
|
+
},
|
|
118
122
|
{ value: 'bst-internal', label: 'bst-internal', hint: 'internal BST projects' },
|
|
119
123
|
{ value: 'lit-wc', label: 'lit-wc', hint: 'Lit / web component libraries' },
|
|
120
124
|
{ value: 'open-source', label: 'open-source', hint: 'public OSS repos' },
|
|
@@ -126,9 +130,7 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
|
|
|
126
130
|
}
|
|
127
131
|
// 0.21.1: prefer the existing on-disk value over the profile default
|
|
128
132
|
// so re-running `rea init` doesn't reset an operator's manual edit.
|
|
129
|
-
const autonomyDefault = existingPolicy?.autonomyLevel
|
|
130
|
-
?? layeredBase.autonomy_level
|
|
131
|
-
?? AutonomyLevel.L1;
|
|
133
|
+
const autonomyDefault = existingPolicy?.autonomyLevel ?? layeredBase.autonomy_level ?? AutonomyLevel.L1;
|
|
132
134
|
const autonomyPick = await p.select({
|
|
133
135
|
message: existingPolicy?.autonomyLevel !== undefined
|
|
134
136
|
? `Starting autonomy_level (current: ${existingPolicy.autonomyLevel})`
|
|
@@ -177,9 +179,7 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
|
|
|
177
179
|
// G11.4: "Use Codex adversarial review?" — the default follows the
|
|
178
180
|
// chosen profile (any `*-no-codex` profile defaults to No). An explicit
|
|
179
181
|
// flag on the command line overrides that default for the initial value.
|
|
180
|
-
const codexInitial = options.codex !== undefined
|
|
181
|
-
? options.codex
|
|
182
|
-
: profileDefaultCodexRequired(profileName);
|
|
182
|
+
const codexInitial = options.codex !== undefined ? options.codex : profileDefaultCodexRequired(profileName);
|
|
183
183
|
const codexPick = await p.confirm({
|
|
184
184
|
message: 'Use Codex adversarial review? (requires an OpenAI account — can be added later)',
|
|
185
185
|
initialValue: codexInitial,
|
|
@@ -542,9 +542,7 @@ export async function runInit(options) {
|
|
|
542
542
|
process.exit(1);
|
|
543
543
|
}
|
|
544
544
|
const baseProfile = loadProfile(profileName);
|
|
545
|
-
const profileCeiling = baseProfile?.max_autonomy_level ??
|
|
546
|
-
HARD_DEFAULTS.max_autonomy_level ??
|
|
547
|
-
AutonomyLevel.L2;
|
|
545
|
+
const profileCeiling = baseProfile?.max_autonomy_level ?? HARD_DEFAULTS.max_autonomy_level ?? AutonomyLevel.L2;
|
|
548
546
|
try {
|
|
549
547
|
const t = translateReagentPolicy(reagentPolicyPath, {
|
|
550
548
|
profileCeiling,
|
|
@@ -586,21 +584,11 @@ export async function runInit(options) {
|
|
|
586
584
|
: (existingPolicy?.codexRequired ?? profileDefaultCodexRequired(profileName));
|
|
587
585
|
config = {
|
|
588
586
|
profile: profileName,
|
|
589
|
-
autonomyLevel: existingPolicy?.autonomyLevel
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
?? AutonomyLevel.L2,
|
|
595
|
-
blockAiAttribution: existingPolicy?.blockAiAttribution
|
|
596
|
-
?? layeredBase.block_ai_attribution
|
|
597
|
-
?? true,
|
|
598
|
-
blockedPaths: existingPolicy?.blockedPaths
|
|
599
|
-
?? layeredBase.blocked_paths
|
|
600
|
-
?? ['.env', '.env.*'],
|
|
601
|
-
notificationChannel: existingPolicy?.notificationChannel
|
|
602
|
-
?? layeredBase.notification_channel
|
|
603
|
-
?? '',
|
|
587
|
+
autonomyLevel: existingPolicy?.autonomyLevel ?? layeredBase.autonomy_level ?? AutonomyLevel.L1,
|
|
588
|
+
maxAutonomyLevel: existingPolicy?.maxAutonomyLevel ?? layeredBase.max_autonomy_level ?? AutonomyLevel.L2,
|
|
589
|
+
blockAiAttribution: existingPolicy?.blockAiAttribution ?? layeredBase.block_ai_attribution ?? true,
|
|
590
|
+
blockedPaths: existingPolicy?.blockedPaths ?? layeredBase.blocked_paths ?? ['.env', '.env.*'],
|
|
591
|
+
notificationChannel: existingPolicy?.notificationChannel ?? layeredBase.notification_channel ?? '',
|
|
604
592
|
codexRequired,
|
|
605
593
|
fromReagent,
|
|
606
594
|
reagentPolicyPath,
|
|
@@ -67,9 +67,24 @@ async function walkFiles(srcDir) {
|
|
|
67
67
|
*/
|
|
68
68
|
export async function enumerateCanonicalFiles(pkgRoot = PKG_ROOT) {
|
|
69
69
|
const mappings = [
|
|
70
|
-
{
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
{
|
|
71
|
+
srcDir: path.join(pkgRoot, 'hooks'),
|
|
72
|
+
dstPrefix: '.claude/hooks',
|
|
73
|
+
source: 'hook',
|
|
74
|
+
mode: 0o755,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
srcDir: path.join(pkgRoot, 'agents'),
|
|
78
|
+
dstPrefix: '.claude/agents',
|
|
79
|
+
source: 'agent',
|
|
80
|
+
mode: 0o644,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
srcDir: path.join(pkgRoot, 'commands'),
|
|
84
|
+
dstPrefix: '.claude/commands',
|
|
85
|
+
source: 'command',
|
|
86
|
+
mode: 0o644,
|
|
87
|
+
},
|
|
73
88
|
{ srcDir: path.join(pkgRoot, '.husky'), dstPrefix: '.husky', source: 'husky', mode: 0o755 },
|
|
74
89
|
];
|
|
75
90
|
const out = [];
|
|
@@ -78,8 +78,7 @@ export async function classifyCommitMsgHook(hookPath) {
|
|
|
78
78
|
}
|
|
79
79
|
// Pre-0.13 rea body had no marker but always contained the attribution
|
|
80
80
|
// grep. Treat that shape as upgradeable rather than foreign.
|
|
81
|
-
if (content.includes('block_ai_attribution') &&
|
|
82
|
-
content.includes('AI attribution detected')) {
|
|
81
|
+
if (content.includes('block_ai_attribution') && content.includes('AI attribution detected')) {
|
|
83
82
|
return { kind: 'unmarked' };
|
|
84
83
|
}
|
|
85
84
|
return { kind: 'foreign', reason: 'no-marker' };
|
package/dist/cli/install/copy.js
CHANGED
|
@@ -131,9 +131,7 @@ async function assertSafeDestination(resolvedRoot, dstPath) {
|
|
|
131
131
|
// Containment: resolve without following symlinks on the leaf so an attacker
|
|
132
132
|
// cannot smuggle us out via a symlink in the leaf itself.
|
|
133
133
|
const resolvedDst = path.resolve(dstPath);
|
|
134
|
-
const rootWithSep = resolvedRoot.endsWith(path.sep)
|
|
135
|
-
? resolvedRoot
|
|
136
|
-
: resolvedRoot + path.sep;
|
|
134
|
+
const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
137
135
|
if (resolvedDst !== resolvedRoot && !resolvedDst.startsWith(rootWithSep)) {
|
|
138
136
|
throw new UnsafeInstallPathError('escape', resolvedDst, undefined, `refusing to write outside install root: ${resolvedDst} is not under ${resolvedRoot}`);
|
|
139
137
|
}
|
|
@@ -171,9 +169,7 @@ async function assertSafeDestination(resolvedRoot, dstPath) {
|
|
|
171
169
|
*/
|
|
172
170
|
async function assertSafeDirectory(resolvedRoot, dirPath) {
|
|
173
171
|
const resolvedDir = path.resolve(dirPath);
|
|
174
|
-
const rootWithSep = resolvedRoot.endsWith(path.sep)
|
|
175
|
-
? resolvedRoot
|
|
176
|
-
: resolvedRoot + path.sep;
|
|
172
|
+
const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
177
173
|
if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(rootWithSep)) {
|
|
178
174
|
throw new UnsafeInstallPathError('escape', resolvedDir, undefined, `refusing to operate on directory outside install root: ${resolvedDir}`);
|
|
179
175
|
}
|
|
@@ -234,9 +230,7 @@ async function assertSafeDirectory(resolvedRoot, dirPath) {
|
|
|
234
230
|
*/
|
|
235
231
|
async function snapshotAncestors(resolvedRoot, dstPath) {
|
|
236
232
|
const snapshot = new Map();
|
|
237
|
-
const rootWithSep = resolvedRoot.endsWith(path.sep)
|
|
238
|
-
? resolvedRoot
|
|
239
|
-
: resolvedRoot + path.sep;
|
|
233
|
+
const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
240
234
|
const leafDir = path.dirname(path.resolve(dstPath));
|
|
241
235
|
let cursor = leafDir;
|
|
242
236
|
let reachedRoot = false;
|
|
@@ -334,10 +328,7 @@ async function verifyAncestorsUnchanged(snapshot) {
|
|
|
334
328
|
*/
|
|
335
329
|
async function writeFileExclusiveNoFollow(srcPath, dstPath) {
|
|
336
330
|
const contents = await fsPromises.readFile(srcPath);
|
|
337
|
-
const flags = fs.constants.O_WRONLY |
|
|
338
|
-
fs.constants.O_CREAT |
|
|
339
|
-
fs.constants.O_EXCL |
|
|
340
|
-
fs.constants.O_NOFOLLOW;
|
|
331
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW;
|
|
341
332
|
const fh = await fsPromises.open(dstPath, flags, 0o644);
|
|
342
333
|
try {
|
|
343
334
|
await fh.writeFile(contents);
|
|
@@ -47,9 +47,7 @@ export function resolveContained(resolvedRoot, candidate) {
|
|
|
47
47
|
throw new UnsafeInstallPathError('escape', candidate, undefined, `refusing path with parent-directory segments: ${candidate}`);
|
|
48
48
|
}
|
|
49
49
|
const absolute = path.resolve(resolvedRoot, candidate);
|
|
50
|
-
const rootWithSep = resolvedRoot.endsWith(path.sep)
|
|
51
|
-
? resolvedRoot
|
|
52
|
-
: resolvedRoot + path.sep;
|
|
50
|
+
const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
53
51
|
if (absolute !== resolvedRoot && !absolute.startsWith(rootWithSep)) {
|
|
54
52
|
throw new UnsafeInstallPathError('escape', absolute, undefined, `refusing to resolve outside install root: ${absolute} is not under ${resolvedRoot}`);
|
|
55
53
|
}
|
|
@@ -62,9 +60,7 @@ export function resolveContained(resolvedRoot, candidate) {
|
|
|
62
60
|
*/
|
|
63
61
|
export async function assertSafeDestination(resolvedRoot, dstPath) {
|
|
64
62
|
const resolvedDst = path.resolve(dstPath);
|
|
65
|
-
const rootWithSep = resolvedRoot.endsWith(path.sep)
|
|
66
|
-
? resolvedRoot
|
|
67
|
-
: resolvedRoot + path.sep;
|
|
63
|
+
const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
68
64
|
if (resolvedDst !== resolvedRoot && !resolvedDst.startsWith(rootWithSep)) {
|
|
69
65
|
throw new UnsafeInstallPathError('escape', resolvedDst, undefined, `refusing to write outside install root: ${resolvedDst} is not under ${resolvedRoot}`);
|
|
70
66
|
}
|
|
@@ -95,9 +91,7 @@ export async function assertSafeDestination(resolvedRoot, dstPath) {
|
|
|
95
91
|
}
|
|
96
92
|
export async function assertSafeDirectory(resolvedRoot, dirPath) {
|
|
97
93
|
const resolvedDir = path.resolve(dirPath);
|
|
98
|
-
const rootWithSep = resolvedRoot.endsWith(path.sep)
|
|
99
|
-
? resolvedRoot
|
|
100
|
-
: resolvedRoot + path.sep;
|
|
94
|
+
const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
101
95
|
if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(rootWithSep)) {
|
|
102
96
|
throw new UnsafeInstallPathError('escape', resolvedDir, undefined, `refusing to operate on directory outside install root: ${resolvedDir}`);
|
|
103
97
|
}
|
|
@@ -126,9 +120,7 @@ export async function assertSafeDirectory(resolvedRoot, dirPath) {
|
|
|
126
120
|
}
|
|
127
121
|
export async function snapshotAncestors(resolvedRoot, dstPath) {
|
|
128
122
|
const snapshot = new Map();
|
|
129
|
-
const rootWithSep = resolvedRoot.endsWith(path.sep)
|
|
130
|
-
? resolvedRoot
|
|
131
|
-
: resolvedRoot + path.sep;
|
|
123
|
+
const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
132
124
|
const leafDir = path.dirname(path.resolve(dstPath));
|
|
133
125
|
let cursor = leafDir;
|
|
134
126
|
let reachedRoot = false;
|
|
@@ -196,10 +188,7 @@ export async function verifyAncestorsUnchanged(snapshot) {
|
|
|
196
188
|
* `unlink`s first and then calls this.
|
|
197
189
|
*/
|
|
198
190
|
export async function writeFileExclusiveNoFollow(dstPath, contents, mode = 0o644) {
|
|
199
|
-
const flags = fs.constants.O_WRONLY |
|
|
200
|
-
fs.constants.O_CREAT |
|
|
201
|
-
fs.constants.O_EXCL |
|
|
202
|
-
fs.constants.O_NOFOLLOW;
|
|
191
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW;
|
|
203
192
|
const fh = await fsPromises.open(dstPath, flags, mode);
|
|
204
193
|
try {
|
|
205
194
|
await fh.writeFile(contents);
|
|
@@ -313,11 +313,7 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
|
|
|
313
313
|
})();
|
|
314
314
|
const bodyLines = lines.slice(0, trimmedTailIdx + 1);
|
|
315
315
|
const separator = bodyLines.length === 0 ? [] : [''];
|
|
316
|
-
const newLines = [
|
|
317
|
-
...bodyLines,
|
|
318
|
-
...separator,
|
|
319
|
-
buildManagedBlock(entries, eol),
|
|
320
|
-
];
|
|
316
|
+
const newLines = [...bodyLines, ...separator, buildManagedBlock(entries, eol)];
|
|
321
317
|
const content = newLines.join(eol) + eol;
|
|
322
318
|
await writeAtomic(absPath, content);
|
|
323
319
|
return {
|
|
@@ -442,9 +442,7 @@ export async function resolveHooksDir(targetDir) {
|
|
|
442
442
|
if (configured === null) {
|
|
443
443
|
return { dir: null, configured: false };
|
|
444
444
|
}
|
|
445
|
-
const absolute = path.isAbsolute(configured)
|
|
446
|
-
? configured
|
|
447
|
-
: path.join(targetDir, configured);
|
|
445
|
+
const absolute = path.isAbsolute(configured) ? configured : path.join(targetDir, configured);
|
|
448
446
|
return { dir: absolute, configured: true };
|
|
449
447
|
}
|
|
450
448
|
/**
|
|
@@ -534,8 +532,7 @@ export async function classifyPrePushInstall(targetDir) {
|
|
|
534
532
|
if (classification.kind === 'absent') {
|
|
535
533
|
return { action: 'install', hookPath };
|
|
536
534
|
}
|
|
537
|
-
if (classification.kind === 'rea-managed' ||
|
|
538
|
-
classification.kind === 'rea-managed-legacy-v1') {
|
|
535
|
+
if (classification.kind === 'rea-managed' || classification.kind === 'rea-managed-legacy-v1') {
|
|
539
536
|
return { action: 'refresh', hookPath };
|
|
540
537
|
}
|
|
541
538
|
if (classification.kind === 'rea-managed-husky' ||
|
|
@@ -631,9 +628,7 @@ async function resolveLockDir(targetDir) {
|
|
|
631
628
|
const { stdout } = await execFileAsync('git', ['-C', targetDir, 'rev-parse', '--git-common-dir'], { encoding: 'utf8' });
|
|
632
629
|
const commonDir = stdout.trim();
|
|
633
630
|
if (commonDir.length > 0) {
|
|
634
|
-
const absolute = path.isAbsolute(commonDir)
|
|
635
|
-
? commonDir
|
|
636
|
-
: path.join(targetDir, commonDir);
|
|
631
|
+
const absolute = path.isAbsolute(commonDir) ? commonDir : path.join(targetDir, commonDir);
|
|
637
632
|
return path.join(absolute, 'rea-prepush.lockdir');
|
|
638
633
|
}
|
|
639
634
|
}
|