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,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
|
+
};
|