prae-cli 0.1.2 → 0.2.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/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as main } from "./cli-B6X4uAj5.js";
2
+ import { t as main } from "./cli-6hDu8-nF.js";
3
3
  import "./views-BU0sOciR.js";
4
4
 
5
5
  //#region src/bin.ts
@@ -1,12 +1,149 @@
1
1
  import { a as renderPacks, c as renderShell, i as renderOverview, l as VERDICT_LABEL$1, n as renderApprovals, o as esc, r as renderCompliance, s as renderNav, t as navFor } from "./views-BU0sOciR.js";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
4
  import { dirname, extname, join, normalize, resolve, sep } from "node:path";
4
5
  import { createHash } from "node:crypto";
5
- import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { composePatchFile, preflightOps, revisionOf, takeSnapshot } from "dsh-overlay-check";
8
8
  import { createServer } from "node:http";
9
9
 
10
+ //#region ../kernel/src/command.ts
11
+ /** Wrappers whose first operand is the program actually being run. */
12
+ const WRAPPERS = new Set([
13
+ "sudo",
14
+ "command",
15
+ "env",
16
+ "nohup",
17
+ "time",
18
+ "xargs"
19
+ ]);
20
+ /** Wrapper options that consume the next token, so it is not the program. */
21
+ const OPTION_WITH_VALUE = new Set([
22
+ "-u",
23
+ "-g",
24
+ "-p",
25
+ "-h",
26
+ "-C",
27
+ "-r",
28
+ "-t",
29
+ "-U",
30
+ "-I",
31
+ "-n",
32
+ "-P",
33
+ "-L",
34
+ "-s"
35
+ ]);
36
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
37
+ /**
38
+ * Split a line into pipeline segments of words, one scan, quote-aware.
39
+ *
40
+ * Splitting on `|` before parsing quotes is the classic mistake — it turns
41
+ * `echo "a | b"` into two segments — so quoting and separators are read
42
+ * together.
43
+ */
44
+ function scan(command) {
45
+ const parts = [];
46
+ let tokens = [];
47
+ let current = "";
48
+ let quote;
49
+ const endToken = () => {
50
+ if (current !== "") tokens.push(current);
51
+ current = "";
52
+ };
53
+ const endSegment = () => {
54
+ endToken();
55
+ if (tokens.length > 0) parts.push(tokens);
56
+ tokens = [];
57
+ };
58
+ for (let at = 0; at < command.length; at += 1) {
59
+ const ch = command[at];
60
+ if (quote !== void 0) {
61
+ if (ch === quote) quote = void 0;
62
+ else current += ch;
63
+ continue;
64
+ }
65
+ if (ch === "'" || ch === "\"") {
66
+ quote = ch;
67
+ continue;
68
+ }
69
+ if (ch === ";") {
70
+ endSegment();
71
+ continue;
72
+ }
73
+ if (ch === "|" || ch === "&") {
74
+ endSegment();
75
+ if (command[at + 1] === ch) at += 1;
76
+ continue;
77
+ }
78
+ if (/\s/.test(ch)) {
79
+ endToken();
80
+ continue;
81
+ }
82
+ current += ch;
83
+ }
84
+ endSegment();
85
+ return parts;
86
+ }
87
+ /** Decompose a command line into pipeline segments. */
88
+ function segments(command) {
89
+ const found = [];
90
+ for (const tokens of scan(command)) {
91
+ let at = 0;
92
+ while (at < tokens.length) {
93
+ const token = tokens[at];
94
+ if (ASSIGNMENT.test(token)) {
95
+ at += 1;
96
+ continue;
97
+ }
98
+ if (WRAPPERS.has(token)) {
99
+ at += 1;
100
+ while (at < tokens.length) {
101
+ const option = tokens[at];
102
+ if (!option.startsWith("-")) break;
103
+ at += OPTION_WITH_VALUE.has(option) ? 2 : 1;
104
+ }
105
+ continue;
106
+ }
107
+ break;
108
+ }
109
+ const name = tokens[at];
110
+ if (name === void 0) continue;
111
+ found.push({
112
+ name: (name.split("/").pop() ?? name).toLowerCase(),
113
+ tokens: tokens.slice(at + 1)
114
+ });
115
+ }
116
+ return found;
117
+ }
118
+ /** `git*` matches `git`, `github`; bare strings match exactly. */
119
+ function matches$1(pattern, value) {
120
+ if (pattern.endsWith("*")) return value.startsWith(pattern.slice(0, -1));
121
+ return value === pattern;
122
+ }
123
+ const list = (v) => v === void 0 ? [] : typeof v === "string" ? [v] : v;
124
+ /**
125
+ * Judge one command line against one matcher. Every present field must hold.
126
+ */
127
+ function commandMatches(match, command) {
128
+ const parts = segments(command);
129
+ if (parts.length === 0) return false;
130
+ const names = list(match.name);
131
+ const args = list(match.token);
132
+ const pipes = list(match.pipedTo);
133
+ if (names.length > 0) {
134
+ const hit = parts.filter((segment) => names.some((pattern) => matches$1(pattern, segment.name)));
135
+ if (hit.length === 0) return false;
136
+ if (args.length > 0 && !hit.some((segment) => args.every((wanted) => segment.tokens.some((token) => matches$1(wanted, token))))) return false;
137
+ } else if (args.length > 0) {
138
+ if (!parts.some((segment) => args.every((wanted) => segment.tokens.some((token) => matches$1(wanted, token))))) return false;
139
+ }
140
+ if (pipes.length > 0) {
141
+ if (!parts.slice(1).some((segment) => pipes.some((pattern) => matches$1(pattern, segment.name)))) return false;
142
+ }
143
+ return true;
144
+ }
145
+
146
+ //#endregion
10
147
  //#region ../kernel/src/path.ts
