@cirvix_ai/agent-control 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +42 -0
  3. package/README.md +341 -0
  4. package/action/README.md +100 -0
  5. package/action/action.yml +134 -0
  6. package/action/report.mjs +144 -0
  7. package/bin/cirvix.mjs +1073 -0
  8. package/package.json +60 -0
  9. package/src/commands/demo.mjs +315 -0
  10. package/src/commands/init.mjs +558 -0
  11. package/src/commands/policy.mjs +345 -0
  12. package/src/commands/sarif.mjs +176 -0
  13. package/src/commands/scan.mjs +210 -0
  14. package/src/commands/status.mjs +208 -0
  15. package/src/commands/upgrade.mjs +162 -0
  16. package/src/core/approvals.mjs +388 -0
  17. package/src/core/audit.mjs +181 -0
  18. package/src/core/canonical.mjs +316 -0
  19. package/src/core/daemon.mjs +352 -0
  20. package/src/core/decisions.mjs +253 -0
  21. package/src/core/delegation.mjs +658 -0
  22. package/src/core/detect.mjs +337 -0
  23. package/src/core/entitlement-gate.mjs +100 -0
  24. package/src/core/entitlements.mjs +285 -0
  25. package/src/core/format.mjs +33 -0
  26. package/src/core/gateway.mjs +959 -0
  27. package/src/core/guard.mjs +568 -0
  28. package/src/core/http-transport.mjs +505 -0
  29. package/src/core/journal.mjs +419 -0
  30. package/src/core/jsonrpc.mjs +152 -0
  31. package/src/core/meter.mjs +225 -0
  32. package/src/core/normalize.mjs +516 -0
  33. package/src/core/notices.mjs +80 -0
  34. package/src/core/pipeline.mjs +629 -0
  35. package/src/core/policy-dsl.mjs +611 -0
  36. package/src/core/policy.mjs +710 -0
  37. package/src/core/prompts.mjs +146 -0
  38. package/src/core/risk.mjs +509 -0
  39. package/src/core/sanitize.mjs +279 -0
  40. package/src/core/secret-detect.mjs +533 -0
  41. package/src/core/secrets.mjs +312 -0
  42. package/src/core/uds.mjs +383 -0
  43. package/src/core/vault.mjs +530 -0
  44. package/src/index.mjs +143 -0
  45. package/src/testing.mjs +145 -0
