@bookedsolid/rea 0.25.0 → 0.26.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 +10 -7
- package/agents/codex-adversarial.md +4 -0
- package/agents/rea-orchestrator.md +9 -0
- package/commands/codex-review.md +4 -0
- package/dist/audit/append.d.ts +1 -0
- package/dist/audit/append.js +1 -0
- package/dist/audit/content-token.d.ts +98 -0
- package/dist/audit/content-token.js +136 -0
- package/dist/audit/local-review-event.d.ts +136 -0
- package/dist/audit/local-review-event.js +43 -0
- package/dist/cli/doctor.js +17 -0
- package/dist/cli/hook.d.ts +44 -0
- package/dist/cli/hook.js +77 -0
- package/dist/cli/index.js +9 -0
- package/dist/cli/init.js +197 -46
- package/dist/cli/install/pre-push.d.ts +15 -3
- package/dist/cli/install/pre-push.js +55 -5
- package/dist/cli/install/settings-merge.js +13 -0
- package/dist/cli/preflight.d.ts +120 -0
- package/dist/cli/preflight.js +487 -0
- package/dist/cli/review.d.ts +56 -0
- package/dist/cli/review.js +325 -0
- package/dist/hooks/bash-scanner/walker.js +232 -2
- package/dist/policy/loader.d.ts +65 -0
- package/dist/policy/loader.js +33 -0
- package/dist/policy/types.d.ts +89 -0
- package/hooks/_lib/cmd-segments.sh +383 -14
- package/hooks/_lib/policy-read.sh +255 -0
- package/hooks/local-review-gate.sh +460 -0
- package/package.json +1 -1
- package/templates/CLAUDE.md.local-first.md +87 -0
- package/templates/pre-push.local-first.sh +65 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rea review` — local-first codex review CLI (0.26.0+).
|
|
3
|
+
*
|
|
4
|
+
* Runs `codex exec review` against the working tree (or a specified
|
|
5
|
+
* base ref), parses the verdict, and writes a `rea.local_review`
|
|
6
|
+
* audit entry that `rea preflight` consults.
|
|
7
|
+
*
|
|
8
|
+
* Exit codes:
|
|
9
|
+
*
|
|
10
|
+
* 0 — pass (or skipped because `mode: off` + codex unavailable)
|
|
11
|
+
* 1 — concerns (configurable via --strict-fail-on)
|
|
12
|
+
* 2 — blocking, codex error, or codex unavailable in `mode: enforced`
|
|
13
|
+
*
|
|
14
|
+
* Behavior matrix:
|
|
15
|
+
*
|
|
16
|
+
* policy.local_review.mode codex available? result
|
|
17
|
+
* ------------------------ --------------- ----------------------
|
|
18
|
+
* enforced or unset (def.) yes run review, audit
|
|
19
|
+
* enforced or unset (def.) no exit 2 with helpful msg
|
|
20
|
+
* off yes run review, audit
|
|
21
|
+
* off no exit 0, audit skipped
|
|
22
|
+
*
|
|
23
|
+
* The `provider` field on the audit record is `'codex'` today. Future
|
|
24
|
+
* providers (Claude-subagent, Pi, Gemma) write the SAME `rea.local_review`
|
|
25
|
+
* shape with their own `provider:` value — `rea preflight` accepts any.
|
|
26
|
+
*
|
|
27
|
+
* The CLI is a thin wrapper around `runCodexReview` from
|
|
28
|
+
* `src/hooks/push-gate/codex-runner.ts`. We do NOT re-implement codex
|
|
29
|
+
* spawning. The push-gate's iron-gate defaults (gpt-5.4 + high reasoning)
|
|
30
|
+
* apply identically here so a local review carries the same weight as
|
|
31
|
+
* the push-gate's review.
|
|
32
|
+
*/
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { spawnSync } from 'node:child_process';
|
|
35
|
+
import { appendAuditRecord } from '../audit/append.js';
|
|
36
|
+
import { LOCAL_REVIEW_TOOL_NAME, LOCAL_REVIEW_SKIPPED_UNAVAILABLE_TOOL_NAME, LOCAL_REVIEW_SERVER_NAME, } from '../audit/local-review-event.js';
|
|
37
|
+
import { Tier, InvocationStatus } from '../policy/types.js';
|
|
38
|
+
import { loadPolicyAsync } from '../policy/loader.js';
|
|
39
|
+
import { CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, IRON_GATE_DEFAULT_MODEL, IRON_GATE_DEFAULT_REASONING, createRealGitExecutor, runCodexReview, } from '../hooks/push-gate/codex-runner.js';
|
|
40
|
+
import { resolvePushGatePolicy } from '../hooks/push-gate/policy.js';
|
|
41
|
+
import { resolveBaseRef } from '../hooks/push-gate/base.js';
|
|
42
|
+
import { summarizeReview } from '../hooks/push-gate/findings.js';
|
|
43
|
+
import { computeTreeToken, EMPTY_TREE_SHA } from '../audit/content-token.js';
|
|
44
|
+
import { err, log } from './utils.js';
|
|
45
|
+
const PROVIDER_CODEX = 'codex';
|
|
46
|
+
/**
|
|
47
|
+
* Probe `codex --version` synchronously. Same shape as the push-gate's
|
|
48
|
+
* pre-flight probe — ENOENT/EACCES means "not installed".
|
|
49
|
+
*/
|
|
50
|
+
function probeCodexAvailable(cwd) {
|
|
51
|
+
const probe = spawnSync('codex', ['--version'], {
|
|
52
|
+
cwd,
|
|
53
|
+
timeout: 2000,
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
});
|
|
56
|
+
if (probe.error !== undefined) {
|
|
57
|
+
return { available: false };
|
|
58
|
+
}
|
|
59
|
+
if (probe.status !== 0) {
|
|
60
|
+
return { available: false };
|
|
61
|
+
}
|
|
62
|
+
const version = (probe.stdout ?? '').toString().trim();
|
|
63
|
+
return version.length > 0 ? { available: true, version } : { available: true };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the effective `local_review.mode`. A missing policy is treated
|
|
67
|
+
* as `enforced` — the protective default. A missing `local_review` block
|
|
68
|
+
* also enforces. Only an explicit `mode: off` opts out.
|
|
69
|
+
*/
|
|
70
|
+
async function resolveLocalReviewMode(baseDir) {
|
|
71
|
+
let policy;
|
|
72
|
+
try {
|
|
73
|
+
policy = await loadPolicyAsync(baseDir);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Missing/invalid policy — protective default.
|
|
77
|
+
return { mode: 'enforced', policy: undefined };
|
|
78
|
+
}
|
|
79
|
+
const mode = policy.review?.local_review?.mode ?? 'enforced';
|
|
80
|
+
return { mode, policy };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Public runner — exposed so tests can drive the function in-process and
|
|
84
|
+
* the commander binding can stay thin. Throws via `process.exit` (CLI
|
|
85
|
+
* convention across `src/cli/`).
|
|
86
|
+
*/
|
|
87
|
+
export async function runReview(options) {
|
|
88
|
+
const baseDir = process.cwd();
|
|
89
|
+
const strictFailOn = options.strictFailOn ?? 'blocking';
|
|
90
|
+
const { mode, policy } = await resolveLocalReviewMode(baseDir);
|
|
91
|
+
// Probe codex before any heavy lifting so we can branch on availability.
|
|
92
|
+
const probe = probeCodexAvailable(baseDir);
|
|
93
|
+
// Codex unavailable — branch on policy mode.
|
|
94
|
+
if (!probe.available) {
|
|
95
|
+
if (mode === 'off') {
|
|
96
|
+
// Off mode: skip silently and audit so the absence is forensically
|
|
97
|
+
// visible. Exit 0 — the team has explicitly opted out.
|
|
98
|
+
const skipped = {
|
|
99
|
+
reason: 'codex-not-installed',
|
|
100
|
+
provider: PROVIDER_CODEX,
|
|
101
|
+
};
|
|
102
|
+
// Best-effort HEAD probe for the audit record.
|
|
103
|
+
try {
|
|
104
|
+
const git = createRealGitExecutor(baseDir);
|
|
105
|
+
const head = git.headSha();
|
|
106
|
+
if (head.length > 0)
|
|
107
|
+
skipped.head_sha = head;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* no head — leave undefined */
|
|
111
|
+
}
|
|
112
|
+
await safeAudit(baseDir, LOCAL_REVIEW_SKIPPED_UNAVAILABLE_TOOL_NAME, InvocationStatus.Allowed, skipped, policy);
|
|
113
|
+
if (options.json === true) {
|
|
114
|
+
process.stdout.write(JSON.stringify({ status: 'skipped', reason: 'codex-not-installed' }) + '\n');
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
log('codex not found on PATH — review skipped (policy.review.local_review.mode: off).');
|
|
118
|
+
}
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
// Enforced mode: hard-refuse with a helpful message.
|
|
122
|
+
err('codex CLI not found on PATH.');
|
|
123
|
+
console.error('');
|
|
124
|
+
console.error(' Install: npm i -g @openai/codex');
|
|
125
|
+
console.error(' Or set: policy.review.local_review.mode: off');
|
|
126
|
+
console.error(' (in .rea/policy.yaml — disables local-review enforcement');
|
|
127
|
+
console.error(' for teams without codex/claude installed)');
|
|
128
|
+
console.error('');
|
|
129
|
+
process.exit(2);
|
|
130
|
+
}
|
|
131
|
+
// Codex available — run the review.
|
|
132
|
+
let outcome;
|
|
133
|
+
try {
|
|
134
|
+
outcome = await executeCodexReview(baseDir, options);
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
138
|
+
err(`codex review failed: ${msg}`);
|
|
139
|
+
// Audit the error so operators can correlate failures.
|
|
140
|
+
await safeAudit(baseDir, LOCAL_REVIEW_TOOL_NAME, InvocationStatus.Error, {
|
|
141
|
+
provider: PROVIDER_CODEX,
|
|
142
|
+
error: msg,
|
|
143
|
+
kind: classifyCodexError(e),
|
|
144
|
+
}, policy);
|
|
145
|
+
process.exit(2);
|
|
146
|
+
}
|
|
147
|
+
// Write the canonical audit record. THIS is the entry `rea preflight`
|
|
148
|
+
// looks for. Use server_name='rea' (the pre-existing convention) and
|
|
149
|
+
// tool_name='rea.local_review'.
|
|
150
|
+
//
|
|
151
|
+
// 0.26.0 helix-026 finding-1: `content_token` is the field preflight
|
|
152
|
+
// matches coverage on. `head_sha` is recorded for forensics. The token
|
|
153
|
+
// stays optional so legacy `codex.review` entries and future providers
|
|
154
|
+
// that can't compute a tree fingerprint still flow through preflight's
|
|
155
|
+
// back-compat head-sha fallback.
|
|
156
|
+
const metadata = {
|
|
157
|
+
head_sha: outcome.headSha,
|
|
158
|
+
base_ref: outcome.baseRef,
|
|
159
|
+
verdict: outcome.verdict,
|
|
160
|
+
finding_count: outcome.findingCount,
|
|
161
|
+
provider: PROVIDER_CODEX,
|
|
162
|
+
model: outcome.model,
|
|
163
|
+
reasoning_effort: outcome.reasoningEffort,
|
|
164
|
+
duration_seconds: outcome.durationSeconds,
|
|
165
|
+
};
|
|
166
|
+
if (outcome.contentToken.length > 0)
|
|
167
|
+
metadata.content_token = outcome.contentToken;
|
|
168
|
+
if (probe.version !== undefined)
|
|
169
|
+
metadata.provider_version = probe.version;
|
|
170
|
+
await safeAudit(baseDir, LOCAL_REVIEW_TOOL_NAME, outcome.verdict === 'blocking' ? InvocationStatus.Denied : InvocationStatus.Allowed, metadata, policy);
|
|
171
|
+
// Decide exit code based on strictFailOn.
|
|
172
|
+
let exitCode;
|
|
173
|
+
if (outcome.verdict === 'blocking') {
|
|
174
|
+
exitCode = 2;
|
|
175
|
+
}
|
|
176
|
+
else if (outcome.verdict === 'concerns') {
|
|
177
|
+
exitCode = strictFailOn === 'concerns' ? 1 : 0;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
exitCode = 0;
|
|
181
|
+
}
|
|
182
|
+
if (options.json === true) {
|
|
183
|
+
process.stdout.write(JSON.stringify({
|
|
184
|
+
status: outcome.verdict,
|
|
185
|
+
finding_count: outcome.findingCount,
|
|
186
|
+
head_sha: outcome.headSha,
|
|
187
|
+
base_ref: outcome.baseRef,
|
|
188
|
+
provider: PROVIDER_CODEX,
|
|
189
|
+
model: outcome.model,
|
|
190
|
+
reasoning_effort: outcome.reasoningEffort,
|
|
191
|
+
duration_seconds: outcome.durationSeconds,
|
|
192
|
+
exit_code: exitCode,
|
|
193
|
+
}) + '\n');
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
log(`local review: ${outcome.verdict} (${outcome.findingCount} finding(s)) — head=${outcome.headSha.slice(0, 12)} base=${outcome.baseRef}`);
|
|
197
|
+
log(`audit entry written: tool_name=${LOCAL_REVIEW_TOOL_NAME}`);
|
|
198
|
+
}
|
|
199
|
+
process.exit(exitCode);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Execute the codex review subprocess and translate the output to a
|
|
203
|
+
* verdict. Reuses the push-gate's resolved policy so `codex_model` /
|
|
204
|
+
* `codex_reasoning_effort` / `timeout_ms` flow through identically.
|
|
205
|
+
*/
|
|
206
|
+
async function executeCodexReview(baseDir, options) {
|
|
207
|
+
const resolved = await resolvePushGatePolicy(baseDir);
|
|
208
|
+
const git = createRealGitExecutor(baseDir);
|
|
209
|
+
const explicit = options.base !== undefined && options.base.length > 0 ? options.base : undefined;
|
|
210
|
+
const base = explicit !== undefined
|
|
211
|
+
? resolveBaseRef(git, { explicit })
|
|
212
|
+
: resolveBaseRef(git);
|
|
213
|
+
// 0.26.0 round-25 P2-B fix: do NOT throw on empty HEAD. An unborn-HEAD
|
|
214
|
+
// repo (`git init` + immediately `rea review`, before any commit) is a
|
|
215
|
+
// legitimate scaffolding state — `create-helix-app` and similar tools
|
|
216
|
+
// bootstrap consumer repos this way. Pre-fix, `runReview()` threw
|
|
217
|
+
// "could not resolve HEAD sha — is this a valid git repo?" which under
|
|
218
|
+
// `refuse_at: commit/both` caused a deadlock: the commit-tier hook
|
|
219
|
+
// refused commits until rea review wrote an audit entry, but rea
|
|
220
|
+
// review refused without HEAD.
|
|
221
|
+
//
|
|
222
|
+
// Resolution: when HEAD is unborn, use git's well-known empty-tree
|
|
223
|
+
// SHA as the synthetic head_sha for the audit record. `computeTreeToken`
|
|
224
|
+
// already returns empty cleanly in this state; the working-tree tokens
|
|
225
|
+
// takes over via `git stash create` once the tree is dirty (round-25
|
|
226
|
+
// P1-A path), or remains empty for a truly empty repo. Preflight's
|
|
227
|
+
// content-token match (round-25 P1-A) handles either case.
|
|
228
|
+
//
|
|
229
|
+
// Round-27 F2 fix: EMPTY_TREE_SHA promoted to a shared constant in
|
|
230
|
+
// `src/audit/content-token.ts` so `rea preflight` (reader) uses the
|
|
231
|
+
// SAME value when its `git rev-parse HEAD` probe fails. Without that
|
|
232
|
+
// symmetry the reader returned `''` and short-circuited the lookup,
|
|
233
|
+
// deadlocking the documented `git init → rea review → git commit`
|
|
234
|
+
// bootstrap flow under `refuse_at: both`.
|
|
235
|
+
const resolvedHeadSha = git.headSha();
|
|
236
|
+
const headSha = resolvedHeadSha.length > 0 ? resolvedHeadSha : EMPTY_TREE_SHA;
|
|
237
|
+
// 0.26.0 helix-026 finding-1: capture working-tree token as the content
|
|
238
|
+
// token for preflight coverage matching. Computed BEFORE codex runs so
|
|
239
|
+
// we never race a concurrent commit (the token reflects the state codex
|
|
240
|
+
// is about to review). An empty token is allowed — preflight falls back
|
|
241
|
+
// to head_sha matching when the field is absent.
|
|
242
|
+
const contentToken = computeTreeToken(baseDir);
|
|
243
|
+
const codexResult = await runCodexReview({
|
|
244
|
+
baseRef: base.ref,
|
|
245
|
+
cwd: baseDir,
|
|
246
|
+
timeoutMs: resolved.timeout_ms,
|
|
247
|
+
env: process.env,
|
|
248
|
+
...(resolved.codex_model !== undefined ? { model: resolved.codex_model } : {}),
|
|
249
|
+
...(resolved.codex_reasoning_effort !== undefined
|
|
250
|
+
? { reasoningEffort: resolved.codex_reasoning_effort }
|
|
251
|
+
: {}),
|
|
252
|
+
});
|
|
253
|
+
const summary = summarizeReview(codexResult.reviewText);
|
|
254
|
+
return {
|
|
255
|
+
verdict: summary.verdict,
|
|
256
|
+
findingCount: summary.findings.length,
|
|
257
|
+
baseRef: base.ref,
|
|
258
|
+
headSha,
|
|
259
|
+
contentToken,
|
|
260
|
+
durationSeconds: codexResult.durationSeconds,
|
|
261
|
+
model: resolved.codex_model ?? IRON_GATE_DEFAULT_MODEL,
|
|
262
|
+
reasoningEffort: resolved.codex_reasoning_effort ?? IRON_GATE_DEFAULT_REASONING,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function classifyCodexError(e) {
|
|
266
|
+
if (e instanceof CodexNotInstalledError)
|
|
267
|
+
return 'not-installed';
|
|
268
|
+
if (e instanceof CodexTimeoutError)
|
|
269
|
+
return 'timeout';
|
|
270
|
+
if (e instanceof CodexProtocolError)
|
|
271
|
+
return 'protocol';
|
|
272
|
+
if (e instanceof CodexSubprocessError)
|
|
273
|
+
return 'subprocess';
|
|
274
|
+
return 'unknown';
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Best-effort audit append — never throws. An audit failure must not
|
|
278
|
+
* change the CLI exit code.
|
|
279
|
+
*/
|
|
280
|
+
async function safeAudit(baseDir, toolName, status, metadata, policy) {
|
|
281
|
+
try {
|
|
282
|
+
const cleanMeta = {};
|
|
283
|
+
for (const [k, v] of Object.entries(metadata)) {
|
|
284
|
+
if (v !== undefined)
|
|
285
|
+
cleanMeta[k] = v;
|
|
286
|
+
}
|
|
287
|
+
await appendAuditRecord(baseDir, {
|
|
288
|
+
tool_name: toolName,
|
|
289
|
+
server_name: LOCAL_REVIEW_SERVER_NAME,
|
|
290
|
+
tier: Tier.Read,
|
|
291
|
+
status,
|
|
292
|
+
...(Object.keys(cleanMeta).length > 0 ? { metadata: cleanMeta } : {}),
|
|
293
|
+
...(policy !== undefined ? { policy } : {}),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
298
|
+
process.stderr.write(`rea: audit append failed (${toolName}): ${msg}\n`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Attach `rea review` to a commander Program.
|
|
303
|
+
*/
|
|
304
|
+
export function registerReviewCommand(program) {
|
|
305
|
+
program
|
|
306
|
+
.command('review')
|
|
307
|
+
.description('Run a local codex adversarial review of the working tree, write a `rea.local_review` audit entry, and exit 0 (pass), 1 (concerns), or 2 (blocking). The push-gate is the BACKUP layer — this is the primary review surface.')
|
|
308
|
+
.option('--base <ref>', 'explicit base ref to diff against (default: @{upstream} → origin/HEAD → main/master)')
|
|
309
|
+
.option('--strict-fail-on <level>', 'verdict floor that triggers non-zero exit: `concerns` or `blocking` (default `blocking`)', (raw) => {
|
|
310
|
+
if (raw !== 'concerns' && raw !== 'blocking') {
|
|
311
|
+
throw new Error(`--strict-fail-on must be "concerns" or "blocking", got ${JSON.stringify(raw)}`);
|
|
312
|
+
}
|
|
313
|
+
return raw;
|
|
314
|
+
})
|
|
315
|
+
.option('--json', 'emit a single-line JSON result instead of human-readable output')
|
|
316
|
+
.action(async (opts) => {
|
|
317
|
+
await runReview({
|
|
318
|
+
...(opts.base !== undefined ? { base: opts.base } : {}),
|
|
319
|
+
...(opts.strictFailOn !== undefined ? { strictFailOn: opts.strictFailOn } : {}),
|
|
320
|
+
...(opts.json === true ? { json: true } : {}),
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
// Path constant for tests — not consumed elsewhere.
|
|
325
|
+
export const REA_AUDIT_RELATIVE = path.join('.rea', 'audit.jsonl');
|
|
@@ -8968,9 +8968,37 @@ function wordToString(word) {
|
|
|
8968
8968
|
case 'Lit':
|
|
8969
8969
|
value += stringifyField(part['Value']);
|
|
8970
8970
|
break;
|
|
8971
|
-
case 'SglQuoted':
|
|
8972
|
-
|
|
8971
|
+
case 'SglQuoted': {
|
|
8972
|
+
// 0.26.1 helix-028 P1-1: ANSI-C `$'...'` quoting expands
|
|
8973
|
+
// `\n`/`\t`/`\xHH`/`\NNN`/`\u…`/`\cX` etc. at parse time. mvdan-sh
|
|
8974
|
+
// emits `$'...'` as `SglQuoted` with `Dollar: true` and the RAW
|
|
8975
|
+
// escape source in `Value` (e.g. `\n` arrives as backslash-n, not
|
|
8976
|
+
// LF). Pre-fix the walker concatenated the raw value verbatim,
|
|
8977
|
+
// and the downstream `stripBashBackslashEscapes` mangled `\n` →
|
|
8978
|
+
// `n` (regex `\\([A-Za-z0-9./_~-])` strips backslash from any
|
|
8979
|
+
// letter), turning `.rea/HALT\ntrue` into `.rea/HALTntrue` — which
|
|
8980
|
+
// never matched the protected pattern. Real bash, of course,
|
|
8981
|
+
// expanded `\n` to LF, so the redirect target was actually
|
|
8982
|
+
// `.rea/HALT` and the kill-switch got overwritten. Decode
|
|
8983
|
+
// explicitly here so downstream consumers see real bytes.
|
|
8984
|
+
const raw = stringifyField(part['Value']);
|
|
8985
|
+
const isAnsiC = part['Dollar'] === true;
|
|
8986
|
+
if (isAnsiC) {
|
|
8987
|
+
const decoded = decodeAnsiC(raw);
|
|
8988
|
+
if (decoded === null) {
|
|
8989
|
+
// Unsupported escape — fail closed. Mark word as dynamic so
|
|
8990
|
+
// the protected/blocked path matchers refuse on uncertainty.
|
|
8991
|
+
dynamic = true;
|
|
8992
|
+
}
|
|
8993
|
+
else {
|
|
8994
|
+
value += decoded;
|
|
8995
|
+
}
|
|
8996
|
+
}
|
|
8997
|
+
else {
|
|
8998
|
+
value += raw;
|
|
8999
|
+
}
|
|
8973
9000
|
break;
|
|
9001
|
+
}
|
|
8974
9002
|
case 'DblQuoted': {
|
|
8975
9003
|
const innerParts = asArray(part['Parts']);
|
|
8976
9004
|
for (const ip of innerParts) {
|
|
@@ -9010,6 +9038,208 @@ function stringifyField(v) {
|
|
|
9010
9038
|
return v;
|
|
9011
9039
|
return '';
|
|
9012
9040
|
}
|
|
9041
|
+
/**
|
|
9042
|
+
* Decode bash ANSI-C `$'...'` escape sequences. Returns the decoded
|
|
9043
|
+
* string, or `null` if the input contains an escape we don't support
|
|
9044
|
+
* (caller must fail closed on null — treat the word as dynamic so the
|
|
9045
|
+
* protected/blocked path matcher refuses on uncertainty).
|
|
9046
|
+
*
|
|
9047
|
+
* Bash spec covers the following escapes inside `$'...'`:
|
|
9048
|
+
* - `\\` literal backslash
|
|
9049
|
+
* - `\'` `\"` literal quote
|
|
9050
|
+
* - `\?` literal question mark
|
|
9051
|
+
* - `\a` `\b` BEL / BS
|
|
9052
|
+
* - `\e` `\E` ESC
|
|
9053
|
+
* - `\f` `\n` FF / LF
|
|
9054
|
+
* - `\r` `\t` CR / TAB
|
|
9055
|
+
* - `\v` VT
|
|
9056
|
+
* - `\NNN` octal (1–3 digits)
|
|
9057
|
+
* - `\xHH` hex (1–2 digits)
|
|
9058
|
+
* - `\uHHHH` unicode codepoint (1–4 hex digits)
|
|
9059
|
+
* - `\UHHHHHHHH` unicode codepoint (1–8 hex digits)
|
|
9060
|
+
* - `\cX` control char (X xor 0x40)
|
|
9061
|
+
*
|
|
9062
|
+
* 0.26.1 helix-028 P1-1.
|
|
9063
|
+
*/
|
|
9064
|
+
function decodeAnsiC(raw) {
|
|
9065
|
+
let out = '';
|
|
9066
|
+
let i = 0;
|
|
9067
|
+
const n = raw.length;
|
|
9068
|
+
while (i < n) {
|
|
9069
|
+
const ch = raw.charCodeAt(i);
|
|
9070
|
+
if (ch !== 0x5c /* '\\' */) {
|
|
9071
|
+
out += raw[i];
|
|
9072
|
+
i += 1;
|
|
9073
|
+
continue;
|
|
9074
|
+
}
|
|
9075
|
+
// Lone trailing backslash — bash keeps it literal.
|
|
9076
|
+
if (i + 1 >= n) {
|
|
9077
|
+
out += '\\';
|
|
9078
|
+
i += 1;
|
|
9079
|
+
continue;
|
|
9080
|
+
}
|
|
9081
|
+
const next = raw[i + 1];
|
|
9082
|
+
// Single-char escapes.
|
|
9083
|
+
switch (next) {
|
|
9084
|
+
case '\\':
|
|
9085
|
+
out += '\\';
|
|
9086
|
+
i += 2;
|
|
9087
|
+
continue;
|
|
9088
|
+
case "'":
|
|
9089
|
+
out += "'";
|
|
9090
|
+
i += 2;
|
|
9091
|
+
continue;
|
|
9092
|
+
case '"':
|
|
9093
|
+
out += '"';
|
|
9094
|
+
i += 2;
|
|
9095
|
+
continue;
|
|
9096
|
+
case '?':
|
|
9097
|
+
out += '?';
|
|
9098
|
+
i += 2;
|
|
9099
|
+
continue;
|
|
9100
|
+
case 'a':
|
|
9101
|
+
out += '\x07';
|
|
9102
|
+
i += 2;
|
|
9103
|
+
continue;
|
|
9104
|
+
case 'b':
|
|
9105
|
+
out += '\x08';
|
|
9106
|
+
i += 2;
|
|
9107
|
+
continue;
|
|
9108
|
+
case 'e':
|
|
9109
|
+
case 'E':
|
|
9110
|
+
out += '\x1b';
|
|
9111
|
+
i += 2;
|
|
9112
|
+
continue;
|
|
9113
|
+
case 'f':
|
|
9114
|
+
out += '\x0c';
|
|
9115
|
+
i += 2;
|
|
9116
|
+
continue;
|
|
9117
|
+
case 'n':
|
|
9118
|
+
out += '\n';
|
|
9119
|
+
i += 2;
|
|
9120
|
+
continue;
|
|
9121
|
+
case 'r':
|
|
9122
|
+
out += '\r';
|
|
9123
|
+
i += 2;
|
|
9124
|
+
continue;
|
|
9125
|
+
case 't':
|
|
9126
|
+
out += '\t';
|
|
9127
|
+
i += 2;
|
|
9128
|
+
continue;
|
|
9129
|
+
case 'v':
|
|
9130
|
+
out += '\x0b';
|
|
9131
|
+
i += 2;
|
|
9132
|
+
continue;
|
|
9133
|
+
default:
|
|
9134
|
+
break;
|
|
9135
|
+
}
|
|
9136
|
+
// \xHH — 1 or 2 hex digits.
|
|
9137
|
+
if (next === 'x') {
|
|
9138
|
+
let j = i + 2;
|
|
9139
|
+
let hex = '';
|
|
9140
|
+
while (j < n && hex.length < 2 && /[0-9a-fA-F]/.test(raw[j])) {
|
|
9141
|
+
hex += raw[j];
|
|
9142
|
+
j += 1;
|
|
9143
|
+
}
|
|
9144
|
+
if (hex.length === 0) {
|
|
9145
|
+
// `\x` with no digits is unspecified; bash treats it literally as
|
|
9146
|
+
// backslash-x. Mirror that — preserve and continue.
|
|
9147
|
+
out += '\\x';
|
|
9148
|
+
i += 2;
|
|
9149
|
+
continue;
|
|
9150
|
+
}
|
|
9151
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
9152
|
+
i = j;
|
|
9153
|
+
continue;
|
|
9154
|
+
}
|
|
9155
|
+
// \NNN — 1, 2, or 3 octal digits. `next` itself is the first digit.
|
|
9156
|
+
if (next >= '0' && next <= '7') {
|
|
9157
|
+
let j = i + 1;
|
|
9158
|
+
let oct = '';
|
|
9159
|
+
while (j < n && oct.length < 3 && raw[j] >= '0' && raw[j] <= '7') {
|
|
9160
|
+
oct += raw[j];
|
|
9161
|
+
j += 1;
|
|
9162
|
+
}
|
|
9163
|
+
out += String.fromCharCode(parseInt(oct, 8) & 0xff);
|
|
9164
|
+
i = j;
|
|
9165
|
+
continue;
|
|
9166
|
+
}
|
|
9167
|
+
// \uHHHH — 1 to 4 hex digits.
|
|
9168
|
+
if (next === 'u') {
|
|
9169
|
+
let j = i + 2;
|
|
9170
|
+
let hex = '';
|
|
9171
|
+
while (j < n && hex.length < 4 && /[0-9a-fA-F]/.test(raw[j])) {
|
|
9172
|
+
hex += raw[j];
|
|
9173
|
+
j += 1;
|
|
9174
|
+
}
|
|
9175
|
+
if (hex.length === 0) {
|
|
9176
|
+
out += '\\u';
|
|
9177
|
+
i += 2;
|
|
9178
|
+
continue;
|
|
9179
|
+
}
|
|
9180
|
+
const cp = parseInt(hex, 16);
|
|
9181
|
+
try {
|
|
9182
|
+
out += String.fromCodePoint(cp);
|
|
9183
|
+
}
|
|
9184
|
+
catch {
|
|
9185
|
+
return null;
|
|
9186
|
+
}
|
|
9187
|
+
i = j;
|
|
9188
|
+
continue;
|
|
9189
|
+
}
|
|
9190
|
+
// \UHHHHHHHH — 1 to 8 hex digits.
|
|
9191
|
+
if (next === 'U') {
|
|
9192
|
+
let j = i + 2;
|
|
9193
|
+
let hex = '';
|
|
9194
|
+
while (j < n && hex.length < 8 && /[0-9a-fA-F]/.test(raw[j])) {
|
|
9195
|
+
hex += raw[j];
|
|
9196
|
+
j += 1;
|
|
9197
|
+
}
|
|
9198
|
+
if (hex.length === 0) {
|
|
9199
|
+
out += '\\U';
|
|
9200
|
+
i += 2;
|
|
9201
|
+
continue;
|
|
9202
|
+
}
|
|
9203
|
+
const cp = parseInt(hex, 16);
|
|
9204
|
+
// String.fromCodePoint throws RangeError on out-of-range values
|
|
9205
|
+
// (>0x10FFFF). Bash silently truncates; we fail closed via null.
|
|
9206
|
+
try {
|
|
9207
|
+
out += String.fromCodePoint(cp);
|
|
9208
|
+
}
|
|
9209
|
+
catch {
|
|
9210
|
+
return null;
|
|
9211
|
+
}
|
|
9212
|
+
i = j;
|
|
9213
|
+
continue;
|
|
9214
|
+
}
|
|
9215
|
+
// \cX — control char (X xor 0x40). X may be any printable ASCII.
|
|
9216
|
+
if (next === 'c') {
|
|
9217
|
+
if (i + 2 >= n) {
|
|
9218
|
+
// Lone `\c` at end — treat as literal.
|
|
9219
|
+
out += '\\c';
|
|
9220
|
+
i += 2;
|
|
9221
|
+
continue;
|
|
9222
|
+
}
|
|
9223
|
+
const xCh = raw[i + 2].charCodeAt(0);
|
|
9224
|
+
// Standard form: bash xors with 0x40 then masks to 7 bits. So
|
|
9225
|
+
// \cJ → 'J' (0x4a) ^ 0x40 = 0x0a (LF). \c? is special-cased to DEL.
|
|
9226
|
+
if (raw[i + 2] === '?') {
|
|
9227
|
+
out += '\x7f';
|
|
9228
|
+
}
|
|
9229
|
+
else {
|
|
9230
|
+
out += String.fromCharCode((xCh ^ 0x40) & 0x7f);
|
|
9231
|
+
}
|
|
9232
|
+
i += 3;
|
|
9233
|
+
continue;
|
|
9234
|
+
}
|
|
9235
|
+
// Unknown escape: bash preserves `\X` literally for unknown X. We
|
|
9236
|
+
// could mirror that, but the safer posture for a security scanner is
|
|
9237
|
+
// to fail closed — refuse on uncertainty so an attacker can't hide
|
|
9238
|
+
// payload bytes behind an escape we forgot to model.
|
|
9239
|
+
return null;
|
|
9240
|
+
}
|
|
9241
|
+
return out;
|
|
9242
|
+
}
|
|
9013
9243
|
function asArray(v) {
|
|
9014
9244
|
if (Array.isArray(v)) {
|
|
9015
9245
|
return v;
|
package/dist/policy/loader.d.ts
CHANGED
|
@@ -90,6 +90,29 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
90
90
|
* verdict. Set to 0 to disable caching (every push re-invokes codex).
|
|
91
91
|
*/
|
|
92
92
|
cache_ttl_ms: z.ZodOptional<z.ZodNumber>;
|
|
93
|
+
/**
|
|
94
|
+
* 0.26.0 local-first enforcement. Strict so a typo in the off-switch
|
|
95
|
+
* surface (`mode: of`, `refuse_at: pushh`) fails policy load instead
|
|
96
|
+
* of silently disabling. `bypass_env_var` is constrained to the
|
|
97
|
+
* shell-safe identifier alphabet so a nonsense value can't smuggle
|
|
98
|
+
* shell metacharacters through the Bash-tier gate that reads it.
|
|
99
|
+
*/
|
|
100
|
+
local_review: z.ZodOptional<z.ZodObject<{
|
|
101
|
+
mode: z.ZodOptional<z.ZodEnum<["enforced", "off"]>>;
|
|
102
|
+
max_age_seconds: z.ZodOptional<z.ZodNumber>;
|
|
103
|
+
refuse_at: z.ZodOptional<z.ZodEnum<["push", "commit", "both"]>>;
|
|
104
|
+
bypass_env_var: z.ZodOptional<z.ZodString>;
|
|
105
|
+
}, "strict", z.ZodTypeAny, {
|
|
106
|
+
mode?: "enforced" | "off" | undefined;
|
|
107
|
+
max_age_seconds?: number | undefined;
|
|
108
|
+
refuse_at?: "push" | "commit" | "both" | undefined;
|
|
109
|
+
bypass_env_var?: string | undefined;
|
|
110
|
+
}, {
|
|
111
|
+
mode?: "enforced" | "off" | undefined;
|
|
112
|
+
max_age_seconds?: number | undefined;
|
|
113
|
+
refuse_at?: "push" | "commit" | "both" | undefined;
|
|
114
|
+
bypass_env_var?: string | undefined;
|
|
115
|
+
}>>;
|
|
93
116
|
}, "strict", z.ZodTypeAny, {
|
|
94
117
|
codex_required?: boolean | undefined;
|
|
95
118
|
concerns_blocks?: boolean | undefined;
|
|
@@ -99,6 +122,12 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
99
122
|
codex_model?: string | undefined;
|
|
100
123
|
codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
|
|
101
124
|
cache_ttl_ms?: number | undefined;
|
|
125
|
+
local_review?: {
|
|
126
|
+
mode?: "enforced" | "off" | undefined;
|
|
127
|
+
max_age_seconds?: number | undefined;
|
|
128
|
+
refuse_at?: "push" | "commit" | "both" | undefined;
|
|
129
|
+
bypass_env_var?: string | undefined;
|
|
130
|
+
} | undefined;
|
|
102
131
|
}, {
|
|
103
132
|
codex_required?: boolean | undefined;
|
|
104
133
|
concerns_blocks?: boolean | undefined;
|
|
@@ -108,6 +137,12 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
108
137
|
codex_model?: string | undefined;
|
|
109
138
|
codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
|
|
110
139
|
cache_ttl_ms?: number | undefined;
|
|
140
|
+
local_review?: {
|
|
141
|
+
mode?: "enforced" | "off" | undefined;
|
|
142
|
+
max_age_seconds?: number | undefined;
|
|
143
|
+
refuse_at?: "push" | "commit" | "both" | undefined;
|
|
144
|
+
bypass_env_var?: string | undefined;
|
|
145
|
+
} | undefined;
|
|
111
146
|
}>>;
|
|
112
147
|
redact: z.ZodOptional<z.ZodObject<{
|
|
113
148
|
match_timeout_ms: z.ZodOptional<z.ZodNumber>;
|
|
@@ -185,6 +220,16 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
185
220
|
}, {
|
|
186
221
|
patterns?: string[] | undefined;
|
|
187
222
|
}>>;
|
|
223
|
+
commit_hygiene: z.ZodOptional<z.ZodObject<{
|
|
224
|
+
warn_at_commits: z.ZodOptional<z.ZodNumber>;
|
|
225
|
+
refuse_at_commits: z.ZodOptional<z.ZodNumber>;
|
|
226
|
+
}, "strict", z.ZodTypeAny, {
|
|
227
|
+
warn_at_commits?: number | undefined;
|
|
228
|
+
refuse_at_commits?: number | undefined;
|
|
229
|
+
}, {
|
|
230
|
+
warn_at_commits?: number | undefined;
|
|
231
|
+
refuse_at_commits?: number | undefined;
|
|
232
|
+
}>>;
|
|
188
233
|
}, "strict", z.ZodTypeAny, {
|
|
189
234
|
version: string;
|
|
190
235
|
profile: string;
|
|
@@ -215,6 +260,12 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
215
260
|
codex_model?: string | undefined;
|
|
216
261
|
codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
|
|
217
262
|
cache_ttl_ms?: number | undefined;
|
|
263
|
+
local_review?: {
|
|
264
|
+
mode?: "enforced" | "off" | undefined;
|
|
265
|
+
max_age_seconds?: number | undefined;
|
|
266
|
+
refuse_at?: "push" | "commit" | "both" | undefined;
|
|
267
|
+
bypass_env_var?: string | undefined;
|
|
268
|
+
} | undefined;
|
|
218
269
|
} | undefined;
|
|
219
270
|
redact?: {
|
|
220
271
|
match_timeout_ms?: number | undefined;
|
|
@@ -238,6 +289,10 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
238
289
|
architecture_review?: {
|
|
239
290
|
patterns?: string[] | undefined;
|
|
240
291
|
} | undefined;
|
|
292
|
+
commit_hygiene?: {
|
|
293
|
+
warn_at_commits?: number | undefined;
|
|
294
|
+
refuse_at_commits?: number | undefined;
|
|
295
|
+
} | undefined;
|
|
241
296
|
}, {
|
|
242
297
|
version: string;
|
|
243
298
|
profile: string;
|
|
@@ -268,6 +323,12 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
268
323
|
codex_model?: string | undefined;
|
|
269
324
|
codex_reasoning_effort?: "low" | "medium" | "high" | undefined;
|
|
270
325
|
cache_ttl_ms?: number | undefined;
|
|
326
|
+
local_review?: {
|
|
327
|
+
mode?: "enforced" | "off" | undefined;
|
|
328
|
+
max_age_seconds?: number | undefined;
|
|
329
|
+
refuse_at?: "push" | "commit" | "both" | undefined;
|
|
330
|
+
bypass_env_var?: string | undefined;
|
|
331
|
+
} | undefined;
|
|
271
332
|
} | undefined;
|
|
272
333
|
redact?: {
|
|
273
334
|
match_timeout_ms?: number | undefined;
|
|
@@ -291,6 +352,10 @@ declare const PolicySchema: z.ZodObject<{
|
|
|
291
352
|
architecture_review?: {
|
|
292
353
|
patterns?: string[] | undefined;
|
|
293
354
|
} | undefined;
|
|
355
|
+
commit_hygiene?: {
|
|
356
|
+
warn_at_commits?: number | undefined;
|
|
357
|
+
refuse_at_commits?: number | undefined;
|
|
358
|
+
} | undefined;
|
|
294
359
|
}>;
|
|
295
360
|
/**
|
|
296
361
|
* Async policy loader with TTL cache and mtime-based invalidation.
|