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.
- package/.claude/hooks/lib/session-modules/.gitkeep +0 -0
- package/.claude/hooks/lib/sh-utils.js +241 -0
- package/.claude/hooks/lint-on-save.js +240 -0
- package/.claude/hooks/sh-circuit-breaker.js +111 -0
- package/.claude/hooks/sh-config-guard.js +252 -0
- package/.claude/hooks/sh-data-boundary.js +315 -0
- package/.claude/hooks/sh-dep-audit.js +101 -0
- package/.claude/hooks/sh-elicitation.js +241 -0
- package/.claude/hooks/sh-evidence.js +193 -0
- package/.claude/hooks/sh-gate.js +330 -0
- package/.claude/hooks/sh-injection-guard.js +165 -0
- package/.claude/hooks/sh-instructions.js +210 -0
- package/.claude/hooks/sh-output-control.js +183 -0
- package/.claude/hooks/sh-permission-learn.js +223 -0
- package/.claude/hooks/sh-permission.js +157 -0
- package/.claude/hooks/sh-pipeline.js +639 -0
- package/.claude/hooks/sh-postcompact.js +173 -0
- package/.claude/hooks/sh-precompact.js +114 -0
- package/.claude/hooks/sh-quiet-inject.js +147 -0
- package/.claude/hooks/sh-session-end.js +143 -0
- package/.claude/hooks/sh-session-start.js +196 -0
- package/.claude/hooks/sh-subagent.js +86 -0
- package/.claude/hooks/sh-task-gate.js +138 -0
- package/.claude/hooks/sh-user-prompt.js +181 -0
- package/.claude/hooks/sh-worktree.js +227 -0
- package/.claude/patterns/injection-patterns.json +137 -0
- package/.claude/rules/binding-governance.md +62 -0
- package/.claude/rules/channel-security.md +90 -0
- package/.claude/rules/coding-principles.md +79 -0
- package/.claude/rules/dev-environment.md +37 -0
- package/.claude/rules/implementation-context.md +112 -0
- package/.claude/rules/language.md +26 -0
- package/.claude/rules/security.md +109 -0
- package/.claude/rules/testing.md +43 -0
- package/LICENSE +21 -0
- package/README.ja.md +107 -0
- package/README.md +105 -0
- package/bin/shield-harness.js +141 -0
- 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
|
+
};
|