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.
@@ -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
+ };