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,275 @@
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[], hook_commands: 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 and collect command strings across all events
75
+ const hooks = settings.hooks || {};
76
+ let hookCount = 0;
77
+ const hookEvents = [];
78
+ const hookCommands = [];
79
+ for (const [event, entries] of Object.entries(hooks)) {
80
+ hookEvents.push(event);
81
+ for (const entry of Array.isArray(entries) ? entries : []) {
82
+ const hookList = entry.hooks || [];
83
+ hookCount += hookList.length;
84
+ for (const h of hookList) {
85
+ if (h.command) {
86
+ hookCommands.push(h.command);
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ return {
93
+ deny_rules: denyRules,
94
+ hook_count: hookCount,
95
+ hook_events: hookEvents,
96
+ hook_commands: hookCommands.sort(),
97
+ sandbox:
98
+ settings.sandbox !== undefined
99
+ ? Boolean(settings.sandbox.enabled !== false)
100
+ : true,
101
+ unsandboxed: Boolean(settings.allowUnsandboxedCommands),
102
+ disableAllHooks: Boolean(settings.disableAllHooks),
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Check for dangerous mutations between stored and current config.
108
+ * @param {Object} stored - Previous security fields
109
+ * @param {Object} current - Current security fields
110
+ * @returns {{ blocked: boolean, reasons: string[] }}
111
+ */
112
+ function detectDangerousMutations(stored, current) {
113
+ const reasons = [];
114
+
115
+ // Check 1: deny rules removed
116
+ for (const rule of stored.deny_rules) {
117
+ if (!current.deny_rules.includes(rule)) {
118
+ reasons.push(`deny rule removed: "${rule}"`);
119
+ }
120
+ }
121
+
122
+ // Check 2: hooks removed (event-level check)
123
+ if (current.hook_count < stored.hook_count) {
124
+ const removedCount = stored.hook_count - current.hook_count;
125
+ reasons.push(`${removedCount} hook(s) removed from configuration`);
126
+ }
127
+ for (const event of stored.hook_events) {
128
+ if (!current.hook_events.includes(event)) {
129
+ reasons.push(`hook event "${event}" entirely removed`);
130
+ }
131
+ }
132
+
133
+ // Check 2b: hook command content swap (B23 — same count, different commands)
134
+ const storedCmds = stored.hook_commands || [];
135
+ const currentCmds = current.hook_commands || [];
136
+ if (storedCmds.length > 0 && currentCmds.length > 0) {
137
+ for (const cmd of storedCmds) {
138
+ if (!currentCmds.includes(cmd)) {
139
+ reasons.push(`hook command removed or swapped: "${cmd}"`);
140
+ }
141
+ }
142
+ }
143
+
144
+ // Check 3: sandbox disabled
145
+ if (stored.sandbox && !current.sandbox) {
146
+ reasons.push("sandbox.enabled set to false");
147
+ }
148
+
149
+ // Check 4: unsandboxed commands allowed
150
+ if (!stored.unsandboxed && current.unsandboxed) {
151
+ reasons.push("allowUnsandboxedCommands set to true");
152
+ }
153
+
154
+ // Check 5: all hooks disabled
155
+ if (!stored.disableAllHooks && current.disableAllHooks) {
156
+ reasons.push("disableAllHooks set to true");
157
+ }
158
+
159
+ return {
160
+ blocked: reasons.length > 0,
161
+ reasons,
162
+ };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Main
167
+ // ---------------------------------------------------------------------------
168
+
169
+ if (require.main === module) {
170
+ try {
171
+ const input = readHookInput();
172
+
173
+ const settings = readSettings();
174
+ if (!settings) {
175
+ deny(`[${HOOK_NAME}] settings.json not found or unreadable — fail-close`);
176
+ return;
177
+ }
178
+
179
+ const currentFields = extractSecurityFields(settings);
180
+ const settingsContent = fs.readFileSync(SETTINGS_FILE, "utf8");
181
+ const currentHash = sha256(settingsContent);
182
+
183
+ const stored = loadStoredConfig();
184
+
185
+ // First run: record baseline
186
+ if (!stored) {
187
+ saveConfigSnapshot({
188
+ hash: currentHash,
189
+ ...currentFields,
190
+ });
191
+
192
+ try {
193
+ appendEvidence({
194
+ hook: HOOK_NAME,
195
+ event: "ConfigChange",
196
+ decision: "allow",
197
+ action: "baseline_recorded",
198
+ settings_hash: `sha256:${currentHash}`,
199
+ session_id: input.sessionId,
200
+ });
201
+ } catch {
202
+ // Non-blocking
203
+ }
204
+
205
+ allow(`[${HOOK_NAME}] Config baseline recorded`);
206
+ return;
207
+ }
208
+
209
+ // Check for dangerous mutations
210
+ const mutations = detectDangerousMutations(stored, currentFields);
211
+
212
+ if (mutations.blocked) {
213
+ try {
214
+ appendEvidence({
215
+ hook: HOOK_NAME,
216
+ event: "ConfigChange",
217
+ decision: "deny",
218
+ reasons: mutations.reasons,
219
+ settings_hash: `sha256:${currentHash}`,
220
+ previous_hash: `sha256:${stored.hash}`,
221
+ session_id: input.sessionId,
222
+ });
223
+ } catch {
224
+ // Non-blocking
225
+ }
226
+
227
+ deny(
228
+ `[${HOOK_NAME}] Blocked dangerous config change: ${mutations.reasons.join("; ")}`,
229
+ );
230
+ return;
231
+ }
232
+
233
+ // Safe change — update snapshot and allow
234
+ saveConfigSnapshot({
235
+ hash: currentHash,
236
+ ...currentFields,
237
+ });
238
+
239
+ try {
240
+ appendEvidence({
241
+ hook: HOOK_NAME,
242
+ event: "ConfigChange",
243
+ decision: "allow",
244
+ action: "config_updated",
245
+ settings_hash: `sha256:${currentHash}`,
246
+ previous_hash: stored ? `sha256:${stored.hash}` : null,
247
+ session_id: input.sessionId,
248
+ });
249
+ } catch {
250
+ // Non-blocking
251
+ }
252
+
253
+ allow();
254
+ } catch (err) {
255
+ // SECURITY hook — fail-close (§2.3b)
256
+ process.stdout.write(
257
+ JSON.stringify({
258
+ reason: `[${HOOK_NAME}] Hook error (fail-close): ${err.message}`,
259
+ }),
260
+ );
261
+ process.exit(2);
262
+ }
263
+ } // end require.main === module
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Exports (for testing)
267
+ // ---------------------------------------------------------------------------
268
+
269
+ module.exports = {
270
+ readSettings,
271
+ loadStoredConfig,
272
+ saveConfigSnapshot,
273
+ extractSecurityFields,
274
+ detectDangerousMutations,
275
+ };
@@ -0,0 +1,390 @@
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
+ * Applies URL decoding before parsing to defeat percent-encoding evasion.
88
+ * @param {string} url
89
+ * @returns {string|null} Lowercase hostname or null.
90
+ */
91
+ function extractHostFromUrl(url) {
92
+ // Decode percent-encoded characters before parsing (SSRF evasion defense)
93
+ let decoded = url;
94
+ try {
95
+ decoded = decodeURIComponent(url);
96
+ } catch {
97
+ // Malformed percent encoding — proceed with original
98
+ }
99
+
100
+ try {
101
+ const parsed = new URL(decoded);
102
+ return parsed.hostname.toLowerCase();
103
+ } catch {
104
+ // Try extracting with regex as fallback
105
+ const match = decoded.match(/^https?:\/\/([^/:]+)/i);
106
+ return match ? match[1].toLowerCase() : null;
107
+ }
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Config Loaders (fail-safe: missing config = skip check)
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Load production hosts config.
116
+ * Returns null if file doesn't exist (= no restrictions configured).
117
+ *
118
+ * Expected format:
119
+ * {
120
+ * "hosts": ["prod-db.example.com", "production.internal"],
121
+ * "patterns": ["prod-", "production\\."]
122
+ * }
123
+ *
124
+ * @returns {{ hosts: string[], patterns: RegExp[] } | null}
125
+ */
126
+ function loadProductionHosts() {
127
+ if (!fs.existsSync(PRODUCTION_HOSTS_FILE)) return null;
128
+
129
+ try {
130
+ const config = JSON.parse(fs.readFileSync(PRODUCTION_HOSTS_FILE, "utf8"));
131
+ const hosts = (config.hosts || []).map((h) => h.toLowerCase());
132
+ const patterns = (config.patterns || []).map((p) => new RegExp(p, "i"));
133
+ return { hosts, patterns };
134
+ } catch {
135
+ // Corrupted config — fail-close for security
136
+ return { hosts: [], patterns: DEFAULT_PROD_PATTERNS };
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Load allowed jurisdictions config.
142
+ * Returns null if file doesn't exist (= no jurisdiction restrictions).
143
+ *
144
+ * Expected format:
145
+ * {
146
+ * "allowed": ["JP", "US", "EU"],
147
+ * "tld_map": { ".jp": "JP", ".us": "US", ".eu": "EU", ".de": "EU", ... }
148
+ * }
149
+ *
150
+ * @returns {{ allowed: Set<string>, tldMap: Object } | null}
151
+ */
152
+ function loadAllowedJurisdictions() {
153
+ if (!fs.existsSync(ALLOWED_JURISDICTIONS_FILE)) return null;
154
+
155
+ try {
156
+ const config = JSON.parse(
157
+ fs.readFileSync(ALLOWED_JURISDICTIONS_FILE, "utf8"),
158
+ );
159
+ const allowed = new Set((config.allowed || []).map((j) => j.toUpperCase()));
160
+ const tldMap = config.tld_map || {};
161
+ return { allowed, tldMap };
162
+ } catch {
163
+ // Corrupted config — skip jurisdiction check (cannot determine safely)
164
+ return null;
165
+ }
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Production Host Check
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /**
173
+ * Check if a hostname matches production host patterns.
174
+ * @param {string} hostname - Lowercase hostname to check.
175
+ * @param {{ hosts: string[], patterns: RegExp[] }} config
176
+ * @returns {boolean}
177
+ */
178
+ function isProductionHost(hostname, config) {
179
+ // Exact match
180
+ if (config.hosts.includes(hostname)) return true;
181
+
182
+ // Pattern match
183
+ for (const pattern of config.patterns) {
184
+ if (pattern.test(hostname)) return true;
185
+ }
186
+
187
+ return false;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Internal Host Check (SSRF / private network defense)
192
+ // ---------------------------------------------------------------------------
193
+
194
+ // Cloud metadata service IPs and hostnames
195
+ const CLOUD_METADATA_HOSTS = new Set([
196
+ "169.254.169.254", // AWS, Azure
197
+ "metadata.google.internal", // GCP
198
+ "100.100.100.200", // Alibaba Cloud
199
+ ]);
200
+
201
+ // Localhost aliases
202
+ const LOCALHOST_ALIASES = new Set([
203
+ "127.0.0.1",
204
+ "localhost",
205
+ "0.0.0.0",
206
+ "[::1]",
207
+ "::1",
208
+ ]);
209
+
210
+ /**
211
+ * Check if a hostname is an internal/private network address.
212
+ * Detects: cloud metadata endpoints, localhost aliases, RFC 1918 private IPs.
213
+ * @param {string} hostname - Lowercase hostname to check.
214
+ * @returns {boolean}
215
+ */
216
+ function isInternalHost(hostname) {
217
+ if (!hostname) return false;
218
+
219
+ const h = hostname.toLowerCase();
220
+
221
+ // Cloud metadata endpoints
222
+ if (CLOUD_METADATA_HOSTS.has(h)) return true;
223
+
224
+ // Localhost aliases
225
+ if (LOCALHOST_ALIASES.has(h)) return true;
226
+
227
+ // RFC 1918 private IP ranges
228
+ const ipMatch = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
229
+ if (ipMatch) {
230
+ const [, a, b] = ipMatch.map(Number);
231
+
232
+ // 10.0.0.0/8
233
+ if (a === 10) return true;
234
+
235
+ // 172.16.0.0/12 (172.16.x.x — 172.31.x.x)
236
+ if (a === 172 && b >= 16 && b <= 31) return true;
237
+
238
+ // 192.168.0.0/16
239
+ if (a === 192 && b === 168) return true;
240
+
241
+ // Link-local (169.254.x.x) — covers AWS metadata and other link-local
242
+ if (a === 169 && b === 254) return true;
243
+ }
244
+
245
+ // Hostname ending with .internal (GCP convention)
246
+ if (h.endsWith(".internal")) return true;
247
+
248
+ return false;
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Jurisdiction Check
253
+ // ---------------------------------------------------------------------------
254
+
255
+ /**
256
+ * Estimate jurisdiction from hostname TLD.
257
+ * @param {string} hostname
258
+ * @param {Object} tldMap - TLD to jurisdiction code mapping.
259
+ * @returns {string|null} Jurisdiction code (e.g., "JP") or null if unknown.
260
+ */
261
+ function estimateJurisdiction(hostname, tldMap) {
262
+ // Extract TLD (last dot-segment)
263
+ const parts = hostname.split(".");
264
+ if (parts.length < 2) return null;
265
+
266
+ const tld = "." + parts[parts.length - 1];
267
+
268
+ // Check custom TLD map
269
+ const upperTld = tld.toLowerCase();
270
+ for (const [key, value] of Object.entries(tldMap)) {
271
+ if (key.toLowerCase() === upperTld) return value.toUpperCase();
272
+ }
273
+
274
+ return null;
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Main (only runs when executed directly, not when required for testing)
279
+ // ---------------------------------------------------------------------------
280
+
281
+ if (require.main === module) {
282
+ try {
283
+ const input = readHookInput();
284
+ const toolName = input.toolName;
285
+ const toolInput = input.toolInput;
286
+
287
+ // Check channel source for evidence metadata (§8.6.3)
288
+ let isChannel = false;
289
+ try {
290
+ const session = readSession();
291
+ isChannel = session.source === "channel";
292
+ } catch {
293
+ // Session read failure is non-blocking
294
+ }
295
+
296
+ // --- Step 1: Production DB host detection ---
297
+ const prodConfig = loadProductionHosts();
298
+
299
+ if (prodConfig) {
300
+ let hostsToCheck = [];
301
+
302
+ if (toolName === "Bash") {
303
+ const command = (toolInput.command || "").trim();
304
+ hostsToCheck = extractHostsFromCommand(command);
305
+ } else if (toolName === "WebFetch") {
306
+ const url = toolInput.url || "";
307
+ const host = extractHostFromUrl(url);
308
+ if (host) hostsToCheck = [host];
309
+ }
310
+
311
+ for (const host of hostsToCheck) {
312
+ if (isProductionHost(host, prodConfig)) {
313
+ appendEvidence({
314
+ event: "data_boundary_deny",
315
+ hook: "sh-data-boundary",
316
+ tool: toolName,
317
+ host: host,
318
+ reason: "production_host_detected",
319
+ is_channel: isChannel,
320
+ });
321
+ deny(`Production environment access is prohibited: ${host}`);
322
+ return;
323
+ }
324
+ }
325
+ }
326
+
327
+ // --- Step 2: Jurisdiction check (WebFetch only) ---
328
+ if (toolName === "WebFetch") {
329
+ const jurisdictionConfig = loadAllowedJurisdictions();
330
+
331
+ if (jurisdictionConfig) {
332
+ const url = toolInput.url || "";
333
+ const host = extractHostFromUrl(url);
334
+
335
+ if (host) {
336
+ const jurisdiction = estimateJurisdiction(
337
+ host,
338
+ jurisdictionConfig.tldMap,
339
+ );
340
+
341
+ if (jurisdiction && !jurisdictionConfig.allowed.has(jurisdiction)) {
342
+ appendEvidence({
343
+ event: "data_boundary_deny",
344
+ hook: "sh-data-boundary",
345
+ tool: toolName,
346
+ host: host,
347
+ jurisdiction: jurisdiction,
348
+ reason: "unauthorized_jurisdiction",
349
+ is_channel: isChannel,
350
+ });
351
+ deny(
352
+ `Unauthorized jurisdiction detected: ${jurisdiction} (host: ${host}). ` +
353
+ `Allowed: ${[...jurisdictionConfig.allowed].join(", ")}`,
354
+ );
355
+ return;
356
+ }
357
+ }
358
+ }
359
+ }
360
+
361
+ // --- Step 3: All checks passed ---
362
+ allow();
363
+ } catch (err) {
364
+ // fail-close: any uncaught error = deny
365
+ process.stdout.write(
366
+ JSON.stringify({
367
+ reason: `Hook error (sh-data-boundary): ${err.message}`,
368
+ }),
369
+ );
370
+ process.exit(2);
371
+ }
372
+ } // end require.main === module
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Exports (for testing)
376
+ // ---------------------------------------------------------------------------
377
+
378
+ module.exports = {
379
+ // Config paths (for test override)
380
+ PRODUCTION_HOSTS_FILE,
381
+ ALLOWED_JURISDICTIONS_FILE,
382
+ // Functions
383
+ extractHostsFromCommand,
384
+ extractHostFromUrl,
385
+ loadProductionHosts,
386
+ loadAllowedJurisdictions,
387
+ isProductionHost,
388
+ isInternalHost,
389
+ estimateJurisdiction,
390
+ };