shield-harness 1.0.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 (39) hide show
  1. package/.claude/hooks/lib/session-modules/.gitkeep +0 -0
  2. package/.claude/hooks/lib/sh-utils.js +241 -0
  3. package/.claude/hooks/lint-on-save.js +240 -0
  4. package/.claude/hooks/sh-circuit-breaker.js +111 -0
  5. package/.claude/hooks/sh-config-guard.js +252 -0
  6. package/.claude/hooks/sh-data-boundary.js +315 -0
  7. package/.claude/hooks/sh-dep-audit.js +101 -0
  8. package/.claude/hooks/sh-elicitation.js +241 -0
  9. package/.claude/hooks/sh-evidence.js +193 -0
  10. package/.claude/hooks/sh-gate.js +330 -0
  11. package/.claude/hooks/sh-injection-guard.js +165 -0
  12. package/.claude/hooks/sh-instructions.js +210 -0
  13. package/.claude/hooks/sh-output-control.js +183 -0
  14. package/.claude/hooks/sh-permission-learn.js +223 -0
  15. package/.claude/hooks/sh-permission.js +157 -0
  16. package/.claude/hooks/sh-pipeline.js +639 -0
  17. package/.claude/hooks/sh-postcompact.js +173 -0
  18. package/.claude/hooks/sh-precompact.js +114 -0
  19. package/.claude/hooks/sh-quiet-inject.js +147 -0
  20. package/.claude/hooks/sh-session-end.js +143 -0
  21. package/.claude/hooks/sh-session-start.js +196 -0
  22. package/.claude/hooks/sh-subagent.js +86 -0
  23. package/.claude/hooks/sh-task-gate.js +138 -0
  24. package/.claude/hooks/sh-user-prompt.js +181 -0
  25. package/.claude/hooks/sh-worktree.js +227 -0
  26. package/.claude/patterns/injection-patterns.json +137 -0
  27. package/.claude/rules/binding-governance.md +62 -0
  28. package/.claude/rules/channel-security.md +90 -0
  29. package/.claude/rules/coding-principles.md +79 -0
  30. package/.claude/rules/dev-environment.md +37 -0
  31. package/.claude/rules/implementation-context.md +112 -0
  32. package/.claude/rules/language.md +26 -0
  33. package/.claude/rules/security.md +109 -0
  34. package/.claude/rules/testing.md +43 -0
  35. package/LICENSE +21 -0
  36. package/README.ja.md +107 -0
  37. package/README.md +105 -0
  38. package/bin/shield-harness.js +141 -0
  39. package/package.json +33 -0
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env node
2
+ // sh-gate.js — Destructive command blocker + hook evasion defense
3
+ // Spec: DETAILED_DESIGN.md §3.2
4
+ // Event: PreToolUse (Bash)
5
+ // Target response time: < 50ms
6
+ "use strict";
7
+
8
+ const {
9
+ readHookInput,
10
+ allow,
11
+ deny,
12
+ nfkcNormalize,
13
+ normalizePath,
14
+ appendEvidence,
15
+ } = require("./lib/sh-utils");
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Pattern Arrays (§3.2 — all 7 attack vectors + destructive commands)
19
+ // ---------------------------------------------------------------------------
20
+
21
+ // Destructive commands — catastrophic file system / device operations
22
+ const DESTRUCTIVE_PATTERNS = [
23
+ [/^rm\s+-rf\s+\//, "rm -rf / (root filesystem destruction)"],
24
+ [/^rm\s+-rf\s+~/, "rm -rf ~ (home directory destruction)"],
25
+ [/^del\s+\/s\s+\/q\s+[A-Z]:\\/, "del /s /q (Windows recursive delete)"],
26
+ [/^format\s+[A-Z]:/, "format drive (disk format)"],
27
+ [/^mkfs\./, "mkfs (filesystem creation on device)"],
28
+ [/^dd\s+if=.*\s+of=\/dev\//, "dd to device (raw disk write)"],
29
+ ];
30
+
31
+ // E-1: Tool switching — bypass Edit/Write tool via Bash scripting languages
32
+ const TOOL_SWITCHING_PATTERNS = [
33
+ [/sed\s+-i/, "sed -i (in-place edit bypasses Edit tool)"],
34
+ [
35
+ /sed\s.*['"][^'"]*[/][^'"]*[ew]\s*['"]/,
36
+ "sed e/w modifier (execute/write via sed)",
37
+ ],
38
+ [/sed\s.*-e\s/, "sed -e (expression, potential execute)"],
39
+ [/python3?\s+-c\s+['"].*open\(/, "python -c open() (file write via python)"],
40
+ [/node\s+-e\s+['"].*fs\./, "node -e fs.* (file write via node)"],
41
+ [/ruby\s+-e\s+['"].*File\./, "ruby -e File.* (file write via ruby)"],
42
+ [/perl\s+-[pei]/, "perl -p/-e/-i (in-place or eval mode)"],
43
+ [
44
+ /powershell.*-Command.*Set-Content/i,
45
+ "PowerShell Set-Content (file write via powershell)",
46
+ ],
47
+ [/echo\s+.*>\s/, "echo redirect (file write via echo)"],
48
+ [/printf\s+.*>\s/, "printf redirect (file write via printf)"],
49
+ [/\|\s*tee\s/, "pipe to tee (file write via tee)"],
50
+ ];
51
+
52
+ // E-3: Dynamic linker — execute arbitrary code via loader injection
53
+ const DYNAMIC_LINKER_PATTERNS = [
54
+ [/LD_PRELOAD=/, "LD_PRELOAD (shared library injection)"],
55
+ [/LD_LIBRARY_PATH=/, "LD_LIBRARY_PATH (library path hijack)"],
56
+ [/DYLD_INSERT_LIBRARIES=/, "DYLD_INSERT_LIBRARIES (macOS library injection)"],
57
+ [/ld-linux/, "ld-linux (direct dynamic linker invocation)"],
58
+ [/\/lib.*\/ld-/, "/lib*/ld- (dynamic linker path)"],
59
+ [/\/usr\/lib.*\/ld-/, "/usr/lib*/ld- (dynamic linker path)"],
60
+ [/rundll32/i, "rundll32 (Windows DLL execution)"],
61
+ ];
62
+
63
+ // E-4: sed dangerous modifiers (subset of tool switching, explicit check)
64
+ const SED_DANGER_PATTERNS = [
65
+ [
66
+ /sed\s.*['"][^'"]*[/][^'"]*[ew]\s*['"]/,
67
+ "sed e/w modifier (arbitrary command execution)",
68
+ ],
69
+ ];
70
+
71
+ // E-5: Self-config modification — agent modifying its own governance files
72
+ const CONFIG_MODIFY_PATTERNS = [
73
+ [/>\s*\.claude\//, "redirect to .claude/ (config overwrite)"],
74
+ [/>>\s*\.claude\//, "append redirect to .claude/ (config modification)"],
75
+ [/tee\s+.*\.claude\//, "tee to .claude/ (config write)"],
76
+ [/cp\s+.*\.claude\//, "cp to .claude/ (config copy)"],
77
+ [/mv\s+.*\.claude\//, "mv to .claude/ (config move)"],
78
+ ];
79
+
80
+ // FR-02-06: PATH hijack — override command resolution
81
+ const PATH_HIJACK_PATTERNS = [
82
+ [/^PATH=/, "PATH= (command search path override)"],
83
+ [/export\s+PATH=/, "export PATH= (persistent path override)"],
84
+ [/\$SHELL/, "$SHELL (shell variable reference)"],
85
+ [/\$PATH/, "$PATH (path variable reference)"],
86
+ [/env\s+-[SiuC]/, "env -S/-i/-u/-C (environment manipulation)"],
87
+ [/env\s+--split-string/, "env --split-string (argument injection)"],
88
+ ];
89
+
90
+ // FR-02-09, FR-02-10: Windows-specific attack vectors
91
+ const WINDOWS_PATTERNS = [
92
+ [/\.lnk\b/, ".lnk (Windows shortcut — potential code execution)"],
93
+ [/\.scf\b/, ".scf (Shell Command File — potential code execution)"],
94
+ [/\.url\b/, ".url (Internet shortcut — potential redirect)"],
95
+ [/\.cmd\b/, ".cmd (Windows batch — uncontrolled execution)"],
96
+ [/\.bat\b/, ".bat (Windows batch — uncontrolled execution)"],
97
+ [/\bpowershell\b.*-enc/i, "powershell -enc (encoded command — obfuscation)"],
98
+ [/::\$DATA/, "NTFS ADS (Alternate Data Stream)"],
99
+ [/\\\\\?\\UNC\\/, "UNC extended path (network path injection)"],
100
+ ];
101
+
102
+ // E-8: Pipeline environment variable spoofing (§8.1)
103
+ const PIPELINE_SPOOFING_PATTERNS = [
104
+ [/export\s+SH_PIPELINE/, "export SH_PIPELINE (pipeline env spoofing)"],
105
+ [/SH_PIPELINE=1/, "SH_PIPELINE=1 (pipeline env spoofing)"],
106
+ [/env\s+SH_PIPELINE/, "env SH_PIPELINE (pipeline env spoofing)"],
107
+ [/set\s+SH_PIPELINE/, "set SH_PIPELINE (pipeline env spoofing)"],
108
+ ];
109
+
110
+ // E-2: Path obfuscation (checked after normalization)
111
+ const PATH_OBFUSCATION_PATTERNS = [
112
+ [/\/proc\/self\/root/, "/proc/self/root (filesystem escape)"],
113
+ [/\/proc\/[0-9]+\/root/, "/proc/PID/root (filesystem escape)"],
114
+ [/PROGRA~[0-9]/, "8.3 short name (path obfuscation)"],
115
+ [/::\$DATA/, "NTFS ADS :$DATA (hidden data stream)"],
116
+ [/::\$INDEX_ALLOCATION/, "NTFS ADS :$INDEX_ALLOCATION"],
117
+ ];
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Pattern matching engine
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Test a command against an array of [RegExp, label] patterns.
125
+ * @param {string} command - Normalized command string
126
+ * @param {Array<[RegExp, string]>} patterns - Pattern array
127
+ * @returns {{ pattern: RegExp, label: string } | null}
128
+ */
129
+ function matchPatterns(command, patterns) {
130
+ for (const [pattern, label] of patterns) {
131
+ if (pattern.test(command)) {
132
+ return { pattern, label };
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Evidence recording helper
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /**
143
+ * Record a deny decision to the evidence ledger.
144
+ * @param {string} hookName
145
+ * @param {string} decision - "deny"
146
+ * @param {string} reason
147
+ * @param {string} command - Truncated command for audit
148
+ * @param {string} sessionId
149
+ */
150
+ function recordEvidence(hookName, decision, reason, command, sessionId) {
151
+ try {
152
+ appendEvidence({
153
+ hook: hookName,
154
+ event: "PreToolUse",
155
+ tool: "Bash",
156
+ decision,
157
+ reason,
158
+ command: command.length > 120 ? command.slice(0, 120) + "..." : command,
159
+ session_id: sessionId,
160
+ });
161
+ } catch (_) {
162
+ // Evidence recording failure must not block the deny response
163
+ }
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Main: 10-step judgment flow (§3.2)
168
+ // ---------------------------------------------------------------------------
169
+
170
+ try {
171
+ const input = readHookInput();
172
+ const command = (input.toolInput && input.toolInput.command) || "";
173
+
174
+ // Empty command = not a Bash tool call or no-op — allow
175
+ if (!command) {
176
+ allow();
177
+ }
178
+
179
+ // Step 1: Path normalization (E-2 defense)
180
+ // normalizePath resolves symlinks, Windows backslashes, 8.3 short names
181
+ // We apply it to the command string to detect obfuscated paths
182
+ let normalizedCommand = command;
183
+ // Note: normalizePath works on file paths; for commands we rely on
184
+ // NFKC normalization + pattern matching. Path-specific normalization
185
+ // is applied within PATH_OBFUSCATION_PATTERNS check.
186
+
187
+ // Step 2: NFKC normalization (E-7 defense)
188
+ // Normalizes zero-width characters, homoglyphs, fullwidth chars
189
+ normalizedCommand = nfkcNormalize(normalizedCommand);
190
+
191
+ // Step 3: Destructive command detection
192
+ let match = matchPatterns(normalizedCommand, DESTRUCTIVE_PATTERNS);
193
+ if (match) {
194
+ recordEvidence(
195
+ "sh-gate",
196
+ "deny",
197
+ match.label,
198
+ normalizedCommand,
199
+ input.sessionId,
200
+ );
201
+ deny(`[sh-gate] Blocked: ${match.label}`);
202
+ }
203
+
204
+ // Step 4: Tool switching detection (E-1 defense)
205
+ match = matchPatterns(normalizedCommand, TOOL_SWITCHING_PATTERNS);
206
+ if (match) {
207
+ recordEvidence(
208
+ "sh-gate",
209
+ "deny",
210
+ match.label,
211
+ normalizedCommand,
212
+ input.sessionId,
213
+ );
214
+ deny(`[sh-gate] Blocked: ${match.label}. Use the Edit tool instead.`);
215
+ }
216
+
217
+ // Step 5: sed dangerous modifier detection (E-4 defense)
218
+ match = matchPatterns(normalizedCommand, SED_DANGER_PATTERNS);
219
+ if (match) {
220
+ recordEvidence(
221
+ "sh-gate",
222
+ "deny",
223
+ match.label,
224
+ normalizedCommand,
225
+ input.sessionId,
226
+ );
227
+ deny(
228
+ `[sh-gate] Blocked: ${match.label}. sed e/w modifiers are prohibited.`,
229
+ );
230
+ }
231
+
232
+ // Step 6: Dynamic linker detection (E-3 defense)
233
+ match = matchPatterns(normalizedCommand, DYNAMIC_LINKER_PATTERNS);
234
+ if (match) {
235
+ recordEvidence(
236
+ "sh-gate",
237
+ "deny",
238
+ match.label,
239
+ normalizedCommand,
240
+ input.sessionId,
241
+ );
242
+ deny(
243
+ `[sh-gate] Blocked: ${match.label}. Dynamic linker manipulation is prohibited.`,
244
+ );
245
+ }
246
+
247
+ // Step 7: Self-config modification detection (E-5 defense)
248
+ match = matchPatterns(normalizedCommand, CONFIG_MODIFY_PATTERNS);
249
+ if (match) {
250
+ recordEvidence(
251
+ "sh-gate",
252
+ "deny",
253
+ match.label,
254
+ normalizedCommand,
255
+ input.sessionId,
256
+ );
257
+ deny(
258
+ `[sh-gate] Blocked: ${match.label}. Modifying .claude/ config is prohibited.`,
259
+ );
260
+ }
261
+
262
+ // Step 8: Absolute path enforcement / PATH hijack (FR-02-06)
263
+ match = matchPatterns(normalizedCommand, PATH_HIJACK_PATTERNS);
264
+ if (match) {
265
+ recordEvidence(
266
+ "sh-gate",
267
+ "deny",
268
+ match.label,
269
+ normalizedCommand,
270
+ input.sessionId,
271
+ );
272
+ deny(
273
+ `[sh-gate] Blocked: ${match.label}. PATH/environment manipulation is prohibited.`,
274
+ );
275
+ }
276
+
277
+ // Step 9: Windows-specific detection (FR-02-09, FR-02-10)
278
+ match = matchPatterns(normalizedCommand, WINDOWS_PATTERNS);
279
+ if (match) {
280
+ recordEvidence(
281
+ "sh-gate",
282
+ "deny",
283
+ match.label,
284
+ normalizedCommand,
285
+ input.sessionId,
286
+ );
287
+ deny(
288
+ `[sh-gate] Blocked: ${match.label}. Windows shell attack vector detected.`,
289
+ );
290
+ }
291
+
292
+ // Additional: Pipeline spoofing detection (E-8, §8.1)
293
+ match = matchPatterns(normalizedCommand, PIPELINE_SPOOFING_PATTERNS);
294
+ if (match) {
295
+ recordEvidence(
296
+ "sh-gate",
297
+ "deny",
298
+ match.label,
299
+ normalizedCommand,
300
+ input.sessionId,
301
+ );
302
+ deny(
303
+ `[sh-gate] Blocked: ${match.label}. Pipeline environment spoofing detected.`,
304
+ );
305
+ }
306
+
307
+ // Additional: Path obfuscation detection (E-2 post-normalization)
308
+ match = matchPatterns(normalizedCommand, PATH_OBFUSCATION_PATTERNS);
309
+ if (match) {
310
+ recordEvidence(
311
+ "sh-gate",
312
+ "deny",
313
+ match.label,
314
+ normalizedCommand,
315
+ input.sessionId,
316
+ );
317
+ deny(`[sh-gate] Blocked: ${match.label}. Path obfuscation detected.`);
318
+ }
319
+
320
+ // Step 10: All checks passed — allow
321
+ allow();
322
+ } catch (err) {
323
+ // fail-close: any uncaught error = deny (§2.3b)
324
+ process.stdout.write(
325
+ JSON.stringify({
326
+ reason: `Hook error (sh-gate): ${err.message}`,
327
+ }),
328
+ );
329
+ process.exit(2);
330
+ }
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ // sh-injection-guard.js — 9-category 50+ pattern injection detection (Injection Stage 2)
3
+ // Spec: DETAILED_DESIGN.md §3.3
4
+ // Hook event: PreToolUse
5
+ // Matcher: Bash|Edit|Write|Read|WebFetch
6
+ // Target response time: < 50ms
7
+ "use strict";
8
+
9
+ const {
10
+ readHookInput,
11
+ allow,
12
+ deny,
13
+ nfkcNormalize,
14
+ loadPatterns,
15
+ appendEvidence,
16
+ } = require("./lib/sh-utils");
17
+
18
+ // Zero-width character regex (checked BEFORE pattern matching to prevent bypass)
19
+ // U+200B-200F: zero-width space, non-joiner, joiner, LTR mark, RTL mark
20
+ // U+2028-2029: line separator, paragraph separator
21
+ // U+2060-2064: word joiner, invisible operators
22
+ // U+FEFF: byte order mark
23
+ // U+00AD: soft hyphen
24
+ // U+034F: combining grapheme joiner
25
+ const ZERO_WIDTH_RE =
26
+ /[\u200b-\u200f\u2028\u2029\u2060-\u2064\ufeff\u00ad\u034f]/;
27
+
28
+ /**
29
+ * Extract the text to scan from tool_input based on tool_name.
30
+ * @param {string} toolName
31
+ * @param {Object} toolInput
32
+ * @returns {string} text to scan (empty string if nothing to scan)
33
+ */
34
+ function extractText(toolName, toolInput) {
35
+ switch (toolName) {
36
+ case "Bash":
37
+ return toolInput.command || "";
38
+ case "Edit":
39
+ return toolInput.new_string || "";
40
+ case "Write":
41
+ return toolInput.content || "";
42
+ case "Read":
43
+ return toolInput.file_path || "";
44
+ case "WebFetch":
45
+ return toolInput.url || "";
46
+ default:
47
+ return "";
48
+ }
49
+ }
50
+
51
+ try {
52
+ const input = readHookInput();
53
+ const { toolName, toolInput, sessionId } = input;
54
+
55
+ // Step 0: Extract text to scan
56
+ const rawText = extractText(toolName, toolInput);
57
+
58
+ // If no text to scan, allow (nothing to check)
59
+ if (!rawText) {
60
+ allow();
61
+ }
62
+
63
+ // Step 1: NFKC normalization
64
+ const text = nfkcNormalize(rawText);
65
+
66
+ // Step 2: Zero-width character detection (BEFORE pattern load — prevents bypass)
67
+ if (ZERO_WIDTH_RE.test(rawText)) {
68
+ // Test against raw text (pre-NFKC) since NFKC may normalize some away
69
+ appendEvidence({
70
+ hook: "sh-injection-guard",
71
+ event: "deny",
72
+ tool: toolName,
73
+ category: "zero_width",
74
+ severity: "high",
75
+ detail: "Zero-width character detected in raw input",
76
+ session_id: sessionId,
77
+ });
78
+ deny(
79
+ "[sh-injection-guard] Zero-width character detected. " +
80
+ "Invisible characters can be used to bypass security patterns. " +
81
+ "Category: zero_width (severity: high)",
82
+ );
83
+ }
84
+
85
+ // Step 3: Load injection patterns (fail-close on missing/corrupted file)
86
+ const patterns = loadPatterns();
87
+
88
+ if (!patterns || !patterns.categories) {
89
+ deny("[sh-injection-guard] injection-patterns.json has invalid structure.");
90
+ }
91
+
92
+ // Step 4: Match each category's patterns in severity order
93
+ // Collect medium-severity warnings (not blocking)
94
+ const warnings = [];
95
+ const categories = patterns.categories;
96
+
97
+ for (const [categoryName, category] of Object.entries(categories)) {
98
+ const severity = category.severity || "medium";
99
+ const categoryPatterns = category.patterns || [];
100
+
101
+ for (const patternStr of categoryPatterns) {
102
+ let re;
103
+ try {
104
+ re = new RegExp(patternStr, "i");
105
+ } catch {
106
+ // Invalid regex in patterns file — skip (don't crash the hook)
107
+ continue;
108
+ }
109
+
110
+ if (re.test(text)) {
111
+ if (severity === "critical" || severity === "high") {
112
+ // Deny immediately with evidence
113
+ appendEvidence({
114
+ hook: "sh-injection-guard",
115
+ event: "deny",
116
+ tool: toolName,
117
+ category: categoryName,
118
+ severity,
119
+ pattern: patternStr,
120
+ session_id: sessionId,
121
+ });
122
+ deny(
123
+ `[sh-injection-guard] Injection pattern detected. ` +
124
+ `Category: ${categoryName} (severity: ${severity}). ` +
125
+ `Description: ${category.description || "N/A"}`,
126
+ );
127
+ }
128
+
129
+ if (severity === "medium") {
130
+ // Collect warning — do not deny
131
+ warnings.push({
132
+ category: categoryName,
133
+ severity,
134
+ pattern: patternStr,
135
+ description: category.description || "",
136
+ });
137
+ // Only record the first match per category for warnings
138
+ break;
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ // Step 5: If only medium warnings, allow with additionalContext
145
+ if (warnings.length > 0) {
146
+ const warningMessages = warnings.map(
147
+ (w) => `[${w.category}] ${w.description}`,
148
+ );
149
+ allow(
150
+ `[sh-injection-guard] Warning: potential security concern detected.\n` +
151
+ warningMessages.join("\n"),
152
+ );
153
+ }
154
+
155
+ // All patterns passed — allow
156
+ allow();
157
+ } catch (err) {
158
+ // fail-close: any uncaught error = deny
159
+ process.stdout.write(
160
+ JSON.stringify({
161
+ reason: `Hook error (sh-injection-guard): ${err.message}`,
162
+ }),
163
+ );
164
+ process.exit(2);
165
+ }
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env node
2
+ // sh-instructions.js — Rule file integrity monitoring
3
+ // Spec: DETAILED_DESIGN.md §5.8
4
+ // Event: InstructionsLoaded
5
+ // Target response time: < 200ms
6
+ "use strict";
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const {
11
+ readHookInput,
12
+ allow,
13
+ sha256,
14
+ appendEvidence,
15
+ } = require("./lib/sh-utils");
16
+
17
+ const HOOK_NAME = "sh-instructions";
18
+ const CLAUDE_MD = "CLAUDE.md";
19
+ const RULES_DIR = path.join(".claude", "rules");
20
+ const HASHES_FILE = path.join(".claude", "logs", "instructions-hashes.json");
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Hash Collection
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Compute SHA-256 hash for a file.
28
+ * @param {string} filePath
29
+ * @returns {string|null}
30
+ */
31
+ function hashFile(filePath) {
32
+ try {
33
+ return sha256(fs.readFileSync(filePath, "utf8"));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Collect current hashes for CLAUDE.md and all rule files.
41
+ * @returns {Object} { filePath: hash }
42
+ */
43
+ function collectCurrentHashes() {
44
+ const hashes = {};
45
+
46
+ // CLAUDE.md
47
+ if (fs.existsSync(CLAUDE_MD)) {
48
+ hashes[CLAUDE_MD] = hashFile(CLAUDE_MD);
49
+ }
50
+
51
+ // .claude/rules/*.md
52
+ if (fs.existsSync(RULES_DIR)) {
53
+ try {
54
+ const files = fs.readdirSync(RULES_DIR).filter((f) => f.endsWith(".md"));
55
+ for (const f of files) {
56
+ const fp = path.join(RULES_DIR, f);
57
+ hashes[fp] = hashFile(fp);
58
+ }
59
+ } catch {
60
+ // Directory read failure — non-critical
61
+ }
62
+ }
63
+
64
+ return hashes;
65
+ }
66
+
67
+ /**
68
+ * Load stored hashes from disk.
69
+ * @returns {Object|null} null if no baseline exists
70
+ */
71
+ function loadStoredHashes() {
72
+ try {
73
+ if (!fs.existsSync(HASHES_FILE)) return null;
74
+ return JSON.parse(fs.readFileSync(HASHES_FILE, "utf8"));
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Save hashes to disk.
82
+ * @param {Object} hashes
83
+ */
84
+ function saveHashes(hashes) {
85
+ const dir = path.dirname(HASHES_FILE);
86
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
87
+ fs.writeFileSync(HASHES_FILE, JSON.stringify(hashes, null, 2));
88
+ }
89
+
90
+ /**
91
+ * Detect changes between stored and current hashes.
92
+ * @param {Object} stored
93
+ * @param {Object} current
94
+ * @returns {{ added: string[], modified: string[], removed: string[] }}
95
+ */
96
+ function detectChanges(stored, current) {
97
+ const added = [];
98
+ const modified = [];
99
+ const removed = [];
100
+
101
+ // Check current against stored
102
+ for (const [file, hash] of Object.entries(current)) {
103
+ if (!(file in stored)) {
104
+ added.push(file);
105
+ } else if (stored[file] !== hash) {
106
+ modified.push(file);
107
+ }
108
+ }
109
+
110
+ // Check for removed files
111
+ for (const file of Object.keys(stored)) {
112
+ if (!(file in current)) {
113
+ removed.push(file);
114
+ }
115
+ }
116
+
117
+ return { added, modified, removed };
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Main
122
+ // ---------------------------------------------------------------------------
123
+
124
+ try {
125
+ const input = readHookInput();
126
+ const currentHashes = collectCurrentHashes();
127
+ const storedHashes = loadStoredHashes();
128
+
129
+ // First run: save baseline, no warning
130
+ if (!storedHashes) {
131
+ saveHashes(currentHashes);
132
+
133
+ try {
134
+ appendEvidence({
135
+ hook: HOOK_NAME,
136
+ event: "InstructionsLoaded",
137
+ decision: "allow",
138
+ action: "baseline_recorded",
139
+ file_count: Object.keys(currentHashes).length,
140
+ session_id: input.sessionId,
141
+ });
142
+ } catch {
143
+ // Non-blocking
144
+ }
145
+
146
+ allow(
147
+ `[${HOOK_NAME}] Baseline recorded: ${Object.keys(currentHashes).length} files`,
148
+ );
149
+ }
150
+
151
+ // Detect changes
152
+ const changes = detectChanges(storedHashes, currentHashes);
153
+ const hasChanges =
154
+ changes.added.length > 0 ||
155
+ changes.modified.length > 0 ||
156
+ changes.removed.length > 0;
157
+
158
+ // Update stored hashes
159
+ saveHashes(currentHashes);
160
+
161
+ if (hasChanges) {
162
+ // Build warning message
163
+ const warnings = ["[RULE FILE CHANGE DETECTED]"];
164
+
165
+ if (changes.added.length > 0) {
166
+ warnings.push(` Added: ${changes.added.join(", ")}`);
167
+ }
168
+ if (changes.modified.length > 0) {
169
+ warnings.push(` Modified: ${changes.modified.join(", ")}`);
170
+ }
171
+ if (changes.removed.length > 0) {
172
+ warnings.push(` Removed: ${changes.removed.join(", ")}`);
173
+ }
174
+
175
+ warnings.push("Re-read these files to ensure instructions are current.");
176
+
177
+ try {
178
+ appendEvidence({
179
+ hook: HOOK_NAME,
180
+ event: "InstructionsLoaded",
181
+ decision: "allow",
182
+ action: "changes_detected",
183
+ changes,
184
+ session_id: input.sessionId,
185
+ });
186
+ } catch {
187
+ // Non-blocking
188
+ }
189
+
190
+ allow(warnings.join("\n"));
191
+ }
192
+
193
+ // No changes
194
+ allow();
195
+ } catch (_err) {
196
+ // Operational hook — fail-open
197
+ allow();
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Exports (for testing)
202
+ // ---------------------------------------------------------------------------
203
+
204
+ module.exports = {
205
+ hashFile,
206
+ collectCurrentHashes,
207
+ loadStoredHashes,
208
+ saveHashes,
209
+ detectChanges,
210
+ };