@@ -0,0 +1,611 @@
1
+ /**
2
+ * The policy DSL — the syntax people actually write.
3
+ *
4
+ * allow:
5
+ * tool = git.status
6
+ *
7
+ * allow:
8
+ * tool = filesystem.read
9
+ * path = ./src/**
10
+ *
11
+ * deny:
12
+ * tool = shell.exec
13
+ * command = "rm -rf"
14
+ *
15
+ * deny:
16
+ * network.destination = 169.254.169.254
17
+ *
18
+ * require_approval:
19
+ * tool = database.write
20
+ *
21
+ * require_approval:
22
+ * tool = shell.exec
23
+ * risk >= HIGH
24
+ *
25
+ * This compiles to the JSON rule shape the engine already evaluates. It is a
26
+ * front end, not a second engine — `compile()` produces exactly the objects
27
+ * `parseRules()` validates, and every semantic in this file is expressible in
28
+ * that JSON. That constraint is deliberate: the moment the DSL can say
29
+ * something the JSON cannot, there are two policy languages, and the one the
30
+ * conformance suite checks is no longer the one customers write.
31
+ *
32
+ * DELIBERATELY NOT YAML, AND DELIBERATELY NOT CEDAR
33
+ *
34
+ * Not YAML: the format needs one parser, in the CLI, with no dependencies, and
35
+ * YAML's surface area (anchors, merge keys, the Norway problem, implicit typing
36
+ * that turns `no` into `false`) is a liability in a file that decides whether a
37
+ * credential can be read. This grammar is a hundred lines and has no
38
+ * surprises.
39
+ *
40
+ * Not Cedar: the product's documentation once claimed Cedar and the engine was
41
+ * never Cedar. Rather than adopt a policy language to match old marketing, the
42
+ * docs were corrected. This DSL is what the engine actually does.
43
+ *
44
+ * THREE COMPILATION DECISIONS WORTH READING
45
+ *
46
+ * 1. `risk >= HIGH` compiles to `{path:"risk", op:"in", value:["high","critical"]}`
47
+ * rather than to a new comparator. The Node and Python engines share a
48
+ * conformance fixture; adding an operator to one is how they drift. Ordinal
49
+ * comparison over a four-value enum is exactly an `in` over its tail.
50
+ *
51
+ * 2. `command = "rm -rf"` compiles to a CONTAINS match, not equality. Nobody
52
+ * writing that line means "the command is exactly the two words rm -rf" —
53
+ * they mean "this appears in the command". Compiling it to equality would
54
+ * produce a rule that reads as protective, validates cleanly, and never
55
+ * fires once.
56
+ *
57
+ * 3. RELATIVE PATHS ARE ANCHORED TO THE WORKSPACE AT COMPILE TIME. Resources
58
+ * are canonicalized to absolute paths before matching, so a literal
59
+ * `./src/**` pattern would never match anything. `./src/**` becomes
60
+ * `<cwd>/src/**`. The source file stays portable; the compiled rule is
61
+ * specific to the workspace it was loaded in, which is what a
62
+ * workspace-relative rule means.
63
+ */
64
+
65
+ import { EFFECT } from "./decisions.mjs";
66
+ import { RISK_ORDER, riskRank } from "./risk.mjs";
67
+ import { canonicalAction } from "./normalize.mjs";
68
+
69
+ /** Block headers, and the effect each produces. */
70
+ const BLOCKS = {
71
+ allow: EFFECT.PERMIT,
72
+ permit: EFFECT.PERMIT,
73
+ deny: EFFECT.FORBID,
74
+ forbid: EFFECT.FORBID,
75
+ require_approval: EFFECT.HOLD,
76
+ hold: EFFECT.HOLD,
77
+ sanitize: EFFECT.SANITIZE,
78
+ audit_only: EFFECT.AUDIT_ONLY,
79
+ audit: EFFECT.AUDIT_ONLY,
80
+ };
81
+
82
+ /** Attribute → where it lands on the compiled rule. */
83
+ const ATTRIBUTES = {
84
+ tool: { kind: "action" },
85
+ action: { kind: "action" },
86
+ agent: { kind: "agent" },
87
+ path: { kind: "resource" },
88
+ file: { kind: "resource" },
89
+ resource: { kind: "resource" },
90
+ url: { kind: "resource" },
91
+ command: { kind: "condition", path: "command", contains: true },
92
+ "network.destination": { kind: "condition", path: "egress.destination", contains: true },
93
+ destination: { kind: "condition", path: "egress.destination", contains: true },
94
+ risk: { kind: "risk" },
95
+ env: { kind: "condition", path: "environment" },
96
+ environment: { kind: "condition", path: "environment" },
97
+ workspace: { kind: "condition", path: "path.insideWorkspace", boolean: true },
98
+ external: { kind: "condition", path: "egress.external", boolean: true },
99
+ allowlisted: { kind: "condition", path: "egress.allowlisted", boolean: true },
100
+ touched_secret: { kind: "condition", path: "session.touchedSecret", boolean: true },
101
+ secrets: { kind: "condition", path: "secrets.detected", numeric: true },
102
+ server: { kind: "condition", path: "mcp.server" },
103
+
104
+ // Rule metadata rather than matching.
105
+ name: { kind: "meta", field: "name" },
106
+ reason: { kind: "meta", field: "reason" },
107
+ remediation: { kind: "meta", field: "remediation" },
108
+ approvers: { kind: "meta", field: "approvers", list: true },
109
+ targets: { kind: "sanitize", field: "targets", list: true },
110
+ strategies: { kind: "sanitize", field: "strategies", list: true },
111
+ };
112
+
113
+ const OPERATORS = {
114
+ "=": "eq",
115
+ "==": "eq",
116
+ "!=": "ne",
117
+ ">=": "gte",
118
+ "<=": "lte",
119
+ ">": "gt",
120
+ "<": "lt",
121
+ "~": "matches",
122
+ "~=": "matches",
123
+ };
124
+
125
+ /* -------------------------------------------------------------------------- */
126
+ /* Parsing */
127
+ /* -------------------------------------------------------------------------- */
128
+
129
+ export class PolicySyntaxError extends Error {
130
+ constructor(message, line, text) {
131
+ super(`Line ${line}: ${message}${text ? `\n ${text.trim()}` : ""}`);
132
+ this.name = "PolicySyntaxError";
133
+ this.line = line;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Splits source into blocks. Structure only — no meaning assigned yet, so a
139
+ * syntax error is reported as a syntax error rather than as a mysterious
140
+ * semantic one three stages later.
141
+ *
142
+ * @returns {{blocks: Array, tests: Array}}
143
+ */
144
+ export function parse(source) {
145
+ const lines = String(source ?? "").split(/\r?\n/);
146
+ const blocks = [];
147
+ const tests = [];
148
+ let current = null;
149
+
150
+ const closeCurrent = () => {
151
+ if (current) (current.kind === "test" ? tests : blocks).push(current);
152
+ current = null;
153
+ };
154
+
155
+ for (let i = 0; i < lines.length; i++) {
156
+ const raw = lines[i];
157
+ const lineNo = i + 1;
158
+
159
+ // Comments and blanks. A `#` inside a quoted value is not a comment, so
160
+ // stripping is only safe on a line that starts with one.
161
+ const trimmed = raw.trim();
162
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("//")) continue;
163
+
164
+ const indented = /^\s/.test(raw);
165
+
166
+ if (!indented) {
167
+ closeCurrent();
168
+
169
+ const testHeader = trimmed.match(/^test\s+(?:"([^"]*)"|'([^']*)'|(\S+))\s*:$/i);
170
+ if (testHeader) {
171
+ current = {
172
+ kind: "test",
173
+ name: testHeader[1] ?? testHeader[2] ?? testHeader[3],
174
+ line: lineNo,
175
+ attributes: [],
176
+ };
177
+ continue;
178
+ }
179
+
180
+ const header = trimmed.match(/^([a-z_]+)\s*:$/i);
181
+ if (!header) {
182
+ throw new PolicySyntaxError(
183
+ `Expected a block header such as "allow:" or "deny:", got "${trimmed}".`,
184
+ lineNo,
185
+ raw,
186
+ );
187
+ }
188
+ const effect = BLOCKS[header[1].toLowerCase()];
189
+ if (!effect) {
190
+ throw new PolicySyntaxError(
191
+ `Unknown block "${header[1]}". Expected one of: ${Object.keys(BLOCKS).join(", ")}.`,
192
+ lineNo,
193
+ raw,
194
+ );
195
+ }
196
+ current = { kind: "rule", effect, line: lineNo, attributes: [] };
197
+ continue;
198
+ }
199
+
200
+ if (!current) {
201
+ throw new PolicySyntaxError("Indented attribute with no block above it.", lineNo, raw);
202
+ }
203
+
204
+ // `expect deny` — only inside a test block.
205
+ const expect = trimmed.match(/^expect\s+([a-z_]+)$/i);
206
+ if (expect) {
207
+ if (current.kind !== "test") {
208
+ throw new PolicySyntaxError("`expect` is only valid inside a `test` block.", lineNo, raw);
209
+ }
210
+ current.expect = expect[1].toLowerCase();
211
+ continue;
212
+ }
213
+
214
+ const attr = trimmed.match(/^([A-Za-z_][A-Za-z0-9_.]*)\s*(!=|>=|<=|~=|==|=|>|<|~)\s*(.+)$/);
215
+ if (!attr) {
216
+ throw new PolicySyntaxError(
217
+ `Expected "attribute = value", got "${trimmed}".`,
218
+ lineNo,
219
+ raw,
220
+ );
221
+ }
222
+
223
+ current.attributes.push({
224
+ key: attr[1].toLowerCase(),
225
+ op: attr[2],
226
+ value: unquote(attr[3].trim()),
227
+ quoted: /^["']/.test(attr[3].trim()),
228
+ line: lineNo,
229
+ raw,
230
+ });
231
+ }
232
+
233
+ closeCurrent();
234
+ return { blocks, tests };
235
+ }
236
+
237
+ function unquote(value) {
238
+ const s = String(value).trim();
239
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
240
+ return s.slice(1, -1);
241
+ }
242
+ // A trailing comment on an unquoted value.
243
+ return s.replace(/\s+#.*$/, "").trim();
244
+ }
245
+
246
+ /* -------------------------------------------------------------------------- */
247
+ /* Compilation */
248
+ /* -------------------------------------------------------------------------- */
249
+
250
+ /**
251
+ * Compiles parsed blocks into engine rules.
252
+ *
253
+ * @param {string} source
254
+ * @param {object} [opts]
255
+ * @param {string} [opts.cwd] workspace root, for anchoring relative paths
256
+ * @param {string} [opts.origin] file name, used in generated rule names
257
+ * @returns {{rules: Array, tests: Array}}
258
+ */
259
+ export function compile(source, { cwd = process.cwd(), origin = "policy" } = {}) {
260
+ const { blocks, tests } = parse(source);
261
+ const rules = [];
262
+ const used = new Set();
263
+
264
+ blocks.forEach((block, index) => {
265
+ const rule = {
266
+ name: "",
267
+ effect: block.effect,
268
+ agents: [],
269
+ actions: [],
270
+ resources: [],
271
+ when: [],
272
+ };
273
+ const sanitize = {};
274
+ let explicitName = null;
275
+
276
+ for (const attr of block.attributes) {
277
+ const spec = ATTRIBUTES[attr.key];
278
+ if (!spec) {
279
+ throw new PolicySyntaxError(
280
+ `Unknown attribute "${attr.key}". Known: ${Object.keys(ATTRIBUTES).join(", ")}.`,
281
+ attr.line,
282
+ attr.raw,
283
+ );
284
+ }
285
+ const op = OPERATORS[attr.op];
286
+ if (!op) {
287
+ throw new PolicySyntaxError(`Unknown operator "${attr.op}".`, attr.line, attr.raw);
288
+ }
289
+
290
+ switch (spec.kind) {
291
+ case "action": {
292
+ requireEquality(attr, "tool");
293
+ // `filesystem.read` and `fs.read` resolve to the same action, so a
294
+ // rule written either way governs the same calls.
295
+ rule.actions.push(canonicalAction(attr.value));
296
+ break;
297
+ }
298
+ case "agent": {
299
+ requireEquality(attr, "agent");
300
+ rule.agents.push(attr.value);
301
+ break;
302
+ }
303
+ case "resource": {
304
+ requireEquality(attr, attr.key);
305
+ rule.resources.push(anchor(attr.value, cwd));
306
+ break;
307
+ }
308
+ case "risk": {
309
+ rule.when.push(riskCondition(attr));
310
+ break;
311
+ }
312
+ case "condition": {
313
+ rule.when.push(condition(spec, attr, op));
314
+ break;
315
+ }
316
+ case "meta": {
317
+ if (spec.field === "name") explicitName = attr.value;
318
+ else if (spec.list) rule[spec.field] = splitList(attr.value);
319
+ else rule[spec.field] = attr.value;
320
+ break;
321
+ }
322
+ case "sanitize": {
323
+ sanitize[spec.field] = splitList(attr.value);
324
+ break;
325
+ }
326
+ default:
327
+ break;
328
+ }
329
+ }
330
+
331
+ // A block with no matching attributes at all matches every call. That is
332
+ // almost never intended and is catastrophic on an `allow`, so it is a
333
+ // compile error rather than a warning.
334
+ if (!rule.actions.length && !rule.resources.length && !rule.agents.length && !rule.when.length) {
335
+ throw new PolicySyntaxError(
336
+ `This ${invertEffect(block.effect)} block has no conditions, so it would match every call.`,
337
+ block.line,
338
+ "",
339
+ );
340
+ }
341
+
342
+ rule.name = uniqueName(explicitName ?? generateName(block, rule, origin, index), used);
343
+ if (Object.keys(sanitize).length) rule.sanitize = sanitize;
344
+
345
+ // Empty arrays mean "match anything" to the engine, which is right, but
346
+ // dropping them keeps the compiled JSON readable when it is printed.
347
+ for (const key of ["agents", "actions", "resources", "when"]) {
348
+ if (Array.isArray(rule[key]) && rule[key].length === 0) delete rule[key];
349
+ }
350
+
351
+ if (!rule.reason) rule.reason = describe(block.effect, rule);
352
+
353
+ rules.push(rule);
354
+ });
355
+
356
+ return { rules, tests: tests.map((t) => compileTest(t, cwd)) };
357
+ }
358
+
359
+ function requireEquality(attr, label) {
360
+ if (attr.op !== "=" && attr.op !== "==" && attr.op !== "~" && attr.op !== "~=") {
361
+ throw new PolicySyntaxError(
362
+ `"${label}" supports = and ~ (glob), not ${attr.op}.`,
363
+ attr.line,
364
+ attr.raw,
365
+ );
366
+ }
367
+ }
368
+
369
+ /**
370
+ * `risk >= HIGH` → `in [high, critical]`.
371
+ *
372
+ * Every comparator is expressed as membership in the matching subset of the
373
+ * four levels, so the engine needs no ordinal knowledge and the two language
374
+ * implementations cannot disagree about what "at least HIGH" means.
375
+ */
376
+ function riskCondition(attr) {
377
+ const level = String(attr.value).toLowerCase();
378
+ if (!RISK_ORDER.includes(level)) {
379
+ throw new PolicySyntaxError(
380
+ `Unknown risk level "${attr.value}". Expected one of: ${RISK_ORDER.join(", ")}.`,
381
+ attr.line,
382
+ attr.raw,
383
+ );
384
+ }
385
+ const rank = riskRank(level);
386
+ const keep = {
387
+ ">=": (r) => r >= rank,
388
+ ">": (r) => r > rank,
389
+ "<=": (r) => r <= rank,
390
+ "<": (r) => r < rank,
391
+ "=": (r) => r === rank,
392
+ "==": (r) => r === rank,
393
+ "!=": (r) => r !== rank,
394
+ }[attr.op];
395
+
396
+ if (!keep) {
397
+ throw new PolicySyntaxError(`"risk" does not support ${attr.op}.`, attr.line, attr.raw);
398
+ }
399
+
400
+ return {
401
+ path: "risk",
402
+ op: "in",
403
+ value: RISK_ORDER.filter((_, i) => keep(i)),
404
+ };
405
+ }
406
+
407
+ function condition(spec, attr, op) {
408
+ if (spec.boolean) {
409
+ const value = /^(true|yes|1|on)$/i.test(attr.value);
410
+ return { path: spec.path, op: op === "ne" ? "ne" : "eq", value };
411
+ }
412
+ if (spec.numeric) {
413
+ const n = Number(attr.value);
414
+ if (!Number.isFinite(n)) {
415
+ throw new PolicySyntaxError(`"${attr.key}" needs a number, got "${attr.value}".`, attr.line, attr.raw);
416
+ }
417
+ return { path: spec.path, op, value: n };
418
+ }
419
+ // `command = "rm -rf"` means contains. See the header.
420
+ //
421
+ // `**` and not `*`: a single star does not cross `/`, and the two things this
422
+ // ever wraps are shell commands and URLs — both of which are mostly slashes.
423
+ // Compiled with `*`, `command = "rm -rf"` matched `rm -rf` and did NOT match
424
+ // `rm -rf /`, so the rule protecting against the canonical destructive
425
+ // command was the one case it let through.
426
+ if (spec.contains && (op === "eq" || op === "matches")) {
427
+ const pattern = /[*?]/.test(attr.value) ? attr.value : `**${attr.value}**`;
428
+ return { path: spec.path, op: "matches", value: pattern };
429
+ }
430
+ return { path: spec.path, op, value: attr.value };
431
+ }
432
+
433
+ function splitList(value) {
434
+ return String(value)
435
+ .split(/[,\s]+/)
436
+ .map((s) => s.trim())
437
+ .filter(Boolean);
438
+ }
439
+
440
+ /**
441
+ * Anchors a workspace-relative pattern to an absolute one.
442
+ *
443
+ * `./src/**` → `<cwd>/src/**`. Already-absolute paths, `**`-prefixed patterns,
444
+ * and URLs are left exactly as written.
445
+ */
446
+ export function anchor(pattern, cwd) {
447
+ const p = String(pattern).replace(/\\/g, "/");
448
+ if (p.startsWith("**") || p.startsWith("*") || p === "*") return p;
449
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(p)) return p;
450
+ if (p.startsWith("/") || /^[A-Za-z]:\//.test(p)) return p;
451
+ if (p.startsWith("~/")) return p.replace(/^~\//, "**/");
452
+ const root = String(cwd).replace(/\\/g, "/").replace(/\/+$/, "");
453
+ return `${root}/${p.replace(/^\.\//, "")}`;
454
+ }
455
+
456
+ function generateName(block, rule, origin, index) {
457
+ const verb = { permit: "allow", forbid: "deny", hold: "approve", sanitize: "sanitize", audit_only: "audit" }[block.effect];
458
+ const subject =
459
+ rule.actions[0] ??
460
+ rule.resources[0]?.split("/").filter(Boolean).pop() ??
461
+ rule.when[0]?.path ??
462
+ `rule-${index + 1}`;
463
+ return `${verb}-${String(subject).replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase()}`;
464
+ }
465
+
466
+ function uniqueName(base, used) {
467
+ let name = base;
468
+ let n = 2;
469
+ while (used.has(name)) name = `${base}-${n++}`;
470
+ used.add(name);
471
+ return name;
472
+ }
473
+
474
+ function invertEffect(effect) {
475
+ return { permit: "allow", forbid: "deny", hold: "require_approval", sanitize: "sanitize", audit_only: "audit_only" }[effect] ?? effect;
476
+ }
477
+
478
+ function describe(effect, rule) {
479
+ const what = rule.actions?.length ? rule.actions.join(", ") : "matching calls";
480
+ const where = rule.resources?.length ? ` on ${rule.resources.join(", ")}` : "";
481
+ return {
482
+ permit: `Permitted: ${what}${where}.`,
483
+ forbid: `Denied: ${what}${where}.`,
484
+ hold: `Held for approval: ${what}${where}.`,
485
+ sanitize: `Sanitized before forwarding: ${what}${where}.`,
486
+ audit_only: `Observed, not enforced: ${what}${where}.`,
487
+ }[effect];
488
+ }
489
+
490
+ /* -------------------------------------------------------------------------- */
491
+ /* Tests */
492
+ /* -------------------------------------------------------------------------- */
493
+
494
+ /**
495
+ * Compiles a `test` block into a case the runner can execute.
496
+ *
497
+ * test "denies reading .env":
498
+ * tool = filesystem.read
499
+ * path = .env
500
+ * expect deny
501
+ *
502
+ * A policy file that ships its own tests is one an operator can change safely,
503
+ * which is the difference between a rule set that evolves and one nobody dares
504
+ * touch after the author leaves.
505
+ */
506
+ function compileTest(block, cwd) {
507
+ const call = { tool: null, arguments: {}, agent: "test", environment: "local" };
508
+ let expected = block.expect ?? null;
509
+
510
+ for (const attr of block.attributes) {
511
+ switch (attr.key) {
512
+ case "tool":
513
+ case "action":
514
+ call.tool = attr.value;
515
+ break;
516
+ case "path":
517
+ case "file":
518
+ case "resource":
519
+ call.arguments.path = anchorForTest(attr.value, cwd);
520
+ break;
521
+ case "url":
522
+ case "destination":
523
+ case "network.destination":
524
+ call.arguments.url = attr.value;
525
+ break;
526
+ case "command":
527
+ call.arguments.command = attr.value;
528
+ break;
529
+ case "agent":
530
+ call.agent = attr.value;
531
+ break;
532
+ case "env":
533
+ case "environment":
534
+ call.environment = attr.value;
535
+ break;
536
+ case "server":
537
+ call.server = attr.value;
538
+ break;
539
+ case "expect":
540
+ expected = attr.value;
541
+ break;
542
+ default:
543
+ throw new PolicySyntaxError(
544
+ `"${attr.key}" is not something a test case can set.`,
545
+ attr.line,
546
+ attr.raw,
547
+ );
548
+ }
549
+ }
550
+
551
+ if (!expected) {
552
+ throw new PolicySyntaxError(`Test "${block.name}" has no \`expect\` line.`, block.line, "");
553
+ }
554
+
555
+ return { name: block.name, call, expect: expected, line: block.line };
556
+ }
557
+
558
+ /** Test paths stay relative unless the author wrote them absolute. */
559
+ function anchorForTest(value, cwd) {
560
+ const p = String(value).replace(/\\/g, "/");
561
+ if (p.startsWith("/") || /^[A-Za-z]:\//.test(p) || /^[a-z]+:\/\//i.test(p)) return p;
562
+ if (p.startsWith("~/")) return p;
563
+ return `${String(cwd).replace(/\\/g, "/").replace(/\/+$/, "")}/${p.replace(/^\.\//, "")}`;
564
+ }
565
+
566
+ /* -------------------------------------------------------------------------- */
567
+ /* Serialization */
568
+ /* -------------------------------------------------------------------------- */
569
+
570
+ /**
571
+ * Renders engine rules back as DSL source.
572
+ *
573
+ * Used by `cirvix policy explain` and by `init` when writing a starter file, so
574
+ * the rules a user sees are in the syntax they would edit rather than in JSON
575
+ * they then have to translate.
576
+ */
577
+ export function toSource(rules, { cwd = process.cwd() } = {}) {
578
+ const relative = (p) => {
579
+ const root = String(cwd).replace(/\\/g, "/").replace(/\/+$/, "");
580
+ const s = String(p).replace(/\\/g, "/");
581
+ return s.startsWith(root + "/") ? `./${s.slice(root.length + 1)}` : s;
582
+ };
583
+
584
+ const out = [];
585
+ for (const rule of rules) {
586
+ const header = invertEffect(rule.effect);
587
+ out.push(`# ${rule.reason ?? rule.name}`);
588
+ out.push(`${header}:`);
589
+ out.push(` name = ${rule.name}`);
590
+ for (const a of rule.agents ?? []) out.push(` agent = ${a}`);
591
+ for (const a of rule.actions ?? []) out.push(` tool = ${a}`);
592
+ for (const r of rule.resources ?? []) out.push(` path = ${relative(r)}`);
593
+ for (const c of rule.when ?? []) out.push(` ${sourceCondition(c)}`);
594
+ for (const a of rule.approvers ?? []) out.push(` approvers = ${a}`);
595
+ out.push("");
596
+ }
597
+ return out.join("\n");
598
+ }
599
+
600
+ function sourceCondition(cond) {
601
+ if (cond.path === "risk" && cond.op === "in" && Array.isArray(cond.value)) {
602
+ const lowest = cond.value.map(riskRank).sort((a, b) => a - b)[0] ?? 0;
603
+ return `risk >= ${RISK_ORDER[lowest].toUpperCase()}`;
604
+ }
605
+ const key =
606
+ Object.entries(ATTRIBUTES).find(([, s]) => s.kind === "condition" && s.path === cond.path)?.[0] ??
607
+ cond.path;
608
+ const op = Object.entries(OPERATORS).find(([, o]) => o === cond.op)?.[0] ?? "=";
609
+ const value = Array.isArray(cond.value) ? cond.value.join(", ") : cond.value;
610
+ return `${key} ${op} ${typeof value === "string" && /\s/.test(value) ? JSON.stringify(value) : value}`;
611
+ }