prae-cli 0.6.0 → 0.6.2

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/README.md CHANGED
@@ -90,6 +90,14 @@ next to the result rather than in a footnote: a removed, edited, or reordered
90
90
  row breaks the chain; a rewrite of the whole file by someone holding this code
91
91
  does not.
92
92
 
93
+ ### `prae report <ledger.jsonl> [--out <file.html>] [--pack <pack.json>]`
94
+
95
+ Render the decision record as a printable evidence report. With `--pack`,
96
+ each clause also names the framework risk the rule cites — OWASP Top 10 for
97
+ LLM Applications 2026 today — so a reviewer reads risk → control → decision →
98
+ seal in one row. Without the pack the clause id stands alone rather than a
99
+ name guessed from it.
100
+
93
101
  ### `prae console <ledger.jsonl> --out <dir> [--pack <file>]`
94
102
 
95
103
  Write the control plane as linked pages: Overview, Policy packs, Playground,
package/lib/bin.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import "./evaluate-CkYySl7T.js";
3
- import { t as main } from "./cli-Cr0U-PFD.js";
4
- import "./src-CXU0_W1P.js";
5
- import "./views-DalPkBZZ.js";
3
+ import "./views-CAD9738P.js";
4
+ import { t as main } from "./cli-BUgU-LpN.js";
5
+ import "./src-Ciggx9ja.js";
6
6
 
7
7
  //#region src/bin.ts
8
8
  main(process.argv.slice(2)).catch((error) => {
@@ -1,6 +1,6 @@
1
1
  import { t as evaluate } from "./evaluate-CkYySl7T.js";
2
- import { m as seal, t as Ledger } from "./src-CXU0_W1P.js";
3
- 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-DalPkBZZ.js";
2
+ import { a as renderPacks, c as renderShell, d as cite, i as renderOverview, l as VERDICT_LABEL$1, n as renderApprovals, o as esc, r as renderCompliance, s as renderNav, t as navFor, u as FRAMEWORKS } from "./views-CAD9738P.js";
3
+ import { m as seal, t as Ledger } from "./src-Ciggx9ja.js";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import { dirname, extname, join, normalize, resolve, sep } from "node:path";
@@ -302,7 +302,8 @@ function readRule(check$1, path, value) {
302
302
  "id",
303
303
  "title",
304
304
  "when",
305
- "effect"
305
+ "effect",
306
+ "frameworks"
306
307
  ]);
307
308
  const id = check$1.text(`${path}.id`, value.id);
308
309
  const effect = readEffect(check$1, `${path}.effect`, value.effect);
@@ -318,8 +319,40 @@ function readRule(check$1, path, value) {
318
319
  const title = check$1.text(`${path}.title`, value.title);
319
320
  if (title !== void 0) rule.title = title;
320
321
  }
322
+ if (value.frameworks !== void 0) {
323
+ const frameworks = readFrameworks(check$1, `${path}.frameworks`, value.frameworks);
324
+ if (frameworks !== void 0) rule.frameworks = frameworks;
325
+ }
321
326
  return rule;
322
327
  }
328
+ /**
329
+ * A citation names a framework edition and controls the registry knows. A
330
+ * typo here would be a rule that claims a mapping no view can render, so it
331
+ * is a pack error rather than an ignored key — the same line the rest of the
332
+ * parser takes.
333
+ */
334
+ function readFrameworks(check$1, path, value) {
335
+ if (!isObject(value)) {
336
+ check$1.fail(path, "expected an object keyed by framework id");
337
+ return;
338
+ }
339
+ const out = {};
340
+ for (const [framework, controls] of Object.entries(value)) {
341
+ const known = FRAMEWORKS[framework];
342
+ if (known === void 0) {
343
+ check$1.fail(`${path}.${framework}`, `unknown framework; expected one of ${Object.keys(FRAMEWORKS).map((k) => `\`${k}\``).join(", ")}`);
344
+ continue;
345
+ }
346
+ if (!Array.isArray(controls) || !controls.every((c) => typeof c === "string")) {
347
+ check$1.fail(`${path}.${framework}`, "expected an array of control ids");
348
+ continue;
349
+ }
350
+ const ids = controls;
351
+ for (const control of ids) if (known.controls[control] === void 0) check$1.fail(`${path}.${framework}`, `unknown control \`${control}\` in ${known.name}`);
352
+ if (ids.length > 0) out[framework] = [...new Set(ids)];
353
+ }
354
+ return out;
355
+ }
323
356
  /** Parse and validate a pack. Never throws; every problem lands in `errors`. */
