shield-harness 0.1.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/permissions-validator.js +211 -0
- package/.claude/hooks/lib/sh-utils.js +22 -0
- package/.claude/hooks/lib/tier-policy-gen.js +348 -0
- package/.claude/hooks/sh-evidence.js +14 -1
- package/.claude/hooks/sh-gate.js +9 -1
- package/.claude/hooks/sh-injection-guard.js +28 -9
- package/.claude/hooks/sh-pipeline.js +36 -0
- package/.claude/hooks/sh-session-start.js +28 -0
- package/.claude/permissions-spec.json +440 -0
- package/README.ja.md +25 -0
- package/README.md +25 -0
- package/bin/shield-harness.js +83 -0
- package/package.json +8 -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,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// permissions-validator.js — Validates alignment between permissions-spec.json and settings.json
|
|
3
|
+
// Part of the permanent countermeasure system for design-implementation divergence prevention
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Load and validate permissions-spec.json.
|
|
11
|
+
* @param {string} specPath - Path to permissions-spec.json
|
|
12
|
+
* @returns {{ deny: string[], ask: string[], allow: string[], expected_counts: object }}
|
|
13
|
+
* @throws {Error} If file is missing, invalid JSON, or malformed
|
|
14
|
+
*/
|
|
15
|
+
function loadPermissionsSpec(specPath) {
|
|
16
|
+
if (!fs.existsSync(specPath)) {
|
|
17
|
+
throw new Error(`Permissions spec not found: ${specPath}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const raw = fs.readFileSync(specPath, "utf8");
|
|
21
|
+
let spec;
|
|
22
|
+
try {
|
|
23
|
+
spec = JSON.parse(raw);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
throw new Error(`Permissions spec is not valid JSON: ${err.message}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!spec.permissions) {
|
|
29
|
+
throw new Error("Permissions spec missing 'permissions' field");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const perms = spec.permissions;
|
|
33
|
+
const deny = (perms.deny || []).map((entry) =>
|
|
34
|
+
typeof entry === "string" ? entry : entry.rule,
|
|
35
|
+
);
|
|
36
|
+
const ask = (perms.ask || []).map((entry) =>
|
|
37
|
+
typeof entry === "string" ? entry : entry.rule,
|
|
38
|
+
);
|
|
39
|
+
const allow = (perms.allow || []).map((entry) =>
|
|
40
|
+
typeof entry === "string" ? entry : entry.rule,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
deny,
|
|
45
|
+
ask,
|
|
46
|
+
allow,
|
|
47
|
+
expected_counts: spec.expected_counts || null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Load permissions from settings.json.
|
|
53
|
+
* @param {string} settingsPath - Path to settings.json
|
|
54
|
+
* @returns {{ deny: string[], ask: string[], allow: string[] }}
|
|
55
|
+
* @throws {Error} If file is missing, invalid JSON, or missing permissions
|
|
56
|
+
*/
|
|
57
|
+
function loadSettingsPermissions(settingsPath) {
|
|
58
|
+
if (!fs.existsSync(settingsPath)) {
|
|
59
|
+
throw new Error(`Settings file not found: ${settingsPath}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const raw = fs.readFileSync(settingsPath, "utf8");
|
|
63
|
+
let settings;
|
|
64
|
+
try {
|
|
65
|
+
settings = JSON.parse(raw);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw new Error(`Settings file is not valid JSON: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const perms = settings.permissions || {};
|
|
71
|
+
return {
|
|
72
|
+
deny: perms.deny || [],
|
|
73
|
+
ask: perms.ask || [],
|
|
74
|
+
allow: perms.allow || [],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Compute the set difference: items in setA but not in setB.
|
|
80
|
+
* @param {string[]} setA
|
|
81
|
+
* @param {string[]} setB
|
|
82
|
+
* @returns {string[]}
|
|
83
|
+
*/
|
|
84
|
+
function setDiff(setA, setB) {
|
|
85
|
+
const bSet = new Set(setB);
|
|
86
|
+
return setA.filter((item) => !bSet.has(item));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Diff permissions spec against settings.json.
|
|
91
|
+
* @param {{ deny: string[], ask: string[], allow: string[] }} spec
|
|
92
|
+
* @param {{ deny: string[], ask: string[], allow: string[] }} settings
|
|
93
|
+
* @returns {object} Diff result with missing/extra rules per category
|
|
94
|
+
*/
|
|
95
|
+
function diffPermissions(spec, settings) {
|
|
96
|
+
const result = {
|
|
97
|
+
missingDeny: setDiff(spec.deny, settings.deny),
|
|
98
|
+
extraDeny: setDiff(settings.deny, spec.deny),
|
|
99
|
+
missingAsk: setDiff(spec.ask, settings.ask),
|
|
100
|
+
extraAsk: setDiff(settings.ask, spec.ask),
|
|
101
|
+
missingAllow: setDiff(spec.allow, settings.allow),
|
|
102
|
+
extraAllow: setDiff(settings.allow, spec.allow),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
result.totalMissing =
|
|
106
|
+
result.missingDeny.length +
|
|
107
|
+
result.missingAsk.length +
|
|
108
|
+
result.missingAllow.length;
|
|
109
|
+
result.totalExtra =
|
|
110
|
+
result.extraDeny.length + result.extraAsk.length + result.extraAllow.length;
|
|
111
|
+
result.aligned = result.totalMissing === 0 && result.totalExtra === 0;
|
|
112
|
+
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validate alignment between permissions-spec.json and settings.json.
|
|
118
|
+
* @param {string} specPath
|
|
119
|
+
* @param {string} settingsPath
|
|
120
|
+
* @returns {{ aligned: boolean, diff: object, summary: string, counts: string }}
|
|
121
|
+
*/
|
|
122
|
+
function validateAlignment(specPath, settingsPath) {
|
|
123
|
+
const spec = loadPermissionsSpec(specPath);
|
|
124
|
+
const settings = loadSettingsPermissions(settingsPath);
|
|
125
|
+
const diff = diffPermissions(spec, settings);
|
|
126
|
+
|
|
127
|
+
const counts = `${spec.deny.length} deny, ${spec.ask.length} ask, ${spec.allow.length} allow`;
|
|
128
|
+
|
|
129
|
+
if (diff.aligned) {
|
|
130
|
+
return { aligned: true, diff, summary: "All rules aligned", counts };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const parts = [];
|
|
134
|
+
if (diff.missingDeny.length > 0) {
|
|
135
|
+
parts.push(`${diff.missingDeny.length} deny rules missing from settings`);
|
|
136
|
+
}
|
|
137
|
+
if (diff.extraDeny.length > 0) {
|
|
138
|
+
parts.push(`${diff.extraDeny.length} extra deny rules in settings`);
|
|
139
|
+
}
|
|
140
|
+
if (diff.missingAsk.length > 0) {
|
|
141
|
+
parts.push(`${diff.missingAsk.length} ask rules missing from settings`);
|
|
142
|
+
}
|
|
143
|
+
if (diff.extraAsk.length > 0) {
|
|
144
|
+
parts.push(`${diff.extraAsk.length} extra ask rules in settings`);
|
|
145
|
+
}
|
|
146
|
+
if (diff.missingAllow.length > 0) {
|
|
147
|
+
parts.push(`${diff.missingAllow.length} allow rules missing from settings`);
|
|
148
|
+
}
|
|
149
|
+
if (diff.extraAllow.length > 0) {
|
|
150
|
+
parts.push(`${diff.extraAllow.length} extra allow rules in settings`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const summary = parts.join("; ");
|
|
154
|
+
return { aligned: false, diff, summary, counts };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Format a human-readable report of the alignment diff.
|
|
159
|
+
* @param {{ aligned: boolean, diff: object, summary: string }} result
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
function formatReport(result) {
|
|
163
|
+
if (result.aligned) {
|
|
164
|
+
return "Permissions alignment: OK";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const lines = ["Permissions alignment: DIVERGENCE DETECTED", ""];
|
|
168
|
+
const { diff } = result;
|
|
169
|
+
|
|
170
|
+
if (diff.missingDeny.length > 0) {
|
|
171
|
+
lines.push("Missing deny rules (in spec but not in settings.json):");
|
|
172
|
+
diff.missingDeny.forEach((r) => lines.push(` - ${r}`));
|
|
173
|
+
lines.push("");
|
|
174
|
+
}
|
|
175
|
+
if (diff.extraDeny.length > 0) {
|
|
176
|
+
lines.push("Extra deny rules (in settings.json but not in spec):");
|
|
177
|
+
diff.extraDeny.forEach((r) => lines.push(` - ${r}`));
|
|
178
|
+
lines.push("");
|
|
179
|
+
}
|
|
180
|
+
if (diff.missingAsk.length > 0) {
|
|
181
|
+
lines.push("Missing ask rules (in spec but not in settings.json):");
|
|
182
|
+
diff.missingAsk.forEach((r) => lines.push(` - ${r}`));
|
|
183
|
+
lines.push("");
|
|
184
|
+
}
|
|
185
|
+
if (diff.extraAsk.length > 0) {
|
|
186
|
+
lines.push("Extra ask rules (in settings.json but not in spec):");
|
|
187
|
+
diff.extraAsk.forEach((r) => lines.push(` - ${r}`));
|
|
188
|
+
lines.push("");
|
|
189
|
+
}
|
|
190
|
+
if (diff.missingAllow.length > 0) {
|
|
191
|
+
lines.push("Missing allow rules (in spec but not in settings.json):");
|
|
192
|
+
diff.missingAllow.forEach((r) => lines.push(` - ${r}`));
|
|
193
|
+
lines.push("");
|
|
194
|
+
}
|
|
195
|
+
if (diff.extraAllow.length > 0) {
|
|
196
|
+
lines.push("Extra allow rules (in settings.json but not in spec):");
|
|
197
|
+
diff.extraAllow.forEach((r) => lines.push(` - ${r}`));
|
|
198
|
+
lines.push("");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return lines.join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
loadPermissionsSpec,
|
|
206
|
+
loadSettingsPermissions,
|
|
207
|
+
setDiff,
|
|
208
|
+
diffPermissions,
|
|
209
|
+
validateAlignment,
|
|
210
|
+
formatReport,
|
|
211
|
+
};
|
|
@@ -306,6 +306,25 @@ function loadPatterns() {
|
|
|
306
306
|
}
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
// --- Repeat Deny Tracking (§4.5 FR-04-07) ---
|
|
310
|
+
|
|
311
|
+
const REPEAT_DENY_THRESHOLD = 3;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Track deny occurrences per pattern key in session.
|
|
315
|
+
* Increments deny_tracker[patternKey] in session.json.
|
|
316
|
+
* @param {string} patternKey - Identifier for the denied pattern
|
|
317
|
+
* @returns {{ exceeded: boolean, count: number }}
|
|
318
|
+
*/
|
|
319
|
+
function trackDeny(patternKey) {
|
|
320
|
+
const session = readSession();
|
|
321
|
+
if (!session.deny_tracker) session.deny_tracker = {};
|
|
322
|
+
const count = (session.deny_tracker[patternKey] || 0) + 1;
|
|
323
|
+
session.deny_tracker[patternKey] = count;
|
|
324
|
+
writeSession(session);
|
|
325
|
+
return { exceeded: count >= REPEAT_DENY_THRESHOLD, count };
|
|
326
|
+
}
|
|
327
|
+
|
|
309
328
|
module.exports = {
|
|
310
329
|
// Constants
|
|
311
330
|
SH_DIR,
|
|
@@ -337,4 +356,7 @@ module.exports = {
|
|
|
337
356
|
loadPatterns,
|
|
338
357
|
// Hash Chain
|
|
339
358
|
verifyHashChain,
|
|
359
|
+
// Deny Tracking
|
|
360
|
+
trackDeny,
|
|
361
|
+
REPEAT_DENY_THRESHOLD,
|
|
340
362
|
};
|
|
@@ -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
|
package/.claude/hooks/sh-gate.js
CHANGED
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
nfkcNormalize,
|
|
13
13
|
normalizePath,
|
|
14
14
|
appendEvidence,
|
|
15
|
+
trackDeny,
|
|
15
16
|
} = require("./lib/sh-utils");
|
|
16
17
|
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
@@ -324,7 +325,14 @@ if (require.main === module) {
|
|
|
324
325
|
normalizedCommand,
|
|
325
326
|
input.sessionId,
|
|
326
327
|
);
|
|
327
|
-
|
|
328
|
+
const tracker = trackDeny(`gate:${match.label}`);
|
|
329
|
+
if (tracker.exceeded) {
|
|
330
|
+
deny(
|
|
331
|
+
`[sh-gate] PROBING DETECTED: "${match.label}" denied ${tracker.count} times. User confirmation required.`,
|
|
332
|
+
);
|
|
333
|
+
} else {
|
|
334
|
+
deny(`[sh-gate] Blocked: ${match.label}`);
|
|
335
|
+
}
|
|
328
336
|
return;
|
|
329
337
|
}
|
|
330
338
|
|