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,252 @@
1
+ #!/usr/bin/env node
2
+ // sh-config-guard.js — Settings.json mutation guard
3
+ // Spec: DETAILED_DESIGN.md §5.3
4
+ // Event: ConfigChange
5
+ // Target response time: < 100ms
6
+ "use strict";
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const {
11
+ readHookInput,
12
+ allow,
13
+ deny,
14
+ sha256,
15
+ appendEvidence,
16
+ } = require("./lib/sh-utils");
17
+
18
+ const HOOK_NAME = "sh-config-guard";
19
+ const SETTINGS_FILE = path.join(".claude", "settings.json");
20
+ const CONFIG_HASH_FILE = path.join(".claude", "logs", "config-hash.json");
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Config Analysis
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Read and parse settings.json.
28
+ * @returns {Object|null}
29
+ */
30
+ function readSettings() {
31
+ try {
32
+ if (!fs.existsSync(SETTINGS_FILE)) return null;
33
+ return JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf8"));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Load previously stored config snapshot.
41
+ * @returns {{ hash: string, deny_rules: string[], hook_count: number, sandbox: boolean }|null}
42
+ */
43
+ function loadStoredConfig() {
44
+ try {
45
+ if (!fs.existsSync(CONFIG_HASH_FILE)) return null;
46
+ const data = JSON.parse(fs.readFileSync(CONFIG_HASH_FILE, "utf8"));
47
+ // Validate shield-harness format (deny_rules array required)
48
+ // Reject legacy-format snapshots (hash + snapshot_keys only)
49
+ if (!Array.isArray(data.deny_rules)) return null;
50
+ return data;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Save current config snapshot.
58
+ * @param {Object} snapshot
59
+ */
60
+ function saveConfigSnapshot(snapshot) {
61
+ const dir = path.dirname(CONFIG_HASH_FILE);
62
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
63
+ fs.writeFileSync(CONFIG_HASH_FILE, JSON.stringify(snapshot, null, 2));
64
+ }
65
+
66
+ /**
67
+ * Extract security-critical fields from settings.
68
+ * @param {Object} settings
69
+ * @returns {{ deny_rules: string[], hook_count: number, hook_events: string[], sandbox: boolean, unsandboxed: boolean, disableAllHooks: boolean }}
70
+ */
71
+ function extractSecurityFields(settings) {
72
+ const denyRules = (settings.permissions && settings.permissions.deny) || [];
73
+
74
+ // Count total hooks across all events
75
+ const hooks = settings.hooks || {};
76
+ let hookCount = 0;
77
+ const hookEvents = [];
78
+ for (const [event, entries] of Object.entries(hooks)) {
79
+ hookEvents.push(event);
80
+ for (const entry of Array.isArray(entries) ? entries : []) {
81
+ const hookList = entry.hooks || [];
82
+ hookCount += hookList.length;
83
+ }
84
+ }
85
+
86
+ return {
87
+ deny_rules: denyRules,
88
+ hook_count: hookCount,
89
+ hook_events: hookEvents,
90
+ sandbox:
91
+ settings.sandbox !== undefined
92
+ ? Boolean(settings.sandbox.enabled !== false)
93
+ : true,
94
+ unsandboxed: Boolean(settings.allowUnsandboxedCommands),
95
+ disableAllHooks: Boolean(settings.disableAllHooks),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Check for dangerous mutations between stored and current config.
101
+ * @param {Object} stored - Previous security fields
102
+ * @param {Object} current - Current security fields
103
+ * @returns {{ blocked: boolean, reasons: string[] }}
104
+ */
105
+ function detectDangerousMutations(stored, current) {
106
+ const reasons = [];
107
+
108
+ // Check 1: deny rules removed
109
+ for (const rule of stored.deny_rules) {
110
+ if (!current.deny_rules.includes(rule)) {
111
+ reasons.push(`deny rule removed: "${rule}"`);
112
+ }
113
+ }
114
+
115
+ // Check 2: hooks removed (event-level check)
116
+ if (current.hook_count < stored.hook_count) {
117
+ const removedCount = stored.hook_count - current.hook_count;
118
+ reasons.push(`${removedCount} hook(s) removed from configuration`);
119
+ }
120
+ for (const event of stored.hook_events) {
121
+ if (!current.hook_events.includes(event)) {
122
+ reasons.push(`hook event "${event}" entirely removed`);
123
+ }
124
+ }
125
+
126
+ // Check 3: sandbox disabled
127
+ if (stored.sandbox && !current.sandbox) {
128
+ reasons.push("sandbox.enabled set to false");
129
+ }
130
+
131
+ // Check 4: unsandboxed commands allowed
132
+ if (!stored.unsandboxed && current.unsandboxed) {
133
+ reasons.push("allowUnsandboxedCommands set to true");
134
+ }
135
+
136
+ // Check 5: all hooks disabled
137
+ if (!stored.disableAllHooks && current.disableAllHooks) {
138
+ reasons.push("disableAllHooks set to true");
139
+ }
140
+
141
+ return {
142
+ blocked: reasons.length > 0,
143
+ reasons,
144
+ };
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Main
149
+ // ---------------------------------------------------------------------------
150
+
151
+ try {
152
+ const input = readHookInput();
153
+
154
+ const settings = readSettings();
155
+ if (!settings) {
156
+ deny(`[${HOOK_NAME}] settings.json not found or unreadable — fail-close`);
157
+ }
158
+
159
+ const currentFields = extractSecurityFields(settings);
160
+ const settingsContent = fs.readFileSync(SETTINGS_FILE, "utf8");
161
+ const currentHash = sha256(settingsContent);
162
+
163
+ const stored = loadStoredConfig();
164
+
165
+ // First run: record baseline
166
+ if (!stored) {
167
+ saveConfigSnapshot({
168
+ hash: currentHash,
169
+ ...currentFields,
170
+ });
171
+
172
+ try {
173
+ appendEvidence({
174
+ hook: HOOK_NAME,
175
+ event: "ConfigChange",
176
+ decision: "allow",
177
+ action: "baseline_recorded",
178
+ settings_hash: `sha256:${currentHash}`,
179
+ session_id: input.sessionId,
180
+ });
181
+ } catch {
182
+ // Non-blocking
183
+ }
184
+
185
+ allow(`[${HOOK_NAME}] Config baseline recorded`);
186
+ }
187
+
188
+ // Check for dangerous mutations
189
+ const mutations = detectDangerousMutations(stored, currentFields);
190
+
191
+ if (mutations.blocked) {
192
+ try {
193
+ appendEvidence({
194
+ hook: HOOK_NAME,
195
+ event: "ConfigChange",
196
+ decision: "deny",
197
+ reasons: mutations.reasons,
198
+ settings_hash: `sha256:${currentHash}`,
199
+ previous_hash: `sha256:${stored.hash}`,
200
+ session_id: input.sessionId,
201
+ });
202
+ } catch {
203
+ // Non-blocking
204
+ }
205
+
206
+ deny(
207
+ `[${HOOK_NAME}] Blocked dangerous config change: ${mutations.reasons.join("; ")}`,
208
+ );
209
+ }
210
+
211
+ // Safe change — update snapshot and allow
212
+ saveConfigSnapshot({
213
+ hash: currentHash,
214
+ ...currentFields,
215
+ });
216
+
217
+ try {
218
+ appendEvidence({
219
+ hook: HOOK_NAME,
220
+ event: "ConfigChange",
221
+ decision: "allow",
222
+ action: "config_updated",
223
+ settings_hash: `sha256:${currentHash}`,
224
+ previous_hash: stored ? `sha256:${stored.hash}` : null,
225
+ session_id: input.sessionId,
226
+ });
227
+ } catch {
228
+ // Non-blocking
229
+ }
230
+
231
+ allow();
232
+ } catch (err) {
233
+ // SECURITY hook — fail-close (§2.3b)
234
+ process.stdout.write(
235
+ JSON.stringify({
236
+ reason: `[${HOOK_NAME}] Hook error (fail-close): ${err.message}`,
237
+ }),
238
+ );
239
+ process.exit(2);
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Exports (for testing)
244
+ // ---------------------------------------------------------------------------
245
+
246
+ module.exports = {
247
+ readSettings,
248
+ loadStoredConfig,
249
+ saveConfigSnapshot,
250
+ extractSecurityFields,
251
+ detectDangerousMutations,
252
+ };
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env node
2
+ // sh-data-boundary.js — Production data boundary guard + jurisdiction tracking
3
+ // Spec: DETAILED_DESIGN.md §3.4
4
+ // Hook event: PreToolUse
5
+ // Matcher: Bash|WebFetch
6
+ // Target response time: < 50ms
7
+ "use strict";
8
+
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const {
12
+ readHookInput,
13
+ allow,
14
+ deny,
15
+ readSession,
16
+ appendEvidence,
17
+ } = require("./lib/sh-utils");
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Config Paths
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const SH_CONFIG_DIR = path.join(".shield-harness", "config");
24
+ const PRODUCTION_HOSTS_FILE = path.join(SH_CONFIG_DIR, "production-hosts.json");
25
+ const ALLOWED_JURISDICTIONS_FILE = path.join(
26
+ SH_CONFIG_DIR,
27
+ "allowed-jurisdictions.json",
28
+ );
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Default Production Host Patterns (used when config file has "patterns" key)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const DEFAULT_PROD_PATTERNS = [
35
+ /\bprod-/i,
36
+ /\bproduction\./i,
37
+ /\.prod\./i,
38
+ /\bprod\b.*\.(rds|database|db|sql)/i,
39
+ ];
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Hostname Extraction from Bash Commands
43
+ // ---------------------------------------------------------------------------
44
+
45
+ // Commands that commonly connect to external hosts
46
+ const HOST_EXTRACTORS = [
47
+ // curl/wget: extract URL or host argument
48
+ {
49
+ pattern: /\b(?:curl|wget)\s+(?:[^\s]*\s+)*(?:https?:\/\/)?([^\s/:]+)/i,
50
+ group: 1,
51
+ },
52
+ // ssh: user@host or just host
53
+ { pattern: /\bssh\s+(?:[^\s]*\s+)*(?:\w+@)?([^\s/:]+)/i, group: 1 },
54
+ // psql: -h host
55
+ { pattern: /\bpsql\b.*?\s+-h\s+([^\s]+)/i, group: 1 },
56
+ // psql: host in connection string
57
+ { pattern: /\bpsql\b.*?(?:host=|\/\/)([^\s/:;]+)/i, group: 1 },
58
+ // mysql: -h host
59
+ { pattern: /\bmysql\b.*?\s+-h\s+([^\s]+)/i, group: 1 },
60
+ // mongosh/mongo: host in connection string
61
+ {
62
+ pattern: /\b(?:mongosh|mongo)\b.*?(?:mongodb(?:\+srv)?:\/\/)([^\s/:]+)/i,
63
+ group: 1,
64
+ },
65
+ // redis-cli: -h host
66
+ { pattern: /\bredis-cli\b.*?\s+-h\s+([^\s]+)/i, group: 1 },
67
+ ];
68
+
69
+ /**
70
+ * Extract hostnames from a Bash command string.
71
+ * @param {string} command
72
+ * @returns {string[]} Array of extracted hostnames (lowercase).
73
+ */
74
+ function extractHostsFromCommand(command) {
75
+ const hosts = [];
76
+ for (const extractor of HOST_EXTRACTORS) {
77
+ const match = command.match(extractor.pattern);
78
+ if (match && match[extractor.group]) {
79
+ hosts.push(match[extractor.group].toLowerCase());
80
+ }
81
+ }
82
+ return hosts;
83
+ }
84
+
85
+ /**
86
+ * Extract hostname from a URL string.
87
+ * @param {string} url
88
+ * @returns {string|null} Lowercase hostname or null.
89
+ */
90
+ function extractHostFromUrl(url) {
91
+ try {
92
+ const parsed = new URL(url);
93
+ return parsed.hostname.toLowerCase();
94
+ } catch {
95
+ // Try extracting with regex as fallback
96
+ const match = url.match(/^https?:\/\/([^/:]+)/i);
97
+ return match ? match[1].toLowerCase() : null;
98
+ }
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Config Loaders (fail-safe: missing config = skip check)
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Load production hosts config.
107
+ * Returns null if file doesn't exist (= no restrictions configured).
108
+ *
109
+ * Expected format:
110
+ * {
111
+ * "hosts": ["prod-db.example.com", "production.internal"],
112
+ * "patterns": ["prod-", "production\\."]
113
+ * }
114
+ *
115
+ * @returns {{ hosts: string[], patterns: RegExp[] } | null}
116
+ */
117
+ function loadProductionHosts() {
118
+ if (!fs.existsSync(PRODUCTION_HOSTS_FILE)) return null;
119
+
120
+ try {
121
+ const config = JSON.parse(fs.readFileSync(PRODUCTION_HOSTS_FILE, "utf8"));
122
+ const hosts = (config.hosts || []).map((h) => h.toLowerCase());
123
+ const patterns = (config.patterns || []).map((p) => new RegExp(p, "i"));
124
+ return { hosts, patterns };
125
+ } catch {
126
+ // Corrupted config — fail-close for security
127
+ return { hosts: [], patterns: DEFAULT_PROD_PATTERNS };
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Load allowed jurisdictions config.
133
+ * Returns null if file doesn't exist (= no jurisdiction restrictions).
134
+ *
135
+ * Expected format:
136
+ * {
137
+ * "allowed": ["JP", "US", "EU"],
138
+ * "tld_map": { ".jp": "JP", ".us": "US", ".eu": "EU", ".de": "EU", ... }
139
+ * }
140
+ *
141
+ * @returns {{ allowed: Set<string>, tldMap: Object } | null}
142
+ */
143
+ function loadAllowedJurisdictions() {
144
+ if (!fs.existsSync(ALLOWED_JURISDICTIONS_FILE)) return null;
145
+
146
+ try {
147
+ const config = JSON.parse(
148
+ fs.readFileSync(ALLOWED_JURISDICTIONS_FILE, "utf8"),
149
+ );
150
+ const allowed = new Set((config.allowed || []).map((j) => j.toUpperCase()));
151
+ const tldMap = config.tld_map || {};
152
+ return { allowed, tldMap };
153
+ } catch {
154
+ // Corrupted config — skip jurisdiction check (cannot determine safely)
155
+ return null;
156
+ }
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Production Host Check
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /**
164
+ * Check if a hostname matches production host patterns.
165
+ * @param {string} hostname - Lowercase hostname to check.
166
+ * @param {{ hosts: string[], patterns: RegExp[] }} config
167
+ * @returns {boolean}
168
+ */
169
+ function isProductionHost(hostname, config) {
170
+ // Exact match
171
+ if (config.hosts.includes(hostname)) return true;
172
+
173
+ // Pattern match
174
+ for (const pattern of config.patterns) {
175
+ if (pattern.test(hostname)) return true;
176
+ }
177
+
178
+ return false;
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Jurisdiction Check
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /**
186
+ * Estimate jurisdiction from hostname TLD.
187
+ * @param {string} hostname
188
+ * @param {Object} tldMap - TLD to jurisdiction code mapping.
189
+ * @returns {string|null} Jurisdiction code (e.g., "JP") or null if unknown.
190
+ */
191
+ function estimateJurisdiction(hostname, tldMap) {
192
+ // Extract TLD (last dot-segment)
193
+ const parts = hostname.split(".");
194
+ if (parts.length < 2) return null;
195
+
196
+ const tld = "." + parts[parts.length - 1];
197
+
198
+ // Check custom TLD map
199
+ const upperTld = tld.toLowerCase();
200
+ for (const [key, value] of Object.entries(tldMap)) {
201
+ if (key.toLowerCase() === upperTld) return value.toUpperCase();
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Main
209
+ // ---------------------------------------------------------------------------
210
+
211
+ try {
212
+ const input = readHookInput();
213
+ const toolName = input.toolName;
214
+ const toolInput = input.toolInput;
215
+
216
+ // Check channel source for evidence metadata (§8.6.3)
217
+ let isChannel = false;
218
+ try {
219
+ const session = readSession();
220
+ isChannel = session.source === "channel";
221
+ } catch {
222
+ // Session read failure is non-blocking
223
+ }
224
+
225
+ // --- Step 1: Production DB host detection ---
226
+ const prodConfig = loadProductionHosts();
227
+
228
+ if (prodConfig) {
229
+ let hostsToCheck = [];
230
+
231
+ if (toolName === "Bash") {
232
+ const command = (toolInput.command || "").trim();
233
+ hostsToCheck = extractHostsFromCommand(command);
234
+ } else if (toolName === "WebFetch") {
235
+ const url = toolInput.url || "";
236
+ const host = extractHostFromUrl(url);
237
+ if (host) hostsToCheck = [host];
238
+ }
239
+
240
+ for (const host of hostsToCheck) {
241
+ if (isProductionHost(host, prodConfig)) {
242
+ appendEvidence({
243
+ event: "data_boundary_deny",
244
+ hook: "sh-data-boundary",
245
+ tool: toolName,
246
+ host: host,
247
+ reason: "production_host_detected",
248
+ is_channel: isChannel,
249
+ });
250
+ deny(`Production environment access is prohibited: ${host}`);
251
+ }
252
+ }
253
+ }
254
+
255
+ // --- Step 2: Jurisdiction check (WebFetch only) ---
256
+ if (toolName === "WebFetch") {
257
+ const jurisdictionConfig = loadAllowedJurisdictions();
258
+
259
+ if (jurisdictionConfig) {
260
+ const url = toolInput.url || "";
261
+ const host = extractHostFromUrl(url);
262
+
263
+ if (host) {
264
+ const jurisdiction = estimateJurisdiction(
265
+ host,
266
+ jurisdictionConfig.tldMap,
267
+ );
268
+
269
+ if (jurisdiction && !jurisdictionConfig.allowed.has(jurisdiction)) {
270
+ appendEvidence({
271
+ event: "data_boundary_deny",
272
+ hook: "sh-data-boundary",
273
+ tool: toolName,
274
+ host: host,
275
+ jurisdiction: jurisdiction,
276
+ reason: "unauthorized_jurisdiction",
277
+ is_channel: isChannel,
278
+ });
279
+ deny(
280
+ `Unauthorized jurisdiction detected: ${jurisdiction} (host: ${host}). ` +
281
+ `Allowed: ${[...jurisdictionConfig.allowed].join(", ")}`,
282
+ );
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ // --- Step 3: All checks passed ---
289
+ allow();
290
+ } catch (err) {
291
+ // fail-close: any uncaught error = deny
292
+ process.stdout.write(
293
+ JSON.stringify({
294
+ reason: `Hook error (sh-data-boundary): ${err.message}`,
295
+ }),
296
+ );
297
+ process.exit(2);
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Exports (for testing)
302
+ // ---------------------------------------------------------------------------
303
+
304
+ module.exports = {
305
+ // Config paths (for test override)
306
+ PRODUCTION_HOSTS_FILE,
307
+ ALLOWED_JURISDICTIONS_FILE,
308
+ // Functions
309
+ extractHostsFromCommand,
310
+ extractHostFromUrl,
311
+ loadProductionHosts,
312
+ loadAllowedJurisdictions,
313
+ isProductionHost,
314
+ estimateJurisdiction,
315
+ };
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ // sh-dep-audit.js — Dependency package install detection + security scan advisory
3
+ // Spec: DETAILED_DESIGN.md §4.3
4
+ // Hook event: PostToolUse
5
+ // Matcher: Bash
6
+ // Target response time: < 30ms
7
+ "use strict";
8
+
9
+ const { readHookInput, allow, appendEvidence } = require("./lib/sh-utils");
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Constants / Patterns
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const HOOK_NAME = "sh-dep-audit";
16
+
17
+ // Package manager install detection patterns (FR-11-04)
18
+ const INSTALL_PATTERNS = [
19
+ {
20
+ regex: /npm (install|i|add|ci)\b/,
21
+ manager: "npm",
22
+ scan: "npm audit --json",
23
+ },
24
+ { regex: /pnpm (add|install)\b/, manager: "pnpm", scan: "pnpm audit --json" },
25
+ { regex: /yarn add\b/, manager: "yarn", scan: "yarn audit --json" },
26
+ { regex: /bun (add|install)\b/, manager: "bun", scan: "bun audit" },
27
+ { regex: /pip3? install\b/, manager: "pip", scan: "pip-audit --format=json" },
28
+ { regex: /cargo (add|install)\b/, manager: "cargo", scan: "cargo audit" },
29
+ { regex: /go get\b/, manager: "go", scan: "govulncheck ./..." },
30
+ ];
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Helper Functions
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Detect package install command in a string.
38
+ * @param {string} command
39
+ * @returns {{ manager: string, scan: string } | null}
40
+ */
41
+ function detectInstall(command) {
42
+ if (!command) return null;
43
+ for (const pattern of INSTALL_PATTERNS) {
44
+ if (pattern.regex.test(command)) {
45
+ return { manager: pattern.manager, scan: pattern.scan };
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Main
53
+ // ---------------------------------------------------------------------------
54
+
55
+ try {
56
+ const input = readHookInput();
57
+ const { toolInput, sessionId } = input;
58
+ const command = (toolInput.command || "").trim();
59
+
60
+ // Detect install command
61
+ const match = detectInstall(command);
62
+
63
+ if (!match) {
64
+ allow();
65
+ // Unreachable after allow(), but explicit return for clarity
66
+ return;
67
+ }
68
+
69
+ // Record evidence (non-blocking)
70
+ try {
71
+ appendEvidence({
72
+ hook: HOOK_NAME,
73
+ event: "PostToolUse",
74
+ tool: "Bash",
75
+ decision: "allow",
76
+ reason: `${match.manager} install detected`,
77
+ command: command.length > 120 ? command.slice(0, 120) + "..." : command,
78
+ session_id: sessionId,
79
+ });
80
+ } catch (_) {
81
+ // Evidence failure is non-blocking
82
+ }
83
+
84
+ // Advisory: recommend security scan
85
+ allow(
86
+ `[${HOOK_NAME}] ${match.manager} によるパッケージインストールを検出しました。` +
87
+ `セキュリティスキャンを推奨します: \`${match.scan}\``,
88
+ );
89
+ } catch (_err) {
90
+ // Operational hook — on error, allow through.
91
+ allow();
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Exports (for testing)
96
+ // ---------------------------------------------------------------------------
97
+
98
+ module.exports = {
99
+ INSTALL_PATTERNS,
100
+ detectInstall,
101
+ };