324
357
  function parsePack(source) {
325
358
  let raw;
@@ -404,14 +437,16 @@ function ago(iso, now) {
404
437
  const s = seconds % 60;
405
438
  return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
406
439
  }
407
- function row(entry) {
440
+ function row(entry, pack) {
408
441
  const call = entry.subject === void 0 ? entry.tool : `${entry.tool} ${entry.subject}`;
409
442
  const actor = entry.actor ?? entry.agent ?? "—";
443
+ const rule = pack?.rules.find((r) => r.id === entry.rule);
444
+ const cited = rule === void 0 ? "" : cite(rule).map((c) => `<br><span style="color:var(--secondary)">${esc(c.control)} ${esc(c.controlName)}</span>`).join("");
410
445
  return ` <div class="grid row">
411
446
  <div class="seq">${entry.seq}</div>
412
447
  <div class="call">${esc(call)}</div>
413
448
  <div class="actor">${esc(actor)}</div>
414
- <div class="clause">${esc(entry.rule ?? "—")}</div>
449
+ <div class="clause">${esc(entry.rule ?? "—")}${cited}</div>
415
450
  <div class="verdict v-${esc(entry.verdict)}">${esc(VERDICT_LABEL$1[entry.verdict] ?? entry.verdict)}</div>
416
451
  <div class="seal">${esc(seal(entry))}</div>
417
452
  </div>`;
@@ -452,7 +487,7 @@ function renderLedgerView(entries, verification, options) {
452
487
  const table = ` <div class="grid thead">
453
488
  <div>SEQ</div><div>CALL</div><div>ACTOR</div><div>CLAUSE</div><div>VERDICT</div><div>SEAL</div>
454
489
  </div>
455
- ${entries.length === 0 ? " <div class=\"empty\">No decisions recorded yet.</div>" : [...entries].reverse().map(row).join("\n")}`;
490
+ ${entries.length === 0 ? " <div class=\"empty\">No decisions recorded yet.</div>" : [...entries].reverse().map((entry) => row(entry, options.pack)).join("\n")}`;
456
491
  const first = held.at(-1);
457
492
  const awaiting = first === void 0 ? "" : ` <section class="await">
458
493
  <div class="k">AWAITING HUMAN · 1 OF ${held.length}</div>
@@ -874,8 +909,11 @@ const USAGE = `prae — operator policy for agent harnesses
874
909
  Check one receipt with nothing but the file: recompute its hash, verify
875
910
  its signature, and print the key fingerprint to compare against your own.
876
911
 
877
- prae report <ledger.jsonl> [--out <file.html>]
878
- Render the decision record as a printable evidence report.
912
+ prae report <ledger.jsonl> [--out <file.html>] [--pack <pack.json>]
913
+ Render the decision record as a printable evidence report. With
914
+ --pack, each clause also names the framework risk it addresses
915
+ (OWASP LLM Top 10 2026), so a reviewer can trace risk → control →
916
+ decision → seal.
879
917
 
880
918
  prae console <ledger.jsonl> --out <dir> [--pack <file>] [--serve [port]]
881
919
  [--instructions <dir>]
@@ -925,6 +963,7 @@ async function check(path) {
925
963
  for (const rule of pack.rules) {
926
964
  const label = rule.title ?? rule.effect.because;
927
965
  console.log(` ${rule.id.padEnd(10)} ${rule.effect.kind.toUpperCase().padEnd(8)} ${label}`);
966
+ for (const c of cite(rule)) console.log(` ${"".padEnd(10)} ${"".padEnd(8)} ↳ ${c.frameworkShort} · ${c.control} ${c.controlName}`);
928
967
  }
929
968
  }
930
969
  /**
@@ -982,7 +1021,7 @@ async function mcp(root, packPath, ledgerPath) {
982
1021
  const chosen = packPath ?? bundled;
983
1022
  if (chosen === void 0) return fail("mcp needs --pack <file>; no bundled secret-catcher found");
984
1023
  const pack = await loadPack(chosen);
985
- const { Ledger: Ledger$1 } = await import("./src-Kubj3MTX.js");
1024
+ const { Ledger: Ledger$1 } = await import("./src-C9L6NBvu.js");
986
1025
  const ledger = ledgerPath === void 0 ? void 0 : Ledger$1.open(ledgerPath);
987
1026
  console.error(`prae mcp: serving ${resolve$1(root)} under ${pack.pack} ${pack.version}` + (ledgerPath === void 0 ? " (no ledger)" : ` · ledger ${ledgerPath}`));
988
1027
  await serveMcp({
@@ -1034,7 +1073,7 @@ async function explain(path, tool, pairs) {
1034
1073
  * a key that arrived beside it is checked against nothing.
1035
1074
  */
1036
1075
  async function keygen(dir) {
1037
- const { generateKeypair, fingerprint } = await import("./src-Kubj3MTX.js");
1076
+ const { generateKeypair, fingerprint } = await import("./src-C9L6NBvu.js");
1038
1077
  const { writeFileSync: writeFileSync$1 } = await import("node:fs");
1039
1078
  const keys = generateKeypair();
1040
1079
  const priv = join(dir, "prae-signing.pem");
@@ -1050,7 +1089,7 @@ async function keygen(dir) {
1050
1089
  console.log("private key with `signingKey` to sign every row.");
1051
1090
  }
1052
1091
  async function receipt(path, seq, keyPath, out) {
1053
- const { Ledger: Ledger$1, makeReceipt, fingerprint } = await import("./src-Kubj3MTX.js");
1092
+ const { Ledger: Ledger$1, makeReceipt, fingerprint } = await import("./src-C9L6NBvu.js");
1054
1093
  const { readFileSync: readFileSync$1 } = await import("node:fs");
1055
1094
  const { createPublicKey } = await import("node:crypto");
1056
1095
  const privateKey = readFileSync$1(keyPath, "utf8");
@@ -1080,7 +1119,7 @@ async function receipt(path, seq, keyPath, out) {
1080
1119
  * file and sets the exit code, so scripts can branch on it.
1081
1120
  */
1082
1121
  async function verifyReceiptCmd(path) {
1083
- const { checkReceipt } = await import("./receipt-check-Dql0uqxw.js");
1122
+ const { checkReceipt } = await import("./receipt-check-DrDE3Wwi.js");
1084
1123
  let parsed;
1085
1124
  try {
1086
1125
  parsed = JSON.parse(await readFile(path, "utf8"));
@@ -1151,7 +1190,7 @@ async function verify(path, keyPath) {
1151
1190
  const last = entries.at(-1);
1152
1191
  console.log(`${result.entries} entries · chain intact${last === void 0 ? "" : ` · seal ${seal(last)}`}`);
1153
1192
  if (keyPath !== void 0) {
1154
- const { verifySignature, fingerprint } = await import("./src-Kubj3MTX.js");
1193
+ const { verifySignature, fingerprint } = await import("./src-C9L6NBvu.js");
1155
1194
  const { readFileSync: readFileSync$1 } = await import("node:fs");
1156
1195
  const publicKey = readFileSync$1(keyPath, "utf8");
1157
1196
  let signed = 0;
@@ -1190,18 +1229,20 @@ function flag(argv, name) {
1190
1229
  * has to go and check separately. A report that looks authoritative while
1191
1230
  * quietly sitting on a broken chain is worse than no report.
1192
1231
  */
1193
- async function report(path, out) {
1232
+ async function report(path, out, packPath) {
1194
1233
  let ledger;
1195
1234
  try {
1196
1235
  ledger = Ledger.open(path);
1197
1236
  } catch (error) {
1198
1237
  return fail(error instanceof Error ? error.message : String(error));
1199
1238
  }
1239
+ const pack = packPath === void 0 ? void 0 : await loadPack(packPath);
1200
1240
  const entries = ledger.read();
1201
1241
  const verification = ledger.verify();
1202
1242
  const html = renderLedgerView(entries, verification, {
1203
1243
  ledgerPath: path,
1204
- generatedAt: (/* @__PURE__ */ new Date()).toISOString()
1244
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1245
+ ...pack === void 0 ? {} : { pack }
1205
1246
  });
1206
1247
  if (out === void 0) {
1207
1248
  process.stdout.write(html);
@@ -1325,7 +1366,7 @@ async function consoleOut(path, argv) {
1325
1366
  const [{ ingest }, { extract }, { renderInstructions }] = await Promise.all([
1326
1367
  import("./src-CraUX64d.js"),
1327
1368
  import("./src-CQ0UmFUK.js"),
1328
- import("./instructions-D4VOn2lk.js")
1369
+ import("./instructions-oGCdVxHM.js")
1329
1370
  ]);
1330
1371
  const read = ingest(instructionsDir);
1331
1372
  instructionsView = renderInstructions(data, read, extract(read.statements));
@@ -1460,7 +1501,7 @@ async function main(argv) {
1460
1501
  case "report": {
1461
1502
  const path = rest[0];
1462
1503
  if (path === void 0) fail("report needs a ledger file");
1463
- return report(path, flag(rest, "out"));
1504
+ return report(path, flag(rest, "out"), flag(rest, "pack"));
1464
1505
  }
1465
1506
  case "console": {
1466
1507
  const path = rest[0];
@@ -172,9 +172,9 @@ Prefer the interactive UI? Swap `@deepseek-ai/dsh-headless` for
172
172
  | Attempt | Expected | Why |
173
173
  | --- | --- | --- |
174
174
  | Ask for the file by another relative spelling, `../pilot-target/docs/…`, an absolute path | **deny** | Paths are normalized and anchored; the content rule has no path condition anyway |
175
- | Ask it to `cat` / `head` / `tail` the file through the shell | **held (ask)** by `SEC-005` | Shell output is not content-inspected; the pilot pack holds the known dumpers for a human. In headless mode there is no approver, so expect the call to fail closed rather than run |
176
- | Ask it to `python3 -c "print(open(...).read())"`, `node -e`, `awk`, or a here-doc | **not caught** by this pack | Shell is governed by *command* rules, not by content inspection — the list of programs that can print a file is unbounded. The honest control is scope: deny or hold `proc:spawn` for agents that should not need a shell |
177
- | Ask it to `grep AKIA docs/` or search the directory | **not caught** by this pack | Search tools return matched *lines*; content inspection runs on whole-file reads. A pilot pack can hold `grep`/`glob` by tool name (`"tool": ["grep"]` → `ask`) if that matters to you — say so and we will ship it as an option |
175
+ | Ask it to `cat` / `head` / `tail` the file through the shell | **held (ask)** by `SEC-005` before it runs; and from `prae-gate 0.6.0`, if it does run, the **output is withheld** (`SEC-004`, `phase: output`) | Two doors: the pilot pack holds the known dumpers for a human, and the result of any shell command is content-inspected before the model sees it. The command runs; the model gets a structured refusal saying so |
176
+ | Ask it to `python3 -c "print(open(...).read())"`, `node -e`, `awk`, or a here-doc | **output withheld** (`SEC-004`) from `prae-gate 0.6.0` | No program list is involved: the result is scanned, not the command. Say it precisely — the process ran, the model saw nothing of what it printed. To stop the process itself, scope `proc:spawn` |
177
+ | Ask it to `grep AKIA docs/` or search the directory | **output withheld** from `prae-gate 0.6.0` | Search results are lines, so nothing is inspected before the call; the lines it returns are, under the read rules |
178
178
  | Copy the file under `tests/fixtures/` first, then read it | **allowed, recorded** | That is the carve-out working as designed (`SEC-003` still writes a warn row). Tighten or remove the carve-out in the pack if your repos keep real secrets in fixtures |
179
179
  | Edit the ledger to hide the refusal | **chain breaks** on `prae verify` | And with a signing key configured, the signature fails without the private key — `prae keygen`, then `signingKey` in the plugin config |
180
180
 
@@ -116,14 +116,23 @@ counterparty can check without trusting the machine it came from.
116
116
  it; the dsh plugin covers the harness's `read` tool. An agent with direct
117
117
  filesystem access outside both is covered only where its harness routes
118
118
  those calls through a gated seam.
119
- - **The shell is governed by scope, not by content.** `cat`, `python -c`,
120
- `node -e`, a here-doc — the set of programs that can print a file is
121
- unbounded, so shell output is never content-inspected. The pilot pack holds
122
- the common dumpers (`SEC-005`, `ask`); the honest control for agents that
123
- should not need a shell is to deny or hold `proc:spawn`.
124
- - **Search tools return lines, not files.** `grep`/`glob` results are not
125
- content-inspected. Hold them by tool name (`"tool": ["grep"]` → `ask`) where
126
- that matters; `docs/pilot-dsh.md` §4 lists this with the rest.
119
+ - **The shell is content-inspected on the way out, not the way in.** `cat`,
120
+ `python -c`, `node -e`, `sed`, a here-doc — the set of programs that can
121
+ print a file is unbounded, so no list of them is a control. What is bounded
122
+ is the result: every tool result is scanned with the same detector before
123
+ the model sees it, and a result carrying a credential is withheld (`SEC-004`
124
+ in the shipped pack, `prae-gate ≥ 0.6.0`). Be exact about what that means:
125
+ **the command ran**; the model never saw what it printed. The ledger row says
126
+ `phase: output`, and the refusal the agent gets says `executed: true`. If a
127
+ process must not run at all, that is a scope rule on `proc:spawn`, not a
128
+ content rule.
129
+ - **Search tools are covered by the same door.** `grep`/`glob` return lines,
130
+ not files, so nothing is inspected before they run; their output is, and a
131
+ matched line that carries a credential is withheld under the read rules.
132
+ - **Shell rows carry a fingerprint, never the line.** `command: { program,
133
+ hash }` — the program word and a hash of the whole line — so an auditor can
134
+ see what ran and match repeats, while a line that carried a secret is never
135
+ quoted into the log.
127
136
  - **Detection is regex + entropy.** Same floor the mature at-rest scanners
128
137
  stand on; novel unformatted secrets can pass. The engine is an interface,
129
138
  not a ceiling.
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import "./evaluate-CkYySl7T.js";
2
- import { a as planWrite, i as dshHome, n as applyWrite, o as praeRow, r as defaultProfile, s as profileAt, t as main } from "./cli-Cr0U-PFD.js";
3
- import "./src-CXU0_W1P.js";
4
- import "./views-DalPkBZZ.js";
2
+ import "./views-CAD9738P.js";
3
+ import { a as planWrite, i as dshHome, n as applyWrite, o as praeRow, r as defaultProfile, s as profileAt, t as main } from "./cli-BUgU-LpN.js";
4
+ import "./src-Ciggx9ja.js";
5
5
 
6
6
  export { applyWrite, defaultProfile, dshHome, main, planWrite, praeRow, profileAt };
@@ -1,4 +1,4 @@
1
- import { c as renderShell, o as esc, s as renderNav, t as navFor } from "./views-DalPkBZZ.js";
1
+ import { c as renderShell, o as esc, s as renderNav, t as navFor } from "./views-CAD9738P.js";
2
2
 
3
3
  //#region src/console/instructions.ts
4
4
  const STYLE = `
package/lib/playground.js CHANGED
@@ -466,6 +466,55 @@
466
466
  };
467
467
  }
468
468
 
469
+ //#endregion
470
+ //#region ../kernel/src/frameworks.ts
471
+ const FRAMEWORKS = { "owasp-llm-top-10-2026": {
472
+ name: "OWASP Top 10 for LLM Applications — 2026",
473
+ short: "OWASP LLM 2026",
474
+ controls: {
475
+ LLM01: {
476
+ name: "Prompt Injection",
477
+ summary: "Instructions in a prompt or in retrieved content alter what the model does."
478
+ },
479
+ LLM02: {
480
+ name: "Sensitive Information Disclosure",
481
+ summary: "Credentials, personal data or proprietary content reach the model or its output."
482
+ },
483
+ LLM03: {
484
+ name: "Excessive Agency",
485
+ summary: "The model can take actions, or holds permissions, beyond what its task needs."
486
+ },
487
+ LLM04: {
488
+ name: "Supply Chain",
489
+ summary: "A compromised model, dataset, plugin or dependency enters the application."
490
+ },
491
+ LLM05: {
492
+ name: "Data and Model Poisoning",
493
+ summary: "Training, fine-tuning or embedding data is manipulated to change behaviour."
494
+ },
495
+ LLM06: {
496
+ name: "Unbounded Consumption",
497
+ summary: "The application permits resource use without a limit, exhausting budget or service."
498
+ },
499
+ LLM07: {
500
+ name: "Misinformation",
501
+ summary: "The model produces false or misleading content that is acted on as if true."
502
+ },
503
+ LLM08: {
504
+ name: "Hidden Context Exposure",
505
+ summary: "System prompts, hidden instructions or internal context are revealed."
506
+ },
507
+ LLM09: {
508
+ name: "Vector and Embedding Weaknesses",
509
+ summary: "Retrieval stores are manipulated or leak across tenants and permissions."
510
+ },
511
+ LLM10: {
512
+ name: "Improper Output Handling",
513
+ summary: "Model output is passed to downstream systems without validation."
514
+ }
515
+ }
516
+ } };
517
+
469
518
  //#endregion
470
519
  //#region ../kernel/src/pack.ts
471
520
  function isObject(value) {
@@ -761,7 +810,8 @@
761
810
  "id",
762
811
  "title",
763
812
  "when",
764
- "effect"
813
+ "effect",
814
+ "frameworks"
765
815
  ]);
766
816
  const id = check.text(`${path}.id`, value.id);
767
817
  const effect = readEffect(check, `${path}.effect`, value.effect);
@@ -777,8 +827,40 @@
777
827
  const title = check.text(`${path}.title`, value.title);
778
828
  if (title !== void 0) rule.title = title;
779
829
  }
