shield-harness 0.1.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 (43) hide show
  1. package/.claude/hooks/lib/ocsf-mapper.js +279 -0
  2. package/.claude/hooks/lib/openshell-detect.js +235 -0
  3. package/.claude/hooks/lib/policy-compat.js +176 -0
  4. package/.claude/hooks/lib/session-modules/.gitkeep +0 -0
  5. package/.claude/hooks/lib/sh-utils.js +340 -0
  6. package/.claude/hooks/lint-on-save.js +240 -0
  7. package/.claude/hooks/sh-circuit-breaker.js +113 -0
  8. package/.claude/hooks/sh-config-guard.js +275 -0
  9. package/.claude/hooks/sh-data-boundary.js +390 -0
  10. package/.claude/hooks/sh-dep-audit.js +101 -0
  11. package/.claude/hooks/sh-elicitation.js +244 -0
  12. package/.claude/hooks/sh-evidence.js +193 -0
  13. package/.claude/hooks/sh-gate.js +365 -0
  14. package/.claude/hooks/sh-injection-guard.js +196 -0
  15. package/.claude/hooks/sh-instructions.js +212 -0
  16. package/.claude/hooks/sh-output-control.js +217 -0
  17. package/.claude/hooks/sh-permission-learn.js +227 -0
  18. package/.claude/hooks/sh-permission.js +157 -0
  19. package/.claude/hooks/sh-pipeline.js +623 -0
  20. package/.claude/hooks/sh-postcompact.js +173 -0
  21. package/.claude/hooks/sh-precompact.js +114 -0
  22. package/.claude/hooks/sh-quiet-inject.js +148 -0
  23. package/.claude/hooks/sh-session-end.js +143 -0
  24. package/.claude/hooks/sh-session-start.js +277 -0
  25. package/.claude/hooks/sh-subagent.js +86 -0
  26. package/.claude/hooks/sh-task-gate.js +141 -0
  27. package/.claude/hooks/sh-user-prompt.js +185 -0
  28. package/.claude/hooks/sh-worktree.js +230 -0
  29. package/.claude/patterns/injection-patterns.json +137 -0
  30. package/.claude/policies/openshell-default.yaml +65 -0
  31. package/.claude/rules/binding-governance.md +62 -0
  32. package/.claude/rules/channel-security.md +90 -0
  33. package/.claude/rules/coding-principles.md +79 -0
  34. package/.claude/rules/dev-environment.md +40 -0
  35. package/.claude/rules/implementation-context.md +132 -0
  36. package/.claude/rules/language.md +26 -0
  37. package/.claude/rules/security.md +109 -0
  38. package/.claude/rules/testing.md +43 -0
  39. package/LICENSE +21 -0
  40. package/README.ja.md +176 -0
  41. package/README.md +174 -0
  42. package/bin/shield-harness.js +241 -0
  43. package/package.json +42 -0