11
148
  /**
12
149
  * Path normalization and glob containment, with no filesystem access.
@@ -98,6 +235,8 @@ function matchSegments(path, pattern) {
98
235
  function within(path, glob) {
99
236
  const target = normalize$1(path);
100
237
  if (target.escaped) return false;
238
+ const trimmed = glob.trim();
239
+ if (!(trimmed === "**" || trimmed.startsWith("**/")) && target.absolute !== trimmed.startsWith("/")) return false;
101
240
  const pattern = normalize$1(glob);
102
241
  if (matchSegments(target.segments, pattern.segments)) return true;
103
242
  return matchSegments(target.segments, [...pattern.segments, "**"]);
@@ -129,8 +268,8 @@ function hostOf(url) {
129
268
  * an operator writing the suffix form means the organisation, not a rule that
130
269
  * covers every subdomain except the one they first thought of.
131
270
  */
132
- function hostIn(host, list) {
133
- return list.some((entry) => {
271
+ function hostIn(host, list$1) {
272
+ return list$1.some((entry) => {
134
273
  const want = entry.toLowerCase();
135
274
  if (want.startsWith(".")) return host === want.slice(1) || host.endsWith(want);
136
275
  return host === want;
@@ -183,6 +322,11 @@ function matches(when, facts) {
183
322
  if (!listed(facts.capability, when.capability)) return false;
184
323
  if (!listed(facts.plugin, when.plugin)) return false;
185
324
  if (!listed(facts.agent, when.agent)) return false;
325
+ if (when.command !== void 0) {
326
+ const raw = facts.args.command ?? facts.args.cmd ?? facts.args.script;
327
+ if (typeof raw !== "string") return false;
328
+ if (!commandMatches(when.command, raw)) return false;
329
+ }
186
330
  if (when.path !== void 0) {
187
331
  const raw = facts.args[when.path.arg ?? "path"];
188
332
  if (typeof raw !== "string") return false;
@@ -332,6 +476,32 @@ var Check = class {
332
476
  return value;
333
477
  }
334
478
  };
479
+ function readCommandMatch(check$1, path, value) {
480
+ if (!isObject(value)) {
481
+ check$1.fail(path, "expected an object");
482
+ return;
483
+ }
484
+ check$1.keys(path, value, [
485
+ "name",
486
+ "token",
487
+ "pipedTo"
488
+ ]);
489
+ if (value.name === void 0 && value.token === void 0 && value.pipedTo === void 0) check$1.fail(path, "expected `name`, `token` or `pipedTo`; a command matcher with none matches nothing");
490
+ const out = {};
491
+ if (value.token !== void 0) {
492
+ const tokens = check$1.names(`${path}.token`, value.token);
493
+ if (tokens !== void 0) out.token = tokens;
494
+ }
495
+ if (value.name !== void 0) {
496
+ const names = check$1.names(`${path}.name`, value.name);
497
+ if (names !== void 0) out.name = names;
498
+ }
499
+ if (value.pipedTo !== void 0) {
500
+ const pipes = check$1.names(`${path}.pipedTo`, value.pipedTo);
501
+ if (pipes !== void 0) out.pipedTo = pipes;
502
+ }
503
+ return out;
504
+ }
335
505
  function readPathMatch(check$1, path, value) {
336
506
  if (!isObject(value)) {
337
507
  check$1.fail(path, "expected an object");
@@ -375,12 +545,12 @@ function readHostMatch(check$1, path, value) {
375
545
  if (arg !== void 0) out.arg = arg;
376
546
  }
377
547
  if (value.in !== void 0) {
378
- const list = check$1.globs(`${path}.in`, value.in);
379
- if (list !== void 0) out.in = list;
548
+ const list$1 = check$1.globs(`${path}.in`, value.in);
549
+ if (list$1 !== void 0) out.in = list$1;
380
550
  }
381
551
  if (value.notIn !== void 0) {
382
- const list = check$1.globs(`${path}.notIn`, value.notIn);
383
- if (list !== void 0) out.notIn = list;
552
+ const list$1 = check$1.globs(`${path}.notIn`, value.notIn);
553
+ if (list$1 !== void 0) out.notIn = list$1;
384
554
  }
385
555
  return out;
386
556
  }
@@ -425,6 +595,7 @@ function readMatch(check$1, path, value) {
425
595
  "capability",
426
596
  "plugin",
427
597
  "agent",
598
+ "command",
428
599
  "path",
429
600
  "host",
430
601
  "arg",
@@ -440,6 +611,10 @@ function readMatch(check$1, path, value) {
440
611
  const names = check$1.names(`${path}.${key}`, value[key]);
441
612
  if (names !== void 0) out[key] = names;
442
613
  }
614
+ if (value.command !== void 0) {
615
+ const match = readCommandMatch(check$1, `${path}.command`, value.command);
616
+ if (match !== void 0) out.command = match;
617
+ }
443
618
  if (value.path !== void 0) {
444
619
  const match = readPathMatch(check$1, `${path}.path`, value.path);
445
620
  if (match !== void 0) out.path = match;
@@ -450,12 +625,12 @@ function readMatch(check$1, path, value) {
450
625
  }
451
626
  if (value.arg !== void 0) if (!Array.isArray(value.arg)) check$1.fail(`${path}.arg`, "expected an array of argument matchers");
452
627
  else {
453
- const list = [];
628
+ const list$1 = [];
454
629
  value.arg.forEach((entry, index) => {
455
630
  const one = readArgMatch(check$1, `${path}.arg[${index}]`, entry);
456
- if (one !== void 0) list.push(one);
631
+ if (one !== void 0) list$1.push(one);
457
632
  });
458
- out.arg = list;
633
+ out.arg = list$1;
459
634
  }
460
635
  if (value.count !== void 0) if (!isObject(value.count)) check$1.fail(`${path}.count`, "expected an object");
461
636
  else {
@@ -1227,6 +1402,7 @@ const USAGE = `prae — operator policy for agent harnesses
1227
1402
  Proposals only: an existing pack is never modified.
1228
1403
 
1229
1404
  prae init [--pack <file>] [--profile <dir>] [--ledger <file>]
1405
+ (no --pack mounts the built-in baseline of 8 high-risk rules)
1230
1406
  [--approver <name>] [--apply]
1231
1407
  Compose the overlay row that mounts PRAE with a pack. Prints the
1232
1408
  diff; writes only with --apply.
@@ -1367,7 +1543,7 @@ async function report(path, out) {
1367
1543
  * that step is a proposal a human approves, and it starts from this list.
1368
1544
  */
1369
1545
  async function instructions(from, asJson) {
1370
- const { ingest } = await import("./src-C5neqxZc.js");
1546
+ const { ingest } = await import("./src-BrZXeEez.js");
1371
1547
  const result = ingest(from);
1372
1548
  if (asJson) {
1373
1549
  console.log(JSON.stringify(result, null, 2));
@@ -1403,7 +1579,7 @@ async function instructions(from, asJson) {
1403
1579
  * on purpose rather than something that happened to their policy.
1404
1580
  */
1405
1581
  async function propose(from, options) {
1406
- const [{ ingest }, { extract }] = await Promise.all([import("./src-C5neqxZc.js"), import("./src-Dd2_8m2g.js")]);
1582
+ const [{ ingest }, { extract }] = await Promise.all([import("./src-BrZXeEez.js"), import("./src-Dd2_8m2g.js")]);
1407
1583
  const read = ingest(from);
1408
1584
  if (read.files.length === 0) {
1409
1585
  console.log(`No instruction files found at or above ${from}.`);
@@ -1463,7 +1639,7 @@ async function consoleOut(path, argv) {
1463
1639
  const instructionsDir = flag(argv, "instructions");
1464
1640
  if (instructionsDir !== void 0) {
1465
1641
  const [{ ingest }, { extract }, { renderInstructions }] = await Promise.all([
1466
- import("./src-C5neqxZc.js"),
1642
+ import("./src-BrZXeEez.js"),
1467
1643
  import("./src-Dd2_8m2g.js"),
1468
1644
  import("./instructions-ijuj4IP1.js")
1469
1645
  ]);
@@ -1499,8 +1675,13 @@ async function consoleOut(path, argv) {
1499
1675
  console.log("loopback only, read-only. ctrl-c to stop.");
1500
1676
  }
1501
1677
  async function init(argv) {
1502
- const packPath = flag(argv, "pack");
1503
- if (packPath === void 0) fail("init needs --pack <file>");
1678
+ const bundledBaseline = [join(import.meta.dirname, "prae-baseline.prae.json"), join(import.meta.dirname, "..", "..", "..", "examples", "prae-baseline.prae.json")].find((candidate) => existsSync(candidate));
1679
+ const packPath = flag(argv, "pack") ?? bundledBaseline;
1680
+ if (packPath === void 0) fail("init needs --pack <file>; no bundled baseline found");
1681
+ if (flag(argv, "pack") === void 0) {
1682
+ console.log("No --pack given; mounting the built-in baseline (8 rules).");
1683
+ console.log(`See it with: prae check ${packPath}\n`);
1684
+ }
1504
1685
  const pack = await loadPack(packPath);
1505
1686
  const source = await readFile(packPath, "utf8");
1506
1687
  const profileDir = flag(argv, "profile");
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as planWrite, i as dshHome, n as applyWrite, o as praeRow, r as defaultProfile, s as profileAt, t as main } from "./cli-B6X4uAj5.js";
1
+ import { a as planWrite, i as dshHome, n as applyWrite, o as praeRow, r as defaultProfile, s as profileAt, t as main } from "./cli-6hDu8-nF.js";
2
2
  import "./views-BU0sOciR.js";
3
3
 
4
4
  export { applyWrite, defaultProfile, dshHome, main, planWrite, praeRow, profileAt };
package/lib/playground.js CHANGED
@@ -1,6 +1,143 @@
1
1
  (function() {
2
2
 
3
3
 
4
+ //#region ../kernel/src/command.ts
5
+ /** Wrappers whose first operand is the program actually being run. */
6
+ const WRAPPERS = new Set([
7
+ "sudo",
8
+ "command",
9
+ "env",
10
+ "nohup",
11
+ "time",
12
+ "xargs"
13
+ ]);
14
+ /** Wrapper options that consume the next token, so it is not the program. */
15
+ const OPTION_WITH_VALUE = new Set([
16
+ "-u",
17
+ "-g",
18
+ "-p",
19
+ "-h",
20
+ "-C",
21
+ "-r",
22
+ "-t",
23
+ "-U",
24
+ "-I",
25
+ "-n",
26
+ "-P",
27
+ "-L",
28
+ "-s"
29
+ ]);
30
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
31
+ /**
32
+ * Split a line into pipeline segments of words, one scan, quote-aware.
33
+ *
34
+ * Splitting on `|` before parsing quotes is the classic mistake — it turns
35
+ * `echo "a | b"` into two segments — so quoting and separators are read
36
+ * together.
37
+ */
38
+ function scan(command) {
39
+ const parts = [];
40
+ let tokens = [];
41
+ let current = "";
42
+ let quote;
43
+ const endToken = () => {
44
+ if (current !== "") tokens.push(current);
45
+ current = "";
46
+ };
47
+ const endSegment = () => {
48
+ endToken();
49
+ if (tokens.length > 0) parts.push(tokens);
50
+ tokens = [];
51
+ };
52
+ for (let at = 0; at < command.length; at += 1) {
53
+ const ch = command[at];
54
+ if (quote !== void 0) {
55
+ if (ch === quote) quote = void 0;
56
+ else current += ch;
57
+ continue;
58
+ }
59
+ if (ch === "'" || ch === "\"") {
60
+ quote = ch;
61
+ continue;
62
+ }
63
+ if (ch === ";") {
64
+ endSegment();
65
+ continue;
66
+ }
67
+ if (ch === "|" || ch === "&") {
68
+ endSegment();
69
+ if (command[at + 1] === ch) at += 1;
70
+ continue;
71
+ }
72
+ if (/\s/.test(ch)) {
73
+ endToken();
74
+ continue;
75
+ }
76
+ current += ch;
77
+ }
78
+ endSegment();
79
+ return parts;
80
+ }
81
+ /** Decompose a command line into pipeline segments. */
82
+ function segments(command) {
83
+ const found = [];
84
+ for (const tokens of scan(command)) {
85
+ let at = 0;
86
+ while (at < tokens.length) {
87
+ const token = tokens[at];
88
+ if (ASSIGNMENT.test(token)) {
89
+ at += 1;
90
+ continue;
91
+ }
92
+ if (WRAPPERS.has(token)) {
93
+ at += 1;
94
+ while (at < tokens.length) {
95
+ const option = tokens[at];
96
+ if (!option.startsWith("-")) break;
97
+ at += OPTION_WITH_VALUE.has(option) ? 2 : 1;
98
+ }
99
+ continue;
100
+ }
101
+ break;
102
+ }
103
+ const name = tokens[at];
104
+ if (name === void 0) continue;
105
+ found.push({
106
+ name: (name.split("/").pop() ?? name).toLowerCase(),
107
+ tokens: tokens.slice(at + 1)
108
+ });
109
+ }
110
+ return found;
111
+ }
112
+ /** `git*` matches `git`, `github`; bare strings match exactly. */
113
+ function matches$1(pattern, value) {
114
+ if (pattern.endsWith("*")) return value.startsWith(pattern.slice(0, -1));
115
+ return value === pattern;
116
+ }
117
+ const list = (v) => v === void 0 ? [] : typeof v === "string" ? [v] : v;
118
+ /**
119
+ * Judge one command line against one matcher. Every present field must hold.
120
+ */
121
+ function commandMatches(match, command) {
122
+ const parts = segments(command);
123
+ if (parts.length === 0) return false;
124
+ const names = list(match.name);
125
+ const args = list(match.token);
126
+ const pipes = list(match.pipedTo);
127
+ if (names.length > 0) {
128
+ const hit = parts.filter((segment) => names.some((pattern) => matches$1(pattern, segment.name)));
129
+ if (hit.length === 0) return false;
130
+ if (args.length > 0 && !hit.some((segment) => args.every((wanted) => segment.tokens.some((token) => matches$1(wanted, token))))) return false;
131
+ } else if (args.length > 0) {
132
+ if (!parts.some((segment) => args.every((wanted) => segment.tokens.some((token) => matches$1(wanted, token))))) return false;
133
+ }
134
+ if (pipes.length > 0) {
135
+ if (!parts.slice(1).some((segment) => pipes.some((pattern) => matches$1(pattern, segment.name)))) return false;
136
+ }
137
+ return true;
138
+ }
139
+
140
+ //#endregion
4
141
  //#region ../kernel/src/path.ts
5
142
  /**
6
143
  * Path normalization and glob containment, with no filesystem access.
@@ -92,6 +229,8 @@
92
229
  function within(path, glob) {
93
230
  const target = normalize(path);
94
231
  if (target.escaped) return false;
232
+ const trimmed = glob.trim();
233
+ if (!(trimmed === "**" || trimmed.startsWith("**/")) && target.absolute !== trimmed.startsWith("/")) return false;
95
234
  const pattern = normalize(glob);
96
235
  if (matchSegments(target.segments, pattern.segments)) return true;
97
236
  return matchSegments(target.segments, [...pattern.segments, "**"]);
@@ -123,8 +262,8 @@
123
262
  * an operator writing the suffix form means the organisation, not a rule that
124
263
  * covers every subdomain except the one they first thought of.
125
264
  */
126
- function hostIn(host, list) {
127
- return list.some((entry) => {
265
+ function hostIn(host, list$1) {
266
+ return list$1.some((entry) => {
128
267
  const want = entry.toLowerCase();
129
268
  if (want.startsWith(".")) return host === want.slice(1) || host.endsWith(want);
130
269
  return host === want;
@@ -177,6 +316,11 @@
177
316
  if (!listed(facts.capability, when.capability)) return false;
178
317
  if (!listed(facts.plugin, when.plugin)) return false;
179
318
  if (!listed(facts.agent, when.agent)) return false;
319
+ if (when.command !== void 0) {
320
+ const raw = facts.args.command ?? facts.args.cmd ?? facts.args.script;
321
+ if (typeof raw !== "string") return false;
322
+ if (!commandMatches(when.command, raw)) return false;
323
+ }
180
324
  if (when.path !== void 0) {
181
325
  const raw = facts.args[when.path.arg ?? "path"];
182
326
  if (typeof raw !== "string") return false;
@@ -326,6 +470,32 @@
326
470
  return value;
327
471
  }
328
472
  };
473
+ function readCommandMatch(check, path, value) {
474
+ if (!isObject(value)) {
475
+ check.fail(path, "expected an object");
476
+ return;
477
+ }
478
+ check.keys(path, value, [
479
+ "name",
480
+ "token",
481
+ "pipedTo"
482
+ ]);
483
+ if (value.name === void 0 && value.token === void 0 && value.pipedTo === void 0) check.fail(path, "expected `name`, `token` or `pipedTo`; a command matcher with none matches nothing");
484
+ const out = {};
485
+ if (value.token !== void 0) {
486
+ const tokens = check.names(`${path}.token`, value.token);
487
+ if (tokens !== void 0) out.token = tokens;
488
+ }
489
+ if (value.name !== void 0) {
490
+ const names = check.names(`${path}.name`, value.name);
491
+ if (names !== void 0) out.name = names;
492
+ }
493
+ if (value.pipedTo !== void 0) {
494
+ const pipes = check.names(`${path}.pipedTo`, value.pipedTo);
495
+ if (pipes !== void 0) out.pipedTo = pipes;
496
+ }
497
+ return out;
498
+ }
329
499
  function readPathMatch(check, path, value) {
330
500
  if (!isObject(value)) {
331
501
  check.fail(path, "expected an object");
@@ -369,12 +539,12 @@
369
539
  if (arg !== void 0) out.arg = arg;
370
540
  }
371
541
  if (value.in !== void 0) {
372
- const list = check.globs(`${path}.in`, value.in);
373
- if (list !== void 0) out.in = list;
542
+ const list$1 = check.globs(`${path}.in`, value.in);
543
+ if (list$1 !== void 0) out.in = list$1;
374
544
  }
375
545
  if (value.notIn !== void 0) {
376
- const list = check.globs(`${path}.notIn`, value.notIn);
377
- if (list !== void 0) out.notIn = list;
546
+ const list$1 = check.globs(`${path}.notIn`, value.notIn);
547
+ if (list$1 !== void 0) out.notIn = list$1;
378
548
  }
379
549
  return out;
380
550
  }
@@ -419,6 +589,7 @@
419
589
  "capability",
420
590
  "plugin",
421
591
  "agent",
592
+ "command",
422
593
  "path",
423
594
  "host",
424
595
  "arg",
@@ -434,6 +605,10 @@
434
605
  const names = check.names(`${path}.${key}`, value[key]);
435
606
  if (names !== void 0) out[key] = names;
436
607
  }
608
+ if (value.command !== void 0) {
609
+ const match = readCommandMatch(check, `${path}.command`, value.command);
610
+ if (match !== void 0) out.command = match;
611
+ }
437
612
  if (value.path !== void 0) {
438
613
  const match = readPathMatch(check, `${path}.path`, value.path);
439
614
  if (match !== void 0) out.path = match;
@@ -444,12 +619,12 @@
444
619
  }
445
620
  if (value.arg !== void 0) if (!Array.isArray(value.arg)) check.fail(`${path}.arg`, "expected an array of argument matchers");
446
621
  else {
447
- const list = [];
622
+ const list$1 = [];
448
623
  value.arg.forEach((entry, index) => {
449
624
  const one = readArgMatch(check, `${path}.arg[${index}]`, entry);
450
- if (one !== void 0) list.push(one);
625
+ if (one !== void 0) list$1.push(one);
451
626
  });
452
- out.arg = list;
627
+ out.arg = list$1;
453
628
  }
454
629
  if (value.count !== void 0) if (!isObject(value.count)) check.fail(`${path}.count`, "expected an object");
455
630
  else {
@@ -0,0 +1,102 @@
1
+ {
2
+ "prae": "0.1",
3
+ "pack": "prae-baseline",
4
+ "version": "1.0.0",
5
+ "rules": [
6
+ {
7
+ "id": "BASE-001",
8
+ "title": "No download-and-execute",
9
+ "when": {
10
+ "command": {
11
+ "name": ["curl", "wget", "fetch"],
12
+ "pipedTo": ["sh", "bash", "zsh", "python", "python3", "node"]
13
+ }
14
+ },
15
+ "effect": {
16
+ "kind": "deny",
17
+ "because": "piping a download into an interpreter runs code nobody has read"
18
+ }
19
+ },
20
+ {
21
+ "id": "BASE-002",
22
+ "title": "Recursive force-delete is held",
23
+ "when": { "command": { "name": ["rm", "shred"], "token": "-rf" } },
24
+ "effect": {
25
+ "kind": "ask",
26
+ "because": "a recursive force-delete is not reversible and needs a human"
27
+ }
28
+ },
29
+ {
30
+ "id": "BASE-003",
31
+ "title": "Privilege escalation is held",
32
+ "when": { "command": { "name": ["su", "doas", "pkexec"] } },
33
+ "effect": {
34
+ "kind": "ask",
35
+ "because": "escalating privileges needs a human"
36
+ }
37
+ },
38
+ {
39
+ "id": "BASE-004",
40
+ "title": "Filesystem-wide destruction is refused",
41
+ "when": { "command": { "name": ["mkfs*", "dd", "diskutil", "fdisk"] } },
42
+ "effect": {
43
+ "kind": "deny",
44
+ "because": "block-device and filesystem tools are not granted to agents"
45
+ }
46
+ },
47
+ {
48
+ "id": "BASE-005",
49
+ "title": "Shell history and credentials stay unread",
50
+ "when": {
51
+ "capability": "fs:read",
52
+ "path": {
53
+ "inside": [
54
+ ".env",
55
+ ".env.*",
56
+ "**/.ssh/**",
57
+ "**/.aws/**",
58
+ "**/credentials*",
59
+ "**/*.pem",
60
+ "**/*_history"
61
+ ]
62
+ }
63
+ },
64
+ "effect": {
65
+ "kind": "deny",
66
+ "because": "secrets and credential stores are out of scope for agents"
67
+ }
68
+ },
69
+ {
70
+ "id": "BASE-006",
71
+ "title": "Writes outside the workspace are held",
72
+ "when": { "capability": "fs:write", "path": { "outside": ["./**"] } },
73
+ "effect": {
74
+ "kind": "ask",
75
+ "because": "writing outside the working tree needs a human"
76
+ }
77
+ },
78
+ {
79
+ "id": "BASE-007",
80
+ "title": "Force-push is held",
81
+ "when": { "command": { "name": "git", "token": "--force*" } },
82
+ "effect": {
83
+ "kind": "ask",
84
+ "because": "rewriting shared history needs a human"
85
+ }
86
+ },
87
+ {
88
+ "id": "BASE-008",
89
+ "title": "Package publishes are held",
90
+ "when": {
91
+ "command": {
92
+ "name": ["npm", "pnpm", "yarn", "pip", "cargo", "gem"],
93
+ "token": "publish"
94
+ }
95
+ },
96
+ "effect": {
97
+ "kind": "ask",
98
+ "because": "publishing is outward-facing and needs a human"
99
+ }
100
+ }
101
+ ]
102
+ }
@@ -1,6 +1,6 @@
1
+ import { readFileSync, statSync } from "node:fs";
1
2
  import { basename, dirname, join, resolve } from "node:path";
2
3
  import { createHash } from "node:crypto";
3
- import { readFileSync, statSync } from "node:fs";
4
4
 
5
5
  //#region ../instructions/src/parse.ts
6
6
  const HEADING = /^(#{1,6})\s+(.*)$/;
@@ -14,15 +14,8 @@ const CONTINUATION = /^\s{1,}\S/;
14
14
  function clean(text) {
15
15
  return text.trim().replace(/\s+/g, " ");
16
16
  }
17
- /**
18
- * Parse an instruction file.
19
- *
20
- * `.cursorrules` and friends are plain text: every non-empty line is a
21
- * statement, because there is no structure to read and guessing at one would
22
- * invent provenance that is not in the file.
23
- */
24
- function parseInstructions(text, format = "markdown") {
25
- const hash = createHash("sha256").update(text).digest("hex");
17
+ /** The parse itself. No Node, so this runs anywhere. */
18
+ function parseStatements(text, format = "markdown") {
26
19
  const lines = text.split("\n");
27
20
  if (format === "text") {
28
21
  const statements = [];
@@ -39,7 +32,6 @@ function parseInstructions(text, format = "markdown") {
39
32
  return {
40
33
  format,
41
34
  text,
42
- hash,
43
35
  sections: [{
44
36
  headingPath: [],
45
37
  level: 0,
@@ -145,7 +137,6 @@ function parseInstructions(text, format = "markdown") {
145
137
  return {
146
138
  format,
147
139
  text,
148
- hash,
149
140
  sections
150
141
  };
151
142
  }
@@ -154,6 +145,16 @@ function statementsOf(doc) {
154
145
  return doc.sections.flatMap((section) => section.statements);
155
146
  }
156
147
 
148
+ //#endregion
149
+ //#region ../instructions/src/node.ts
150
+ /** Parse an instruction file and hash it. */
151
+ function parseInstructions(text, format = "markdown") {
152
+ return {
153
+ ...parseStatements(text, format),
154
+ hash: createHash("sha256").update(text).digest("hex")
155
+ };
156
+ }
157
+
157
158
  //#endregion
158
159
  //#region ../instructions/src/discover.ts
159
160
  /** The formats each recognised filename is read as. */
@@ -271,4 +272,4 @@ function ingest(from, options = {}) {
271
272
  }
272
273
 
273
274
  //#endregion
274
- export { DEFAULT_CANDIDATES, DEFAULT_ROOT_MARKERS, FORMATS, discover, ingest, parseInstructions, statementsOf };
275
+ export { DEFAULT_CANDIDATES, DEFAULT_ROOT_MARKERS, FORMATS, discover, ingest, parseInstructions, parseStatements, statementsOf };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prae-cli",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Operator policy for agent harnesses: validate packs, explain a decision, verify a decision log, and provision a governed DSH profile.",
5
5
  "keywords": [
6
6
  "dsh-plugin",
@@ -41,10 +41,10 @@
41
41
  "vitest": "^3.2.4",
42
42
  "@prae/controls": "0.1.0",
43
43
  "@prae/kernel": "0.1.0",
44
- "@prae/instructions": "0.1.0",
45
- "@prae/extract": "0.1.0",
44
+ "@prae/ledger": "0.1.0",
46
45
  "@prae/tokens": "0.1.0",
47
- "@prae/ledger": "0.1.0"
46
+ "@prae/instructions": "0.1.0",
47
+ "@prae/extract": "0.1.0"
48
48
  },
49
49
  "scripts": {
50
50
  "test": "vitest run",