830
+ if (value.frameworks !== void 0) {
831
+ const frameworks = readFrameworks(check, `${path}.frameworks`, value.frameworks);
832
+ if (frameworks !== void 0) rule.frameworks = frameworks;
833
+ }
780
834
  return rule;
781
835
  }
836
+ /**
837
+ * A citation names a framework edition and controls the registry knows. A
838
+ * typo here would be a rule that claims a mapping no view can render, so it
839
+ * is a pack error rather than an ignored key — the same line the rest of the
840
+ * parser takes.
841
+ */
842
+ function readFrameworks(check, path, value) {
843
+ if (!isObject(value)) {
844
+ check.fail(path, "expected an object keyed by framework id");
845
+ return;
846
+ }
847
+ const out = {};
848
+ for (const [framework, controls] of Object.entries(value)) {
849
+ const known = FRAMEWORKS[framework];
850
+ if (known === void 0) {
851
+ check.fail(`${path}.${framework}`, `unknown framework; expected one of ${Object.keys(FRAMEWORKS).map((k) => `\`${k}\``).join(", ")}`);
852
+ continue;
853
+ }
854
+ if (!Array.isArray(controls) || !controls.every((c) => typeof c === "string")) {
855
+ check.fail(`${path}.${framework}`, "expected an array of control ids");
856
+ continue;
857
+ }
858
+ const ids = controls;
859
+ for (const control of ids) if (known.controls[control] === void 0) check.fail(`${path}.${framework}`, `unknown control \`${control}\` in ${known.name}`);
860
+ if (ids.length > 0) out[framework] = [...new Set(ids)];
861
+ }
862
+ return out;
863
+ }
782
864
  /** Parse and validate a pack. Never throws; every problem lands in `errors`. */
