shield-harness 0.2.0 → 0.3.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/ocsf-mapper.js +17 -0
- package/.claude/hooks/lib/tier-policy-gen.js +348 -0
- package/.claude/hooks/sh-evidence.js +14 -1
- package/.claude/hooks/sh-session-start.js +4 -0
- package/README.ja.md +25 -0
- package/README.md +25 -0
- package/bin/shield-harness.js +83 -0
- package/package.json +6 -2
|
@@ -32,6 +32,9 @@ const OCSF_COMMON_FIELDS = new Set([
|
|
|
32
32
|
"tool",
|
|
33
33
|
"seq",
|
|
34
34
|
"severity",
|
|
35
|
+
"sandbox_state",
|
|
36
|
+
"sandbox_version",
|
|
37
|
+
"sandbox_policy_enforced",
|
|
35
38
|
]);
|
|
36
39
|
|
|
37
40
|
// ---------------------------------------------------------------------------
|
|
@@ -254,6 +257,20 @@ function toDetectionFinding(entry) {
|
|
|
254
257
|
finding.resources = [{ type: "tool", name: entry.tool }];
|
|
255
258
|
}
|
|
256
259
|
|
|
260
|
+
// OpenShell sandbox metadata (Beta Phase)
|
|
261
|
+
if (entry.sandbox_state === "active") {
|
|
262
|
+
finding.resources = finding.resources || [];
|
|
263
|
+
finding.resources.push({
|
|
264
|
+
type: "container",
|
|
265
|
+
name: "openshell-sandbox",
|
|
266
|
+
labels: [
|
|
267
|
+
"state:" + entry.sandbox_state,
|
|
268
|
+
entry.sandbox_version ? "version:" + entry.sandbox_version : null,
|
|
269
|
+
entry.sandbox_policy_enforced ? "policy_enforced:true" : null,
|
|
270
|
+
].filter(Boolean),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
257
274
|
// Unmapped (hook-specific fields)
|
|
258
275
|
const unmapped = extractUnmapped(entry);
|
|
259
276
|
if (unmapped) {
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tier-policy-gen.js — Generate OpenShell Policy Schema v1 YAML from permissions-spec.json
|
|
3
|
+
// Spec: ADR-037 Phase Beta, Stream C
|
|
4
|
+
// Purpose: Convert Shield Harness permission rules into OpenShell sandbox policy files
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
// --- Rule Parsing ---
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse a permission rule string into structured components.
|
|
11
|
+
* Supports formats:
|
|
12
|
+
* "Read(~/.ssh/**)" -> { action: "Read", target: "~/.ssh/**" }
|
|
13
|
+
* "Bash(curl *)" -> { action: "Bash", command: "curl", pattern: "*" }
|
|
14
|
+
* "Edit(.claude/hooks/**)" -> { action: "Edit", target: ".claude/hooks/**" }
|
|
15
|
+
* "Glob(*)" -> { action: "Glob", target: "*" }
|
|
16
|
+
* @param {string} rule - Raw permission rule string
|
|
17
|
+
* @returns {{ action: string, target?: string, command?: string, pattern?: string }|null}
|
|
18
|
+
*/
|
|
19
|
+
function parsePermissionRule(rule) {
|
|
20
|
+
if (!rule || typeof rule !== "string") return null;
|
|
21
|
+
|
|
22
|
+
const match = rule.match(/^(\w+)\((.+)\)$/);
|
|
23
|
+
if (!match) return null;
|
|
24
|
+
|
|
25
|
+
const action = match[1];
|
|
26
|
+
const inner = match[2];
|
|
27
|
+
|
|
28
|
+
if (action === "Bash") {
|
|
29
|
+
// Split into command and pattern: "curl *" -> { command: "curl", pattern: "*" }
|
|
30
|
+
const spaceIdx = inner.indexOf(" ");
|
|
31
|
+
if (spaceIdx === -1) {
|
|
32
|
+
return { action, command: inner, pattern: "" };
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
action,
|
|
36
|
+
command: inner.slice(0, spaceIdx),
|
|
37
|
+
pattern: inner.slice(spaceIdx + 1),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { action, target: inner };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- Domain Classification ---
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Network-related commands (Bash rules classified as network domain).
|
|
48
|
+
* @type {Set<string>}
|
|
49
|
+
*/
|
|
50
|
+
const NETWORK_COMMANDS = new Set([
|
|
51
|
+
"curl",
|
|
52
|
+
"wget",
|
|
53
|
+
"nc",
|
|
54
|
+
"ncat",
|
|
55
|
+
"nmap",
|
|
56
|
+
"Invoke-WebRequest",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Destructive commands (classified as process domain).
|
|
61
|
+
* @type {Set<string>}
|
|
62
|
+
*/
|
|
63
|
+
const DESTRUCTIVE_COMMANDS = new Set(["rm", "del", "format"]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Remove trailing glob wildcards from a path for policy use.
|
|
67
|
+
* Example: "~/.ssh/[star][star]" becomes "~/.ssh"
|
|
68
|
+
* Leading globs are preserved (e.g., "[star][star]/.env" stays as-is).
|
|
69
|
+
* @param {string} p - Glob path
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
function cleanGlobPath(p) {
|
|
73
|
+
return p.replace(/\/\*\*$/, "").replace(/\/\*$/, "");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Classify an array of deny rules by security domain.
|
|
78
|
+
* @param {Array<{rule: string, rationale?: string, threat_id?: string}>} rules
|
|
79
|
+
* @returns {{
|
|
80
|
+
* denyRead: string[],
|
|
81
|
+
* denyWrite: string[],
|
|
82
|
+
* network: string[],
|
|
83
|
+
* process: string[],
|
|
84
|
+
* unclassified: string[]
|
|
85
|
+
* }}
|
|
86
|
+
*/
|
|
87
|
+
function classifyRulesByDomain(rules) {
|
|
88
|
+
const result = {
|
|
89
|
+
denyRead: [],
|
|
90
|
+
denyWrite: [],
|
|
91
|
+
network: [],
|
|
92
|
+
process: [],
|
|
93
|
+
unclassified: [],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const readPaths = new Set();
|
|
97
|
+
const writePaths = new Set();
|
|
98
|
+
|
|
99
|
+
for (const entry of rules) {
|
|
100
|
+
const ruleStr = typeof entry === "string" ? entry : entry.rule;
|
|
101
|
+
const parsed = parsePermissionRule(ruleStr);
|
|
102
|
+
|
|
103
|
+
if (!parsed) {
|
|
104
|
+
result.unclassified.push(ruleStr);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
switch (parsed.action) {
|
|
109
|
+
case "Read": {
|
|
110
|
+
const cleaned = cleanGlobPath(parsed.target);
|
|
111
|
+
if (!readPaths.has(cleaned)) {
|
|
112
|
+
readPaths.add(cleaned);
|
|
113
|
+
result.denyRead.push(cleaned);
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
case "Edit":
|
|
119
|
+
case "Write": {
|
|
120
|
+
const cleaned = cleanGlobPath(parsed.target);
|
|
121
|
+
if (!writePaths.has(cleaned)) {
|
|
122
|
+
writePaths.add(cleaned);
|
|
123
|
+
result.denyWrite.push(cleaned);
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case "Bash": {
|
|
129
|
+
const cmd = parsed.command;
|
|
130
|
+
const fullRule =
|
|
131
|
+
parsed.command + (parsed.pattern ? " " + parsed.pattern : "");
|
|
132
|
+
|
|
133
|
+
if (NETWORK_COMMANDS.has(cmd)) {
|
|
134
|
+
result.network.push(fullRule);
|
|
135
|
+
} else if (DESTRUCTIVE_COMMANDS.has(cmd)) {
|
|
136
|
+
result.process.push(fullRule);
|
|
137
|
+
} else if (cmd === "git" && /push\s+--force/.test(parsed.pattern)) {
|
|
138
|
+
result.network.push(fullRule);
|
|
139
|
+
} else if (cmd === "npm" && /publish/.test(parsed.pattern)) {
|
|
140
|
+
result.network.push(fullRule);
|
|
141
|
+
} else {
|
|
142
|
+
// Other bash commands (e.g., cat */.ssh/*) — classify by intent
|
|
143
|
+
result.process.push(fullRule);
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
default:
|
|
149
|
+
result.unclassified.push(ruleStr);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- YAML Generation ---
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Merge two classification results (for strict mode: deny + ask).
|
|
161
|
+
* Deduplicates paths/commands.
|
|
162
|
+
* @param {ReturnType<typeof classifyRulesByDomain>} base
|
|
163
|
+
* @param {ReturnType<typeof classifyRulesByDomain>} extra
|
|
164
|
+
* @returns {ReturnType<typeof classifyRulesByDomain>}
|
|
165
|
+
*/
|
|
166
|
+
function mergeClassified(base, extra) {
|
|
167
|
+
const readSet = new Set(base.denyRead);
|
|
168
|
+
const writeSet = new Set(base.denyWrite);
|
|
169
|
+
const networkSet = new Set(base.network);
|
|
170
|
+
const processSet = new Set(base.process);
|
|
171
|
+
|
|
172
|
+
for (const p of extra.denyRead) {
|
|
173
|
+
if (!readSet.has(p)) {
|
|
174
|
+
readSet.add(p);
|
|
175
|
+
base.denyRead.push(p);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const p of extra.denyWrite) {
|
|
179
|
+
if (!writeSet.has(p)) {
|
|
180
|
+
writeSet.add(p);
|
|
181
|
+
base.denyWrite.push(p);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const p of extra.network) {
|
|
185
|
+
if (!networkSet.has(p)) {
|
|
186
|
+
networkSet.add(p);
|
|
187
|
+
base.network.push(p);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
for (const p of extra.process) {
|
|
191
|
+
if (!processSet.has(p)) {
|
|
192
|
+
processSet.add(p);
|
|
193
|
+
base.process.push(p);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return base;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Generate OpenShell Policy Schema v1 YAML from permissions-spec.json.
|
|
202
|
+
* Uses template literals — no js-yaml dependency.
|
|
203
|
+
* @param {Object} spec - Full permissions-spec.json object
|
|
204
|
+
* @param {{ profile?: string }} [options]
|
|
205
|
+
* @returns {string} YAML policy file content
|
|
206
|
+
*/
|
|
207
|
+
function generatePolicyYaml(spec, options = {}) {
|
|
208
|
+
const profile = options.profile || "standard";
|
|
209
|
+
|
|
210
|
+
if (!spec || !spec.permissions) {
|
|
211
|
+
throw new Error("Invalid spec: missing permissions field");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const classified = classifyRulesByDomain(spec.permissions.deny || []);
|
|
215
|
+
|
|
216
|
+
// In strict mode, also treat ask rules as deny
|
|
217
|
+
if (profile === "strict" && spec.permissions.ask) {
|
|
218
|
+
const askClassified = classifyRulesByDomain(spec.permissions.ask);
|
|
219
|
+
mergeClassified(classified, askClassified);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const lines = [];
|
|
223
|
+
lines.push("# Auto-generated by Shield Harness tier-policy-gen");
|
|
224
|
+
lines.push("# Source: permissions-spec.json v" + (spec.version || "1.0.0"));
|
|
225
|
+
lines.push("# Profile: " + profile);
|
|
226
|
+
lines.push("# Generated: " + new Date().toISOString());
|
|
227
|
+
lines.push("#");
|
|
228
|
+
lines.push("# Usage:");
|
|
229
|
+
lines.push("# openshell sandbox create --policy <this-file> -- claude");
|
|
230
|
+
lines.push("#");
|
|
231
|
+
lines.push("# Static policies require sandbox recreation to change.");
|
|
232
|
+
lines.push(
|
|
233
|
+
"# Network policies can be hot-reloaded: openshell policy set <name> --policy <file> --wait",
|
|
234
|
+
);
|
|
235
|
+
lines.push("");
|
|
236
|
+
lines.push("version: 1");
|
|
237
|
+
|
|
238
|
+
// --- Filesystem policy (static) ---
|
|
239
|
+
lines.push("");
|
|
240
|
+
lines.push("# --- Static (locked at sandbox creation) ---");
|
|
241
|
+
lines.push("");
|
|
242
|
+
lines.push("filesystem_policy:");
|
|
243
|
+
lines.push(" include_workdir: true");
|
|
244
|
+
|
|
245
|
+
if (classified.denyRead.length > 0) {
|
|
246
|
+
lines.push(" deny_read:");
|
|
247
|
+
for (const p of classified.denyRead) {
|
|
248
|
+
lines.push(" - " + p);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (classified.denyWrite.length > 0) {
|
|
253
|
+
lines.push(" deny_write:");
|
|
254
|
+
for (const p of classified.denyWrite) {
|
|
255
|
+
lines.push(" - " + p);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
lines.push(" read_only:");
|
|
260
|
+
lines.push(" - /usr");
|
|
261
|
+
lines.push(" - /lib");
|
|
262
|
+
lines.push(" - /etc");
|
|
263
|
+
lines.push(" read_write:");
|
|
264
|
+
lines.push(" - /sandbox");
|
|
265
|
+
lines.push(" - /tmp");
|
|
266
|
+
|
|
267
|
+
// --- Landlock ---
|
|
268
|
+
lines.push("");
|
|
269
|
+
lines.push("landlock:");
|
|
270
|
+
lines.push(" compatibility: best_effort");
|
|
271
|
+
|
|
272
|
+
// --- Process ---
|
|
273
|
+
lines.push("");
|
|
274
|
+
lines.push("process:");
|
|
275
|
+
lines.push(" run_as_user: sandbox");
|
|
276
|
+
lines.push(" run_as_group: sandbox");
|
|
277
|
+
|
|
278
|
+
// --- Network policies (dynamic / hot-reloadable) ---
|
|
279
|
+
lines.push("");
|
|
280
|
+
lines.push("# --- Dynamic (hot-reloadable) ---");
|
|
281
|
+
lines.push("");
|
|
282
|
+
lines.push("network_policies:");
|
|
283
|
+
|
|
284
|
+
// Default allowlist (matching openshell-default.yaml)
|
|
285
|
+
lines.push(" anthropic_api:");
|
|
286
|
+
lines.push(" name: anthropic-api");
|
|
287
|
+
lines.push(" endpoints:");
|
|
288
|
+
lines.push(" - host: api.anthropic.com");
|
|
289
|
+
lines.push(" port: 443");
|
|
290
|
+
lines.push(" access: full");
|
|
291
|
+
lines.push(" binaries:");
|
|
292
|
+
lines.push(" - path: /usr/local/bin/claude");
|
|
293
|
+
lines.push("");
|
|
294
|
+
lines.push(" github:");
|
|
295
|
+
lines.push(" name: github");
|
|
296
|
+
lines.push(" endpoints:");
|
|
297
|
+
lines.push(" - host: github.com");
|
|
298
|
+
lines.push(" port: 443");
|
|
299
|
+
lines.push(" access: read-only");
|
|
300
|
+
lines.push(' - host: "*.githubusercontent.com"');
|
|
301
|
+
lines.push(" port: 443");
|
|
302
|
+
lines.push(" access: read-only");
|
|
303
|
+
lines.push(" binaries:");
|
|
304
|
+
lines.push(" - path: /usr/bin/git");
|
|
305
|
+
lines.push("");
|
|
306
|
+
lines.push(" npm_registry:");
|
|
307
|
+
lines.push(" name: npm-registry");
|
|
308
|
+
lines.push(" endpoints:");
|
|
309
|
+
lines.push(" - host: registry.npmjs.org");
|
|
310
|
+
lines.push(" port: 443");
|
|
311
|
+
lines.push(" access: read-only");
|
|
312
|
+
lines.push(" binaries:");
|
|
313
|
+
lines.push(" - path: /usr/bin/npm");
|
|
314
|
+
lines.push(" - path: /usr/bin/node");
|
|
315
|
+
|
|
316
|
+
// Append blocked network commands as comments for visibility
|
|
317
|
+
if (classified.network.length > 0) {
|
|
318
|
+
lines.push("");
|
|
319
|
+
lines.push(
|
|
320
|
+
"# Blocked network operations (from permissions-spec.json deny rules):",
|
|
321
|
+
);
|
|
322
|
+
for (const cmd of classified.network) {
|
|
323
|
+
lines.push("# - " + cmd);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Append blocked destructive commands as comments
|
|
328
|
+
if (classified.process.length > 0) {
|
|
329
|
+
lines.push("");
|
|
330
|
+
lines.push(
|
|
331
|
+
"# Blocked process operations (from permissions-spec.json deny rules):",
|
|
332
|
+
);
|
|
333
|
+
for (const cmd of classified.process) {
|
|
334
|
+
lines.push("# - " + cmd);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return lines.join("\n") + "\n";
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = {
|
|
342
|
+
parsePermissionRule,
|
|
343
|
+
classifyRulesByDomain,
|
|
344
|
+
generatePolicyYaml,
|
|
345
|
+
// Internal helpers exported for testing
|
|
346
|
+
cleanGlobPath,
|
|
347
|
+
mergeClassified,
|
|
348
|
+
};
|
|
@@ -111,8 +111,9 @@ try {
|
|
|
111
111
|
|
|
112
112
|
// Check channel source for evidence metadata (§8.6.3)
|
|
113
113
|
let isChannel = false;
|
|
114
|
+
let session = {};
|
|
114
115
|
try {
|
|
115
|
-
|
|
116
|
+
session = readSession();
|
|
116
117
|
isChannel = session.source === "channel";
|
|
117
118
|
} catch {
|
|
118
119
|
// Session read failure is non-blocking for evidence
|
|
@@ -135,6 +136,18 @@ try {
|
|
|
135
136
|
category: null,
|
|
136
137
|
is_channel: isChannel,
|
|
137
138
|
session_id: sessionId,
|
|
139
|
+
// OpenShell metadata (Beta Phase)
|
|
140
|
+
sandbox_state:
|
|
141
|
+
session.sandbox_openshell && session.sandbox_openshell.available
|
|
142
|
+
? "active"
|
|
143
|
+
: "inactive",
|
|
144
|
+
sandbox_version:
|
|
145
|
+
(session.sandbox_openshell && session.sandbox_openshell.version) || null,
|
|
146
|
+
sandbox_policy_enforced: !!(
|
|
147
|
+
session.sandbox_openshell &&
|
|
148
|
+
session.sandbox_openshell.available &&
|
|
149
|
+
session.sandbox_openshell.container_running
|
|
150
|
+
),
|
|
138
151
|
};
|
|
139
152
|
|
|
140
153
|
// Collect context messages
|
|
@@ -269,6 +269,10 @@ try {
|
|
|
269
269
|
}
|
|
270
270
|
: { available: false, reason: openshellResult.reason },
|
|
271
271
|
session_id: input.sessionId,
|
|
272
|
+
sandbox_state: openshellResult.available ? "active" : "inactive",
|
|
273
|
+
sandbox_version: openshellResult.version || null,
|
|
274
|
+
sandbox_policy_enforced:
|
|
275
|
+
openshellResult.available && openshellResult.container_running,
|
|
272
276
|
policy_compat: policyCompat
|
|
273
277
|
? {
|
|
274
278
|
compatible: policyCompat.compatible,
|
package/README.ja.md
CHANGED
|
@@ -111,6 +111,31 @@ Windows ネイティブでは Claude Code のサンドボックス機能(`sand
|
|
|
111
111
|
|
|
112
112
|
### Layer 3b: NVIDIA OpenShell(オプション)
|
|
113
113
|
|
|
114
|
+
#### なぜ Layer 3b が必要か?
|
|
115
|
+
|
|
116
|
+
Layer 1(permissions)と Layer 2(hooks)はツール呼び出しの入力テキスト(実行前のコマンド文字列)を検査します。しかし検査を通過したコマンドが実行されると、**OS 上の子プロセスは自由に動作します**。
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
Layer 1-2(プロセス内):
|
|
120
|
+
Claude Code → [Hook が入力を検査] → コマンド実行 → [子プロセスは自由]
|
|
121
|
+
↑ ここしか制御できない
|
|
122
|
+
|
|
123
|
+
Layer 3b(プロセス外 = カーネルレベル):
|
|
124
|
+
Claude Code → コマンド実行 → [Landlock: ファイルアクセス制御]
|
|
125
|
+
[Seccomp: syscall 制御]
|
|
126
|
+
[Network NS: ネットワーク隔離]
|
|
127
|
+
↑ 子プロセスも含めて全てカーネルが制御
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
| 攻撃ベクトル | Layer 1-2 の対処 | すり抜ける理由 | Layer 3b の防御 |
|
|
131
|
+
| ------------------------------------ | ---------------------------- | ------------------------------------- | ------------------------------------- |
|
|
132
|
+
| パイプチェーンによるファイル読み取り | パターンマッチング | `awk`、`python -c` による間接アクセス | Landlock LSM がカーネルレベルで拒否 |
|
|
133
|
+
| Raw ソケット通信 | `curl`/`wget` の deny ルール | `python`/`node` でソケットを直接操作 | Seccomp BPF がソケット syscall を拒否 |
|
|
134
|
+
| DNS トンネリング | sandbox.network(WSL2 のみ) | DNS クエリにデータを埋め込み | Network Namespace が全 DNS を隔離 |
|
|
135
|
+
| PowerShell ソケット | パターンマッチング | エンコード・難読化 | Seccomp BPF + Network NS の二重防御 |
|
|
136
|
+
|
|
137
|
+
**構造的保証**: エージェント自身がガードレールを無効化することは**不可能** — ポリシーはコンテナ外に存在し、サンドボックス作成時にロックされます。
|
|
138
|
+
|
|
114
139
|
[NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell)(Apache 2.0)は Docker 上で AI エージェントに**カーネルレベルの隔離**を提供します:
|
|
115
140
|
|
|
116
141
|
| メカニズム | 対象 | 保護内容 |
|
package/README.md
CHANGED
|
@@ -109,6 +109,31 @@ For enterprise environments, supplementing with Windows Firewall outbound rules
|
|
|
109
109
|
|
|
110
110
|
### Layer 3b: NVIDIA OpenShell (Optional)
|
|
111
111
|
|
|
112
|
+
#### Why Layer 3b?
|
|
113
|
+
|
|
114
|
+
Layer 1 (permissions) and Layer 2 (hooks) inspect tool call inputs — the command text before execution. Once a command passes these checks, the **spawned child process runs freely at the OS level**.
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
Layer 1-2 (in-process):
|
|
118
|
+
Claude Code → [Hook inspects input] → Command execution → [Child process is free]
|
|
119
|
+
↑ Only controls this point
|
|
120
|
+
|
|
121
|
+
Layer 3b (out-of-process = kernel-level):
|
|
122
|
+
Claude Code → Command execution → [Landlock: Filesystem access control]
|
|
123
|
+
[Seccomp: Syscall control]
|
|
124
|
+
[Network NS: Network isolation]
|
|
125
|
+
↑ Kernel controls ALL processes including children
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
| Attack Vector | Layer 1-2 Defense | Why It Bypasses | Layer 3b Defense |
|
|
129
|
+
| ------------------------ | --------------------------- | -------------------------------------- | ------------------------------------- |
|
|
130
|
+
| Pipe chain file access | Pattern matching | Indirect access via `awk`, `python -c` | Landlock LSM denies at kernel level |
|
|
131
|
+
| Raw socket communication | `curl`/`wget` deny rules | Direct socket via `python`/`node` | Seccomp BPF blocks socket syscalls |
|
|
132
|
+
| DNS tunneling | sandbox.network (WSL2 only) | Data embedded in DNS queries | Network Namespace isolates all DNS |
|
|
133
|
+
| PowerShell sockets | Pattern matching | Encoding/obfuscation | Seccomp BPF + Network NS dual defense |
|
|
134
|
+
|
|
135
|
+
**Structural guarantee**: The agent **cannot** disable its own guardrails — policies exist outside the container and are locked at sandbox creation.
|
|
136
|
+
|
|
112
137
|
[NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) (Apache 2.0) provides **kernel-level isolation** for AI agents via Docker:
|
|
113
138
|
|
|
114
139
|
| Mechanism | Target | Protection |
|
package/bin/shield-harness.js
CHANGED
|
@@ -208,6 +208,84 @@ function printBasicGuide() {
|
|
|
208
208
|
console.log(" 3. openshell sandbox start");
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// generate-policy command
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
const POLICY_PROFILES = ["standard", "strict"];
|
|
216
|
+
const DEFAULT_POLICY_OUTPUT = path.join(
|
|
217
|
+
".claude",
|
|
218
|
+
"policies",
|
|
219
|
+
"openshell-generated.yaml",
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Generate OpenShell policy YAML from permissions-spec.json.
|
|
224
|
+
* @param {string[]} args - CLI arguments (after "generate-policy")
|
|
225
|
+
*/
|
|
226
|
+
function generatePolicy(args) {
|
|
227
|
+
// Parse --output
|
|
228
|
+
let output = DEFAULT_POLICY_OUTPUT;
|
|
229
|
+
const outputIdx = args.indexOf("--output");
|
|
230
|
+
if (outputIdx !== -1 && args[outputIdx + 1]) {
|
|
231
|
+
output = args[outputIdx + 1];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Parse --profile
|
|
235
|
+
let profile = "standard";
|
|
236
|
+
const profileIdx = args.indexOf("--profile");
|
|
237
|
+
if (profileIdx !== -1 && args[profileIdx + 1]) {
|
|
238
|
+
profile = args[profileIdx + 1];
|
|
239
|
+
if (!POLICY_PROFILES.includes(profile)) {
|
|
240
|
+
console.error(`Unknown profile: ${profile}`);
|
|
241
|
+
console.error(`Available profiles: ${POLICY_PROFILES.join(", ")}`);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Read permissions-spec.json
|
|
247
|
+
const specPath = path.join(process.cwd(), ".claude", "permissions-spec.json");
|
|
248
|
+
if (!fs.existsSync(specPath)) {
|
|
249
|
+
console.error("permissions-spec.json not found at: " + specPath);
|
|
250
|
+
console.error("Run 'npx shield-harness init' first.");
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let spec;
|
|
255
|
+
try {
|
|
256
|
+
spec = JSON.parse(fs.readFileSync(specPath, "utf8"));
|
|
257
|
+
} catch (err) {
|
|
258
|
+
console.error("Failed to parse permissions-spec.json: " + err.message);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Generate YAML
|
|
263
|
+
let yaml;
|
|
264
|
+
try {
|
|
265
|
+
const {
|
|
266
|
+
generatePolicyYaml,
|
|
267
|
+
} = require("../.claude/hooks/lib/tier-policy-gen");
|
|
268
|
+
yaml = generatePolicyYaml(spec, { profile });
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.error("Failed to generate policy: " + err.message);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Write output
|
|
275
|
+
const outputPath = path.resolve(process.cwd(), output);
|
|
276
|
+
const outputDir = path.dirname(outputPath);
|
|
277
|
+
if (!fs.existsSync(outputDir)) {
|
|
278
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
279
|
+
}
|
|
280
|
+
fs.writeFileSync(outputPath, yaml);
|
|
281
|
+
|
|
282
|
+
console.log(`Policy generated successfully (profile: ${profile}).`);
|
|
283
|
+
console.log(` Output: ${output}`);
|
|
284
|
+
console.log("");
|
|
285
|
+
console.log("Usage:");
|
|
286
|
+
console.log(` openshell sandbox create --policy ${output} -- claude`);
|
|
287
|
+
}
|
|
288
|
+
|
|
211
289
|
// ---------------------------------------------------------------------------
|
|
212
290
|
// CLI
|
|
213
291
|
// ---------------------------------------------------------------------------
|
|
@@ -227,12 +305,17 @@ if (command === "init") {
|
|
|
227
305
|
}
|
|
228
306
|
}
|
|
229
307
|
init(profile);
|
|
308
|
+
} else if (command === "generate-policy") {
|
|
309
|
+
generatePolicy(args);
|
|
230
310
|
} else {
|
|
231
311
|
const pkg = require("../package.json");
|
|
232
312
|
console.log(`Shield Harness v${pkg.version}`);
|
|
233
313
|
console.log("");
|
|
234
314
|
console.log("Usage:");
|
|
235
315
|
console.log(" npx shield-harness init [--profile minimal|standard|strict]");
|
|
316
|
+
console.log(
|
|
317
|
+
" npx shield-harness generate-policy [--output <path>] [--profile standard|strict]",
|
|
318
|
+
);
|
|
236
319
|
console.log("");
|
|
237
320
|
console.log("Profiles:");
|
|
238
321
|
console.log(" minimal — Minimal config, approval-free");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shield-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Security harness for Claude Code — hooks-driven, zero-hassle defense",
|
|
5
5
|
"bin": {
|
|
6
6
|
"shield-harness": "./bin/shield-harness.js"
|
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
"test:output": "node --test tests/output-security.test.js",
|
|
24
24
|
"test:ocsf": "node --test tests/ocsf-mapper.test.js",
|
|
25
25
|
"test:policy-compat": "node --test tests/policy-compat.test.js",
|
|
26
|
-
"test:permissions": "node --test tests/permissions-alignment.test.js"
|
|
26
|
+
"test:permissions": "node --test tests/permissions-alignment.test.js",
|
|
27
|
+
"test:openshell-detect": "node --test tests/openshell-detect.test.js",
|
|
28
|
+
"test:openshell-evidence": "node --test tests/openshell-evidence.test.js",
|
|
29
|
+
"test:tier-policy": "node --test tests/tier-policy-gen.test.js",
|
|
30
|
+
"test:policy-effectiveness": "node --test tests/policy-effectiveness.test.js"
|
|
27
31
|
},
|
|
28
32
|
"keywords": [
|
|
29
33
|
"claude-code",
|