@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.
Files changed (37) hide show
  1. package/dist/cli/hook.js +49 -0
  2. package/dist/hooks/_lib/path-normalize.d.ts +81 -0
  3. package/dist/hooks/_lib/path-normalize.js +171 -0
  4. package/dist/hooks/_lib/payload.js +1 -1
  5. package/dist/hooks/_lib/protected-paths.d.ts +0 -0
  6. package/dist/hooks/_lib/protected-paths.js +232 -0
  7. package/dist/hooks/_lib/segments.d.ts +102 -0
  8. package/dist/hooks/_lib/segments.js +290 -0
  9. package/dist/hooks/blocked-paths-bash-gate/index.d.ts +55 -0
  10. package/dist/hooks/blocked-paths-bash-gate/index.js +175 -0
  11. package/dist/hooks/blocked-paths-enforcer/index.d.ts +51 -0
  12. package/dist/hooks/blocked-paths-enforcer/index.js +287 -0
  13. package/dist/hooks/dangerous-bash-interceptor/index.d.ts +103 -0
  14. package/dist/hooks/dangerous-bash-interceptor/index.js +669 -0
  15. package/dist/hooks/local-review-gate/index.d.ts +145 -0
  16. package/dist/hooks/local-review-gate/index.js +374 -0
  17. package/dist/hooks/protected-paths-bash-gate/index.d.ts +47 -0
  18. package/dist/hooks/protected-paths-bash-gate/index.js +168 -0
  19. package/dist/hooks/secret-scanner/index.d.ts +143 -0
  20. package/dist/hooks/secret-scanner/index.js +404 -0
  21. package/dist/hooks/settings-protection/index.d.ts +74 -0
  22. package/dist/hooks/settings-protection/index.js +485 -0
  23. package/hooks/blocked-paths-bash-gate.sh +118 -116
  24. package/hooks/blocked-paths-enforcer.sh +152 -256
  25. package/hooks/dangerous-bash-interceptor.sh +168 -386
  26. package/hooks/local-review-gate.sh +523 -410
  27. package/hooks/protected-paths-bash-gate.sh +123 -210
  28. package/hooks/secret-scanner.sh +210 -200
  29. package/hooks/settings-protection.sh +171 -549
  30. package/package.json +1 -1
  31. package/templates/blocked-paths-bash-gate.dogfood-staged.sh +177 -0
  32. package/templates/blocked-paths-enforcer.dogfood-staged.sh +180 -0
  33. package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
  34. package/templates/local-review-gate.dogfood-staged.sh +573 -0
  35. package/templates/protected-paths-bash-gate.dogfood-staged.sh +186 -0
  36. package/templates/secret-scanner.dogfood-staged.sh +240 -0
  37. 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
+ };