@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
|
@@ -0,0 +1,250 @@
|
|
|
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 fs from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import { parse as parseYaml } from 'yaml';
|
|
45
|
+
import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
|
|
46
|
+
import { parseWriteHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
|
|
47
|
+
/**
|
|
48
|
+
* Normalize the incoming file path. Mirrors
|
|
49
|
+
* `hooks/_lib/path-normalize.sh::normalize_path`:
|
|
50
|
+
* - backslashes → forward slashes
|
|
51
|
+
* - URL-decoded
|
|
52
|
+
* - leading `<REA_ROOT>/` stripped (when applicable)
|
|
53
|
+
*
|
|
54
|
+
* Pre-0.16.0 the bash hook ONLY stripped the REA_ROOT prefix, which
|
|
55
|
+
* meant Windows / Git Bash backslash paths bypassed advisory.
|
|
56
|
+
*/
|
|
57
|
+
function normalizePath(rawPath, reaRoot) {
|
|
58
|
+
let p = rawPath.replace(/\\/g, '/');
|
|
59
|
+
try {
|
|
60
|
+
p = decodeURIComponent(p);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Malformed % escape — leave the string unchanged. The bash
|
|
64
|
+
// helper's `printf '%b'` behavior is similar (passes through).
|
|
65
|
+
}
|
|
66
|
+
// Strip leading <REA_ROOT>/. Compare normalized forms.
|
|
67
|
+
const normRoot = reaRoot.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
68
|
+
if (normRoot.length > 0) {
|
|
69
|
+
if (p === normRoot)
|
|
70
|
+
return '';
|
|
71
|
+
const withSep = normRoot + '/';
|
|
72
|
+
if (p.startsWith(withSep)) {
|
|
73
|
+
p = p.slice(withSep.length);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// 2026-05-15 codex round-1 P3 fix: strip chains of leading `./`
|
|
77
|
+
// segments. Mirrors `_lib/path-normalize.sh::path_canonical_form`.
|
|
78
|
+
// Pre-fix `./src/gateway/foo.ts` did NOT match the `src/gateway/`
|
|
79
|
+
// pattern because the leading `./` was preserved. Bash's
|
|
80
|
+
// path_canonical_form collapses `./` chains, so `./src/...`,
|
|
81
|
+
// `././src/...`, etc. all reduce to `src/...`.
|
|
82
|
+
while (p.startsWith('./')) {
|
|
83
|
+
p = p.slice(2);
|
|
84
|
+
}
|
|
85
|
+
return p;
|
|
86
|
+
}
|
|
87
|
+
function buildAdvisoryBanner(filePath, matched) {
|
|
88
|
+
return [
|
|
89
|
+
'ARCHITECTURE ADVISORY: Sensitive path modified\n',
|
|
90
|
+
'\n',
|
|
91
|
+
` File: ${filePath}\n`,
|
|
92
|
+
` Category: ${matched}\n`,
|
|
93
|
+
'\n',
|
|
94
|
+
' This file is in an architecture-sensitive directory.\n',
|
|
95
|
+
' Consider: Does this change maintain backward compatibility?\n',
|
|
96
|
+
' Consider: Should this be reviewed by the principal-engineer agent?\n',
|
|
97
|
+
].join('');
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Read `policy.architecture_review.patterns`. Returns `[]` on:
|
|
101
|
+
* - policy file missing
|
|
102
|
+
* - YAML unparseable
|
|
103
|
+
* - architecture_review unset
|
|
104
|
+
* - architecture_review.patterns unset/empty/non-list
|
|
105
|
+
*
|
|
106
|
+
* 2026-05-15 codex round-1 P3 fix: do NOT use `loadPolicy()` here.
|
|
107
|
+
* The strict zod schema throws on legacy keys / extra fields, which
|
|
108
|
+
* caused the catch to swallow patterns silently — a legacy policy.yaml
|
|
109
|
+
* with one unknown key would disable the advisory entirely, with no
|
|
110
|
+
* indication to the user.
|
|
111
|
+
*
|
|
112
|
+
* The bash original used `policy_list` (a non-strict reader). To match
|
|
113
|
+
* that behavior we read the YAML directly via the same permissive
|
|
114
|
+
* parser that `rea hook policy-get` uses (`yaml` package's `parse`),
|
|
115
|
+
* then pull `architecture_review.patterns` as a list of strings. Any
|
|
116
|
+
* non-string entry is filtered out. Unknown keys ELSEWHERE in the
|
|
117
|
+
* policy are tolerated — only the patterns subset matters for this
|
|
118
|
+
* advisory.
|
|
119
|
+
*/
|
|
120
|
+
function loadArchitecturePatterns(reaRoot, onWarning) {
|
|
121
|
+
const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
|
|
122
|
+
let raw;
|
|
123
|
+
try {
|
|
124
|
+
raw = fs.readFileSync(policyPath, 'utf8');
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// File missing — bash hook treats this as "advisory disabled".
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = parseYaml(raw);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Unparseable YAML — log to stderr (NOT silent) and return [].
|
|
136
|
+
// The advisory still short-circuits to exit 0 since this is an
|
|
137
|
+
// advisory tier, but the user sees a one-line warning instead of
|
|
138
|
+
// mysterious silence.
|
|
139
|
+
onWarning('architecture-review-gate: policy.yaml is unparseable; advisory disabled\n');
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
const ar = parsed['architecture_review'];
|
|
146
|
+
if (ar === undefined || ar === null || typeof ar !== 'object' || Array.isArray(ar)) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
const patterns = ar['patterns'];
|
|
150
|
+
if (!Array.isArray(patterns))
|
|
151
|
+
return [];
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const entry of patterns) {
|
|
154
|
+
if (typeof entry === 'string' && entry.length > 0) {
|
|
155
|
+
out.push(entry);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Quick policy-disable probe. The bash hook reads
|
|
162
|
+
* `architecture_advisory: false` (legacy key — pre-0.20.1 toggle)
|
|
163
|
+
* directly from policy.yaml via grep. The canonical loader doesn't
|
|
164
|
+
* surface this key (it's not in the strict schema), so we re-read
|
|
165
|
+
* the raw YAML text. Returns true when the key is present and
|
|
166
|
+
* literally `false` (no other value disables the hook in the bash
|
|
167
|
+
* implementation).
|
|
168
|
+
*/
|
|
169
|
+
function isAdvisoryDisabled(reaRoot) {
|
|
170
|
+
const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
|
|
171
|
+
let raw;
|
|
172
|
+
try {
|
|
173
|
+
raw = fs.readFileSync(policyPath, 'utf8');
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return /^architecture_advisory:\s*false\b/m.test(raw);
|
|
179
|
+
}
|
|
180
|
+
export async function runArchitectureReviewGate(options = {}) {
|
|
181
|
+
const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
|
|
182
|
+
let stderr = '';
|
|
183
|
+
const writeStderr = (s) => {
|
|
184
|
+
stderr += s;
|
|
185
|
+
if (options.stderrWrite)
|
|
186
|
+
options.stderrWrite(s);
|
|
187
|
+
};
|
|
188
|
+
// 1. HALT.
|
|
189
|
+
const halt = checkHalt(reaRoot);
|
|
190
|
+
if (halt.halted) {
|
|
191
|
+
writeStderr(formatHaltBanner(halt.reason));
|
|
192
|
+
return { exitCode: 2, stderr, matched: null };
|
|
193
|
+
}
|
|
194
|
+
// 2. Disabled?
|
|
195
|
+
if (isAdvisoryDisabled(reaRoot)) {
|
|
196
|
+
return { exitCode: 0, stderr, matched: null };
|
|
197
|
+
}
|
|
198
|
+
// 3. Stdin.
|
|
199
|
+
const stdinRaw = options.stdinOverride !== undefined
|
|
200
|
+
? options.stdinOverride
|
|
201
|
+
: await readStdinWithTimeout(5_000);
|
|
202
|
+
let filePath = '';
|
|
203
|
+
try {
|
|
204
|
+
const payload = parseWriteHookPayload(stdinRaw);
|
|
205
|
+
filePath = payload.filePath;
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
|
|
209
|
+
// Advisory tier: silently exit 0 on malformed payload. The bash
|
|
210
|
+
// hook used `jq -r '.tool_input.file_path // empty'` which
|
|
211
|
+
// coerces malformed JSON to empty stdout, then exits 0. Mirror
|
|
212
|
+
// that — never refuse on a parse error in the advisory path.
|
|
213
|
+
return { exitCode: 0, stderr, matched: null };
|
|
214
|
+
}
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
if (filePath.length === 0) {
|
|
218
|
+
return { exitCode: 0, stderr, matched: null };
|
|
219
|
+
}
|
|
220
|
+
const normalized = normalizePath(filePath, reaRoot);
|
|
221
|
+
if (normalized.length === 0) {
|
|
222
|
+
return { exitCode: 0, stderr, matched: null };
|
|
223
|
+
}
|
|
224
|
+
const patterns = loadArchitecturePatterns(reaRoot, writeStderr);
|
|
225
|
+
if (patterns.length === 0) {
|
|
226
|
+
return { exitCode: 0, stderr, matched: null };
|
|
227
|
+
}
|
|
228
|
+
let matched = null;
|
|
229
|
+
for (const pattern of patterns) {
|
|
230
|
+
if (normalized.startsWith(pattern)) {
|
|
231
|
+
matched = pattern;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (matched === null) {
|
|
236
|
+
return { exitCode: 0, stderr, matched: null };
|
|
237
|
+
}
|
|
238
|
+
writeStderr(buildAdvisoryBanner(normalized, matched));
|
|
239
|
+
return { exitCode: 0, stderr, matched };
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* CLI entry — `rea hook architecture-review-gate`.
|
|
243
|
+
*/
|
|
244
|
+
export async function runHookArchitectureReviewGate(options = {}) {
|
|
245
|
+
const result = await runArchitectureReviewGate({
|
|
246
|
+
...options,
|
|
247
|
+
stderrWrite: (s) => process.stderr.write(s),
|
|
248
|
+
});
|
|
249
|
+
process.exit(result.exitCode);
|
|
250
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary port of `hooks/changeset-security-gate.sh`.
|
|
3
|
+
*
|
|
4
|
+
* 0.33.0 Phase 1 port #3.
|
|
5
|
+
*
|
|
6
|
+
* Guards `.changeset/*.md` files against two failure modes:
|
|
7
|
+
*
|
|
8
|
+
* 1. SECURITY DISCLOSURE LEAK — a GHSA or CVE identifier in a
|
|
9
|
+
* changeset file becomes public via CHANGELOG.md when the
|
|
10
|
+
* release ships. Block the write.
|
|
11
|
+
* 2. MISSING OR MALFORMED FRONTMATTER — a changeset without a
|
|
12
|
+
* proper frontmatter block is silently ignored by the
|
|
13
|
+
* changesets tool, wasting the release entry. Block the write.
|
|
14
|
+
*
|
|
15
|
+
* Behavioral contract preserves the bash hook byte-for-byte:
|
|
16
|
+
*
|
|
17
|
+
* 1. HALT check → exit 2 with shared banner.
|
|
18
|
+
* 2. Tool filter: only `Write`, `Edit`, `MultiEdit`, `NotebookEdit`.
|
|
19
|
+
* Any other tool exits 0.
|
|
20
|
+
* 3. File-path filter: only `.changeset/*.md` files. The
|
|
21
|
+
* `.changeset/README.md` companion is excluded (it's metadata
|
|
22
|
+
* for the changesets tool itself).
|
|
23
|
+
* 4. Security disclosure scan on the resolved content. The
|
|
24
|
+
* ordered pattern list is reproduced verbatim. First match wins;
|
|
25
|
+
* emit the `MATCHED_PATTERN` placeholder.
|
|
26
|
+
* 5. MultiEdit short-circuit for frontmatter: MultiEdit's
|
|
27
|
+
* `edits[].new_string` is a list of replacement FRAGMENTS, not
|
|
28
|
+
* a full file. Running frontmatter validation against the
|
|
29
|
+
* concatenated fragments would reject every legitimate edit.
|
|
30
|
+
* The bash hook added this exemption in 0.15.0; we mirror it.
|
|
31
|
+
* The disclosure scan still runs on the fragments because
|
|
32
|
+
* GHSA/CVE patterns match per-fragment without structural
|
|
33
|
+
* assumption.
|
|
34
|
+
* 6. Frontmatter validation:
|
|
35
|
+
* a. Must start with `---`.
|
|
36
|
+
* b. Must contain at least one `<pkg>: (patch|minor|major)`
|
|
37
|
+
* entry inside the first `---`/`---` block. Accepts
|
|
38
|
+
* single-quoted, double-quoted, and unquoted package
|
|
39
|
+
* names — same explicit alternation form as the bash hook
|
|
40
|
+
* (0.15.0 codex round-1 P2-1 fix).
|
|
41
|
+
* c. Must have a non-empty description after the closing
|
|
42
|
+
* `---`.
|
|
43
|
+
*
|
|
44
|
+
* Block emissions use the Claude Code PreToolUse JSON-on-stdout
|
|
45
|
+
* protocol via `emitJsonBlock`, mirroring `_lib/common.sh::json_output`
|
|
46
|
+
* — JSON on stdout AND the human reason on stderr, exit 2.
|
|
47
|
+
*/
|
|
48
|
+
import type { Buffer } from 'node:buffer';
|
|
49
|
+
export interface ChangesetSecurityGateOptions {
|
|
50
|
+
reaRoot?: string;
|
|
51
|
+
stdinOverride?: string | Buffer;
|
|
52
|
+
stderrWrite?: (s: string) => void;
|
|
53
|
+
stdoutWrite?: (s: string) => void;
|
|
54
|
+
}
|
|
55
|
+
export interface ChangesetSecurityGateResult {
|
|
56
|
+
exitCode: number;
|
|
57
|
+
stderr: string;
|
|
58
|
+
stdout: string;
|
|
59
|
+
}
|
|
60
|
+
export declare function runChangesetSecurityGate(options?: ChangesetSecurityGateOptions): Promise<ChangesetSecurityGateResult>;
|
|
61
|
+
/**
|
|
62
|
+
* CLI entry — `rea hook changeset-security-gate`.
|
|
63
|
+
*/
|
|
64
|
+
export declare function runHookChangesetSecurityGate(options?: ChangesetSecurityGateOptions): Promise<void>;
|
|
65
|
+
export declare const __INTERNAL_DISCLOSURE_PATTERNS_FOR_TESTS: readonly string[];
|
|
66
|
+
export declare const __INTERNAL_FRONTMATTER_PATTERN_FOR_TESTS: RegExp;
|
|
67
|
+
export declare const __INTERNAL_BANNERS_FOR_TESTS: {
|
|
68
|
+
MISSING_FRONTMATTER_BANNER: string;
|
|
69
|
+
INVALID_FRONTMATTER_BANNER: string;
|
|
70
|
+
MISSING_DESCRIPTION_BANNER: string;
|
|
71
|
+
};
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary port of `hooks/changeset-security-gate.sh`.
|
|
3
|
+
*
|
|
4
|
+
* 0.33.0 Phase 1 port #3.
|
|
5
|
+
*
|
|
6
|
+
* Guards `.changeset/*.md` files against two failure modes:
|
|
7
|
+
*
|
|
8
|
+
* 1. SECURITY DISCLOSURE LEAK — a GHSA or CVE identifier in a
|
|
9
|
+
* changeset file becomes public via CHANGELOG.md when the
|
|
10
|
+
* release ships. Block the write.
|
|
11
|
+
* 2. MISSING OR MALFORMED FRONTMATTER — a changeset without a
|
|
12
|
+
* proper frontmatter block is silently ignored by the
|
|
13
|
+
* changesets tool, wasting the release entry. Block the write.
|
|
14
|
+
*
|
|
15
|
+
* Behavioral contract preserves the bash hook byte-for-byte:
|
|
16
|
+
*
|
|
17
|
+
* 1. HALT check → exit 2 with shared banner.
|
|
18
|
+
* 2. Tool filter: only `Write`, `Edit`, `MultiEdit`, `NotebookEdit`.
|
|
19
|
+
* Any other tool exits 0.
|
|
20
|
+
* 3. File-path filter: only `.changeset/*.md` files. The
|
|
21
|
+
* `.changeset/README.md` companion is excluded (it's metadata
|
|
22
|
+
* for the changesets tool itself).
|
|
23
|
+
* 4. Security disclosure scan on the resolved content. The
|
|
24
|
+
* ordered pattern list is reproduced verbatim. First match wins;
|
|
25
|
+
* emit the `MATCHED_PATTERN` placeholder.
|
|
26
|
+
* 5. MultiEdit short-circuit for frontmatter: MultiEdit's
|
|
27
|
+
* `edits[].new_string` is a list of replacement FRAGMENTS, not
|
|
28
|
+
* a full file. Running frontmatter validation against the
|
|
29
|
+
* concatenated fragments would reject every legitimate edit.
|
|
30
|
+
* The bash hook added this exemption in 0.15.0; we mirror it.
|
|
31
|
+
* The disclosure scan still runs on the fragments because
|
|
32
|
+
* GHSA/CVE patterns match per-fragment without structural
|
|
33
|
+
* assumption.
|
|
34
|
+
* 6. Frontmatter validation:
|
|
35
|
+
* a. Must start with `---`.
|
|
36
|
+
* b. Must contain at least one `<pkg>: (patch|minor|major)`
|
|
37
|
+
* entry inside the first `---`/`---` block. Accepts
|
|
38
|
+
* single-quoted, double-quoted, and unquoted package
|
|
39
|
+
* names — same explicit alternation form as the bash hook
|
|
40
|
+
* (0.15.0 codex round-1 P2-1 fix).
|
|
41
|
+
* c. Must have a non-empty description after the closing
|
|
42
|
+
* `---`.
|
|
43
|
+
*
|
|
44
|
+
* Block emissions use the Claude Code PreToolUse JSON-on-stdout
|
|
45
|
+
* protocol via `emitJsonBlock`, mirroring `_lib/common.sh::json_output`
|
|
46
|
+
* — JSON on stdout AND the human reason on stderr, exit 2.
|
|
47
|
+
*/
|
|
48
|
+
import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
|
|
49
|
+
import { parseWriteHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
|
|
50
|
+
/**
|
|
51
|
+
* Tool names accepted by this gate. Mirrors the bash hook's
|
|
52
|
+
* `[[ "$TOOL_NAME" != "Write" && ... ]]` chain.
|
|
53
|
+
*/
|
|
54
|
+
const ACCEPTED_TOOLS = new Set([
|
|
55
|
+
'Write',
|
|
56
|
+
'Edit',
|
|
57
|
+
'MultiEdit',
|
|
58
|
+
'NotebookEdit',
|
|
59
|
+
]);
|
|
60
|
+
/**
|
|
61
|
+
* Pattern list for the disclosure scan. Order matters — first match
|
|
62
|
+
* wins, and the matched pattern string lands in the operator banner.
|
|
63
|
+
*/
|
|
64
|
+
const DISCLOSURE_PATTERNS = [
|
|
65
|
+
/GHSA-[0-9A-Za-z]{4}-[0-9A-Za-z]{4}-[0-9A-Za-z]{4}/,
|
|
66
|
+
/CVE-[0-9]{4}-[0-9]+/,
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* Source strings for the disclosure patterns — these are what the
|
|
70
|
+
* bash hook emitted in its `MATCHED_PATTERN` placeholder so the
|
|
71
|
+
* operator banner matches byte-for-byte.
|
|
72
|
+
*/
|
|
73
|
+
const DISCLOSURE_PATTERN_SOURCES = [
|
|
74
|
+
'GHSA-[0-9A-Za-z]{4}-[0-9A-Za-z]{4}-[0-9A-Za-z]{4}',
|
|
75
|
+
'CVE-[0-9]{4}-[0-9]+',
|
|
76
|
+
];
|
|
77
|
+
/**
|
|
78
|
+
* Frontmatter package-bump line. Accepts:
|
|
79
|
+
* - "@scope/name": patch
|
|
80
|
+
* - '@scope/name': minor
|
|
81
|
+
* - @scope/name : major (unquoted)
|
|
82
|
+
* Mirrors the bash hook's explicit-alternation form (codex P2-1).
|
|
83
|
+
*/
|
|
84
|
+
const FRONTMATTER_BUMP_PATTERN = /^("[^"]+"|'[^']+'|[^"'\s]+): (patch|minor|major)/;
|
|
85
|
+
function emitJsonBlock(reason) {
|
|
86
|
+
const obj = {
|
|
87
|
+
hookSpecificOutput: {
|
|
88
|
+
hookEventName: 'PreToolUse',
|
|
89
|
+
permissionDecision: 'deny',
|
|
90
|
+
permissionDecisionReason: reason,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
return { json: JSON.stringify(obj) + '\n', stderr: reason + '\n' };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Test the file path against the `.changeset/*.md` predicate. Mirrors
|
|
97
|
+
* the bash hook's two grep calls:
|
|
98
|
+
* - must match `\.changeset/[^/]+\.md$`
|
|
99
|
+
* - must NOT match `\.changeset/README\.md$`
|
|
100
|
+
*/
|
|
101
|
+
function isChangesetFile(filePath) {
|
|
102
|
+
if (!/\.changeset\/[^/]+\.md$/.test(filePath))
|
|
103
|
+
return false;
|
|
104
|
+
if (/\.changeset\/README\.md$/.test(filePath))
|
|
105
|
+
return false;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Find the first matching disclosure pattern. Returns the source
|
|
110
|
+
* string (for the operator banner) or `null` when none match.
|
|
111
|
+
*/
|
|
112
|
+
function firstDisclosureMatch(content) {
|
|
113
|
+
for (let i = 0; i < DISCLOSURE_PATTERNS.length; i += 1) {
|
|
114
|
+
const re = DISCLOSURE_PATTERNS[i];
|
|
115
|
+
if (re !== undefined && re.test(content)) {
|
|
116
|
+
return DISCLOSURE_PATTERN_SOURCES[i] ?? null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Extract the frontmatter block (between the first `---` and the
|
|
123
|
+
* second `---`). Returns the lines BETWEEN those delimiters, NOT
|
|
124
|
+
* including the delimiters themselves. Mirrors the bash hook's
|
|
125
|
+
* `awk '/^---/{count++; if(count==2){exit} next} count==1{print}'`.
|
|
126
|
+
*
|
|
127
|
+
* When the second `---` is missing the function returns whatever was
|
|
128
|
+
* captured after the first `---`; the frontmatter validation regex
|
|
129
|
+
* then fails for lack of a bump entry, exactly as bash awk would.
|
|
130
|
+
*/
|
|
131
|
+
function extractFrontmatter(content) {
|
|
132
|
+
const lines = content.split('\n');
|
|
133
|
+
let dashCount = 0;
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const line of lines) {
|
|
136
|
+
if (/^---/.test(line)) {
|
|
137
|
+
dashCount += 1;
|
|
138
|
+
if (dashCount === 2)
|
|
139
|
+
break;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (dashCount === 1)
|
|
143
|
+
out.push(line);
|
|
144
|
+
}
|
|
145
|
+
return out.join('\n');
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Extract the first non-empty line AFTER the closing `---`. Mirrors
|
|
149
|
+
* the bash hook's `awk 'BEGIN{count=0} /^---/{count++; next} count>=2{print}'
|
|
150
|
+
* | grep -v '^[[:space:]]*$' | head -1`.
|
|
151
|
+
*/
|
|
152
|
+
function extractDescription(content) {
|
|
153
|
+
const lines = content.split('\n');
|
|
154
|
+
let dashCount = 0;
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
if (/^---/.test(line)) {
|
|
157
|
+
dashCount += 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (dashCount < 2)
|
|
161
|
+
continue;
|
|
162
|
+
if (line.trim().length === 0)
|
|
163
|
+
continue;
|
|
164
|
+
return line;
|
|
165
|
+
}
|
|
166
|
+
return '';
|
|
167
|
+
}
|
|
168
|
+
function buildDisclosureBanner(matched) {
|
|
169
|
+
return `CHANGESET SECURITY GATE: This changeset contains a security advisory identifier (matched: '${matched}').
|
|
170
|
+
|
|
171
|
+
Do NOT reference GHSA IDs or CVE numbers in changeset files before the advisory is published.
|
|
172
|
+
Changeset files are committed to git — this creates pre-disclosure in public history and CHANGELOG.
|
|
173
|
+
|
|
174
|
+
CORRECT approach for security fix changesets:
|
|
175
|
+
Use vague language only — no identifiers, no vulnerability details.
|
|
176
|
+
|
|
177
|
+
WRONG: 'fix(hooks): patch GHSA-3w3m-7gg4-f82g — symlink-guard now covers Edit tool'
|
|
178
|
+
RIGHT: 'security: extend symlink protection to cover all write-capable tools'
|
|
179
|
+
|
|
180
|
+
WRONG: 'security: fix CVE-2026-1234 prompt injection via tool descriptions'
|
|
181
|
+
RIGHT: 'security: harden middleware chain against indirect instruction attacks'
|
|
182
|
+
|
|
183
|
+
After the release ships:
|
|
184
|
+
1. Publish the GitHub Security Advisory (Security tab → Advisories → Publish)
|
|
185
|
+
2. The GHSA becomes the detailed public disclosure document
|
|
186
|
+
3. Optionally update CHANGELOG.md post-publish to add the GHSA reference`;
|
|
187
|
+
}
|
|
188
|
+
const MISSING_FRONTMATTER_BANNER = `CHANGESET FORMAT GATE: Missing frontmatter block.
|
|
189
|
+
|
|
190
|
+
Every changeset must start with a frontmatter block specifying which package to bump:
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
'@bookedsolid/rea': patch
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
Brief description of what changed and why (close #N if applicable).
|
|
197
|
+
|
|
198
|
+
Bump types: patch (bug fix/security), minor (new feature), major (breaking change)`;
|
|
199
|
+
const INVALID_FRONTMATTER_BANNER = `CHANGESET FORMAT GATE: Frontmatter does not contain a valid package bump entry.
|
|
200
|
+
|
|
201
|
+
The frontmatter must include at least one package/bump pair:
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
'@bookedsolid/rea': patch
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
Valid bump types: patch | minor | major`;
|
|
208
|
+
const MISSING_DESCRIPTION_BANNER = `CHANGESET FORMAT GATE: Missing description after frontmatter.
|
|
209
|
+
|
|
210
|
+
Add a meaningful description explaining what changed and why:
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
'@bookedsolid/rea': patch
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
fix(gateway): policy-loader now uses async I/O with 500ms TTL cache
|
|
217
|
+
|
|
218
|
+
Previously, loadPolicy used fs.readFileSync on every tool invocation, blocking
|
|
219
|
+
the event loop under concurrency. Closes #34.`;
|
|
220
|
+
export async function runChangesetSecurityGate(options = {}) {
|
|
221
|
+
const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
|
|
222
|
+
let stderr = '';
|
|
223
|
+
let stdout = '';
|
|
224
|
+
const writeStderr = (s) => {
|
|
225
|
+
stderr += s;
|
|
226
|
+
if (options.stderrWrite)
|
|
227
|
+
options.stderrWrite(s);
|
|
228
|
+
};
|
|
229
|
+
const writeStdout = (s) => {
|
|
230
|
+
stdout += s;
|
|
231
|
+
if (options.stdoutWrite)
|
|
232
|
+
options.stdoutWrite(s);
|
|
233
|
+
};
|
|
234
|
+
// 1. HALT.
|
|
235
|
+
const halt = checkHalt(reaRoot);
|
|
236
|
+
if (halt.halted) {
|
|
237
|
+
writeStderr(formatHaltBanner(halt.reason));
|
|
238
|
+
return { exitCode: 2, stderr, stdout };
|
|
239
|
+
}
|
|
240
|
+
// 2. Stdin.
|
|
241
|
+
const stdinRaw = options.stdinOverride !== undefined
|
|
242
|
+
? options.stdinOverride
|
|
243
|
+
: await readStdinWithTimeout(5_000);
|
|
244
|
+
let toolName = '';
|
|
245
|
+
let filePath = '';
|
|
246
|
+
let content = '';
|
|
247
|
+
try {
|
|
248
|
+
const payload = parseWriteHookPayload(stdinRaw);
|
|
249
|
+
toolName = payload.toolName;
|
|
250
|
+
filePath = payload.filePath;
|
|
251
|
+
content = payload.content;
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
|
|
255
|
+
writeStderr(`changeset-security-gate: ${err.message} — refusing on uncertainty.\n`);
|
|
256
|
+
return { exitCode: 2, stderr, stdout };
|
|
257
|
+
}
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
// 3. Tool filter.
|
|
261
|
+
if (toolName !== '' && !ACCEPTED_TOOLS.has(toolName)) {
|
|
262
|
+
return { exitCode: 0, stderr, stdout };
|
|
263
|
+
}
|
|
264
|
+
// 4. Path filter.
|
|
265
|
+
if (filePath.length === 0 || !isChangesetFile(filePath)) {
|
|
266
|
+
return { exitCode: 0, stderr, stdout };
|
|
267
|
+
}
|
|
268
|
+
// 5. Disclosure scan (runs for ALL accepted tools incl. MultiEdit).
|
|
269
|
+
const matched = firstDisclosureMatch(content);
|
|
270
|
+
if (matched !== null) {
|
|
271
|
+
const out = emitJsonBlock(buildDisclosureBanner(matched));
|
|
272
|
+
writeStdout(out.json);
|
|
273
|
+
writeStderr(out.stderr);
|
|
274
|
+
return { exitCode: 2, stderr, stdout };
|
|
275
|
+
}
|
|
276
|
+
// 6. MultiEdit short-circuit for frontmatter validation. The bash
|
|
277
|
+
// hook exits 0 here — the disclosure scan above is the only
|
|
278
|
+
// enforcement for fragment-style writes.
|
|
279
|
+
if (toolName === 'MultiEdit') {
|
|
280
|
+
return { exitCode: 0, stderr, stdout };
|
|
281
|
+
}
|
|
282
|
+
// 7. Frontmatter validation.
|
|
283
|
+
const firstLine = content.split('\n', 1)[0] ?? '';
|
|
284
|
+
if (!/^---/.test(firstLine)) {
|
|
285
|
+
const out = emitJsonBlock(MISSING_FRONTMATTER_BANNER);
|
|
286
|
+
writeStdout(out.json);
|
|
287
|
+
writeStderr(out.stderr);
|
|
288
|
+
return { exitCode: 2, stderr, stdout };
|
|
289
|
+
}
|
|
290
|
+
const frontmatter = extractFrontmatter(content);
|
|
291
|
+
let hasBump = false;
|
|
292
|
+
for (const line of frontmatter.split('\n')) {
|
|
293
|
+
if (FRONTMATTER_BUMP_PATTERN.test(line)) {
|
|
294
|
+
hasBump = true;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (!hasBump) {
|
|
299
|
+
const out = emitJsonBlock(INVALID_FRONTMATTER_BANNER);
|
|
300
|
+
writeStdout(out.json);
|
|
301
|
+
writeStderr(out.stderr);
|
|
302
|
+
return { exitCode: 2, stderr, stdout };
|
|
303
|
+
}
|
|
304
|
+
const description = extractDescription(content);
|
|
305
|
+
if (description.length === 0) {
|
|
306
|
+
const out = emitJsonBlock(MISSING_DESCRIPTION_BANNER);
|
|
307
|
+
writeStdout(out.json);
|
|
308
|
+
writeStderr(out.stderr);
|
|
309
|
+
return { exitCode: 2, stderr, stdout };
|
|
310
|
+
}
|
|
311
|
+
return { exitCode: 0, stderr, stdout };
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* CLI entry — `rea hook changeset-security-gate`.
|
|
315
|
+
*/
|
|
316
|
+
export async function runHookChangesetSecurityGate(options = {}) {
|
|
317
|
+
const result = await runChangesetSecurityGate({
|
|
318
|
+
...options,
|
|
319
|
+
stderrWrite: (s) => process.stderr.write(s),
|
|
320
|
+
stdoutWrite: (s) => process.stdout.write(s),
|
|
321
|
+
});
|
|
322
|
+
process.exit(result.exitCode);
|
|
323
|
+
}
|
|
324
|
+
export const __INTERNAL_DISCLOSURE_PATTERNS_FOR_TESTS = DISCLOSURE_PATTERN_SOURCES;
|
|
325
|
+
export const __INTERNAL_FRONTMATTER_PATTERN_FOR_TESTS = FRONTMATTER_BUMP_PATTERN;
|
|
326
|
+
export const __INTERNAL_BANNERS_FOR_TESTS = {
|
|
327
|
+
MISSING_FRONTMATTER_BANNER,
|
|
328
|
+
INVALID_FRONTMATTER_BANNER,
|
|
329
|
+
MISSING_DESCRIPTION_BANNER,
|
|
330
|
+
};
|