783
865
  function parsePack(source) {
784
866
  let raw;
@@ -1,4 +1,4 @@
1
- import { c as verifyReceipt, i as fingerprint } from "./src-CXU0_W1P.js";
1
+ import { c as verifyReceipt, i as fingerprint } from "./src-Ciggx9ja.js";
2
2
 
3
3
  //#region src/receipt-check.ts
4
4
  /** Shape we require before we will call it a receipt at all. */
@@ -17,11 +17,16 @@
17
17
  "./**/*.sample"
18
18
  ]
19
19
  },
20
- "secrets": { "minConfidence": 0.8 }
20
+ "secrets": {
21
+ "minConfidence": 0.8
22
+ }
21
23
  },
22
24
  "effect": {
23
25
  "kind": "deny",
24
26
  "because": "the requested file contains a protected credential; the contents were not returned"
27
+ },
28
+ "frameworks": {
29
+ "owasp-llm-top-10-2026": ["LLM02"]
25
30
  }
26
31
  },
27
32
  {
@@ -29,21 +34,48 @@
29
34
  "title": "Private keys are refused everywhere",
30
35
  "when": {
31
36
  "capability": "fs:read",
32
- "secrets": { "types": ["private-key"] }
37
+ "secrets": {
38
+ "types": ["private-key"]
39
+ }
33
40
  },
34
41
  "effect": {
35
42
  "kind": "deny",
36
43
  "because": "the requested file contains a private key; the contents were not returned"
44
+ },
45
+ "frameworks": {
46
+ "owasp-llm-top-10-2026": ["LLM02"]
37
47
  }
38
48
  },
