@bookedsolid/rea 0.34.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.
@@ -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,74 @@
1
+ /**
2
+ * Node-binary port of `hooks/settings-protection.sh`.
3
+ *
4
+ * 0.35.0 Phase 4 port. The LARGEST hook in the repo at 582 LOC of
5
+ * bash — this is the gate that protects `.claude/settings.json`,
6
+ * `.claude/hooks/*`, `.husky/*`, `.rea/policy.yaml`, and `.rea/HALT`
7
+ * from agent writes. Without it the entire governance layer can be
8
+ * disabled by an agent's own Write tool.
9
+ *
10
+ * Behavioral contract — preserves the bash hook section by section:
11
+ *
12
+ * 1. HALT check → exit 2 with shared banner.
13
+ * 2. Read stdin, extract `tool_input.file_path` (or `notebook_path`
14
+ * via the shared Write payload parser). Missing → exit 0.
15
+ *
16
+ * §5a Path-traversal reject (`..` segment in raw OR normalized form).
17
+ * §5a-bis Interior `/./` segment reject (NORMALIZED form only).
18
+ *
19
+ * §5b Extension-surface allow-list. `.husky/{commit-msg,pre-push,
20
+ * pre-commit,prepare-commit-msg}.d/*` is the documented consumer
21
+ * extension surface — fragments here are NOT protected, with
22
+ * two defense-in-depth checks:
23
+ * (a) Final-component symlink refusal (`fs.lstatSync().isSymbolicLink()`).
24
+ * (b) Intermediate-directory symlink resolution — the parent's
25
+ * realpath must STILL end in `/.husky/<surface>.d/` or
26
+ * `/.husky/<surface>.d` (directory-boundary anchored per
27
+ * 0.20.1 helix-021 #3).
28
+ *
29
+ * §6 Default-protected list resolution. Sourced from
30
+ * `_lib/protected-paths.ts`'s `resolveProtectedPatterns` which
31
+ * honors `protected_writes` (full override) and
32
+ * `protected_paths_relax` (subtractor). Match runs case-insensitive.
33
+ *
34
+ * §6c Intermediate-symlink resolution against the hard-protected list
35
+ * (helix-016 H.1 fix). Parallel to §5b's surface-only check, this
36
+ * runs against ANY protected pattern.
37
+ *
38
+ * §6b REA_HOOK_PATCH_SESSION unlock for `.claude/hooks/` (the only
39
+ * patch-session pattern). When the env var is set with a non-
40
+ * empty reason, audit-log the edit (via the shared TS audit
41
+ * primitive — directly, no shell-out gymnastics) and allow.
42
+ * Audit-append failure is fail-closed — block the edit and
43
+ * surface the failure. This preserves hash-chain integrity.
44
+ *
45
+ * §6c-bis Patch-session patterns blocked when env var is NOT set.
46
+ *
47
+ * Stderr formatting is preserved verbatim from the bash hook so
48
+ * existing log-parsing consumers (if any) keep working.
49
+ */
50
+ import type { Buffer } from 'node:buffer';
51
+ export interface SettingsProtectionOptions {
52
+ reaRoot?: string;
53
+ stdinOverride?: string | Buffer;
54
+ stderrWrite?: (s: string) => void;
55
+ /** Test seam — overrides `process.env.REA_HOOK_PATCH_SESSION`. */
56
+ patchSessionOverride?: string;
57
+ /** Test seam — overrides `process.env.CLAUDE_SESSION_ID`. */
58
+ sessionIdOverride?: string;
59
+ }
60
+ export interface SettingsProtectionResult {
61
+ exitCode: number;
62
+ stderr: string;
63
+ /**
64
+ * When the gate blocks: the matched pattern (one of PROTECTED_PATTERNS,
65
+ * PATCH_SESSION_PATTERNS, or a §5a/§5a-bis sentinel string).
66
+ */
67
+ matched: string | null;
68
+ /** When the gate blocks via §5b extension-surface symlink refusal. */
69
+ surfaceSymlinkRefused: boolean;
70
+ /** When the gate allows under REA_HOOK_PATCH_SESSION. */
71
+ patchSessionAllowed: boolean;
72
+ }
73
+ export declare function runSettingsProtection(options?: SettingsProtectionOptions): Promise<SettingsProtectionResult>;
74
+ export declare function runHookSettingsProtection(options?: SettingsProtectionOptions): Promise<void>;