prae-cli 0.5.2 → 0.6.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/README.md CHANGED
@@ -40,6 +40,40 @@ formats plus entropy, placeholders dropped. Findings name the type, rule,
40
40
  line and confidence — structurally never the secret, so the output is safe to
41
41
  paste anywhere. With `--pack`, also prints the verdict that pack would reach.
42
42
 
43
+ ### `prae test <suite.json> [--pack <pack.json>] [--json]`
44
+
45
+ Run security invariants against a pack and exit non-zero if any stops holding.
46
+ A suite says what an agent attempts and what the pack must decide about it:
47
+
48
+ ```json
49
+ {
50
+ "pack": "./acme.prae.json",
51
+ "scenarios": [
52
+ {
53
+ "name": "Reads outside the workspace are refused",
54
+ "given": "the agent is scoped to ./src and ./docs",
55
+ "when": { "tool": "read", "args": { "file_path": "/etc/shadow" } },
56
+ "then": { "decision": "deny", "rule": "ACME-001" }
57
+ }
58
+ ]
59
+ }
60
+ ```
61
+
62
+ `then` also takes `secretTypes`, asserting which credential classes the
63
+ inspector found before the verdict — so a rule about secrets is tested on the
64
+ detection as well as the decision. `when.repeat: 8` asserts the eighth call of
65
+ that capability, which is how a session budget is written down and how the gate
66
+ counts it.
67
+
68
+ A failure prints the invariant in full — given, when, then, and what it got
69
+ instead — because the useful thing about a red security test is the sentence
70
+ describing what was supposed to hold. `--pack` overrides the suite's own, which
71
+ is how one suite is run against a candidate pack before it is adopted.
72
+
73
+ This evaluates the same pack through the same evaluator and the same content
74
+ inspector the gate uses at runtime. It is not the harness: the adapter that
75
+ turns a live tool call into facts is covered by `prae-gate`'s own suite.
76
+
43
77
  ### `prae mcp --root <dir> [--pack <pack.json>] [--ledger <file>]`
44
78
 
45
79
  Serve gated filesystem access over MCP stdio. Any MCP-capable IDE or agent
package/lib/bin.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import "./evaluate-DMyL3qM_.js";
3
- import { t as main } from "./cli-DfXHsQO7.js";
2
+ import "./evaluate-CkYySl7T.js";
3
+ import { t as main } from "./cli-Cr0U-PFD.js";
4
4
  import "./src-CXU0_W1P.js";
5
5
  import "./views-DalPkBZZ.js";
6
6
 
@@ -1,9 +1,9 @@
1
- import { t as evaluate } from "./evaluate-DMyL3qM_.js";
1
+ import { t as evaluate } from "./evaluate-CkYySl7T.js";
2
2
  import { m as seal, t as Ledger } from "./src-CXU0_W1P.js";
3
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";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { mkdir, readFile, writeFile } from "node:fs/promises";
6
- import { extname, join, normalize, resolve, sep } from "node:path";
6
+ import { dirname, extname, join, normalize, resolve, sep } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { composePatchFile, preflightOps, revisionOf, takeSnapshot } from "dsh-overlay-check";
9
9
  import { createServer } from "node:http";
