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 +1 -1
- package/lib/{cli-B6X4uAj5.js → cli-6hDu8-nF.js} +196 -15
- package/lib/index.js +1 -1
- package/lib/playground.js +184 -9
- package/lib/prae-baseline.prae.json +102 -0
- package/lib/{src-C5neqxZc.js → src-BrZXeEez.js} +14 -13
- package/package.json +4 -4
package/lib/bin.js
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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
|
|
1503
|
-
|
|
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-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
45
|
-
"@prae/extract": "0.1.0",
|
|
44
|
+
"@prae/ledger": "0.1.0",
|
|
46
45
|
"@prae/tokens": "0.1.0",
|
|
47
|
-
"@prae/
|
|
46
|
+
"@prae/instructions": "0.1.0",
|
|
47
|
+
"@prae/extract": "0.1.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"test": "vitest run",
|