@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.
- package/dist/cli/hook.js +28 -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/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/protected-paths-bash-gate/index.d.ts +47 -0
- package/dist/hooks/protected-paths-bash-gate/index.js +168 -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/protected-paths-bash-gate.sh +123 -210
- 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/protected-paths-bash-gate.dogfood-staged.sh +186 -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,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>;
|