@@ -853,6 +853,12 @@ const USAGE = `prae — operator policy for agent harnesses
853
853
  contents are returned, and every decision lands in the ledger.
854
854
  (no --pack mounts the bundled secret-catcher pack)
855
855
 
856
+ prae test <suite.json> [--pack <pack.json>] [--json]
857
+ Run security scenarios against a pack and report PASS/FAIL. Each
858
+ scenario says what an agent attempts and what the pack must decide;
859
+ the exit code is non-zero if any invariant no longer holds, so a
860
+ control pack can be defended in CI. --pack overrides the suite's own.
861
+
856
862
  prae verify <ledger.jsonl> [--key <public.pem>]
857
863
  Re-derive every hash in a decision log and report any break. With
858
864
  --key, also verify each row's signature against that pinned key.
@@ -970,7 +976,7 @@ async function scanFile(path, packPath, json) {
970
976
  * not come up inspecting nothing.
971
977
  */
972
978
  async function mcp(root, packPath, ledgerPath) {
973
- const { serveMcp } = await import("./mcp-CEN48TpN.js");
979
+ const { serveMcp } = await import("./mcp-Bq0DNPPR.js");
974
980
  const { resolve: resolve$1 } = await import("node:path");
975
981
  const bundled = [join(import.meta.dirname, "secret-catcher.prae.json"), join(import.meta.dirname, "..", "..", "..", "examples", "secret-catcher.prae.json")].find((candidate) => existsSync(candidate));
976
982
  const chosen = packPath ?? bundled;
@@ -1086,6 +1092,52 @@ async function verifyReceiptCmd(path) {
1086
1092
  else console.error(line);
1087
1093
  if (!result.ok) process.exit(1);
1088
1094
  }
1095
+ /**
1096
+ * `prae test`: assert a pack's invariants and set an exit code.
1097
+ *
1098
+ * The checking lives in `test-run.ts`. This does the three things a command
1099
+ * does — read the files, supply the inspector, and decide the exit code.
1100
+ */
1101
+ async function test(suitePath, packOverride, asJson) {
1102
+ let raw;
1103
+ try {
1104
+ raw = await readFile(suitePath, "utf8");
1105
+ } catch {
1106
+ return fail(`cannot read ${suitePath}`);
1107
+ }
1108
+ let parsed;
1109
+ try {
1110
+ parsed = JSON.parse(raw);
1111
+ } catch (error) {
1112
+ return fail(`${suitePath} is not valid JSON: ${error instanceof Error ? error.message : error}`);
1113
+ }
1114
+ const { parseSuite, renderSuite, runSuite } = await import("./test-run-04ubUcSb.js");
1115
+ const suite = parseSuite(parsed);
1116
+ if (!suite.ok) {
1117
+ console.error(`prae: ${suitePath} is not a valid test suite`);
1118
+ for (const message of suite.errors) console.error(` ${message}`);
1119
+ process.exit(1);
1120
+ }
1121
+ const chosen = packOverride ?? (suite.suite.pack === void 0 ? void 0 : resolve(dirname(suitePath), suite.suite.pack));
1122
+ if (chosen === void 0) return fail("test needs a pack: set `pack` in the suite or pass --pack");
1123
+ const pack = await loadPack(chosen);
1124
+ const { capabilityForTool } = await import("capmark");
1125
+ const { scan } = await import("./src-Vnr0fcpK.js");
1126
+ const inspect = pack.rules.some((rule) => rule.when.secrets !== void 0) ? (path) => {
1127
+ try {
1128
+ return scan(readFileSync(path, "utf8"));
1129
+ } catch {
1130
+ return;
1131
+ }
1132
+ } : void 0;
1133
+ const result = runSuite(pack, suite.suite, {
1134
+ capabilityFor: (tool) => capabilityForTool(tool)?.id,
1135
+ ...inspect === void 0 ? {} : { inspect }
1136
+ });
1137
+ if (asJson) console.log(JSON.stringify(result, null, 2));
1138
+ else for (const line of renderSuite(result)) console.log(line);
1139
+ if (!result.ok) process.exit(1);
1140
+ }
1089
1141
  async function verify(path, keyPath) {
1090
1142
  let ledger;
1091
1143
  try {
@@ -1175,7 +1227,7 @@ async function report(path, out) {
1175
1227
  * that step is a proposal a human approves, and it starts from this list.
1176
1228
  */
1177
1229
  async function instructions(from, asJson) {
1178
- const { ingest } = await import("./src-C4fnUm9V.js");
1230
+ const { ingest } = await import("./src-CraUX64d.js");
1179
1231
  const result = ingest(from);
1180
1232
  if (asJson) {
1181
1233
  console.log(JSON.stringify(result, null, 2));
@@ -1211,7 +1263,7 @@ async function instructions(from, asJson) {
1211
1263
  * on purpose rather than something that happened to their policy.
1212
1264
  */
1213
1265
  async function propose(from, options) {
1214
- const [{ ingest }, { extract }] = await Promise.all([import("./src-C4fnUm9V.js"), import("./src-BfYsUDW9.js")]);
1266
+ const [{ ingest }, { extract }] = await Promise.all([import("./src-CraUX64d.js"), import("./src-CQ0UmFUK.js")]);
1215
1267
  const read = ingest(from);
1216
1268
  if (read.files.length === 0) {
1217
1269
  console.log(`No instruction files found at or above ${from}.`);
@@ -1271,8 +1323,8 @@ async function consoleOut(path, argv) {
1271
1323
  const instructionsDir = flag(argv, "instructions");
1272
1324
  if (instructionsDir !== void 0) {
1273
1325
  const [{ ingest }, { extract }, { renderInstructions }] = await Promise.all([
1274
- import("./src-C4fnUm9V.js"),
1275
- import("./src-BfYsUDW9.js"),
1326
+ import("./src-CraUX64d.js"),
1327
+ import("./src-CQ0UmFUK.js"),
1276
1328
  import("./instructions-D4VOn2lk.js")
1277
1329
  ]);
1278
1330
  const read = ingest(instructionsDir);
@@ -1389,6 +1441,12 @@ async function main(argv) {
1389
1441
  if (path === void 0 || seq === void 0 || key === void 0) fail("receipt needs <ledger.jsonl> <seq> --key <private.pem>");
1390
1442
  return receipt(path, Number(seq), key, flag(rest, "out"));
1391
1443
  }
1444
+ case "test": {
1445
+ const [suite] = rest;
1446
+ if (suite === void 0) fail("test needs a suite file");
1447
+ await test(suite, flag(argv, "pack"), argv.includes("--json"));
1448
+ return;
1449
+ }
1392
1450
  case "verify": {
1393
1451
  const path = rest[0];
1394
1452
  if (path === void 0) fail("verify needs a ledger file");
@@ -73,6 +73,22 @@ before the tool runs. The refusal is delivered to the agent as the tool result.
73
73
  — one-minute profile, the exact command, the expected refusal, and the bypasses
74
74
  to try with what each one does. Requires `prae-gate ≥ 0.5.1`.
75
75
 
76
+ ## Prove the pack still holds, in your own pipeline
77
+
78
+ `prae test` runs a pack against written invariants and exits non-zero when one
79
+ stops holding — so a pack edit that quietly stops refusing something fails a
80
+ build instead of reaching an agent:
81
+
82
+ ```bash
83
+ npx prae-cli test ./your-invariants.json --pack ./secret-catcher.prae.json
84
+ ```
85
+
86
+ Each scenario names what an agent attempts and what the pack must decide about
87
+ it, including which credential classes the inspector had to find first — so the
88
+ detection is asserted alongside the verdict. The file format is in
89
+ `packages/cli/README.md`, and `demo/workspace/papaya.tests.json` in this
90
+ repository is a worked suite of fourteen. Needs `prae-cli >= 0.6.0`.
91
+
76
92
  ## What the ledger holds afterwards
77
93
 
78
94
  One row per intercepted read — verdict, rule, subject path, and for content
@@ -80,13 +80,16 @@ function segments(command) {
80
80
  const found = [];
81
81
  for (const tokens of scan(command)) {
82
82
  let at = 0;
83
+ const wrappers = [];
83
84
  while (at < tokens.length) {
84
85
  const token = tokens[at];
85
86
  if (ASSIGNMENT.test(token)) {
86
87
  at += 1;
87
88
  continue;
88
89
  }
89
- if (WRAPPERS.has(token)) {
90
+ const word = (token.split("/").pop() ?? token).toLowerCase();
91
+ if (WRAPPERS.has(word)) {
92
+ wrappers.push(word);
90
93
  at += 1;
91
94
  while (at < tokens.length) {
92
95
  const option = tokens[at];
@@ -98,10 +101,19 @@ function segments(command) {
98
101
  break;
99
102
  }
100
103
  const name = tokens[at];
101
- if (name === void 0) continue;
104
+ if (name === void 0) {
105
+ const last = wrappers.at(-1);
106
+ if (last !== void 0) found.push({
107
+ name: last,
108
+ tokens: [],
109
+ wrappers
110
+ });
111
+ continue;
112
+ }
102
113
  found.push({
103
114
  name: (name.split("/").pop() ?? name).toLowerCase(),
104
- tokens: tokens.slice(at + 1)
115
+ tokens: tokens.slice(at + 1),
116
+ wrappers
105
117
  });
106
118
  }
107
119
  return found;
@@ -122,7 +134,7 @@ function commandMatches(match, command) {
122
134
  const args = list(match.token);
123
135
  const pipes = list(match.pipedTo);
124
136
  if (names.length > 0) {
125
- const hit = parts.filter((segment) => names.some((pattern) => matches$1(pattern, segment.name)));
137
+ const hit = parts.filter((segment) => names.some((pattern) => matches$1(pattern, segment.name) || segment.wrappers.some((wrapper) => matches$1(pattern, wrapper))));
126
138
  if (hit.length === 0) return false;
127
139
  if (args.length > 0 && !hit.some((segment) => args.every((wanted) => segment.tokens.some((token) => matches$1(wanted, token))))) return false;
128
140
  } else if (args.length > 0) {
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import "./evaluate-DMyL3qM_.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-DfXHsQO7.js";
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
3
  import "./src-CXU0_W1P.js";
4
4
  import "./views-DalPkBZZ.js";
5
5
 
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "./evaluate-DMyL3qM_.js";
1
+ import { t as evaluate } from "./evaluate-CkYySl7T.js";
2
2
  import { a as scan, n as MAX_SCAN_BYTES } from "./src-Br1tGPtS.js";
3
3
  import { readFile, readdir, stat } from "node:fs/promises";
4
4
  import { resolve, sep } from "node:path";
package/lib/playground.js CHANGED
@@ -83,13 +83,16 @@
83
83
  const found = [];
84
84
  for (const tokens of scan(command)) {
85
85
  let at = 0;
86
+ const wrappers = [];
86
87
  while (at < tokens.length) {
87
88
  const token = tokens[at];
88
89
  if (ASSIGNMENT.test(token)) {
89
90
  at += 1;
90
91
  continue;
91
92
  }
92
- if (WRAPPERS.has(token)) {
93
+ const word = (token.split("/").pop() ?? token).toLowerCase();
94
+ if (WRAPPERS.has(word)) {
95
+ wrappers.push(word);
93
96
  at += 1;
94
97
  while (at < tokens.length) {
95
98
  const option = tokens[at];
@@ -101,10 +104,19 @@
101
104
  break;
102
105
  }
103
106
  const name = tokens[at];
104
- if (name === void 0) continue;
107
+ if (name === void 0) {
108
+ const last = wrappers.at(-1);
109
+ if (last !== void 0) found.push({
110
+ name: last,
111
+ tokens: [],
112
+ wrappers
113
+ });
114
+ continue;
115
+ }
105
116
  found.push({
106
117
  name: (name.split("/").pop() ?? name).toLowerCase(),
107
- tokens: tokens.slice(at + 1)
118
+ tokens: tokens.slice(at + 1),
119
+ wrappers
108
120
  });
109
121
  }
110
122
  return found;
@@ -125,7 +137,7 @@
125
137
  const args = list(match.token);
126
138
  const pipes = list(match.pipedTo);
127
139
  if (names.length > 0) {
128
- const hit = parts.filter((segment) => names.some((pattern) => matches$1(pattern, segment.name)));
140
+ const hit = parts.filter((segment) => names.some((pattern) => matches$1(pattern, segment.name) || segment.wrappers.some((wrapper) => matches$1(pattern, wrapper))));
129
141
  if (hit.length === 0) return false;
130
142
  if (args.length > 0 && !hit.some((segment) => args.every((wanted) => segment.tokens.some((token) => matches$1(wanted, token))))) return false;
131
143
  } else if (args.length > 0) {
@@ -0,0 +1,137 @@
1
+ import { t as evaluate } from "./evaluate-CkYySl7T.js";
2
+
3
+ //#region src/test-run.ts
4
+ /** The path argument a scenario's call carries, if any. */
5
+ function pathOf(args) {
6
+ for (const key of [
7
+ "path",
8
+ "file_path",
9
+ "filePath",
10
+ "target"
11
+ ]) {
12
+ const value = args[key];
13
+ if (typeof value === "string" && value !== "") return value;
14
+ }
15
+ }
16
+ /**
17
+ * Reject a malformed suite by saying what is wrong with it.
18
+ *
19
+ * A test file that silently runs nothing is the worst possible outcome here —
20
+ * it reports success — so every scenario must name a tool and an expected
21
+ * decision, and anything else is an error rather than a default.
22
+ */
23
+ function parseSuite(parsed) {
24
+ const errors = [];
25
+ if (typeof parsed !== "object" || parsed === null) return {
26
+ ok: false,
27
+ errors: ["expected a JSON object"]
28
+ };
29
+ const root = parsed;
30
+ const scenarios = root.scenarios;
31
+ if (!Array.isArray(scenarios) || scenarios.length === 0) return {
32
+ ok: false,
33
+ errors: ["`scenarios` must be a non-empty array"]
34
+ };
35
+ const kinds = new Set([
36
+ "allow",
37
+ "deny",
38
+ "ask",
39
+ "correct"
40
+ ]);
41
+ scenarios.forEach((raw, index) => {
42
+ const at = `scenarios[${index}]`;
43
+ if (typeof raw !== "object" || raw === null) {
44
+ errors.push(`${at}: expected an object`);
45
+ return;
46
+ }
47
+ const s = raw;
48
+ if (typeof s.name !== "string" || s.name === "") errors.push(`${at}.name: required`);
49
+ const when = s.when;
50
+ if (typeof when !== "object" || when === null) errors.push(`${at}.when: required`);
51
+ else {
52
+ if (typeof when.tool !== "string" || when.tool === "") errors.push(`${at}.when.tool: required`);
53
+ if (when.repeat !== void 0 && (typeof when.repeat !== "number" || when.repeat < 1)) errors.push(`${at}.when.repeat: must be a positive number`);
54
+ }
55
+ const then = s.then;
56
+ if (typeof then !== "object" || then === null) errors.push(`${at}.then: required`);
57
+ else if (typeof then.decision !== "string" || !kinds.has(then.decision)) errors.push(`${at}.then.decision: one of allow, deny, ask, correct`);
58
+ });
59
+ if (errors.length > 0) return {
60
+ ok: false,
61
+ errors
62
+ };
63
+ return {
64
+ ok: true,
65
+ suite: {
66
+ ...typeof root.pack === "string" ? { pack: root.pack } : {},
67
+ scenarios
68
+ }
69
+ };
70
+ }
71
+ function runSuite(pack, suite, options) {
72
+ const results = suite.scenarios.map((scenario) => {
73
+ const args = scenario.when.args ?? {};
74
+ const capability = options.capabilityFor(scenario.when.tool);
75
+ const target = pathOf(args);
76
+ const findings = target === void 0 || options.inspect === void 0 ? void 0 : options.inspect(target);
77
+ const decision = evaluate(pack, {
78
+ tool: scenario.when.tool,
79
+ ...capability === void 0 ? {} : { capability },
80
+ args,
81
+ ...findings !== void 0 && findings.length > 0 ? { secrets: findings } : {},
82
+ ...capability === void 0 || scenario.when.repeat === void 0 ? {} : { counts: { [capability]: scenario.when.repeat } }
83
+ });
84
+ const secretTypes = [...new Set((findings ?? []).map((f) => f.type))];
85
+ const failures = [];
86
+ if (decision.kind !== scenario.then.decision) failures.push(`decision was ${decision.kind}, expected ${scenario.then.decision}`);
87
+ if (scenario.then.rule !== void 0 && decision.rule !== scenario.then.rule) failures.push(`rule was ${decision.rule ?? "none"}, expected ${scenario.then.rule}`);
88
+ if (scenario.then.secretTypes !== void 0) {
89
+ const missing = scenario.then.secretTypes.filter((type) => !secretTypes.includes(type));
90
+ if (missing.length > 0) failures.push(`no finding of type ${missing.join(", ")}`);
91
+ }
92
+ return {
93
+ name: scenario.name,
94
+ ...scenario.given === void 0 ? {} : { given: scenario.given },
95
+ when: scenario.when,
96
+ expected: scenario.then,
97
+ actual: {
98
+ decision: decision.kind,
99
+ ...decision.rule === void 0 ? {} : { rule: decision.rule },
100
+ secretTypes
101
+ },
102
+ pass: failures.length === 0,
103
+ failures
104
+ };
105
+ });
106
+ const failed = results.filter((r) => !r.pass).length;
107
+ return {
108
+ pack: `${pack.pack} ${pack.version}`,
109
+ passed: results.length - failed,
110
+ failed,
111
+ results,
112
+ ok: failed === 0
113
+ };
114
+ }
115
+ /** The report, as lines. Phrased for a reader; the command only prints it. */
116
+ function renderSuite(result) {
117
+ const lines = [`prae test — ${result.pack}`, ""];
118
+ const width = Math.max(...result.results.map((r) => r.name.length), 20);
119
+ for (const r of result.results) {
120
+ const mark = r.pass ? "✓" : "✕";
121
+ const shown = r.pass ? `${r.actual.decision.toUpperCase()}${r.actual.rule === void 0 ? "" : ` ${r.actual.rule}`}` : r.failures.join("; ");
122
+ lines.push(` ${mark} ${r.name.padEnd(width)} ${shown}`);
123
+ }
124
+ for (const r of result.results.filter((one) => !one.pass)) {
125
+ lines.push("", ` ✕ ${r.name}`);
126
+ if (r.given !== void 0) lines.push(` GIVEN ${r.given}`);
127
+ lines.push(` WHEN ${r.when.tool}${r.when.repeat === void 0 ? "" : ` ×${r.when.repeat}`} ${JSON.stringify(r.when.args ?? {})}`);
128
+ lines.push(` THEN ${r.expected.decision}${r.expected.rule === void 0 ? "" : ` · ${r.expected.rule}`}`);
129
+ lines.push(` GOT ${r.failures.join("; ")}`);
130
+ }
131
+ const n = result.results.length;
132
+ lines.push("", `${n} scenario${n === 1 ? "" : "s"} · ${result.passed} passed · ${result.failed} failed`, result.ok ? "SECURITY TEST: PASS" : "SECURITY TEST: FAIL");
133
+ return lines;
134
+ }
135
+
136
+ //#endregion
137
+ export { parseSuite, renderSuite, runSuite };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "prae-cli",
3
- "version": "0.5.2",
4
- "description": "Operator policy for agent harnesses: validate packs, explain a decision, verify a decision log, and provision a governed DSH profile.",
3
+ "version": "0.6.0",
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",
7
7
  "governance",
@@ -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",
45
44
  "@prae/inspect": "0.1.0",
46
45
  "@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
File without changes