@bookedsolid/rea 0.32.0 → 0.33.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/dist/cli/hook.js +28 -0
- package/dist/hooks/_lib/payload.d.ts +38 -0
- package/dist/hooks/_lib/payload.js +79 -0
- package/dist/hooks/_lib/segments.d.ts +25 -0
- package/dist/hooks/_lib/segments.js +338 -16
- package/dist/hooks/architecture-review-gate/index.d.ts +58 -0
- package/dist/hooks/architecture-review-gate/index.js +250 -0
- package/dist/hooks/changeset-security-gate/index.d.ts +71 -0
- package/dist/hooks/changeset-security-gate/index.js +330 -0
- package/dist/hooks/dependency-audit-gate/index.d.ts +91 -0
- package/dist/hooks/dependency-audit-gate/index.js +294 -0
- package/dist/hooks/env-file-protection/index.d.ts +55 -0
- package/dist/hooks/env-file-protection/index.js +159 -0
- package/hooks/architecture-review-gate.sh +92 -77
- package/hooks/changeset-security-gate.sh +114 -149
- package/hooks/dependency-audit-gate.sh +115 -156
- package/hooks/env-file-protection.sh +130 -97
- package/package.json +1 -1
- package/templates/architecture-review-gate.dogfood-staged.sh +116 -0
- package/templates/changeset-security-gate.dogfood-staged.sh +137 -0
- package/templates/dependency-audit-gate.dogfood-staged.sh +138 -0
- package/templates/env-file-protection.dogfood-staged.sh +157 -0
package/dist/cli/hook.js
CHANGED
|
@@ -39,6 +39,10 @@ import { checkHalt, formatHaltBanner } from '../hooks/_lib/halt-check.js';
|
|
|
39
39
|
import { runHookPrIssueLinkGate } from '../hooks/pr-issue-link-gate/index.js';
|
|
40
40
|
import { runHookSecurityDisclosureGate } from '../hooks/security-disclosure-gate/index.js';
|
|
41
41
|
import { runHookAttributionAdvisory } from '../hooks/attribution-advisory/index.js';
|
|
42
|
+
import { runHookEnvFileProtection } from '../hooks/env-file-protection/index.js';
|
|
43
|
+
import { runHookDependencyAuditGate } from '../hooks/dependency-audit-gate/index.js';
|
|
44
|
+
import { runHookChangesetSecurityGate } from '../hooks/changeset-security-gate/index.js';
|
|
45
|
+
import { runHookArchitectureReviewGate } from '../hooks/architecture-review-gate/index.js';
|
|
42
46
|
import { loadPolicy } from '../policy/loader.js';
|
|
43
47
|
import { appendAuditRecord, InvocationStatus, Tier } from '../audit/append.js';
|
|
44
48
|
import { CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, } from '../audit/codex-event.js';
|
|
@@ -973,6 +977,30 @@ export function registerHookCommand(program) {
|
|
|
973
977
|
.action(async () => {
|
|
974
978
|
await runHookAttributionAdvisory();
|
|
975
979
|
});
|
|
980
|
+
hook
|
|
981
|
+
.command('env-file-protection')
|
|
982
|
+
.description('Node-binary port of `hooks/env-file-protection.sh` (0.33.0). Reads a Claude Code PreToolUse Bash payload from stdin; when the command sources, cps, or reads a `.env*`/`.envrc` file via text-reading utilities (cat/head/tail/grep/sed/awk/etc.), exits 2 with banner. Same-segment co-occurrence required for the utility-vs-filename match so multi-segment commands do not false-positive.')
|
|
983
|
+
.action(async () => {
|
|
984
|
+
await runHookEnvFileProtection();
|
|
985
|
+
});
|
|
986
|
+
hook
|
|
987
|
+
.command('dependency-audit-gate')
|
|
988
|
+
.description('Node-binary port of `hooks/dependency-audit-gate.sh` (0.33.0). Reads a Claude Code PreToolUse Bash payload from stdin; when the command is `(npm|pnpm|yarn) (install|i|add) <pkg>`, verifies each named package exists on the npm registry via `npm view <pkg> name` (5s timeout, capped at 5 packages/command). Exit 2 with multi-line banner on any missing package, otherwise exit 0.')
|
|
989
|
+
.action(async () => {
|
|
990
|
+
await runHookDependencyAuditGate();
|
|
991
|
+
});
|
|
992
|
+
hook
|
|
993
|
+
.command('changeset-security-gate')
|
|
994
|
+
.description('Node-binary port of `hooks/changeset-security-gate.sh` (0.33.0). Reads a Claude Code PreToolUse Write/Edit/MultiEdit/NotebookEdit payload from stdin; for writes targeting `.changeset/*.md`, blocks GHSA/CVE pre-disclosure and validates frontmatter (`---`-delimited block with `<pkg>: (patch|minor|major)` + non-empty description). MultiEdit short-circuits frontmatter validation because fragments are not full files. Block emissions use the Claude Code JSON-on-stdout protocol.')
|
|
995
|
+
.action(async () => {
|
|
996
|
+
await runHookChangesetSecurityGate();
|
|
997
|
+
});
|
|
998
|
+
hook
|
|
999
|
+
.command('architecture-review-gate')
|
|
1000
|
+
.description('Node-binary port of `hooks/architecture-review-gate.sh` (0.33.0). PostToolUse Write/Edit advisory. Reads `policy.architecture_review.patterns` and prints an advisory banner to stderr when the just-written file matches a configured prefix. ALWAYS exits 0 unless HALT (exit 2). Path normalization handles Windows backslashes + URL-encoding; empty/unset patterns short-circuit silently.')
|
|
1001
|
+
.action(async () => {
|
|
1002
|
+
await runHookArchitectureReviewGate();
|
|
1003
|
+
});
|
|
976
1004
|
hook
|
|
977
1005
|
.command('policy-get')
|
|
978
1006
|
.description('Read a value from `.rea/policy.yaml` via the canonical YAML parser. Used by bash-tier hooks (`hooks/_lib/policy-read.sh::policy_nested_scalar`) so inline AND block YAML forms agree at a single source of truth. Default scalar mode: prints raw value or empty. With `--json`: emits JSON (scalar or object/array; missing path → `null`). Unparseable YAML → empty / null, exit 1.')
|
|
@@ -68,6 +68,44 @@ export declare class TypePayloadError extends Error {
|
|
|
68
68
|
* non-string type.
|
|
69
69
|
*/
|
|
70
70
|
export declare function parseHookPayload(raw: string | Buffer): HookPayload;
|
|
71
|
+
/**
|
|
72
|
+
* Result of a write-tier hook payload extraction. Covers all four
|
|
73
|
+
* write-class tools (Write, Edit, MultiEdit, NotebookEdit).
|
|
74
|
+
*/
|
|
75
|
+
export interface WriteHookPayload {
|
|
76
|
+
/** `tool_name` from the payload, or `''` when absent. */
|
|
77
|
+
toolName: string;
|
|
78
|
+
/**
|
|
79
|
+
* `tool_input.file_path` (Write/Edit/MultiEdit) OR
|
|
80
|
+
* `tool_input.notebook_path` (NotebookEdit), or `''` when absent.
|
|
81
|
+
*/
|
|
82
|
+
filePath: string;
|
|
83
|
+
/**
|
|
84
|
+
* Concatenated content payload. Resolution order matches
|
|
85
|
+
* `hooks/_lib/payload-read.sh::extract_write_content`:
|
|
86
|
+
*
|
|
87
|
+
* 1. `tool_input.content` (Write)
|
|
88
|
+
* 2. `tool_input.new_string` (Edit)
|
|
89
|
+
* 3. `tool_input.edits[].new_string` joined (MultiEdit, `\n`)
|
|
90
|
+
* 4. `tool_input.new_source` (NotebookEdit cell)
|
|
91
|
+
*
|
|
92
|
+
* Returns `''` when none of these are present. Defensive coercion:
|
|
93
|
+
* a non-string `new_string`, non-array `edits`, or non-string
|
|
94
|
+
* fragments fail closed (treated as missing) rather than throwing —
|
|
95
|
+
* mirrors the bash hook's `.tool_input.new_string // ""` + the
|
|
96
|
+
* type-guard branches added in 0.16.0.
|
|
97
|
+
*/
|
|
98
|
+
content: string;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Parse a Claude Code Write/Edit/MultiEdit/NotebookEdit stdin payload.
|
|
102
|
+
*
|
|
103
|
+
* Same fail-closed posture as `parseHookPayload`: malformed JSON →
|
|
104
|
+
* throws `MalformedPayloadError`; type-mismatched fields → throws
|
|
105
|
+
* `TypePayloadError`. Callers fail-closed on these for blocking-tier
|
|
106
|
+
* gates (changeset-security-gate refuses on uncertainty).
|
|
107
|
+
*/
|
|
108
|
+
export declare function parseWriteHookPayload(raw: string | Buffer): WriteHookPayload;
|
|
71
109
|
/**
|
|
72
110
|
* Read all of stdin into a string with a soft byte cap and a hard
|
|
73
111
|
* timeout. Mirrors the `readStdinWithTimeout` helper in
|
|
@@ -104,6 +104,85 @@ export function parseHookPayload(raw) {
|
|
|
104
104
|
}
|
|
105
105
|
return { toolName, command };
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Parse a Claude Code Write/Edit/MultiEdit/NotebookEdit stdin payload.
|
|
109
|
+
*
|
|
110
|
+
* Same fail-closed posture as `parseHookPayload`: malformed JSON →
|
|
111
|
+
* throws `MalformedPayloadError`; type-mismatched fields → throws
|
|
112
|
+
* `TypePayloadError`. Callers fail-closed on these for blocking-tier
|
|
113
|
+
* gates (changeset-security-gate refuses on uncertainty).
|
|
114
|
+
*/
|
|
115
|
+
export function parseWriteHookPayload(raw) {
|
|
116
|
+
const text = typeof raw === 'string' ? raw : raw.toString('utf8');
|
|
117
|
+
if (text.trim().length === 0) {
|
|
118
|
+
return { toolName: '', filePath: '', content: '' };
|
|
119
|
+
}
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(text);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
126
|
+
throw new MalformedPayloadError(`hook payload is not valid JSON: ${detail}`);
|
|
127
|
+
}
|
|
128
|
+
if (parsed === null) {
|
|
129
|
+
return { toolName: '', filePath: '', content: '' };
|
|
130
|
+
}
|
|
131
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
132
|
+
throw new MalformedPayloadError(`hook payload top-level is ${Array.isArray(parsed) ? 'array' : typeof parsed}, expected object`);
|
|
133
|
+
}
|
|
134
|
+
const toolName = typeof parsed.tool_name === 'string' ? parsed.tool_name : '';
|
|
135
|
+
const ti = parsed.tool_input;
|
|
136
|
+
let filePath = '';
|
|
137
|
+
let content = '';
|
|
138
|
+
if (ti !== undefined && ti !== null) {
|
|
139
|
+
if (typeof ti !== 'object') {
|
|
140
|
+
throw new TypePayloadError(`hook payload tool_input is ${typeof ti}, expected object`);
|
|
141
|
+
}
|
|
142
|
+
// file_path or notebook_path. Both are optional; either string or absent.
|
|
143
|
+
if (ti.file_path !== undefined) {
|
|
144
|
+
if (typeof ti.file_path !== 'string') {
|
|
145
|
+
throw new TypePayloadError(`hook payload tool_input.file_path is ${typeof ti.file_path}, expected string`);
|
|
146
|
+
}
|
|
147
|
+
filePath = ti.file_path;
|
|
148
|
+
}
|
|
149
|
+
else if (ti.notebook_path !== undefined) {
|
|
150
|
+
if (typeof ti.notebook_path !== 'string') {
|
|
151
|
+
throw new TypePayloadError(`hook payload tool_input.notebook_path is ${typeof ti.notebook_path}, expected string`);
|
|
152
|
+
}
|
|
153
|
+
filePath = ti.notebook_path;
|
|
154
|
+
}
|
|
155
|
+
// Content extraction — same priority order as the bash hook.
|
|
156
|
+
if (typeof ti.content === 'string' && ti.content.length > 0) {
|
|
157
|
+
content = ti.content;
|
|
158
|
+
}
|
|
159
|
+
else if (typeof ti.new_string === 'string' && ti.new_string.length > 0) {
|
|
160
|
+
content = ti.new_string;
|
|
161
|
+
}
|
|
162
|
+
else if (Array.isArray(ti.edits) && ti.edits.length > 0) {
|
|
163
|
+
// Defensive: non-string `new_string` fragments collapse to ''
|
|
164
|
+
// (matches the bash helper's `// ""` + `tostring`). The
|
|
165
|
+
// concatenation order is bash hook's `join("\n")`.
|
|
166
|
+
const parts = [];
|
|
167
|
+
for (const edit of ti.edits) {
|
|
168
|
+
if (edit === null || typeof edit !== 'object')
|
|
169
|
+
continue;
|
|
170
|
+
const e = edit;
|
|
171
|
+
if (typeof e.new_string === 'string') {
|
|
172
|
+
parts.push(e.new_string);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
parts.push('');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
content = parts.join('\n');
|
|
179
|
+
}
|
|
180
|
+
else if (typeof ti.new_source === 'string' && ti.new_source.length > 0) {
|
|
181
|
+
content = ti.new_source;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { toolName, filePath, content };
|
|
185
|
+
}
|
|
107
186
|
/**
|
|
108
187
|
* Read all of stdin into a string with a soft byte cap and a hard
|
|
109
188
|
* timeout. Mirrors the `readStdinWithTimeout` helper in
|
|
@@ -69,6 +69,14 @@ export interface CommandSegment {
|
|
|
69
69
|
* Split `cmd` into segments using the quote-aware masking → split →
|
|
70
70
|
* unmask pipeline. Returns an array of `{ raw, head }` tuples in the
|
|
71
71
|
* order they appeared in the original command.
|
|
72
|
+
*
|
|
73
|
+
* 0.33.0 — nested-shell unwrapping was added on top of the original
|
|
74
|
+
* 0.32.0 splitter. When a segment's head is `bash -c|-lc|--c PAYLOAD`
|
|
75
|
+
* or `sh -c|-lc|--c PAYLOAD` (any combination of `-l` and `-c` flags),
|
|
76
|
+
* the PAYLOAD inside the quoted arg becomes additional segments
|
|
77
|
+
* appended after the wrapper segment. Mirrors the bash counterpart's
|
|
78
|
+
* `_rea_unwrap_nested_shells` (helix-017 #3 fix). Recurses up to
|
|
79
|
+
* `MAX_NESTED_DEPTH` levels.
|
|
72
80
|
*/
|
|
73
81
|
export declare function splitSegments(cmd: string): CommandSegment[];
|
|
74
82
|
/**
|
|
@@ -98,3 +106,20 @@ export declare function anySegmentStartsWith(cmd: string, regexSource: string):
|
|
|
98
106
|
* Case-INSENSITIVE, extended regex. Same posture as the bash helper.
|
|
99
107
|
*/
|
|
100
108
|
export declare function anySegmentMatches(cmd: string, regexSource: string): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Returns true if any single segment's RAW text contains matches for
|
|
111
|
+
* BOTH `regexA` AND `regexB`. Mirrors `any_segment_matches_both` from
|
|
112
|
+
* the bash counterpart — used by `env-file-protection` to require that
|
|
113
|
+
* a text-reading utility AND an `.env*` filename co-occur within the
|
|
114
|
+
* same shell segment (a multi-segment construction like
|
|
115
|
+
* `echo "log: cat .env stuff" ; touch foo.env` must NOT fire because
|
|
116
|
+
* the utility and filename live in different segments).
|
|
117
|
+
*
|
|
118
|
+
* Case-INSENSITIVE, extended regex on both patterns. Same posture as
|
|
119
|
+
* the bash helper.
|
|
120
|
+
*
|
|
121
|
+
* 0.33.0 port. The bash helper was introduced in 0.16.2 to fix the
|
|
122
|
+
* helix-017 P2 false-positive class where two independent booleans
|
|
123
|
+
* (any-utility OR any-env) were AND'd across segments.
|
|
124
|
+
*/
|
|
125
|
+
export declare function anySegmentMatchesBoth(cmd: string, regexA: string, regexB: string): boolean;
|
|
@@ -229,22 +229,63 @@ function unmask(text) {
|
|
|
229
229
|
* (lookbehind).
|
|
230
230
|
*/
|
|
231
231
|
function splitOnUnquotedSeparators(masked) {
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
232
|
+
// 2026-05-15 codex round-3 P1 fix: walk char-by-char tracking
|
|
233
|
+
// backslash-escape state instead of using regex lookbehind. The
|
|
234
|
+
// pre-fix regex `(?<!\\)(...)` was a single-char negative lookbehind
|
|
235
|
+
// which treated `echo \\;` as "preceded by `\` → no split". But in
|
|
236
|
+
// bash semantics, `\\` is a literal `\` escape PAIR — the `;` that
|
|
237
|
+
// follows it is NOT escaped, so the command splits into two
|
|
238
|
+
// segments. The pre-fix splitter let `echo \\; npm install evil`
|
|
239
|
+
// pass as a single segment, defeating the dependency-audit-gate
|
|
240
|
+
// segment-anchor check and several other consumers.
|
|
241
|
+
//
|
|
242
|
+
// Strategy: walk left-to-right. When we encounter `\`, advance past
|
|
243
|
+
// the next character (the escape pair consumes 2 bytes). When we
|
|
244
|
+
// encounter a recognized separator at a non-pair position, emit a
|
|
245
|
+
// split. This matches bash's argv-tokenizer semantics for
|
|
246
|
+
// backslash-escape parity.
|
|
247
|
+
//
|
|
248
|
+
// The masker is byte-width-preserving so we can walk `masked`
|
|
249
|
+
// directly without re-syncing with the original.
|
|
239
250
|
const segments = [];
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
251
|
+
let segStart = 0;
|
|
252
|
+
let i = 0;
|
|
253
|
+
const n = masked.length;
|
|
254
|
+
while (i < n) {
|
|
255
|
+
const ch = masked[i];
|
|
256
|
+
if (ch === '\\' && i + 1 < n) {
|
|
257
|
+
// Escape pair — consume both, NEVER treat the next char as a
|
|
258
|
+
// separator. Bash `\\` is a literal `\`; the char following
|
|
259
|
+
// the pair is then evaluated for separator status.
|
|
260
|
+
i += 2;
|
|
243
261
|
continue;
|
|
244
|
-
|
|
245
|
-
|
|
262
|
+
}
|
|
263
|
+
// Separator detection. Order matters: `&&` and `||` are 2-byte
|
|
264
|
+
// separators; the 1-byte forms must not steal their first byte.
|
|
265
|
+
let sepLen = 0;
|
|
266
|
+
if (ch === '&' && masked[i + 1] === '&')
|
|
267
|
+
sepLen = 2;
|
|
268
|
+
else if (ch === '|' && masked[i + 1] === '|')
|
|
269
|
+
sepLen = 2;
|
|
270
|
+
else if (ch === ';' || ch === '|' || ch === '&' || ch === '\n')
|
|
271
|
+
sepLen = 1;
|
|
272
|
+
if (sepLen > 0) {
|
|
273
|
+
const piece = masked.slice(segStart, i);
|
|
274
|
+
const trimmed = piece.trim();
|
|
275
|
+
if (trimmed.length > 0)
|
|
276
|
+
segments.push(trimmed);
|
|
277
|
+
i += sepLen;
|
|
278
|
+
segStart = i;
|
|
246
279
|
continue;
|
|
247
|
-
|
|
280
|
+
}
|
|
281
|
+
i += 1;
|
|
282
|
+
}
|
|
283
|
+
// Tail.
|
|
284
|
+
if (segStart < n) {
|
|
285
|
+
const piece = masked.slice(segStart, n);
|
|
286
|
+
const trimmed = piece.trim();
|
|
287
|
+
if (trimmed.length > 0)
|
|
288
|
+
segments.push(trimmed);
|
|
248
289
|
}
|
|
249
290
|
return segments;
|
|
250
291
|
}
|
|
@@ -389,16 +430,272 @@ function stripSegmentPrefix(seg) {
|
|
|
389
430
|
* Split `cmd` into segments using the quote-aware masking → split →
|
|
390
431
|
* unmask pipeline. Returns an array of `{ raw, head }` tuples in the
|
|
391
432
|
* order they appeared in the original command.
|
|
433
|
+
*
|
|
434
|
+
* 0.33.0 — nested-shell unwrapping was added on top of the original
|
|
435
|
+
* 0.32.0 splitter. When a segment's head is `bash -c|-lc|--c PAYLOAD`
|
|
436
|
+
* or `sh -c|-lc|--c PAYLOAD` (any combination of `-l` and `-c` flags),
|
|
437
|
+
* the PAYLOAD inside the quoted arg becomes additional segments
|
|
438
|
+
* appended after the wrapper segment. Mirrors the bash counterpart's
|
|
439
|
+
* `_rea_unwrap_nested_shells` (helix-017 #3 fix). Recurses up to
|
|
440
|
+
* `MAX_NESTED_DEPTH` levels.
|
|
392
441
|
*/
|
|
393
442
|
export function splitSegments(cmd) {
|
|
394
443
|
if (cmd.length === 0)
|
|
395
444
|
return [];
|
|
445
|
+
return splitSegmentsRecursive(cmd, 0);
|
|
446
|
+
}
|
|
447
|
+
const MAX_NESTED_DEPTH = 8;
|
|
448
|
+
function splitSegmentsRecursive(cmd, depth) {
|
|
396
449
|
const masked = maskQuotedSeparators(cmd);
|
|
397
450
|
const rawSegs = splitOnUnquotedSeparators(masked);
|
|
398
|
-
|
|
451
|
+
const out = [];
|
|
452
|
+
for (const raw of rawSegs) {
|
|
399
453
|
const unmaskedRaw = unmask(raw);
|
|
400
|
-
|
|
401
|
-
|
|
454
|
+
const head = stripSegmentPrefix(unmaskedRaw);
|
|
455
|
+
out.push({ raw: unmaskedRaw, head });
|
|
456
|
+
// Try to unwrap a nested shell payload.
|
|
457
|
+
if (depth < MAX_NESTED_DEPTH) {
|
|
458
|
+
const inner = extractNestedShellPayload(head);
|
|
459
|
+
if (inner !== null) {
|
|
460
|
+
// Append the inner payload's segments AFTER the wrapper segment.
|
|
461
|
+
// This preserves the bash hook's emit-order: the wrapper IS a
|
|
462
|
+
// segment too (so a hook that anchors on `bash` for some other
|
|
463
|
+
// reason still sees it), and the inner segments follow.
|
|
464
|
+
out.push(...splitSegmentsRecursive(inner, depth + 1));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Recognize a nested-shell wrapper segment and return the unquoted
|
|
472
|
+
* payload string. Returns `null` when the segment is not a wrapper.
|
|
473
|
+
*
|
|
474
|
+
* 2026-05-15 codex round-1 P1 fix — extends parity with
|
|
475
|
+
* `_rea_unwrap_nested_shells` in `hooks/_lib/cmd-segments.sh`.
|
|
476
|
+
*
|
|
477
|
+
* Bash-parity matrix:
|
|
478
|
+
*
|
|
479
|
+
* 1. Shell names: bash | sh | zsh | dash
|
|
480
|
+
* (The bash counterpart also includes ksh / mksh / oksh / posh /
|
|
481
|
+
* yash / csh / tcsh / fish per the 0.19.0 M1 security review. We
|
|
482
|
+
* cover the common quartet here; the rare shells fall through to
|
|
483
|
+
* the bash-scanner tier which DOES have full coverage. Extending
|
|
484
|
+
* this list later is a one-line change.)
|
|
485
|
+
* 2. Split-flag forms ANY combination of pre-flags before `-c`:
|
|
486
|
+
* bash -l -c '…' bash -i -c '…' bash -e -c '…'
|
|
487
|
+
* bash -li -c '…' bash --noprofile -c '…'
|
|
488
|
+
* The pre-fix regex `(?:-[a-z]*c|--c)(?:\s+-[a-z]+)*` failed
|
|
489
|
+
* because it required `-c` to appear IN the FIRST flag token —
|
|
490
|
+
* `bash -l -c 'PAYLOAD'` did not match.
|
|
491
|
+
* 3. Combined-flag forms: -c, -lc, -lic, -ic, -cl, -cli, -li, -il
|
|
492
|
+
* (the bash WRAP pattern's `-(c|lc|lic|ic|cl|cli|li|il)` set).
|
|
493
|
+
* 4. ANSI-C-quoted payload: `bash -c $'…'`. Pre-fix the introducer
|
|
494
|
+
* regex `(['"])` could not match the `$` prefix, so the entire
|
|
495
|
+
* ANSI-C wrapper was a single un-unwrapped segment.
|
|
496
|
+
*
|
|
497
|
+
* The walker:
|
|
498
|
+
* - Tokenizes the head into whitespace-separated tokens.
|
|
499
|
+
* - First token must be a recognized shell name.
|
|
500
|
+
* - Walks subsequent flag tokens, each `-[A-Za-z]+` or `--[A-Za-z]+`.
|
|
501
|
+
* - A flag token containing a `c` letter terminates the flag walk
|
|
502
|
+
* (it's the `-c` introducer). The next non-flag token is the
|
|
503
|
+
* payload argument.
|
|
504
|
+
* - The payload argument's first character determines the quote
|
|
505
|
+
* style: `'`, `"`, or `$'` (ANSI-C). Any other character means
|
|
506
|
+
* the payload is unquoted and we return null (don't unwrap — the
|
|
507
|
+
* payload may already be a bare argv).
|
|
508
|
+
*/
|
|
509
|
+
function extractNestedShellPayload(head) {
|
|
510
|
+
// Tokenize on whitespace. The head has already passed through
|
|
511
|
+
// stripSegmentPrefix so leading `sudo`/env-prefixes are gone.
|
|
512
|
+
const trimmed = head.trimStart();
|
|
513
|
+
if (trimmed.length === 0)
|
|
514
|
+
return null;
|
|
515
|
+
// 1. Shell-name token. Full parity with cmd-segments.sh `WRAP`:
|
|
516
|
+
// bash | sh | zsh | dash | ksh | mksh | oksh | posh | yash |
|
|
517
|
+
// csh | tcsh | fish. Codex round-2 P1 (2026-05-15): the round-1
|
|
518
|
+
// quartet (bash|sh|zsh|dash) left ksh/mksh/oksh/posh/yash/csh/
|
|
519
|
+
// tcsh/fish unwrapped — on machines where any of those shells
|
|
520
|
+
// are installed, `mksh -c 'source .env'` and
|
|
521
|
+
// `ksh -c 'npm install missing-pkg'` would bypass
|
|
522
|
+
// env-file-protection / dependency-audit-gate entirely.
|
|
523
|
+
// The bash counterpart caught these via the 0.19.0 M1 security
|
|
524
|
+
// review (WRAP regex extension).
|
|
525
|
+
//
|
|
526
|
+
// NOTE: pwsh (PowerShell) is intentionally OUT — it accepts -c
|
|
527
|
+
// and -Command, and -EncodedCommand base64-decodes at runtime.
|
|
528
|
+
// Adding pwsh requires a separate code path with base64 decode
|
|
529
|
+
// (mirroring the bash counterpart's explicit pwsh exclusion).
|
|
530
|
+
const shellMatch = /^(bash|sh|zsh|dash|ksh|mksh|oksh|posh|yash|csh|tcsh|fish)\b/i.exec(trimmed);
|
|
531
|
+
if (shellMatch === null)
|
|
532
|
+
return null;
|
|
533
|
+
let cursor = shellMatch[0].length;
|
|
534
|
+
// 2. Walk flag tokens. Each token is whitespace-separated and starts
|
|
535
|
+
// with `-`. A flag token containing the letter `c` (case-insens.)
|
|
536
|
+
// is the `-c` introducer; the NEXT token is the payload.
|
|
537
|
+
let sawCFlag = false;
|
|
538
|
+
while (cursor < trimmed.length) {
|
|
539
|
+
// Skip whitespace.
|
|
540
|
+
while (cursor < trimmed.length && /\s/.test(trimmed[cursor])) {
|
|
541
|
+
cursor += 1;
|
|
542
|
+
}
|
|
543
|
+
if (cursor >= trimmed.length)
|
|
544
|
+
return null;
|
|
545
|
+
// Peek next token.
|
|
546
|
+
const rest = trimmed.slice(cursor);
|
|
547
|
+
if (rest[0] !== '-') {
|
|
548
|
+
// Not a flag — must be the payload argument.
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
// Extract the flag token (contiguous non-whitespace).
|
|
552
|
+
const flagMatch = /^(\S+)/.exec(rest);
|
|
553
|
+
if (flagMatch === null)
|
|
554
|
+
return null;
|
|
555
|
+
const flag = flagMatch[0] ?? '';
|
|
556
|
+
cursor += flag.length;
|
|
557
|
+
// Recognized flag-token shapes:
|
|
558
|
+
// `-c` `-l` `-i` `-e` `-lc` `-lic` `-ic` `-cl` `-cli` `-li` `-il`
|
|
559
|
+
// `--c` `--noprofile` (etc.) — we don't enforce the full list,
|
|
560
|
+
// just that it's `-<letters>` or `--<letters>`.
|
|
561
|
+
if (!/^--?[A-Za-z]+$/.test(flag))
|
|
562
|
+
return null;
|
|
563
|
+
// Does this flag contain `c` (the -c introducer letter)?
|
|
564
|
+
// `--c` also counts (rare but bash accepts).
|
|
565
|
+
if (/c/i.test(flag.replace(/^--?/, ''))) {
|
|
566
|
+
sawCFlag = true;
|
|
567
|
+
// Continue the loop — the payload is the NEXT non-flag token.
|
|
568
|
+
// (Bash's argv parser stops walking flags as soon as it sees -c,
|
|
569
|
+
// but we accept additional flags between -c and the payload for
|
|
570
|
+
// safety; the bash WRAP regex similarly tolerates trailing
|
|
571
|
+
// flag-like tokens before the quoted body.)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (!sawCFlag)
|
|
575
|
+
return null;
|
|
576
|
+
if (cursor >= trimmed.length)
|
|
577
|
+
return null;
|
|
578
|
+
// Skip whitespace before payload.
|
|
579
|
+
while (cursor < trimmed.length && /\s/.test(trimmed[cursor])) {
|
|
580
|
+
cursor += 1;
|
|
581
|
+
}
|
|
582
|
+
if (cursor >= trimmed.length)
|
|
583
|
+
return null;
|
|
584
|
+
// 3. Inspect the payload's introducer character.
|
|
585
|
+
const first = trimmed[cursor];
|
|
586
|
+
let quote;
|
|
587
|
+
let isAnsiC = false;
|
|
588
|
+
let payloadStart = cursor;
|
|
589
|
+
if (first === '$' && trimmed[cursor + 1] === "'") {
|
|
590
|
+
// ANSI-C: $'…' — single-quote-style but with C-string escapes.
|
|
591
|
+
quote = "'";
|
|
592
|
+
isAnsiC = true;
|
|
593
|
+
payloadStart = cursor + 2;
|
|
594
|
+
}
|
|
595
|
+
else if (first === "'" || first === '"') {
|
|
596
|
+
quote = first;
|
|
597
|
+
payloadStart = cursor + 1;
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
// Unquoted payload — refuse to unwrap. The bash counterpart's
|
|
601
|
+
// WRAP regex requires a quote introducer too.
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
// 4. Walk the payload, collecting bytes until the matching closing
|
|
605
|
+
// quote. Honor quote-specific escape rules.
|
|
606
|
+
let i = payloadStart;
|
|
607
|
+
let payload = '';
|
|
608
|
+
while (i < trimmed.length) {
|
|
609
|
+
const ch = trimmed[i];
|
|
610
|
+
if (ch === quote) {
|
|
611
|
+
// Closing quote found.
|
|
612
|
+
return payload;
|
|
613
|
+
}
|
|
614
|
+
if (isAnsiC && ch === '\\' && i + 1 < trimmed.length) {
|
|
615
|
+
// ANSI-C escape decoding. Mirror the bash counterpart's escape
|
|
616
|
+
// table (cmd-segments.sh, _rea_unwrap_at_depth). Only the
|
|
617
|
+
// common-enough subset is decoded; unknowns pass through as the
|
|
618
|
+
// literal pair (matches awk default behavior).
|
|
619
|
+
const nxt = trimmed[i + 1];
|
|
620
|
+
switch (nxt) {
|
|
621
|
+
case 'n':
|
|
622
|
+
payload += '\n';
|
|
623
|
+
break;
|
|
624
|
+
case 't':
|
|
625
|
+
payload += '\t';
|
|
626
|
+
break;
|
|
627
|
+
case 'r':
|
|
628
|
+
payload += '\r';
|
|
629
|
+
break;
|
|
630
|
+
case '\\':
|
|
631
|
+
payload += '\\';
|
|
632
|
+
break;
|
|
633
|
+
case "'":
|
|
634
|
+
payload += "'";
|
|
635
|
+
break;
|
|
636
|
+
case '"':
|
|
637
|
+
payload += '"';
|
|
638
|
+
break;
|
|
639
|
+
case 'a':
|
|
640
|
+
payload += '\x07';
|
|
641
|
+
break;
|
|
642
|
+
case 'b':
|
|
643
|
+
payload += '\x08';
|
|
644
|
+
break;
|
|
645
|
+
case 'e':
|
|
646
|
+
case 'E':
|
|
647
|
+
payload += '\x1b';
|
|
648
|
+
break;
|
|
649
|
+
case 'f':
|
|
650
|
+
payload += '\x0c';
|
|
651
|
+
break;
|
|
652
|
+
case 'v':
|
|
653
|
+
payload += '\x0b';
|
|
654
|
+
break;
|
|
655
|
+
case '0':
|
|
656
|
+
payload += '\x00';
|
|
657
|
+
break;
|
|
658
|
+
case 'x': {
|
|
659
|
+
// \xHH or \xH — up to 2 hex digits.
|
|
660
|
+
let hex = '';
|
|
661
|
+
let k = i + 2;
|
|
662
|
+
while (k < trimmed.length && hex.length < 2) {
|
|
663
|
+
const hc = trimmed[k];
|
|
664
|
+
if (!/[0-9a-fA-F]/.test(hc))
|
|
665
|
+
break;
|
|
666
|
+
hex += hc;
|
|
667
|
+
k += 1;
|
|
668
|
+
}
|
|
669
|
+
if (hex.length > 0) {
|
|
670
|
+
payload += String.fromCharCode(parseInt(hex, 16));
|
|
671
|
+
i = k;
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
// Fall through — `\x` with no hex digits is a literal pair.
|
|
675
|
+
payload += '\\x';
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
default:
|
|
679
|
+
// Unknown escape — preserve the literal pair (bash awk
|
|
680
|
+
// default). E.g. `\z` → `\z`.
|
|
681
|
+
payload += '\\' + nxt;
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
i += 2;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (!isAnsiC && quote === '"' && ch === '\\' && i + 1 < trimmed.length) {
|
|
688
|
+
// Double-quote: backslash escapes the next character.
|
|
689
|
+
payload += trimmed[i + 1] ?? '';
|
|
690
|
+
i += 2;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
payload += ch;
|
|
694
|
+
i += 1;
|
|
695
|
+
}
|
|
696
|
+
// Unterminated quote — return what we have. The bash counterpart
|
|
697
|
+
// similarly accepts unterminated quotes as "rest of line is payload".
|
|
698
|
+
return payload;
|
|
402
699
|
}
|
|
403
700
|
/**
|
|
404
701
|
* Returns true if any segment's prefix-stripped head matches the
|
|
@@ -442,3 +739,28 @@ export function anySegmentMatches(cmd, regexSource) {
|
|
|
442
739
|
}
|
|
443
740
|
return false;
|
|
444
741
|
}
|
|
742
|
+
/**
|
|
743
|
+
* Returns true if any single segment's RAW text contains matches for
|
|
744
|
+
* BOTH `regexA` AND `regexB`. Mirrors `any_segment_matches_both` from
|
|
745
|
+
* the bash counterpart — used by `env-file-protection` to require that
|
|
746
|
+
* a text-reading utility AND an `.env*` filename co-occur within the
|
|
747
|
+
* same shell segment (a multi-segment construction like
|
|
748
|
+
* `echo "log: cat .env stuff" ; touch foo.env` must NOT fire because
|
|
749
|
+
* the utility and filename live in different segments).
|
|
750
|
+
*
|
|
751
|
+
* Case-INSENSITIVE, extended regex on both patterns. Same posture as
|
|
752
|
+
* the bash helper.
|
|
753
|
+
*
|
|
754
|
+
* 0.33.0 port. The bash helper was introduced in 0.16.2 to fix the
|
|
755
|
+
* helix-017 P2 false-positive class where two independent booleans
|
|
756
|
+
* (any-utility OR any-env) were AND'd across segments.
|
|
757
|
+
*/
|
|
758
|
+
export function anySegmentMatchesBoth(cmd, regexA, regexB) {
|
|
759
|
+
const reA = new RegExp(regexA, 'i');
|
|
760
|
+
const reB = new RegExp(regexB, 'i');
|
|
761
|
+
for (const seg of splitSegments(cmd)) {
|
|
762
|
+
if (reA.test(seg.raw) && reB.test(seg.raw))
|
|
763
|
+
return true;
|
|
764
|
+
}
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary port of `hooks/architecture-review-gate.sh`.
|
|
3
|
+
*
|
|
4
|
+
* 0.33.0 Phase 1 port #4 — the SIMPLEST tier-1 port.
|
|
5
|
+
*
|
|
6
|
+
* PostToolUse Write/Edit advisory. Reads `policy.architecture_review.
|
|
7
|
+
* patterns` and prints an advisory banner to stderr when the just-
|
|
8
|
+
* written file path begins with one of the configured prefixes.
|
|
9
|
+
* ALWAYS exits 0 — this is a nudge, not a gate.
|
|
10
|
+
*
|
|
11
|
+
* Behavioral contract preserves the bash hook byte-for-byte:
|
|
12
|
+
*
|
|
13
|
+
* 1. HALT check → exit 2 with the shared banner. (Even though the
|
|
14
|
+
* gate is advisory, HALT short-circuits ALL hooks.)
|
|
15
|
+
* 2. `policy.architecture_advisory: false` short-circuit → exit 0
|
|
16
|
+
* silently. The bash hook reads the policy file with a grep
|
|
17
|
+
* `architecture_advisory: false`; we mirror via the canonical
|
|
18
|
+
* YAML loader.
|
|
19
|
+
* 3. Read stdin → `tool_input.file_path` (the bash hook uses
|
|
20
|
+
* `notebook_path` too via fall-through, but the original
|
|
21
|
+
* `jq -r '.tool_input.file_path // empty'` expression does NOT
|
|
22
|
+
* fall through to notebook_path. We preserve that exactly).
|
|
23
|
+
* 4. Empty file_path → exit 0.
|
|
24
|
+
* 5. Path normalization mirrors `_lib/path-normalize.sh::normalize_path`:
|
|
25
|
+
* - Convert backslashes to forward slashes (Windows / Git Bash).
|
|
26
|
+
* - URL-decode `%xx` sequences.
|
|
27
|
+
* - Strip a leading `<REA_ROOT>/` prefix if present so
|
|
28
|
+
* `policy.architecture_review.patterns` can use repo-relative
|
|
29
|
+
* patterns.
|
|
30
|
+
* 6. Read `policy.architecture_review.patterns`. Empty / unset →
|
|
31
|
+
* silent no-op (exit 0). The bst-internal profile pins rea-
|
|
32
|
+
* source patterns; consumer projects opt in by populating their
|
|
33
|
+
* own list.
|
|
34
|
+
* 7. First prefix match wins. Emit the advisory banner to stderr;
|
|
35
|
+
* exit 0.
|
|
36
|
+
*
|
|
37
|
+
* Distinct from the other 0.33.0 ports: this gate is POSTToolUse
|
|
38
|
+
* (fires AFTER the write, advisory only). The shim that invokes it
|
|
39
|
+
* should NOT fail-closed on missing CLI — the pre-0.33.0 bash hook
|
|
40
|
+
* was already a silent no-op when the policy was unset.
|
|
41
|
+
*/
|
|
42
|
+
import type { Buffer } from 'node:buffer';
|
|
43
|
+
export interface ArchitectureReviewGateOptions {
|
|
44
|
+
reaRoot?: string;
|
|
45
|
+
stdinOverride?: string | Buffer;
|
|
46
|
+
stderrWrite?: (s: string) => void;
|
|
47
|
+
}
|
|
48
|
+
export interface ArchitectureReviewGateResult {
|
|
49
|
+
exitCode: number;
|
|
50
|
+
stderr: string;
|
|
51
|
+
/** Test seam — the matched pattern (or `null`). */
|
|
52
|
+
matched: string | null;
|
|
53
|
+
}
|
|
54
|
+
export declare function runArchitectureReviewGate(options?: ArchitectureReviewGateOptions): Promise<ArchitectureReviewGateResult>;
|
|
55
|
+
/**
|
|
56
|
+
* CLI entry — `rea hook architecture-review-gate`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function runHookArchitectureReviewGate(options?: ArchitectureReviewGateOptions): Promise<void>;
|