39
49
  {
40
50
  "id": "SEC-003",
41
51
  "title": "Anything credential-shaped is at least recorded",
42
- "when": { "capability": "fs:read", "secrets": { "minConfidence": 0.5 } },
52
+ "when": {
53
+ "capability": "fs:read",
54
+ "secrets": {
55
+ "minConfidence": 0.5
56
+ }
57
+ },
43
58
  "effect": {
44
59
  "kind": "warn",
45
60
  "because": "a possible credential was noticed in this file; where no stronger rule fired, the read proceeded and this row is the record"
46
61
  }
62
+ },
63
+ {
64
+ "id": "SEC-004",
65
+ "title": "Shell output carrying credentials is withheld",
66
+ "when": {
67
+ "capability": "proc:spawn",
68
+ "secrets": {
69
+ "minConfidence": 0.8
70
+ }
71
+ },
72
+ "effect": {
73
+ "kind": "deny",
74
+ "because": "the command ran and its output contained a protected credential; the output was withheld from the model"
75
+ },
76
+ "frameworks": {
77
+ "owasp-llm-top-10-2026": ["LLM02"]
78
+ }
47
79
  }
48
80
  ]
49
81
  }
@@ -1,3 +1,3 @@
1
- import { a as generateKeypair, c as verifyReceipt, d as canonical, f as chain, i as fingerprint, l as verifySignature, m as seal, n as readEntries, o as makeReceipt, p as hashEntry, r as verifyEntries, s as signEntry, t as Ledger, u as GENESIS } from "./src-CXU0_W1P.js";
1
+ import { a as generateKeypair, c as verifyReceipt, d as canonical, f as chain, i as fingerprint, l as verifySignature, m as seal, n as readEntries, o as makeReceipt, p as hashEntry, r as verifyEntries, s as signEntry, t as Ledger, u as GENESIS } from "./src-Ciggx9ja.js";
2
2
 
