shield-harness 0.2.0 → 0.4.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/policy-drift.js +322 -0
- package/.claude/hooks/lib/tier-policy-gen.js +348 -0
- package/.claude/hooks/sh-config-guard.js +135 -1
- package/.claude/hooks/sh-evidence.js +14 -1
- package/.claude/hooks/sh-session-start.js +45 -0
- package/.claude/permissions-spec.json +11 -1
- package/.claude/policies/openshell-generated.yaml +105 -0
- package/README.ja.md +110 -33
- package/README.md +109 -32
- 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,322 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// policy-drift.js — Detect drift between permissions-spec.json deny rules and OpenShell policy YAML
|
|
3
|
+
// Part of Shield Harness: ensures policy files stay in sync with the canonical deny rules
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
|
|
9
|
+
// --- Dependencies (local modules) ---
|
|
10
|
+
|
|
11
|
+
const { loadPermissionsSpec } = require("./permissions-validator");
|
|
12
|
+
const { classifyRulesByDomain } = require("./tier-policy-gen");
|
|
13
|
+
|
|
14
|
+
// --- YAML Parsing (regex-based, no js-yaml dependency) ---
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract a YAML list section by name.
|
|
18
|
+
* Expects predictable structure generated by tier-policy-gen.js:
|
|
19
|
+
* section_name:
|
|
20
|
+
* - item1
|
|
21
|
+
* - item2
|
|
22
|
+
*
|
|
23
|
+
* @param {string} content - Full YAML file content
|
|
24
|
+
* @param {string} sectionName - Name of the section (e.g., "deny_read")
|
|
25
|
+
* @returns {string[]} List of items found under the section
|
|
26
|
+
*/
|
|
27
|
+
function extractYamlList(content, sectionName) {
|
|
28
|
+
// Match section header followed by indented list items
|
|
29
|
+
var pattern = new RegExp(
|
|
30
|
+
sectionName + ":\\n((?:[ \\t]+-[ \\t]+.+\\n)*)",
|
|
31
|
+
"m",
|
|
32
|
+
);
|
|
33
|
+
var match = content.match(pattern);
|
|
34
|
+
if (!match) return [];
|
|
35
|
+
return match[1]
|
|
36
|
+
.split("\n")
|
|
37
|
+
.filter(function (l) {
|
|
38
|
+
return l.trim().startsWith("- ");
|
|
39
|
+
})
|
|
40
|
+
.map(function (l) {
|
|
41
|
+
return l.trim().replace(/^-\s+/, "");
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extract blocked network operations from YAML comment block.
|
|
47
|
+
* Expects format generated by tier-policy-gen.js:
|
|
48
|
+
* # Blocked network operations (from permissions-spec.json deny rules):
|
|
49
|
+
* # - curl *
|
|
50
|
+
* # - wget *
|
|
51
|
+
*
|
|
52
|
+
* @param {string} content - Full YAML file content
|
|
53
|
+
* @returns {string[]} List of blocked network commands
|
|
54
|
+
*/
|
|
55
|
+
function extractBlockedNetworkComments(content) {
|
|
56
|
+
var pattern =
|
|
57
|
+
/# Blocked network operations[^\n]*:\n((?:#[ \t]+-[ \t]+.+\n)*)/m;
|
|
58
|
+
var match = content.match(pattern);
|
|
59
|
+
if (!match) return [];
|
|
60
|
+
return match[1]
|
|
61
|
+
.split("\n")
|
|
62
|
+
.filter(function (l) {
|
|
63
|
+
return l.trim().startsWith("#");
|
|
64
|
+
})
|
|
65
|
+
.map(function (l) {
|
|
66
|
+
return l.trim().replace(/^#\s+-\s+/, "");
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse all relevant sections from a policy YAML file.
|
|
72
|
+
* Returns structured data without requiring js-yaml.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} content - Full YAML file content
|
|
75
|
+
* @returns {{
|
|
76
|
+
* denyRead: string[],
|
|
77
|
+
* denyWrite: string[],
|
|
78
|
+
* readWrite: string[],
|
|
79
|
+
* blockedNetwork: string[]
|
|
80
|
+
* }}
|
|
81
|
+
*/
|
|
82
|
+
function parsePolicyYaml(content) {
|
|
83
|
+
return {
|
|
84
|
+
denyRead: extractYamlList(content, "deny_read"),
|
|
85
|
+
denyWrite: extractYamlList(content, "deny_write"),
|
|
86
|
+
readWrite: extractYamlList(content, "read_write"),
|
|
87
|
+
blockedNetwork: extractBlockedNetworkComments(content),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- Drift Detection ---
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check drift between permissions-spec deny rules and OpenShell policy YAML.
|
|
95
|
+
*
|
|
96
|
+
* Compares the classified deny rules from permissions-spec.json against
|
|
97
|
+
* the content of policy YAML files in the specified directory.
|
|
98
|
+
*
|
|
99
|
+
* Drift rules:
|
|
100
|
+
* 1. Missing filesystem deny (read): spec denyRead paths not in policy deny_read
|
|
101
|
+
* 2. Missing filesystem deny (write): spec denyWrite paths not in policy deny_write
|
|
102
|
+
* 3. Policy allows denied: paths in denyRead/denyWrite that also appear in read_write
|
|
103
|
+
* 4. Missing network deny: network commands not in blocked network comments
|
|
104
|
+
* 5. No policy files: skip (no drift reported)
|
|
105
|
+
* 6. No spec file: skip (no drift reported)
|
|
106
|
+
* 7. Parse errors: fail-safe (no drift, warning logged)
|
|
107
|
+
*
|
|
108
|
+
* @param {{ specPath: string, policyDir: string }} params
|
|
109
|
+
* @returns {{
|
|
110
|
+
* has_drift: boolean,
|
|
111
|
+
* warnings: string[],
|
|
112
|
+
* details: {
|
|
113
|
+
* missing_filesystem_deny: string[],
|
|
114
|
+
* missing_network_deny: string[],
|
|
115
|
+
* policy_allows_denied: string[]
|
|
116
|
+
* },
|
|
117
|
+
* checked_at: string
|
|
118
|
+
* }}
|
|
119
|
+
*/
|
|
120
|
+
function checkPolicyDrift({ specPath, policyDir }) {
|
|
121
|
+
var emptyResult = {
|
|
122
|
+
has_drift: false,
|
|
123
|
+
warnings: [],
|
|
124
|
+
details: {
|
|
125
|
+
missing_filesystem_deny: [],
|
|
126
|
+
missing_network_deny: [],
|
|
127
|
+
policy_allows_denied: [],
|
|
128
|
+
},
|
|
129
|
+
checked_at: new Date().toISOString(),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Rule 6: No spec file — skip silently
|
|
133
|
+
if (!specPath || !fs.existsSync(specPath)) {
|
|
134
|
+
return emptyResult;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Rule 5: No policy directory or no .yaml files — skip silently
|
|
138
|
+
if (!policyDir || !fs.existsSync(policyDir)) {
|
|
139
|
+
return emptyResult;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
var policyFiles;
|
|
143
|
+
try {
|
|
144
|
+
policyFiles = fs.readdirSync(policyDir).filter(function (f) {
|
|
145
|
+
return f.endsWith(".yaml") || f.endsWith(".yml");
|
|
146
|
+
});
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return emptyResult;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (policyFiles.length === 0) {
|
|
152
|
+
return emptyResult;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Load and classify spec deny rules
|
|
156
|
+
var spec;
|
|
157
|
+
try {
|
|
158
|
+
spec = loadPermissionsSpec(specPath);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return {
|
|
161
|
+
has_drift: false,
|
|
162
|
+
warnings: ["parse_error: spec load failed \u2014 " + err.message],
|
|
163
|
+
details: {
|
|
164
|
+
missing_filesystem_deny: [],
|
|
165
|
+
missing_network_deny: [],
|
|
166
|
+
policy_allows_denied: [],
|
|
167
|
+
},
|
|
168
|
+
checked_at: new Date().toISOString(),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
var classified;
|
|
173
|
+
try {
|
|
174
|
+
classified = classifyRulesByDomain(spec.deny);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return {
|
|
177
|
+
has_drift: false,
|
|
178
|
+
warnings: [
|
|
179
|
+
"parse_error: rule classification failed \u2014 " + err.message,
|
|
180
|
+
],
|
|
181
|
+
details: {
|
|
182
|
+
missing_filesystem_deny: [],
|
|
183
|
+
missing_network_deny: [],
|
|
184
|
+
policy_allows_denied: [],
|
|
185
|
+
},
|
|
186
|
+
checked_at: new Date().toISOString(),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Aggregate policy content from all YAML files
|
|
191
|
+
var aggregated = {
|
|
192
|
+
denyRead: new Set(),
|
|
193
|
+
denyWrite: new Set(),
|
|
194
|
+
readWrite: new Set(),
|
|
195
|
+
blockedNetwork: new Set(),
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
for (var i = 0; i < policyFiles.length; i++) {
|
|
199
|
+
var filePath = path.join(policyDir, policyFiles[i]);
|
|
200
|
+
var content;
|
|
201
|
+
try {
|
|
202
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
203
|
+
} catch (err) {
|
|
204
|
+
// Rule 7: Parse error — fail-safe
|
|
205
|
+
return {
|
|
206
|
+
has_drift: false,
|
|
207
|
+
warnings: [
|
|
208
|
+
"parse_error: cannot read " + filePath + " \u2014 " + err.message,
|
|
209
|
+
],
|
|
210
|
+
details: {
|
|
211
|
+
missing_filesystem_deny: [],
|
|
212
|
+
missing_network_deny: [],
|
|
213
|
+
policy_allows_denied: [],
|
|
214
|
+
},
|
|
215
|
+
checked_at: new Date().toISOString(),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
var parsed;
|
|
220
|
+
try {
|
|
221
|
+
parsed = parsePolicyYaml(content);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
return {
|
|
224
|
+
has_drift: false,
|
|
225
|
+
warnings: [
|
|
226
|
+
"parse_error: cannot parse " + filePath + " \u2014 " + err.message,
|
|
227
|
+
],
|
|
228
|
+
details: {
|
|
229
|
+
missing_filesystem_deny: [],
|
|
230
|
+
missing_network_deny: [],
|
|
231
|
+
policy_allows_denied: [],
|
|
232
|
+
},
|
|
233
|
+
checked_at: new Date().toISOString(),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
parsed.denyRead.forEach(function (p) {
|
|
238
|
+
aggregated.denyRead.add(p);
|
|
239
|
+
});
|
|
240
|
+
parsed.denyWrite.forEach(function (p) {
|
|
241
|
+
aggregated.denyWrite.add(p);
|
|
242
|
+
});
|
|
243
|
+
parsed.readWrite.forEach(function (p) {
|
|
244
|
+
aggregated.readWrite.add(p);
|
|
245
|
+
});
|
|
246
|
+
parsed.blockedNetwork.forEach(function (cmd) {
|
|
247
|
+
aggregated.blockedNetwork.add(cmd);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Compare classified rules against aggregated policy ---
|
|
252
|
+
|
|
253
|
+
var warnings = [];
|
|
254
|
+
var missingFilesystemDeny = [];
|
|
255
|
+
var missingNetworkDeny = [];
|
|
256
|
+
var policyAllowsDenied = [];
|
|
257
|
+
|
|
258
|
+
// Rule 1: Missing filesystem deny (read)
|
|
259
|
+
for (var ri = 0; ri < classified.denyRead.length; ri++) {
|
|
260
|
+
var readPath = classified.denyRead[ri];
|
|
261
|
+
if (!aggregated.denyRead.has(readPath)) {
|
|
262
|
+
warnings.push("deny_read missing in policy: " + readPath);
|
|
263
|
+
missingFilesystemDeny.push(readPath);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Rule 2: Missing filesystem deny (write)
|
|
268
|
+
for (var wi = 0; wi < classified.denyWrite.length; wi++) {
|
|
269
|
+
var writePath = classified.denyWrite[wi];
|
|
270
|
+
if (!aggregated.denyWrite.has(writePath)) {
|
|
271
|
+
warnings.push("deny_write missing in policy: " + writePath);
|
|
272
|
+
missingFilesystemDeny.push(writePath);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Rule 3: Policy allows denied — conflict detection
|
|
277
|
+
var allDeniedPaths = new Set(
|
|
278
|
+
classified.denyRead.concat(classified.denyWrite),
|
|
279
|
+
);
|
|
280
|
+
aggregated.readWrite.forEach(function (rwPath) {
|
|
281
|
+
if (allDeniedPaths.has(rwPath)) {
|
|
282
|
+
warnings.push("conflict: read_write allows denied path: " + rwPath);
|
|
283
|
+
policyAllowsDenied.push(rwPath);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Rule 4: Missing network deny
|
|
288
|
+
for (var ni = 0; ni < classified.network.length; ni++) {
|
|
289
|
+
var netCmd = classified.network[ni];
|
|
290
|
+
if (!aggregated.blockedNetwork.has(netCmd)) {
|
|
291
|
+
warnings.push(
|
|
292
|
+
"blocked network command missing in policy comments: " + netCmd,
|
|
293
|
+
);
|
|
294
|
+
missingNetworkDeny.push(netCmd);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
var hasDrift =
|
|
299
|
+
missingFilesystemDeny.length > 0 ||
|
|
300
|
+
missingNetworkDeny.length > 0 ||
|
|
301
|
+
policyAllowsDenied.length > 0;
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
has_drift: hasDrift,
|
|
305
|
+
warnings: warnings,
|
|
306
|
+
details: {
|
|
307
|
+
missing_filesystem_deny: missingFilesystemDeny,
|
|
308
|
+
missing_network_deny: missingNetworkDeny,
|
|
309
|
+
policy_allows_denied: policyAllowsDenied,
|
|
310
|
+
},
|
|
311
|
+
checked_at: new Date().toISOString(),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
module.exports = {
|
|
316
|
+
// Main entry point
|
|
317
|
+
checkPolicyDrift: checkPolicyDrift,
|
|
318
|
+
// Parsing helpers (exported for unit testing)
|
|
319
|
+
extractYamlList: extractYamlList,
|
|
320
|
+
extractBlockedNetworkComments: extractBlockedNetworkComments,
|
|
321
|
+
parsePolicyYaml: parsePolicyYaml,
|
|
322
|
+
};
|
|
@@ -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
|
+
};
|