@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.
@@ -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
- value += stringifyField(part['Value']);
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;
@@ -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.