3
3
  export { GENESIS, Ledger, canonical, chain, fingerprint, generateKeypair, hashEntry, makeReceipt, readEntries, seal, signEntry, verifyEntries, verifyReceipt, verifySignature };
@@ -1,3 +1,72 @@
1
+ //#region ../kernel/src/frameworks.ts
2
+ const FRAMEWORKS = { "owasp-llm-top-10-2026": {
3
+ name: "OWASP Top 10 for LLM Applications — 2026",
4
+ short: "OWASP LLM 2026",
5
+ controls: {
6
+ LLM01: {
7
+ name: "Prompt Injection",
8
+ summary: "Instructions in a prompt or in retrieved content alter what the model does."
9
+ },
10
+ LLM02: {
11
+ name: "Sensitive Information Disclosure",
12
+ summary: "Credentials, personal data or proprietary content reach the model or its output."
13
+ },
14
+ LLM03: {
15
+ name: "Excessive Agency",
16
+ summary: "The model can take actions, or holds permissions, beyond what its task needs."
17
+ },
18
+ LLM04: {
19
+ name: "Supply Chain",
20
+ summary: "A compromised model, dataset, plugin or dependency enters the application."
21
+ },
22
+ LLM05: {
23
+ name: "Data and Model Poisoning",
24
+ summary: "Training, fine-tuning or embedding data is manipulated to change behaviour."
25
+ },
26
+ LLM06: {
27
+ name: "Unbounded Consumption",
28
+ summary: "The application permits resource use without a limit, exhausting budget or service."
29
+ },
30
+ LLM07: {
31
+ name: "Misinformation",
32
+ summary: "The model produces false or misleading content that is acted on as if true."
33
+ },
34
+ LLM08: {
35
+ name: "Hidden Context Exposure",
36
+ summary: "System prompts, hidden instructions or internal context are revealed."
37
+ },
38
+ LLM09: {
39
+ name: "Vector and Embedding Weaknesses",
40
+ summary: "Retrieval stores are manipulated or leak across tenants and permissions."
41
+ },
42
+ LLM10: {
43
+ name: "Improper Output Handling",
44
+ summary: "Model output is passed to downstream systems without validation."
45
+ }
46
+ }
47
+ } };
48
+ /**
49
+ * Every framework control a rule cites, resolved to names.
50
+ *
51
+ * A pack that parsed cites only ids the registry knows, so the lookups here
52
+ * cannot miss; the fallbacks exist for a `Rule` built by hand in code.
53
+ */
54
+ function cite(rule) {
55
+ const out = [];
56
+ for (const [framework, controls] of Object.entries(rule.frameworks ?? {})) {
57
+ const known = FRAMEWORKS[framework];
58
+ for (const control of controls) out.push({
59
+ framework,
60
+ frameworkName: known?.name ?? framework,
61
+ frameworkShort: known?.short ?? framework,
62
+ control,
63
+ controlName: known?.controls[control]?.name ?? control
64
+ });
65
+ }
66
+ return out;
67
+ }
68
+
69
+ //#endregion
1
70
  //#region src/console/logo.ts
