prae-cli 0.1.1 → 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/index.js CHANGED
@@ -1,3 +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-DGVWMDT0.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
+ import "./views-BU0sOciR.js";
2
3
 
3
4
  export { applyWrite, defaultProfile, dshHome, main, planWrite, praeRow, profileAt };
@@ -0,0 +1,133 @@
1
+ import { c as renderShell, o as esc, s as renderNav, t as navFor } from "./views-BU0sOciR.js";
2
+
3
+ //#region src/console/instructions.ts
4
+ const STYLE = `
5
+ .stack { padding: 22px 30px 8px; }
6
+ .layer {
7
+ display: grid; grid-template-columns: 92px 1fr 120px; gap: 16px;
8
+ padding: 12px 0; border-bottom: 1px solid var(--line-faint);
9
+ font-family: var(--proof); font-size: 12px; align-items: baseline;
10
+ }
11
+ .layer .who { letter-spacing: 0.1em; font-size: 11px; }
12
+ .layer .who.operator { color: var(--signal-text); }
13
+ .layer .who.project { color: var(--muted); }
14
+ .layer .file { color: var(--ink-row); overflow-wrap: anywhere; }
15
+ .layer .count { color: var(--muted); text-align: right; }
16
+
17
+ .proposal {
18
+ padding: 20px 30px; border-bottom: 1px solid var(--line);
19
+ display: grid; grid-template-columns: 1fr 200px; gap: 24px;
20
+ }
21
+ .proposal h3 {
22
+ font-size: 16px; font-weight: 600; letter-spacing: -0.01em; margin: 0 0 10px;
23
+ }
24
+ .quote {
25
+ border-left: 2px solid var(--line-strong); padding: 6px 0 6px 14px;
26
+ font-family: var(--proof); font-size: 12px; color: var(--ink-row);
27
+ line-height: 1.7; margin: 0 0 10px;
28
+ }
29
+ .quote .where {
30
+ display: block; color: var(--muted); font-size: 11px; margin-top: 6px;
31
+ }
32
+ .reading { font-size: 13px; color: var(--secondary); margin: 0 0 10px; }
33
+ .matcher {
34
+ font-family: var(--proof); font-size: 11.5px; color: var(--dim);
35
+ background: var(--sunk); padding: 8px 10px; border-radius: 2px;
36
+ overflow-wrap: anywhere;
37
+ }
38
+ .verdict-col { text-align: right; }
39
+ .verdict-col .kind {
40
+ font-family: var(--proof); font-size: 20px; letter-spacing: 0.08em;
41
+ font-weight: 600;
42
+ }
43
+ .verdict-col .conf {
44
+ font-family: var(--proof); font-size: 10px; letter-spacing: 0.12em;
45
+ color: var(--muted); margin-top: 6px;
46
+ }
47
+ .context-only { padding: 22px 30px 30px; }
48
+ .context-only ul {
49
+ margin: 10px 0 0; padding-left: 18px; font-size: 13px; line-height: 1.9;
50
+ color: var(--secondary); columns: 2; column-gap: 40px;
51
+ }
52
+ @media (max-width: 860px) {
53
+ .proposal { grid-template-columns: 1fr; }
54
+ .verdict-col { text-align: left; }
55
+ .context-only ul { columns: 1; }
56
+ }
57
+ @media print { .context-only ul { columns: 1; } }
58
+ `;
59
+ function renderInstructions(data, read, found) {
60
+ const layers = read.files.map((file) => ` <div class="layer">
61
+ <div class="who ${esc(file.layer)}">${esc(file.layer.toUpperCase())}</div>
62
+ <div class="file">${esc(file.path)}</div>
63
+ <div class="count">${file.sections.reduce((n, s) => n + s.statements.length, 0)} statements</div>
64
+ </div>`).join("\n");
65
+ const proposals = found.proposals.map((proposal) => ` <section class="proposal">
66
+ <div>
67
+ <h3>${esc(proposal.rule.id)} · ${esc(proposal.rule.title ?? "")}</h3>
68
+ <blockquote class="quote">${esc(proposal.from.text)}
69
+ <span class="where">${esc(proposal.from.path)}:${proposal.from.line} · ${esc(proposal.from.layer)}</span>
70
+ </blockquote>
71
+ <p class="reading">Read as: ${esc(proposal.why)}.</p>
72
+ <div class="matcher">${esc(JSON.stringify(proposal.rule.when))}</div>
73
+ </div>
74
+ <div class="verdict-col">
75
+ <div class="kind e-${esc(proposal.rule.effect.kind)}">${esc(proposal.rule.effect.kind.toUpperCase())}</div>
76
+ <div class="conf">${esc(proposal.confidence.toUpperCase())} CONFIDENCE</div>
77
+ </div>
78
+ </section>`).join("\n");
79
+ const body = `<style>${STYLE}</style>
80
+ <section class="stats">
81
+ <div class="stat"><div class="v">${read.files.length}</div><div class="k">FILES</div></div>
82
+ <div class="stat"><div class="v">${read.statements.length}</div><div class="k">STATEMENTS</div></div>
83
+ <div class="stat"><div class="v hold">${found.proposals.length}</div><div class="k">PROPOSED</div></div>
84
+ <div class="stat"><div class="v">${found.skipped.length}</div><div class="k">CONTEXT ONLY</div></div>
85
+ </section>
86
+
87
+ <section class="sect">
88
+ <h2>Precedence</h2>
89
+ <p>Least specific first. An operator file is named by the profile and
90
+ applies to every repository the agent is pointed at; a project file was
91
+ found by walking up from the work and applies only to the checkout that
92
+ shipped it.</p>
93
+ </section>
94
+ <div class="stack">
95
+ ${layers}
96
+ </div>
97
+
98
+ <section class="sect">
99
+ <h2>Proposed</h2>
100
+ <p>Nothing here is in force. Each rule is a <em>reading</em> of the
101
+ sentence beside it, and the only thing that turns a reading into policy
102
+ is a person putting it in a pack. Read every one against its sentence
103
+ before it enforces anything.</p>
104
+ </section>
105
+ ${found.proposals.length === 0 ? " <div class=\"empty\">Nothing was proposed. On most real instruction files that is the expected result, not a failure.</div>" : proposals}
106
+
107
+ <section class="context-only">
108
+ <div class="k" style="font-family:var(--proof);font-size:10px;letter-spacing:0.14em;color:var(--muted)">
109
+ ${found.skipped.length} STATEMENTS PROPOSED NOTHING AND REMAIN CONTEXT
110
+ </div>
111
+ <p style="font-size:13px;color:var(--secondary);max-width:76ch;margin:10px 0 0">
112
+ Most engineering guidance is about code that gets written, not calls an
113
+ agent makes. These are served to the agent and sealed into the ledger;
114
+ they are not enforced, and pretending otherwise would be inventing
115
+ policy nobody wrote.
116
+ </p>
117
+ <ul>
118
+ ${found.skipped.slice(0, 40).map((statement) => ` <li>${esc(statement.text)}</li>`).join("\n")}
119
+ </ul>
120
+ </section>`;
121
+ return renderShell({
122
+ title: "Instructions",
123
+ nav: renderNav(navFor(data), "Instructions"),
124
+ head: ` <header class="head">
125
+ <div><h1>Instructions</h1><div class="meta">${esc(read.hash.slice(0, 16))} · ${read.files.length} files · ${found.proposals.length} proposed, unreviewed</div></div>
126
+ <div class="actions"><button type="button" class="btn" onclick="window.print()">Export evidence</button></div>
127
+ </header>`,
128
+ body
129
+ });
130
+ }
131
+
132
+ //#endregion
133
+ export { renderInstructions };
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
+ }