@@ -0,0 +1,365 @@
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
+ [/^rm\s+-rf\s+\.\//, "rm -rf ./ (relative path destruction)"],
26
+ [/^rm\s+-[a-z]*r[a-z]*\s+-[a-z]*f/, "rm flag separation (destructive)"],
27
+ [/^rm\s+-[a-z]*f[a-z]*\s+-[a-z]*r/, "rm flag separation (destructive)"],
28
+ [/^del\s+\/s\s+\/q\s+[A-Z]:\\/, "del /s /q (Windows recursive delete)"],
29
+ [/^format\s+[A-Z]:/, "format drive (disk format)"],
30
+ [/^mkfs\./, "mkfs (filesystem creation on device)"],
31
+ [/^dd\s+if=.*\s+of=\/dev\//, "dd to device (raw disk write)"],
32
+ [/\bfind\b.*\s-delete/, "find -delete (recursive file deletion)"],
33
+ [/\bshred\b/, "shred (secure file destruction)"],
34
+ ];
35
+
36
+ // E-1: Tool switching — bypass Edit/Write tool via Bash scripting languages
37
+ const TOOL_SWITCHING_PATTERNS = [
38
+ [/sed\s+-i/, "sed -i (in-place edit bypasses Edit tool)"],
39
+ [
40
+ /sed\s.*['"][^'"]*[/][^'"]*[ew]\s*['"]/,
41
+ "sed e/w modifier (execute/write via sed)",
42
+ ],
43
+ [/sed\s.*-e\s/, "sed -e (expression, potential execute)"],
44
+ [/python3?\s+-c\s+['"].*open\(/, "python -c open() (file write via python)"],
45
+ [/node\s+-e\s+['"].*fs\./, "node -e fs.* (file write via node)"],
46
+ [
47
+ /\bnode\s+-e\s+.*child_process/,
48
+ "node -e child_process (arbitrary exec via node)",
49
+ ],
50
+ [/ruby\s+-e\s+['"].*File\./, "ruby -e File.* (file write via ruby)"],
51
+ [/perl\s+-[pei]/, "perl -p/-e/-i (in-place or eval mode)"],
52
+ [
53
+ /powershell.*-Command.*Set-Content/i,
54
+ "PowerShell Set-Content (file write via powershell)",
55
+ ],
56
+ [/echo\s+.*>\s/, "echo redirect (file write via echo)"],
57
+ [/echo\s+.*>(?=\S)/, "echo redirect no-space (file write via echo)"],
58
+ [/printf\s+.*>\s/, "printf redirect (file write via printf)"],
59
+ [/\|\s*tee\s/, "pipe to tee (file write via tee)"],
60
+ [/\blua\s+-e\b/, "lua -e (arbitrary code execution via lua)"],
61
+ [/\bphp\s+-r\b/, "php -r (arbitrary code execution via php)"],
62
+ [
63
+ /\bawk\b.*\bsystem\s*\(/,
64
+ "awk system() (arbitrary command execution via awk)",
65
+ ],
66
+ [/\bbash\s+-c\b/, "bash -c (arbitrary command execution via bash)"],
67
+ ];
68
+
69
+ // E-3: Dynamic linker — execute arbitrary code via loader injection
70
+ const DYNAMIC_LINKER_PATTERNS = [
71
+ [/LD_PRELOAD=/, "LD_PRELOAD (shared library injection)"],
72
+ [/LD_LIBRARY_PATH=/, "LD_LIBRARY_PATH (library path hijack)"],
73
+ [/DYLD_INSERT_LIBRARIES=/, "DYLD_INSERT_LIBRARIES (macOS library injection)"],
74
+ [/ld-linux/, "ld-linux (direct dynamic linker invocation)"],
75
+ [/\/lib.*\/ld-/, "/lib*/ld- (dynamic linker path)"],
76
+ [/\/usr\/lib.*\/ld-/, "/usr/lib*/ld- (dynamic linker path)"],
77
+ [/rundll32/i, "rundll32 (Windows DLL execution)"],
78
+ ];
79
+
80
+ // E-4: sed dangerous modifiers (subset of tool switching, explicit check)
81
+ const SED_DANGER_PATTERNS = [
82
+ [
83
+ /sed\s.*['"][^'"]*[/][^'"]*[ew]\s*['"]/,
84
+ "sed e/w modifier (arbitrary command execution)",
85
+ ],
86
+ ];
87
+
88
+ // E-5: Self-config modification — agent modifying its own governance files
89
+ const CONFIG_MODIFY_PATTERNS = [
90
+ [/>\s*\.claude\//, "redirect to .claude/ (config overwrite)"],
91
+ [/>>\s*\.claude\//, "append redirect to .claude/ (config modification)"],
92
+ [/tee\s+.*\.claude\//, "tee to .claude/ (config write)"],
93
+ [/cp\s+.*\.claude\//, "cp to .claude/ (config copy)"],
94
+ [/mv\s+.*\.claude\//, "mv to .claude/ (config move)"],
95
+ ];
96
+
97
+ // FR-02-06: PATH hijack — override command resolution
98
+ const PATH_HIJACK_PATTERNS = [
99
+ [/^PATH=/, "PATH= (command search path override)"],
100
+ [/export\s+PATH=/, "export PATH= (persistent path override)"],
101
+ [/\$SHELL/, "$SHELL (shell variable reference)"],
102
+ [/\$PATH/, "$PATH (path variable reference)"],
103
+ [/env\s+-[SiuC]/, "env -S/-i/-u/-C (environment manipulation)"],
104
+ [/env\s+--split-string/, "env --split-string (argument injection)"],
105
+ [/NODE_OPTIONS\s*=/, "NODE_OPTIONS= (Node.js runtime manipulation)"],
106
+ ];
107
+
108
+ // Git security bypass patterns
109
+ const GIT_BYPASS_PATTERNS = [
110
+ [/\bgit\b.*--no-verify/, "git --no-verify (hook bypass)"],
111
+ [
112
+ /\bgit\s+config\s+core\.hooksPath/,
113
+ "git config core.hooksPath (hook path hijack)",
114
+ ],
115
+ [/\bgit\s+config\s+alias\./, "git config alias (alias injection)"],
116
+ ];
117
+
118
+ // Variable expansion / command substitution patterns
119
+ const VARIABLE_EXPANSION_PATTERNS = [
120
+ [/\$\(/, "$() (command substitution)"],
121
+ [/\$\{/, "${} (variable expansion)"],
122
+ ];
123
+
124
+ // FR-02-09, FR-02-10: Windows-specific attack vectors
125
+ const WINDOWS_PATTERNS = [
126
+ [/\.lnk\b/, ".lnk (Windows shortcut — potential code execution)"],
127
+ [/\.scf\b/, ".scf (Shell Command File — potential code execution)"],
128
+ [/\.url\b/, ".url (Internet shortcut — potential redirect)"],
129
+ [/\.cmd\b/i, ".cmd (Windows batch — uncontrolled execution)"],
130
+ [/\.bat\b/i, ".bat (Windows batch — uncontrolled execution)"],
131
+ [/\bpowershell\b.*-enc/i, "powershell -enc (encoded command — obfuscation)"],
132
+ [/::\$DATA/, "NTFS ADS (Alternate Data Stream)"],
133
+ [/\\\\\?\\UNC\\/, "UNC extended path (network path injection)"],
134
+ ];
135
+
136
+ // E-8: Pipeline environment variable spoofing (§8.1)
137
+ const PIPELINE_SPOOFING_PATTERNS = [
138
+ [/export\s+SH_PIPELINE/, "export SH_PIPELINE (pipeline env spoofing)"],
139
+ [/SH_PIPELINE=1/, "SH_PIPELINE=1 (pipeline env spoofing)"],
140
+ [/env\s+SH_PIPELINE/, "env SH_PIPELINE (pipeline env spoofing)"],
141
+ [/set\s+SH_PIPELINE/, "set SH_PIPELINE (pipeline env spoofing)"],
142
+ ];
143
+
144
+ // E-2: Path obfuscation (checked after normalization)
145
+ const PATH_OBFUSCATION_PATTERNS = [
146
+ [/\/proc\/self\/root/, "/proc/self/root (filesystem escape)"],
147
+ [/\/proc\/[0-9]+\/root/, "/proc/PID/root (filesystem escape)"],
148
+ [/PROGRA~[0-9]/, "8.3 short name (path obfuscation)"],
149
+ [/::\$DATA/, "NTFS ADS :$DATA (hidden data stream)"],
150
+ [/::\$INDEX_ALLOCATION/, "NTFS ADS :$INDEX_ALLOCATION"],
151
+ ];
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Command preprocessing utilities
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Split a command on pipe chain separators (; && || |).
159
+ * Handles simple cases — does NOT parse quoted strings.
160
+ * @param {string} command
161
+ * @returns {string[]}
162
+ */
163
+ function splitPipeChain(command) {
164
+ return command.split(/\s*(?:;|&&|\|\|)\s*/);
165
+ }
166
+
167
+ /**
168
+ * Strip sudo prefix from a command.
169
+ * Handles: sudo, sudo -u user, sudo -E, sudo --preserve-env, etc.
170
+ * @param {string} command
171
+ * @returns {string}
172
+ */
173
+ function stripSudo(command) {
174
+ return command.replace(
175
+ /^\s*sudo\s+(?:(?:-u\s+\S+|--\S+|-[A-Za-z]+)\s+)*/,
176
+ "",
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Strip command/builtin/env prefix from a command.
182
+ * @param {string} command
183
+ * @returns {string}
184
+ */
185
+ function stripPrefix(command) {
186
+ return command.replace(/^\s*(?:command|builtin)\s+/, "");
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Pattern matching engine
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Test a command against an array of [RegExp, label] patterns.
195
+ * @param {string} command - Normalized command string
196
+ * @param {Array<[RegExp, string]>} patterns - Pattern array
197
+ * @returns {{ pattern: RegExp, label: string } | null}
198
+ */
199
+ function matchPatterns(command, patterns) {
200
+ for (const [pattern, label] of patterns) {
201
+ if (pattern.test(command)) {
202
+ return { pattern, label };
203
+ }
204
+ }
205
+ return null;
206
+ }
207
+
208
+ /**
209
+ * Check a command against all pattern arrays, with pipe-chain splitting,
210
+ * sudo stripping, and prefix stripping.
211
+ * @param {string} command - Raw command string (already NFKC normalized)
212
+ * @param {Array<Array<[RegExp, string]>>} allPatternArrays
213
+ * @returns {{ pattern: RegExp, label: string } | null}
214
+ */
215
+ function matchAllPatterns(command, allPatternArrays) {
216
+ // Check the full command first
217
+ for (const patterns of allPatternArrays) {
218
+ const m = matchPatterns(command, patterns);
219
+ if (m) return m;
220
+ }
221
+
222
+ // Split on chain separators and check each segment
223
+ const segments = splitPipeChain(command);
224
+ if (segments.length > 1) {
225
+ for (const seg of segments) {
226
+ const cleaned = stripPrefix(stripSudo(seg.trim()));
227
+ for (const patterns of allPatternArrays) {
228
+ const m = matchPatterns(cleaned, patterns);
229
+ if (m) return m;
230
+ }
231
+ }
232
+ }
233
+
234
+ // Try sudo + prefix stripping on full command
235
+ const stripped = stripPrefix(stripSudo(command));
236
+ if (stripped !== command) {
237
+ for (const patterns of allPatternArrays) {
238
+ const m = matchPatterns(stripped, patterns);
239
+ if (m) return m;
240
+ }
241
+ }
242
+
243
+ return null;
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Evidence recording helper
248
+ // ---------------------------------------------------------------------------
249
+
250
+ /**
251
+ * Record a deny decision to the evidence ledger.
252
+ * @param {string} hookName
253
+ * @param {string} decision - "deny"
254
+ * @param {string} reason
255
+ * @param {string} command - Truncated command for audit
256
+ * @param {string} sessionId
257
+ */
258
+ function recordEvidence(hookName, decision, reason, command, sessionId) {
259
+ try {
260
+ appendEvidence({
261
+ hook: hookName,
262
+ event: "PreToolUse",
263
+ tool: "Bash",
264
+ decision,
265
+ reason,
266
+ command: command.length > 120 ? command.slice(0, 120) + "..." : command,
267
+ session_id: sessionId,
268
+ });
269
+ } catch (_) {
270
+ // Evidence recording failure must not block the deny response
271
+ }
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Main: 10-step judgment flow (§3.2)
276
+ // ---------------------------------------------------------------------------
277
+
278
+ // Guard: only execute main logic when run directly (not when require'd for testing)
279
+ if (require.main === module) {
280
+ try {
281
+ const input = readHookInput();
282
+ const command = (input.toolInput && input.toolInput.command) || "";
283
+
284
+ // Empty command = not a Bash tool call or no-op — allow
285
+ if (!command) {
286
+ allow();
287
+ return;
288
+ }
289
+
290
+ // Step 1: Path normalization (E-2 defense)
291
+ // normalizePath resolves symlinks, Windows backslashes, 8.3 short names
292
+ // We apply it to the command string to detect obfuscated paths
293
+ let normalizedCommand = command;
294
+ // Note: normalizePath works on file paths; for commands we rely on
295
+ // NFKC normalization + pattern matching. Path-specific normalization
296
+ // is applied within PATH_OBFUSCATION_PATTERNS check.
297
+
298
+ // Step 2: NFKC normalization (E-7 defense)
299
+ // Normalizes zero-width characters, homoglyphs, fullwidth chars
300
+ normalizedCommand = nfkcNormalize(normalizedCommand);
301
+
302
+ // Step 3: Unified pattern matching with pipe-chain splitting,
303
+ // sudo stripping, and prefix stripping
304
+ const allPatternArrays = [
305
+ DESTRUCTIVE_PATTERNS,
306
+ TOOL_SWITCHING_PATTERNS,
307
+ SED_DANGER_PATTERNS,
308
+ DYNAMIC_LINKER_PATTERNS,
309
+ CONFIG_MODIFY_PATTERNS,
310
+ PATH_HIJACK_PATTERNS,
311
+ WINDOWS_PATTERNS,
312
+ PIPELINE_SPOOFING_PATTERNS,
313
+ PATH_OBFUSCATION_PATTERNS,
314
+ GIT_BYPASS_PATTERNS,
315
+ VARIABLE_EXPANSION_PATTERNS,
316
+ ];
317
+
318
+ const match = matchAllPatterns(normalizedCommand, allPatternArrays);
319
+ if (match) {
320
+ recordEvidence(
321
+ "sh-gate",
322
+ "deny",
323
+ match.label,
324
+ normalizedCommand,
325
+ input.sessionId,
326
+ );
327
+ deny(`[sh-gate] Blocked: ${match.label}`);
328
+ return;
329
+ }
330
+
331
+ // Step 10: All checks passed — allow
332
+ allow();
333
+ } catch (err) {
334
+ // fail-close: any uncaught error = deny (§2.3b)
335
+ process.stdout.write(
336
+ JSON.stringify({
337
+ reason: `Hook error (sh-gate): ${err.message}`,
338
+ }),
339
+ );
340
+ process.exit(2);
341
+ }
342
+ } // end require.main guard
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Exports for testing
346
+ // ---------------------------------------------------------------------------
347
+ module.exports = {
348
+ DESTRUCTIVE_PATTERNS,
349
+ TOOL_SWITCHING_PATTERNS,
350
+ DYNAMIC_LINKER_PATTERNS,
351
+ SED_DANGER_PATTERNS,
352
+ CONFIG_MODIFY_PATTERNS,
353
+ PATH_HIJACK_PATTERNS,
354
+ WINDOWS_PATTERNS,
355
+ PIPELINE_SPOOFING_PATTERNS,
356
+ PATH_OBFUSCATION_PATTERNS,
357
+ GIT_BYPASS_PATTERNS,
358
+ VARIABLE_EXPANSION_PATTERNS,
359
+ matchPatterns,
360
+ matchAllPatterns,
361
+ splitPipeChain,
362
+ stripSudo,
363
+ stripPrefix,
364
+ recordEvidence,
365
+ };
@@ -0,0 +1,196 @@
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+00AD: soft hyphen
20
+ // U+034F: combining grapheme joiner
21
+ // U+180E: Mongolian vowel separator
22
+ // U+200A-200F: hair space, zero-width space, non-joiner, joiner, LTR mark, RTL mark
23
+ // U+2028-2029: line separator, paragraph separator
24
+ // U+205F: medium mathematical space
25
+ // U+2060-2064: word joiner, invisible operators
26
+ // U+3000: ideographic space
27
+ // U+FEFF: byte order mark
28
+ const ZERO_WIDTH_RE =
29
+ /[\u00ad\u034f\u180e\u200a-\u200f\u2028\u2029\u205f\u2060-\u2064\u3000\ufeff]/;
30
+
31
+ /**
32
+ * Extract the text to scan from tool_input based on tool_name.
33
+ * @param {string} toolName
34
+ * @param {Object} toolInput
35
+ * @returns {string} text to scan (empty string if nothing to scan)
36
+ */
37
+ function extractText(toolName, toolInput) {
38
+ const parts = [];
39
+ switch (toolName) {
40
+ case "Bash":
41
+ parts.push(toolInput.command || "");
42
+ break;
43
+ case "Edit":
44
+ parts.push(toolInput.new_string || "");
45
+ parts.push(toolInput.old_string || "");
46
+ parts.push(toolInput.file_path || "");
47
+ break;
48
+ case "Write":
49
+ parts.push(toolInput.content || "");
50
+ parts.push(toolInput.file_path || "");
51
+ break;
52
+ case "Read":
53
+ parts.push(toolInput.file_path || "");
54
+ break;
55
+ case "WebFetch":
56
+ parts.push(toolInput.url || "");
57
+ break;
58
+ default:
59
+ return "";
60
+ }
61
+ // Join with newline separator so patterns don't span across fields
62
+ return parts.filter(Boolean).join("\n");
63
+ }
64
+
65
+ // Guard: only execute main logic when run directly (not when require'd for testing)
66
+ if (require.main === module) {
67
+ try {
68
+ const input = readHookInput();
69
+ const { toolName, toolInput, sessionId } = input;
70
+
71
+ // Step 0: Extract text to scan
72
+ const rawText = extractText(toolName, toolInput);
73
+
74
+ // If no text to scan, allow (nothing to check)
75
+ if (!rawText) {
76
+ allow();
77
+ return;
78
+ }
79
+
80
+ // Step 1: NFKC normalization
81
+ const text = nfkcNormalize(rawText);
82
+
83
+ // Step 2: Zero-width character detection (BEFORE pattern load — prevents bypass)
84
+ if (ZERO_WIDTH_RE.test(rawText)) {
85
+ // Test against raw text (pre-NFKC) since NFKC may normalize some away
86
+ appendEvidence({
87
+ hook: "sh-injection-guard",
88
+ event: "deny",
89
+ tool: toolName,
90
+ category: "zero_width",
91
+ severity: "high",
92
+ detail: "Zero-width character detected in raw input",
93
+ session_id: sessionId,
94
+ });
95
+ deny(
96
+ "[sh-injection-guard] Zero-width character detected. " +
97
+ "Invisible characters can be used to bypass security patterns. " +
98
+ "Category: zero_width (severity: high)",
99
+ );
100
+ return;
101
+ }
102
+
103
+ // Step 3: Load injection patterns (fail-close on missing/corrupted file)
104
+ const patterns = loadPatterns();
105
+
106
+ if (!patterns || !patterns.categories) {
107
+ deny(
108
+ "[sh-injection-guard] injection-patterns.json has invalid structure.",
109
+ );
110
+ return;
111
+ }
112
+
113
+ // Step 4: Match each category's patterns in severity order
114
+ // Collect medium-severity warnings (not blocking)
115
+ const warnings = [];
116
+ const categories = patterns.categories;
117
+
118
+ for (const [categoryName, category] of Object.entries(categories)) {
119
+ const severity = category.severity || "medium";
120
+ const categoryPatterns = category.patterns || [];
121
+
122
+ for (const patternStr of categoryPatterns) {
123
+ let re;
124
+ try {
125
+ re = new RegExp(patternStr, "i");
126
+ } catch {
127
+ // Invalid regex in patterns file — skip (don't crash the hook)
128
+ continue;
129
+ }
130
+
131
+ if (re.test(text)) {
132
+ if (severity === "critical" || severity === "high") {
133
+ // Deny immediately with evidence
134
+ appendEvidence({
135
+ hook: "sh-injection-guard",
136
+ event: "deny",
137
+ tool: toolName,
138
+ category: categoryName,
139
+ severity,
140
+ pattern: patternStr,
141
+ session_id: sessionId,
142
+ });
143
+ deny(
144
+ `[sh-injection-guard] Injection pattern detected. ` +
145
+ `Category: ${categoryName} (severity: ${severity}). ` +
146
+ `Description: ${category.description || "N/A"}`,
147
+ );
148
+ return;
149
+ }
150
+
151
+ if (severity === "medium") {
152
+ // Collect warning — do not deny
153
+ warnings.push({
154
+ category: categoryName,
155
+ severity,
156
+ pattern: patternStr,
157
+ description: category.description || "",
158
+ });
159
+ // Only record the first match per category for warnings
160
+ break;
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ // Step 5: If only medium warnings, allow with additionalContext
167
+ if (warnings.length > 0) {
168
+ const warningMessages = warnings.map(
169
+ (w) => `[${w.category}] ${w.description}`,
170
+ );
171
+ allow(
172
+ `[sh-injection-guard] Warning: potential security concern detected.\n` +
173
+ warningMessages.join("\n"),
174
+ );
175
+ }
176
+
177
+ // All patterns passed — allow
178
+ allow();
179
+ } catch (err) {
180
+ // fail-close: any uncaught error = deny
181
+ process.stdout.write(
182
+ JSON.stringify({
183
+ reason: `Hook error (sh-injection-guard): ${err.message}`,
184
+ }),
185
+ );
186
+ process.exit(2);
187
+ }
188
+ } // end require.main guard
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Exports for testing
192
+ // ---------------------------------------------------------------------------
193
+ module.exports = {
194
+ ZERO_WIDTH_RE,
195
+ extractText,
196
+ };