2
71
  /**
3
72
  * The mark, inlined.
@@ -629,7 +698,7 @@ function renderNav(items, active) {
629
698
  * the obligation, and a tool that told them the answer would be selling the
630
699
  * one thing it cannot supply.
631
700
  */
632
- const CONTROLS = [
701
+ const OBLIGATIONS = [
633
702
  {
634
703
  id: "EU AI Act art. 12",
635
704
  asks: "Automatic recording of events over the lifetime of the system, to a degree that enables tracing.",
@@ -671,6 +740,71 @@ const CONTROLS = [
671
740
  })
672
741
  }
673
742
  ];
743
+ const OWASP = "owasp-llm-top-10-2026";
744
+ /** Rules in the pack that cite one control of one framework. */
745
+ function citing(pack, framework, control) {
746
+ return (pack?.rules ?? []).filter((rule) => (rule.frameworks?.[framework] ?? []).includes(control));
747
+ }
748
+ /**
749
+ * Why a runtime execution gate is not the control for a risk no rule cites.
750
+ *
751
+ * Said per risk rather than as one generic line, because "out of scope" with
752
+ * no reason reads as an excuse. Each of these is a boundary a reader can
753
+ * check: the risk lives somewhere a tool-call gate does not stand.
754
+ */
755
+ const NOT_THIS_BOUNDARY = {
756
+ LLM04: "Where a model or dependency came from is settled before an agent runs; the gate sees the call, not the provenance.",
757
+ LLM05: "Training and fine-tuning data sit outside the execution boundary this gate stands on.",
758
+ LLM07: "Whether an answer is true is not a property of a tool call.",
759
+ LLM08: "The assembled system prompt is sealed into the ledger as evidence of what the model was told; nothing here stops the model repeating it.",
760
+ LLM09: "Retrieval stores are upstream of the call the gate judges.",
761
+ LLM10: "What downstream code does with model output is that code's boundary, not this one's."
762
+ };
763
+ function owaspRow(control) {
764
+ const framework = FRAMEWORKS[OWASP];
765
+ const risk = framework?.controls[control];
766
+ const name = risk?.name ?? control;
767
+ return {
768
+ id: `${framework?.short ?? OWASP} · ${control}`,
769
+ asks: `${name}. ${risk?.summary ?? ""}`.trim(),
770
+ evidence: (data) => {
771
+ if (data.pack === void 0) return {
772
+ state: "absent",
773
+ note: "No pack supplied to this view."
774
+ };
775
+ if (control === "LLM01") {
776
+ const bounding = citing(data.pack, OWASP, "LLM03");
777
+ return {
778
+ state: "partial",
779
+ note: bounding.length === 0 ? "Not mitigated: this gate does not detect injected instructions, and no LLM03 control in this pack bounds what one can execute." : `Not mitigated: this gate does not detect injected instructions. Bounded: ${bounding.length} LLM03 control${bounding.length === 1 ? "" : "s"} (${bounding.map((r) => r.id).join(", ")}) decide what an injected instruction can execute.`
780
+ };
781
+ }
782
+ const rules = citing(data.pack, OWASP, control);
783
+ if (rules.length === 0) return {
784
+ state: "absent",
785
+ note: NOT_THIS_BOUNDARY[control] ?? "No rule in this pack cites this risk."
786
+ };
787
+ const ids = new Set(rules.map((rule) => rule.id));
788
+ const decisions = data.entries.filter((entry) => entry.rule !== void 0 && ids.has(entry.rule));
789
+ const listed = rules.map((rule) => rule.id).join(", ");
790
+ return decisions.length > 0 ? {
791
+ state: "covered",
792
+ note: `${rules.length} control${rules.length === 1 ? "" : "s"} mapped (${listed}) · ${decisions.length} sealed decision${decisions.length === 1 ? "" : "s"} under them in this record.`
793
+ } : {
794
+ state: "partial",
795
+ note: `${rules.length} control${rules.length === 1 ? "" : "s"} mapped (${listed}); none has decided a call in this record yet, so the mapping is declared, not exercised.`
796
+ };
797
+ }
798
+ };
799
+ }
800
+ const OWASP_ROWS = Object.keys(FRAMEWORKS[OWASP]?.controls ?? {}).map(owaspRow);
801
+ /**
802
+ * The obligations first, then the OWASP risks. Two kinds of row in one table
803
+ * on purpose: an auditor reads down one list and finds the audit obligations
804
+ * the ledger evidences and the application risks the pack's rules address,
805
+ * each stated in its framework's own words.
806
+ */
807
+ const CONTROLS = [...OBLIGATIONS, ...OWASP_ROWS];
674
808
 
675
809
  //#endregion
676
810
  //#region src/console/views.ts
@@ -789,11 +923,17 @@ function renderPacks(data) {
789
923
  });
