@bookedsolid/rea 0.33.0 → 0.35.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 +49 -0
- package/dist/hooks/_lib/path-normalize.d.ts +81 -0
- package/dist/hooks/_lib/path-normalize.js +171 -0
- package/dist/hooks/_lib/payload.js +1 -1
- package/dist/hooks/_lib/protected-paths.d.ts +0 -0
- package/dist/hooks/_lib/protected-paths.js +232 -0
- package/dist/hooks/_lib/segments.d.ts +102 -0
- package/dist/hooks/_lib/segments.js +290 -0
- package/dist/hooks/blocked-paths-bash-gate/index.d.ts +55 -0
- package/dist/hooks/blocked-paths-bash-gate/index.js +175 -0
- package/dist/hooks/blocked-paths-enforcer/index.d.ts +51 -0
- package/dist/hooks/blocked-paths-enforcer/index.js +287 -0
- package/dist/hooks/dangerous-bash-interceptor/index.d.ts +103 -0
- package/dist/hooks/dangerous-bash-interceptor/index.js +669 -0
- package/dist/hooks/local-review-gate/index.d.ts +145 -0
- package/dist/hooks/local-review-gate/index.js +374 -0
- package/dist/hooks/protected-paths-bash-gate/index.d.ts +47 -0
- package/dist/hooks/protected-paths-bash-gate/index.js +168 -0
- package/dist/hooks/secret-scanner/index.d.ts +143 -0
- package/dist/hooks/secret-scanner/index.js +404 -0
- package/dist/hooks/settings-protection/index.d.ts +74 -0
- package/dist/hooks/settings-protection/index.js +485 -0
- package/hooks/blocked-paths-bash-gate.sh +118 -116
- package/hooks/blocked-paths-enforcer.sh +152 -256
- package/hooks/dangerous-bash-interceptor.sh +168 -386
- package/hooks/local-review-gate.sh +523 -410
- package/hooks/protected-paths-bash-gate.sh +123 -210
- package/hooks/secret-scanner.sh +210 -200
- package/hooks/settings-protection.sh +171 -549
- package/package.json +1 -1
- package/templates/blocked-paths-bash-gate.dogfood-staged.sh +177 -0
- package/templates/blocked-paths-enforcer.dogfood-staged.sh +180 -0
- package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
- package/templates/local-review-gate.dogfood-staged.sh +573 -0
- package/templates/protected-paths-bash-gate.dogfood-staged.sh +186 -0
- package/templates/secret-scanner.dogfood-staged.sh +240 -0
- package/templates/settings-protection.dogfood-staged.sh +204 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary port of `hooks/protected-paths-bash-gate.sh`.
|
|
3
|
+
*
|
|
4
|
+
* 0.35.0 Phase 3 port (paired tier-1 scanner-shim). Like blocked-paths-
|
|
5
|
+
* bash-gate but uses `runProtectedScan` against the
|
|
6
|
+
* `policy.protected_writes` / `policy.protected_paths_relax` resolved
|
|
7
|
+
* set. The bash gate was already a thin shim over the parser-backed
|
|
8
|
+
* scanner; this port drops the shim → CLI → scanner subprocess hop.
|
|
9
|
+
*
|
|
10
|
+
* Behavioral contract — preserves the bash hook byte-for-byte:
|
|
11
|
+
*
|
|
12
|
+
* 1. HALT check → exit 2 with shared banner.
|
|
13
|
+
* 2. Read stdin via `parseHookPayload`. Empty/missing command → exit 0.
|
|
14
|
+
* 3. Non-Bash tool calls bypass.
|
|
15
|
+
* 4. REA_HOOK_PATCH_SESSION-class bypass: when the env var is set with
|
|
16
|
+
* a non-empty reason, the scanner's protected-set is RELAXED for
|
|
17
|
+
* .claude/hooks/ — the patch-session pattern. Implemented by
|
|
18
|
+
* appending `.claude/hooks/` to the relax list when the env var is
|
|
19
|
+
* live (this mirrors the bash gate's §6b semantics for the Bash
|
|
20
|
+
* tier).
|
|
21
|
+
* 5. Load policy permissively (same lesson as 0.34.0 round-2 P2).
|
|
22
|
+
* 6. Run `runProtectedScan` with the resolved policy context.
|
|
23
|
+
* 7. Verdict `block` → exit 2; `allow` → exit 0.
|
|
24
|
+
*
|
|
25
|
+
* Audit-log parity: emits a `rea.hook.protected-paths-bash-gate` entry.
|
|
26
|
+
*/
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import { parse as parseYaml } from 'yaml';
|
|
30
|
+
import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
|
|
31
|
+
import { parseHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
|
|
32
|
+
import { runProtectedScan } from '../bash-scanner/index.js';
|
|
33
|
+
import { appendAuditRecord, InvocationStatus, Tier } from '../../audit/append.js';
|
|
34
|
+
function loadPolicyPermissive(reaRoot) {
|
|
35
|
+
const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
|
|
36
|
+
const empty = { protectedRelax: [] };
|
|
37
|
+
if (!fs.existsSync(policyPath))
|
|
38
|
+
return empty;
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = fs.readFileSync(policyPath, 'utf8');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return empty;
|
|
45
|
+
}
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = parseYaml(raw);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return empty;
|
|
52
|
+
}
|
|
53
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
54
|
+
return empty;
|
|
55
|
+
}
|
|
56
|
+
const obj = parsed;
|
|
57
|
+
const out = { protectedRelax: [] };
|
|
58
|
+
if (Array.isArray(obj['protected_writes'])) {
|
|
59
|
+
out.protectedWrites = [];
|
|
60
|
+
for (const e of obj['protected_writes']) {
|
|
61
|
+
if (typeof e === 'string' && e.length > 0)
|
|
62
|
+
out.protectedWrites.push(e);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(obj['protected_paths_relax'])) {
|
|
66
|
+
for (const e of obj['protected_paths_relax']) {
|
|
67
|
+
if (typeof e === 'string' && e.length > 0)
|
|
68
|
+
out.protectedRelax.push(e);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
export async function runProtectedPathsBashGate(options = {}) {
|
|
74
|
+
const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
|
|
75
|
+
let stderr = '';
|
|
76
|
+
const writeStderr = (s) => {
|
|
77
|
+
stderr += s;
|
|
78
|
+
if (options.stderrWrite)
|
|
79
|
+
options.stderrWrite(s);
|
|
80
|
+
};
|
|
81
|
+
// 1. HALT check.
|
|
82
|
+
const halt = checkHalt(reaRoot);
|
|
83
|
+
if (halt.halted) {
|
|
84
|
+
writeStderr(formatHaltBanner(halt.reason));
|
|
85
|
+
return { exitCode: 2, stderr, verdict: { verdict: 'block', reason: 'rea HALT active' } };
|
|
86
|
+
}
|
|
87
|
+
// 2. Read + parse stdin.
|
|
88
|
+
const stdinRaw = options.stdinOverride !== undefined
|
|
89
|
+
? options.stdinOverride
|
|
90
|
+
: await readStdinWithTimeout(5_000);
|
|
91
|
+
let toolName = '';
|
|
92
|
+
let cmd = '';
|
|
93
|
+
try {
|
|
94
|
+
const payload = parseHookPayload(stdinRaw);
|
|
95
|
+
toolName = payload.toolName;
|
|
96
|
+
cmd = payload.command;
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
|
|
100
|
+
writeStderr(`protected-paths-bash-gate: ${err.message} — refusing on uncertainty.\n`);
|
|
101
|
+
return { exitCode: 2, stderr, verdict: { verdict: 'block', reason: err.message } };
|
|
102
|
+
}
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
// 3. Non-Bash tool calls bypass.
|
|
106
|
+
if (toolName !== '' && toolName !== 'Bash') {
|
|
107
|
+
return { exitCode: 0, stderr, verdict: null };
|
|
108
|
+
}
|
|
109
|
+
// 4. Empty command → allow.
|
|
110
|
+
if (cmd.length === 0) {
|
|
111
|
+
return { exitCode: 0, stderr, verdict: null };
|
|
112
|
+
}
|
|
113
|
+
// 5. Load policy permissively.
|
|
114
|
+
const policy = loadPolicyPermissive(reaRoot);
|
|
115
|
+
const relax = [...policy.protectedRelax];
|
|
116
|
+
// 6. REA_HOOK_PATCH_SESSION — relax .claude/hooks/ when env var is
|
|
117
|
+
// set with a non-empty reason. Mirrors settings-protection.sh §6b
|
|
118
|
+
// posture (the Bash-tier counterpart wasn't enforcing this against
|
|
119
|
+
// .claude/hooks/ until 0.35.0 — that gap is closed here).
|
|
120
|
+
const patchSession = options.patchSessionOverride ?? process.env['REA_HOOK_PATCH_SESSION'] ?? '';
|
|
121
|
+
if (patchSession.length > 0) {
|
|
122
|
+
relax.push('.claude/hooks/');
|
|
123
|
+
}
|
|
124
|
+
// 7. Scan.
|
|
125
|
+
const verdict = runProtectedScan({
|
|
126
|
+
reaRoot,
|
|
127
|
+
policy: {
|
|
128
|
+
...(policy.protectedWrites !== undefined
|
|
129
|
+
? { protected_writes: policy.protectedWrites }
|
|
130
|
+
: {}),
|
|
131
|
+
protected_paths_relax: relax,
|
|
132
|
+
},
|
|
133
|
+
stderr: (line) => writeStderr(line),
|
|
134
|
+
}, cmd);
|
|
135
|
+
// 8. Audit.
|
|
136
|
+
try {
|
|
137
|
+
await appendAuditRecord(reaRoot, {
|
|
138
|
+
tool_name: 'rea.hook.protected-paths-bash-gate',
|
|
139
|
+
server_name: 'rea',
|
|
140
|
+
tier: Tier.Read,
|
|
141
|
+
status: verdict.verdict === 'allow' ? InvocationStatus.Allowed : InvocationStatus.Denied,
|
|
142
|
+
metadata: {
|
|
143
|
+
verdict: verdict.verdict,
|
|
144
|
+
...(verdict.detected_form !== undefined ? { detected_form: verdict.detected_form } : {}),
|
|
145
|
+
...(verdict.hit_pattern !== undefined ? { hit_pattern: verdict.hit_pattern } : {}),
|
|
146
|
+
...(patchSession.length > 0 ? { patch_session: true } : {}),
|
|
147
|
+
command_preview: cmd.slice(0, 256),
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
/* best-effort */
|
|
153
|
+
}
|
|
154
|
+
if (verdict.verdict === 'block') {
|
|
155
|
+
if (typeof verdict.reason === 'string' && verdict.reason.length > 0) {
|
|
156
|
+
writeStderr(verdict.reason + '\n');
|
|
157
|
+
}
|
|
158
|
+
return { exitCode: 2, stderr, verdict };
|
|
159
|
+
}
|
|
160
|
+
return { exitCode: 0, stderr, verdict };
|
|
161
|
+
}
|
|
162
|
+
export async function runHookProtectedPathsBashGate(options = {}) {
|
|
163
|
+
const result = await runProtectedPathsBashGate({
|
|
164
|
+
...options,
|
|
165
|
+
stderrWrite: (s) => process.stderr.write(s),
|
|
166
|
+
});
|
|
167
|
+
process.exit(result.exitCode);
|
|
168
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary port of `hooks/secret-scanner.sh`.
|
|
3
|
+
*
|
|
4
|
+
* 0.34.0 Phase 2 port #3 (tier-2 medium-complexity hooks with enforcer
|
|
5
|
+
* logic).
|
|
6
|
+
*
|
|
7
|
+
* Detects credential patterns in content about to be written via the
|
|
8
|
+
* Write/Edit/MultiEdit/NotebookEdit Claude Code tools and blocks (exit
|
|
9
|
+
* 2) when a HIGH-severity pattern matches a non-placeholder substring.
|
|
10
|
+
* Last-resort pre-write guard — gitleaks (pre-commit) is the primary
|
|
11
|
+
* gate; this hook stops the obvious credential-in-source-file shapes
|
|
12
|
+
* before they ever touch disk.
|
|
13
|
+
*
|
|
14
|
+
* Behavioral contract preserves the bash hook byte-for-byte:
|
|
15
|
+
*
|
|
16
|
+
* 1. HALT check → exit 2 with shared banner.
|
|
17
|
+
* 2. Read stdin via `parseWriteHookPayload`. Extracts `file_path` /
|
|
18
|
+
* `notebook_path` and the canonical content priority:
|
|
19
|
+
* content > new_string > edits[].new_string joined > new_source.
|
|
20
|
+
* Empty content → exit 0.
|
|
21
|
+
* 3. Suffix-based file_path exclusion: `*.env.example` / `*.env.sample`
|
|
22
|
+
* pass through silently. Test files are NOT excluded — the
|
|
23
|
+
* placeholder filter handles legitimate test fixtures.
|
|
24
|
+
* 4. Apply the bash hook's awk line filter:
|
|
25
|
+
* - Strip lines whose trimmed form starts with `#` (shell comment).
|
|
26
|
+
* - Strip lines where `process.env.VAR` is the RHS of an
|
|
27
|
+
* assignment (`= process.env.SOMETHING`).
|
|
28
|
+
* - Strip lines mentioning `os.environ[`.
|
|
29
|
+
* Anything left is the corpus the patterns run against.
|
|
30
|
+
* 5. Run each of the 17 patterns (12 HIGH + 5 MEDIUM) against the
|
|
31
|
+
* filtered corpus. For each match:
|
|
32
|
+
* - Apply `isPlaceholder()` filter (matches the bash hook's
|
|
33
|
+
* `is_placeholder` shell function — placeholder forms like
|
|
34
|
+
* `<your_key>`, `your_api_key`, `example_token`,
|
|
35
|
+
* `aaaaaaa...`, etc. are dropped).
|
|
36
|
+
* - Truncate the matching substring at 60 chars for display.
|
|
37
|
+
* - Cap collected matches at 5 per pattern.
|
|
38
|
+
* 6. If ANY HIGH match remains → exit 2 with the "SECRET DETECTED"
|
|
39
|
+
* banner. Else if MEDIUM matches → emit advisory + exit 0. No
|
|
40
|
+
* matches → exit 0.
|
|
41
|
+
*
|
|
42
|
+
* MultiEdit handling: `parseWriteHookPayload` joins every `edits[i].
|
|
43
|
+
* new_string` with `\n`. This intentionally folds the fragments into
|
|
44
|
+
* one corpus for scanning; the joined newline boundary preserves
|
|
45
|
+
* line-anchored patterns. The bash counterpart used the same join
|
|
46
|
+
* shape via `extract_write_content` in `_lib/payload-read.sh`.
|
|
47
|
+
*
|
|
48
|
+
* 0.14.0 hardening — type-guard against malformed payloads (non-string
|
|
49
|
+
* `new_string`, non-array `edits`, etc.) lives in the shared
|
|
50
|
+
* `parseWriteHookPayload`. Defensive coercion means a crafted
|
|
51
|
+
* `{"edits":42}` payload doesn't throw at the boundary; it's treated as
|
|
52
|
+
* missing.
|
|
53
|
+
*/
|
|
54
|
+
import type { Buffer } from 'node:buffer';
|
|
55
|
+
export interface SecretScannerOptions {
|
|
56
|
+
reaRoot?: string;
|
|
57
|
+
stdinOverride?: string | Buffer;
|
|
58
|
+
stderrWrite?: (s: string) => void;
|
|
59
|
+
}
|
|
60
|
+
export interface SecretScannerResult {
|
|
61
|
+
exitCode: number;
|
|
62
|
+
stderr: string;
|
|
63
|
+
/**
|
|
64
|
+
* Test seam — surfaces the matches the scanner accepted (post-
|
|
65
|
+
* placeholder filter, post-truncation). Ordered HIGH first, then
|
|
66
|
+
* MEDIUM. Useful for assertion-driven tests without grepping stderr.
|
|
67
|
+
*/
|
|
68
|
+
matches: ScannerMatch[];
|
|
69
|
+
}
|
|
70
|
+
export interface ScannerMatch {
|
|
71
|
+
severity: 'HIGH' | 'MEDIUM';
|
|
72
|
+
label: string;
|
|
73
|
+
snippet: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Pattern descriptors. The bash hook used ERE strings via `grep -oE`;
|
|
77
|
+
* the JS port uses native RegExp. Each pattern carries:
|
|
78
|
+
* - severity (HIGH = blocking; MEDIUM = advisory)
|
|
79
|
+
* - label (banner display string)
|
|
80
|
+
* - regex (compiled global; the `g` flag is required for matchAll)
|
|
81
|
+
*
|
|
82
|
+
* Pattern parity with the bash hook is line-by-line. Where the bash
|
|
83
|
+
* hook used POSIX character classes (`[[:space:]]`) we use `\s`; where
|
|
84
|
+
* it used `[A-Za-z0-9]` we keep that literal. Case-insensitive flags
|
|
85
|
+
* are applied per-pattern to match the bash hook's `grep -oE` posture
|
|
86
|
+
* — note that the bash `grep -oE` was case-SENSITIVE by default, so
|
|
87
|
+
* unless a pattern explicitly used `[Aa][Ww][Ss]_…` style alternation
|
|
88
|
+
* we keep the JS form case-sensitive too.
|
|
89
|
+
*/
|
|
90
|
+
export interface SecretPatternDescriptor {
|
|
91
|
+
severity: 'HIGH' | 'MEDIUM';
|
|
92
|
+
label: string;
|
|
93
|
+
regex: RegExp;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Filter content lines the same way the bash hook's awk preprocessor
|
|
97
|
+
* does:
|
|
98
|
+
* - Strip lines whose leading-whitespace-stripped form starts with `#`.
|
|
99
|
+
* - Strip lines where `process.env.VAR` is the RHS of an assignment.
|
|
100
|
+
* The bash hook used two regexes (trailing-non-letter and
|
|
101
|
+
* `;,)` punctuation forms) — we cover both.
|
|
102
|
+
* - Strip lines mentioning `os.environ[`.
|
|
103
|
+
*
|
|
104
|
+
* Newline-preserving so multiline regex anchors (`^…$`) still work on
|
|
105
|
+
* the filtered corpus.
|
|
106
|
+
*/
|
|
107
|
+
export declare function filterContent(content: string): string;
|
|
108
|
+
/**
|
|
109
|
+
* Bash `is_placeholder` parity. Returns true when the match is a known
|
|
110
|
+
* placeholder shape and should NOT be counted as a real secret.
|
|
111
|
+
*
|
|
112
|
+
* Lowercased once at the top; all sub-checks operate on the lower form.
|
|
113
|
+
*/
|
|
114
|
+
export declare function isPlaceholder(match: string): boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Scan filtered content against every pattern in the catalog. Returns
|
|
117
|
+
* the accepted matches in catalog order.
|
|
118
|
+
*/
|
|
119
|
+
export declare function scanContent(filtered: string): ScannerMatch[];
|
|
120
|
+
/**
|
|
121
|
+
* Suffix-based file_path exclusion. `*.env.example` and `*.env.sample`
|
|
122
|
+
* skip the scan entirely — those are documentation files that
|
|
123
|
+
* intentionally carry placeholder credential shapes.
|
|
124
|
+
*
|
|
125
|
+
* Test files are NOT excluded. Real credentials in test fixtures must
|
|
126
|
+
* still be caught; the placeholder filter handles legitimate dummy
|
|
127
|
+
* keys.
|
|
128
|
+
*/
|
|
129
|
+
export declare function isExcludedSuffix(filePath: string): boolean;
|
|
130
|
+
/**
|
|
131
|
+
* Pure executor. Returns `{ exitCode, stderr, matches }`; the CLI
|
|
132
|
+
* wrapper translates them into `process.stderr.write` + `process.exit`.
|
|
133
|
+
*/
|
|
134
|
+
export declare function runSecretScanner(options?: SecretScannerOptions): Promise<SecretScannerResult>;
|
|
135
|
+
/**
|
|
136
|
+
* CLI entry point — `rea hook secret-scanner`.
|
|
137
|
+
*/
|
|
138
|
+
export declare function runHookSecretScanner(options?: SecretScannerOptions): Promise<void>;
|
|
139
|
+
export declare const __INTERNAL_FOR_TESTS: {
|
|
140
|
+
SECRET_PATTERNS: readonly SecretPatternDescriptor[];
|
|
141
|
+
MAX_SNIPPET_LEN: number;
|
|
142
|
+
MAX_MATCHES_PER_PATTERN: number;
|
|
143
|
+
};
|