790
924
  const baseline = pack.baseline?.capabilities;
791
925
  const caps = baseline === void 0 ? `<p style="padding:0 30px 22px;font-size:14px;color:var(--secondary);max-width:76ch">This pack sets no capability ceiling. It governs through its rules alone, and withholds nothing an author declares.</p>` : `<div class="caps">${baseline.map((c) => `<span class="cap">${esc(c)}</span>`).join("")}</div>`;
792
- const rows = pack.rules.map((rule) => ` <div class="grid rules row">
926
+ const cited = /* @__PURE__ */ new Map();
927
+ for (const rule of pack.rules) for (const c of cite(rule)) cited.set(c.framework, c.frameworkName);
928
+ const frameworks = cited.size === 0 ? "" : `<div class="caps">${[...cited.values()].map((name) => `<span class="cap">${esc(name)}</span>`).join("")}</div>`;
929
+ const rows = pack.rules.map((rule) => {
930
+ const tags = cite(rule).map((c) => `<br><span style="color:var(--secondary)">${esc(c.frameworkShort)} · ${esc(c.control)} ${esc(c.controlName)}</span>`).join("");
931
+ return ` <div class="grid rules row">
793
932
  <div class="seq">${esc(rule.id)}</div>
794
- <div class="call">${esc(rule.title ?? rule.effect.because)}<br><span style="color:var(--muted)">${esc(matcherOf(rule))}</span></div>
933
+ <div class="call">${esc(rule.title ?? rule.effect.because)}<br><span style="color:var(--muted)">${esc(matcherOf(rule))}</span>${tags}</div>
795
934
  <div class="effect e-${esc(rule.effect.kind)}">${esc(rule.effect.kind.toUpperCase())}</div>
796
- </div>`).join("\n");
935
+ </div>`;
936
+ }).join("\n");
797
937
  const body = ` <section class="sect">
798
938
  <h2>${esc(pack.pack)} <span style="font-family:var(--proof);font-size:14px;color:var(--muted)">${esc(pack.version)}</span></h2>
799
939
  <p>The operator baseline is intersected with whatever a plugin's own
@@ -801,6 +941,7 @@ function renderPacks(data) {
801
941
  grant a capability an author never asked for.</p>
802
942
  </section>
803
943
  ${caps}
944
+ ${frameworks}
804
945
  <div class="grid rules thead">
805
946
  <div>CLAUSE</div><div>RULE</div><div>EFFECT</div>
806
947
  </div>
@@ -840,11 +981,17 @@ ${CONTROLS.map((control) => {
840
981
  </div>`;
841
982
  }).join("\n")}
842
983
  <section class="sect" style="padding-bottom:26px">
843
- <h2>The gap that recurs</h2>
844
- <p>Three of these rows stop at <em>partial</em> for the same reason: the
845
- harness records what was decided and never who decided it. Attributable
846
- approval needs an identity anchor outside this machine, which is a
847
- control-plane concern this build does not claim.</p>
984
+ <h2>Reading the states</h2>
985
+ <p>For the audit obligations, several rows stop at <em>partial</em> for
986
+ one reason: the harness records what was decided and never who decided
987
+ it. Attributable approval needs an identity anchor outside this machine,
988
+ which is a control-plane concern this build does not claim.</p>
989
+ <p>For the OWASP risks, <em>evidence</em> means rules in this pack cite
990
+ the risk and have decided calls in this record; <em>partial</em> means
991
+ they cite it and have not yet, or — for prompt injection — that the gate
992
+ bounds the risk without claiming to detect it; <em>none</em> means no
993
+ rule cites it, with the reason a tool-call gate is not where that risk
994
+ is controlled.</p>
848
995
  </section>`;
849
996
  return renderShell({
850
997
  title: "Compliance map",
@@ -926,4 +1073,4 @@ ${history}`;
926
1073
  }
927
1074
 
928
1075
  //#endregion
929
- export { renderPacks as a, renderShell as c, renderOverview as i, VERDICT_LABEL as l, renderApprovals as n, esc as o, renderCompliance as r, renderNav as s, navFor as t };
1076
+ export { renderPacks as a, renderShell as c, cite as d, renderOverview as i, VERDICT_LABEL as l, renderApprovals as n, esc as o, renderCompliance as r, renderNav as s, navFor as t, FRAMEWORKS as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prae-cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Operator policy for agent harnesses: validate packs, explain a decision, test a pack against security invariants, verify a decision log, and provision a governed DSH profile.",
5
5
  "keywords": [
6
6
  "dsh-plugin",
@@ -41,9 +41,9 @@
41
41
  "typescript": "^5.9.2",
42
42
  "vitest": "^3.2.4",
43
43
  "@prae/controls": "0.1.0",
44
+ "@prae/extract": "0.1.0",
44
45
  "@prae/inspect": "0.1.0",
45
46
  "@prae/instructions": "0.1.0",
46
- "@prae/extract": "0.1.0",
47
47
  "@prae/kernel": "0.1.0",
48
48
  "@prae/ledger": "0.1.0",
49
49
  "@prae/tokens": "0.1.0"
File without changes