@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,710 @@
1
+ /**
2
+ * The policy engine.
3
+ *
4
+ * A deterministic evaluator over an ordered rule set. Given a request
5
+ * (agent, action, resource, context) it returns a decision with the rule that
6
+ * produced it — the explanation is a first-class output, not a log line,
7
+ * because a refusal an agent cannot read is a refusal it cannot recover from.
8
+ *
9
+ * Three properties are load-bearing and tested:
10
+ *
11
+ * 1. FORBID ALWAYS WINS. A `forbid` match cannot be overridden by any
12
+ * `permit`, regardless of order or specificity. This is what makes a
13
+ * rule set safe to extend: adding a permissive rule can never silently
14
+ * punch a hole through an existing prohibition.
15
+ *
16
+ * 2. DEFAULT DENY. A request that matches nothing is denied. Fail-open is
17
+ * how a control plane becomes decorative the first time a rule file
18
+ * fails to parse.
19
+ *
20
+ * 3. RESOURCES ARE CANONICALIZED BEFORE MATCHING. `./x/../.env`,
21
+ * `.env`, and an absolute path to the same file are one resource. Rules
22
+ * that match on raw strings are bypassed by the first attacker who tries
23
+ * a traversal, and by the first agent that happens to use a relative
24
+ * path.
25
+ *
26
+ * 4. AN OBSERVATION IS NOT AN AUTHORIZATION. `audit_only` rules match, get
27
+ * recorded, and contribute nothing to the verdict — so adding one can
28
+ * never punch a hole through a `forbid`. See `./decisions.mjs`.
29
+ */
30
+
31
+ import { homedir } from "node:os";
32
+
33
+ import { canonicalUrl, expandHome, foldPath } from "./canonical.mjs";
34
+ import {
35
+ DECISION,
36
+ EFFECT,
37
+ VERDICT,
38
+ effectRank,
39
+ toDecision,
40
+ } from "./decisions.mjs";
41
+
42
+ /** @typedef {"permit"|"forbid"|"hold"|"sanitize"|"audit_only"} Effect */
43
+
44
+ // Re-exported rather than redefined: two definitions of "what is a forbid" is
45
+ // two policy engines. Importers of `policy.mjs` keep working unchanged.
46
+ export { EFFECT, VERDICT, DECISION };
47
+
48
+ /* -------------------------------------------------------------------------- */
49
+ /* Matching */
50
+ /* -------------------------------------------------------------------------- */
51
+
52
+ /**
53
+ * Glob matching for tool and resource patterns.
54
+ *
55
+ * `*` matches within a segment, `**` matches across segments, `?` matches one
56
+ * character that is not a separator. Matching is case-insensitive, and every
57
+ * other character is a literal — a `.` in a rule means a dot, not "any
58
+ * character", or `deny **\/.env` would also match `xenv`.
59
+ *
60
+ * DELIBERATELY NOT A REGULAR EXPRESSION.
61
+ *
62
+ * The obvious implementation compiles the glob to a RegExp, and it is what
63
+ * this was. It is also exponential: `*a*a*a*a*b` against a long run of `a`
64
+ * makes the engine explore every way of splitting the input between the
65
+ * wildcards, and JavaScript's backtracking matcher will sit there for minutes.
66
+ * That matters here more than it does in most places, because the pattern
67
+ * comes from a policy rule and the *input* comes from whatever resource an
68
+ * agent named — so a caller on the far side of the enforcement boundary picks
69
+ * the input that triggers it, and a hung evaluator is a hung gateway.
70
+ *
71
+ * This is the standard two-pointer wildcard match instead: it remembers the
72
+ * most recent wildcard and resumes there on a mismatch, which is O(n·m) in the
73
+ * worst case and has no pathological input at all.
74
+ */
75
+ export function matchGlob(pattern, value) {
76
+ if (pattern === "*" || pattern === "**") return true;
77
+
78
+ const p = String(pattern).toLowerCase();
79
+ const v = String(value).toLowerCase();
80
+
81
+ /*
82
+ * Tokenized and matched with dynamic programming, NOT with the two-pointer
83
+ * algorithm this used to use.
84
+ *
85
+ * The two-pointer match remembers exactly one star position, which is correct
86
+ * when every star has the same semantics. Here they do not: `**` crosses `/`
87
+ * and `*` does not. Once the matcher committed to an inner `*`, it had
88
+ * forgotten the outer `**` and could never backtrack far enough — so
89
+ * `matchGlob("**\/*", "/workspace/src/app.ts")` returned FALSE.
90
+ *
91
+ * That is a fail-open bug, not a cosmetic one. A rule written
92
+ * `path = **\/*` — the natural way to say "any file at all" — loaded,
93
+ * validated, appeared in `cirvix policy list`, and matched nothing. The
94
+ * delegation tests found it.
95
+ *
96
+ * The DP is O(n·m) in time and O(m) in space, with no pathological input:
97
+ * the same complexity guarantee the two-pointer version was chosen for, and
98
+ * unlike a backtracking regex there is nothing an attacker can pick to make
99
+ * it explore exponentially.
100
+ */
101
+ const tokens = [];
102
+ for (let i = 0; i < p.length; i++) {
103
+ if (p[i] === "*") {
104
+ const doubled = p[i + 1] === "*";
105
+ tokens.push(doubled ? "**" : "*");
106
+ if (doubled) i++;
107
+ } else if (p[i] === "?") tokens.push("?");
108
+ else tokens.push(p[i]);
109
+ }
110
+
111
+ const T = tokens.length;
112
+ const isStar = (t) => t === "*" || t === "**";
113
+
114
+ // dp[t] — the first `t` tokens match the value consumed so far.
115
+ let dp = new Array(T + 1).fill(false);
116
+ dp[0] = true;
117
+ // A star may match nothing, so it passes the mark straight through.
118
+ for (let t = 0; t < T; t++) if (dp[t] && isStar(tokens[t])) dp[t + 1] = true;
119
+
120
+ for (let i = 0; i < v.length; i++) {
121
+ const c = v[i];
122
+ const next = new Array(T + 1).fill(false);
123
+
124
+ for (let t = 0; t < T; t++) {
125
+ const token = tokens[t];
126
+ if (token === "**") {
127
+ // Open here, or already open and consuming another character.
128
+ if (dp[t] || dp[t + 1]) next[t + 1] = true;
129
+ } else if (token === "*") {
130
+ if ((dp[t] || dp[t + 1]) && c !== "/") next[t + 1] = true;
131
+ } else if (token === "?") {
132
+ if (dp[t] && c !== "/") next[t + 1] = true;
133
+ } else if (dp[t] && c === token) {
134
+ next[t + 1] = true;
135
+ }
136
+ }
137
+
138
+ // Stars that matched nothing at this position.
139
+ for (let t = 0; t < T; t++) if (next[t] && isStar(tokens[t])) next[t + 1] = true;
140
+
141
+ dp = next;
142
+ }
143
+
144
+ return dp[T];
145
+ }
146
+
147
+ function matchAny(patterns, value) {
148
+ if (patterns === undefined || patterns === "*") return true;
149
+ const list = Array.isArray(patterns) ? patterns : [patterns];
150
+ if (list.length === 0) return true;
151
+ return list.some((p) => matchGlob(p, value));
152
+ }
153
+
154
+ /**
155
+ * Canonicalizes a resource so equivalent references collapse to one string.
156
+ * Filesystem paths resolve against `cwd`; URLs normalize host and lowercase.
157
+ */
158
+ export function canonicalizeResource(resource, cwd = process.cwd()) {
159
+ if (typeof resource !== "string" || resource.length === 0) return "";
160
+
161
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(resource)) {
162
+ // Delegated so URLs canonicalize identically everywhere: alternate IPv4
163
+ // spellings, IPv4-mapped IPv6, trailing dots, and userinfo all collapse.
164
+ // Hand-rolled URL handling here is how `169.254.169.254` and
165
+ // `http://2852039166/` became two different resources to the same rule.
166
+ return canonicalUrl(resource) ?? resource;
167
+ }
168
+
169
+ /*
170
+ * Fold before resolving.
171
+ *
172
+ * `~%2F.aws%2Fcredentials` contains no literal separator, so without this it
173
+ * fell through every branch below, stayed a bare token, was judged "inside
174
+ * the workspace" because it had no path to escape it, and a workspace-read
175
+ * rule permitted a credential read. Percent-encoding, Unicode homoglyph
176
+ * separators, and zero-width characters are all normalized here so the rule
177
+ * sees the path the tool will actually open.
178
+ */
179
+ const folded = expandHome(foldPath(resource));
180
+ if (folded !== resource) {
181
+ // Re-entered once with the folded form. One level only: `foldPath` is
182
+ // idempotent by construction, so a second pass cannot change the answer and
183
+ // an unbounded recursion on attacker-controlled input would be its own bug.
184
+ return canonicalizeResource(folded, cwd);
185
+ }
186
+
187
+ // `~` is a real path, and agents write it constantly.
188
+ //
189
+ // Left unexpanded it resolved against the WORKSPACE — `~/.aws/credentials`
190
+ // became `<cwd>/~/.aws/credentials`, a directory that does not exist. A rule
191
+ // written against the absolute home path then failed to match the single most
192
+ // common way an agent names a credential file. The glob rules happened to
193
+ // catch it; the exact-path rules did not, which is the worse half of a
194
+ // near-miss.
195
+ if (resource === "~" || resource.startsWith("~/") || resource.startsWith("~\\")) {
196
+ const home = homedir().replace(/\\/g, "/").replace(/\/+$/, "");
197
+ return resolvePath(home + resource.slice(1).replace(/\\/g, "/"), cwd);
198
+ }
199
+
200
+ // Anything else that looks like a path gets resolved.
201
+ if (/[/\\]/.test(resource) || resource.startsWith(".")) {
202
+ return resolvePath(resource, cwd);
203
+ }
204
+
205
+ return resource;
206
+ }
207
+
208
+ /**
209
+ * Resolves a path the same way on every platform.
210
+ *
211
+ * DELIBERATELY NOT `path.resolve`. That function is platform-aware, and on
212
+ * Windows it prepends the current drive to a drive-less absolute path — so
213
+ * `/etc/passwd` canonicalized to `C:/etc/passwd` there and `/etc/passwd`
214
+ * everywhere else. The consequence is not cosmetic: a rule written
215
+ * `resources: ["/etc/**"]` matched on a Linux runner and silently did not
216
+ * match on a developer's Windows laptop, which is the machine the rule was
217
+ * most likely written to protect.
218
+ *
219
+ * The conformance suite caught this. It is exactly the class of bug two
220
+ * implementations would have disagreed about forever.
221
+ */
222
+ function resolvePath(resource, cwd) {
223
+ const norm = (value) => String(value ?? "").replace(/\\/g, "/");
224
+ const target = norm(resource);
225
+
226
+ const isAbsolute = target.startsWith("/") || /^[A-Za-z]:\//.test(target);
227
+ const combined = isAbsolute ? target : `${norm(cwd).replace(/\/+$/, "")}/${target}`;
228
+
229
+ // A drive letter is carried through untouched rather than invented.
230
+ const drive = combined.match(/^([A-Za-z]:)(\/.*)$/);
231
+ const body = drive ? drive[2] : combined;
232
+
233
+ const parts = [];
234
+ for (const segment of body.split("/")) {
235
+ if (segment === "" || segment === ".") continue;
236
+ if (segment === "..") {
237
+ parts.pop();
238
+ continue;
239
+ }
240
+ parts.push(segment);
241
+ }
242
+
243
+ return (drive ? drive[1] : "") + (body.startsWith("/") ? "/" : "") + parts.join("/");
244
+ }
245
+
246
+ /* -------------------------------------------------------------------------- */
247
+ /* Conditions */
248
+ /* -------------------------------------------------------------------------- */
249
+
250
+ /**
251
+ * Conditions are plain data, never expressions.
252
+ *
253
+ * Deliberately NOT `eval` or `new Function`: a policy file is exactly the kind
254
+ * of thing that gets templated by a script, and turning it into an execution
255
+ * surface would make the security product the vulnerability. Comparators are
256
+ * an explicit, closed set.
257
+ */
258
+ const COMPARATORS = {
259
+ eq: (a, b) => a === b,
260
+ ne: (a, b) => a !== b,
261
+ in: (a, b) => Array.isArray(b) && b.includes(a),
262
+ nin: (a, b) => Array.isArray(b) && !b.includes(a),
263
+ gt: (a, b) => typeof a === "number" && a > b,
264
+ gte: (a, b) => typeof a === "number" && a >= b,
265
+ lt: (a, b) => typeof a === "number" && a < b,
266
+ lte: (a, b) => typeof a === "number" && a <= b,
267
+ matches: (a, b) => typeof a === "string" && matchGlob(String(b), a),
268
+ exists: (a, b) => (b ? a !== undefined && a !== null : a === undefined || a === null),
269
+ contains: (a, b) => Array.isArray(a) && a.includes(b),
270
+ supersetOf: (a, b) =>
271
+ Array.isArray(a) && Array.isArray(b) && b.every((x) => a.includes(x)),
272
+ };
273
+
274
+ function readPath(obj, path) {
275
+ return path.split(".").reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
276
+ }
277
+
278
+ /** Every condition must hold. Unknown comparators fail closed. */
279
+ function conditionsHold(conditions, context) {
280
+ if (!conditions || conditions.length === 0) return true;
281
+ return conditions.every((cond) => {
282
+ const cmp = COMPARATORS[cond.op];
283
+ if (!cmp) return false;
284
+ return cmp(readPath(context, cond.path), cond.value);
285
+ });
286
+ }
287
+
288
+ /* -------------------------------------------------------------------------- */
289
+ /* Evaluation */
290
+ /* -------------------------------------------------------------------------- */
291
+
292
+ /**
293
+ * Evaluate a request against a rule set.
294
+ *
295
+ * @returns {{
296
+ * verdict: "permit"|"deny"|"hold",
297
+ * rule: string|null,
298
+ * reason: string,
299
+ * remediation?: string,
300
+ * approvers?: string[],
301
+ * considered: Array<{rule: string, effect: string, matched: boolean}>,
302
+ * resource: string
303
+ * }}
304
+ */
305
+ export function evaluate(request, rules, options = {}) {
306
+ const cwd = options.cwd ?? process.cwd();
307
+ const resource = canonicalizeResource(request.resource, cwd);
308
+ const context = { ...request.context, agent: request.agent, action: request.action };
309
+
310
+ const considered = [];
311
+ let firstPermit = null;
312
+ let firstHold = null;
313
+ /** Every matching sanitize rule, not just the first — sanitizers compose. */
314
+ const sanitizers = [];
315
+ /** Matching `audit_only` rules. Recorded, never authorizing. */
316
+ const observed = [];
317
+
318
+ /*
319
+ * `rules ?? []`, not `rules`.
320
+ *
321
+ * A missing rule set must default-deny, not throw. It throwing was a real
322
+ * defect: a policy that failed to load, a caller that passed `undefined`, or
323
+ * a config path that resolved to nothing all produced a TypeError inside the
324
+ * decision path rather than a refusal — and an exception in an enforcement
325
+ * hot path is a denial of service against the control plane at best, and an
326
+ * accidental allow at worst, depending on which caller catches it.
327
+ *
328
+ * The Python engine already read `rules or []`. This is also the two engines
329
+ * agreeing again.
330
+ */
331
+ for (const rule of rules ?? []) {
332
+ if (!rule || typeof rule !== "object") continue;
333
+
334
+ const matched =
335
+ matchAny(rule.agents, request.agent) &&
336
+ matchAny(rule.actions, request.action) &&
337
+ matchAny(rule.resources, resource) &&
338
+ conditionsHold(rule.when, context);
339
+
340
+ considered.push({ rule: rule.name, effect: rule.effect, matched });
341
+
342
+ if (!matched) continue;
343
+
344
+ // forbid short-circuits — nothing after it can change the outcome.
345
+ if (rule.effect === EFFECT.FORBID) {
346
+ return finish({
347
+ verdict: VERDICT.DENY,
348
+ decision: DECISION.DENY,
349
+ rule: rule.name,
350
+ reason: rule.reason ?? `Denied by ${rule.name}.`,
351
+ remediation: rule.remediation,
352
+ explicit: true,
353
+ considered,
354
+ resource,
355
+ observed,
356
+ });
357
+ }
358
+ if (rule.effect === EFFECT.HOLD && !firstHold) firstHold = rule;
359
+ if (rule.effect === EFFECT.SANITIZE) sanitizers.push(rule);
360
+ if (rule.effect === EFFECT.PERMIT && !firstPermit) firstPermit = rule;
361
+ // `audit_only` lands here and nowhere else: it is recorded and contributes
362
+ // nothing. A matching observation must not be able to authorize a call.
363
+ if (rule.effect === EFFECT.AUDIT_ONLY) {
364
+ observed.push({ rule: rule.name, reason: rule.reason ?? null });
365
+ }
366
+ }
367
+
368
+ // A hold outranks a permit: if any rule says a human must see this, the
369
+ // presence of some other permissive rule must not quietly skip them.
370
+ if (firstHold) {
371
+ return finish({
372
+ verdict: VERDICT.HOLD,
373
+ decision: DECISION.REQUIRE_APPROVAL,
374
+ rule: firstHold.name,
375
+ reason: firstHold.reason ?? `Held by ${firstHold.name} pending approval.`,
376
+ approvers: firstHold.approvers ?? [],
377
+ explicit: true,
378
+ considered,
379
+ resource,
380
+ observed,
381
+ });
382
+ }
383
+
384
+ // Sanitize outranks permit for the reason given in decisions.mjs: a rule
385
+ // saying "clean this first" must not be skipped because some other rule also
386
+ // said the call was fine. It still requires the call to be permitted at all —
387
+ // a sanitizer alone is not an authorization, so a lone sanitize rule with no
388
+ // permit falls through to default-deny below.
389
+ if (sanitizers.length && firstPermit) {
390
+ return finish({
391
+ verdict: VERDICT.PERMIT,
392
+ decision: DECISION.SANITIZE,
393
+ rule: sanitizers[0].name,
394
+ reason:
395
+ sanitizers[0].reason ??
396
+ `Permitted by ${firstPermit.name}, with sanitization required by ${sanitizers[0].name}.`,
397
+ explicit: true,
398
+ sanitize: sanitizers.map((r) => ({
399
+ rule: r.name,
400
+ /** What to clean: `arguments`, `result`, or both. Defaults to both. */
401
+ targets: normalizeTargets(r.sanitize?.targets),
402
+ /** Which sanitizer families to run. Empty means "all applicable". */
403
+ strategies: Array.isArray(r.sanitize?.strategies) ? r.sanitize.strategies : [],
404
+ reason: r.reason ?? null,
405
+ })),
406
+ permittedBy: firstPermit.name,
407
+ considered,
408
+ resource,
409
+ observed,
410
+ });
411
+ }
412
+
413
+ if (firstPermit) {
414
+ return finish({
415
+ verdict: VERDICT.PERMIT,
416
+ decision: DECISION.ALLOW,
417
+ rule: firstPermit.name,
418
+ reason: firstPermit.reason ?? `Permitted by ${firstPermit.name}.`,
419
+ explicit: true,
420
+ considered,
421
+ resource,
422
+ observed,
423
+ });
424
+ }
425
+
426
+ return finish({
427
+ verdict: VERDICT.DENY,
428
+ decision: DECISION.DENY,
429
+ rule: null,
430
+ reason:
431
+ sanitizers.length > 0
432
+ ? `No rule permits this call. ${sanitizers[0].name} would have sanitized it, but a sanitizer cleans an authorized call — it does not authorize one.`
433
+ : "No rule permits this call. The policy set is default-deny: an action must be explicitly allowed.",
434
+ remediation:
435
+ "Add a permit rule for this action, or run the engine in audit mode to log without enforcing.",
436
+ // Not explicit: nothing named this call, so the risk floor may escalate it.
437
+ explicit: false,
438
+ considered,
439
+ resource,
440
+ observed,
441
+ });
442
+ }
443
+
444
+ /** Drops empty optional fields so a decision record stays legible. */
445
+ function finish(decision) {
446
+ if (!decision.observed?.length) delete decision.observed;
447
+ return decision;
448
+ }
449
+
450
+ function normalizeTargets(targets) {
451
+ const all = ["arguments", "result"];
452
+ if (!targets) return all;
453
+ const list = (Array.isArray(targets) ? targets : [targets]).filter((t) => all.includes(t));
454
+ return list.length ? list : all;
455
+ }
456
+
457
+ /* -------------------------------------------------------------------------- */
458
+ /* Starter policy */
459
+ /* -------------------------------------------------------------------------- */
460
+
461
+ /**
462
+ * The default rule set — what you get with no `--policy`. Chosen so that a
463
+ * developer working normally is not interrupted, while the handful of actions
464
+ * that actually cause incidents are stopped or held.
465
+ *
466
+ * Ordering is irrelevant to correctness (forbid always wins, hold outranks
467
+ * permit) but rules are grouped for readability.
468
+ */
469
+ export const STARTER_RULES = [
470
+ {
471
+ name: "deny-dotenv-read",
472
+ effect: EFFECT.FORBID,
473
+ actions: ["fs.read", "fs.*"],
474
+ resources: ["**/.env", "**/.env.*"],
475
+ reason:
476
+ "Reading .env files is denied outside an approved secrets flow. This is the single most common path from a prompt injection to a live credential.",
477
+ remediation: 'Request the value as a handle: secrets.get("STRIPE_KEY")',
478
+ },
479
+ {
480
+ name: "deny-credential-files",
481
+ effect: EFFECT.FORBID,
482
+ actions: ["fs.read", "fs.*"],
483
+ resources: [
484
+ "**/.aws/**",
485
+ "**/.ssh/**",
486
+ "**/.kube/config",
487
+ "**/.npmrc",
488
+ "**/.netrc",
489
+ "**/.docker/config.json",
490
+ ],
491
+ reason: "Cloud, SSH, and registry credentials are never readable by an agent.",
492
+ remediation: "Use a scoped secret handle instead of the credential file.",
493
+ },
494
+ {
495
+ name: "deny-workspace-escape",
496
+ effect: EFFECT.FORBID,
497
+ actions: ["fs.*"],
498
+ resources: ["*"],
499
+ when: [{ path: "path.insideWorkspace", op: "eq", value: false }],
500
+ reason:
501
+ "The resolved path is outside the workspace root. Traversal and symlinks are resolved before this check.",
502
+ },
503
+ {
504
+ name: "require-approval-destructive",
505
+ effect: EFFECT.HOLD,
506
+ actions: ["fs.delete", "db.write", "db.migrate", "k8s.apply", "shell.exec"],
507
+ resources: ["*"],
508
+ when: [{ path: "environment", op: "in", value: ["production", "prod"] }],
509
+ approvers: ["platform-oncall"],
510
+ reason:
511
+ "Destructive or state-changing action in production. Held for a named human; the call waits rather than failing.",
512
+ },
513
+ {
514
+ name: "deny-external-egress-after-secret",
515
+ effect: EFFECT.FORBID,
516
+ actions: ["http.request", "net.*"],
517
+ resources: ["*"],
518
+ when: [
519
+ { path: "egress.external", op: "eq", value: true },
520
+ { path: "session.touchedSecret", op: "eq", value: true },
521
+ ],
522
+ reason:
523
+ "This session read secret material, so outbound requests to external destinations are blocked for the remainder of it.",
524
+ },
525
+ {
526
+ name: "allow-workspace-read",
527
+ effect: EFFECT.PERMIT,
528
+ actions: ["fs.read", "fs.list", "fs.stat"],
529
+ resources: ["*"],
530
+ when: [{ path: "path.insideWorkspace", op: "eq", value: true }],
531
+ reason: "Read inside the workspace root.",
532
+ },
533
+ {
534
+ name: "allow-workspace-write",
535
+ effect: EFFECT.PERMIT,
536
+ actions: ["fs.write"],
537
+ resources: ["*"],
538
+ when: [{ path: "path.insideWorkspace", op: "eq", value: true }],
539
+ reason: "Write inside the workspace root.",
540
+ },
541
+ {
542
+ name: "allow-allowlisted-egress",
543
+ effect: EFFECT.PERMIT,
544
+ actions: ["http.request", "net.*"],
545
+ resources: ["*"],
546
+ when: [{ path: "egress.allowlisted", op: "eq", value: true }],
547
+ reason: "Destination is on the egress allowlist.",
548
+ },
549
+ {
550
+ name: "allow-read-only-tools",
551
+ effect: EFFECT.PERMIT,
552
+ actions: ["*.read", "*.list", "*.search", "*.get", "*.query"],
553
+ resources: ["*"],
554
+ reason: "Read-only tool call.",
555
+ },
556
+ ];
557
+
558
+ /** Rules serialize to JSON — the on-disk format is `cirvix.policy.json`. */
559
+ export function parseRules(json) {
560
+ const rules = Array.isArray(json) ? json : json?.rules;
561
+ if (!Array.isArray(rules)) {
562
+ throw new Error("Policy file must be an array of rules, or { rules: [...] }.");
563
+ }
564
+ for (const rule of rules) {
565
+ if (!rule.name) throw new Error("Every rule needs a name.");
566
+ if (!Object.values(EFFECT).includes(rule.effect)) {
567
+ throw new Error(
568
+ `Rule "${rule.name}" has effect "${rule.effect}"; expected ${Object.values(EFFECT).join(", ")}.`,
569
+ );
570
+ }
571
+ for (const cond of rule.when ?? []) {
572
+ if (!COMPARATORS[cond.op]) {
573
+ throw new Error(`Rule "${rule.name}" uses unknown operator "${cond.op}".`);
574
+ }
575
+ }
576
+ }
577
+ return rules;
578
+ }
579
+
580
+ /* -------------------------------------------------------------------------- */
581
+ /* Validation */
582
+ /* -------------------------------------------------------------------------- */
583
+
584
+ /**
585
+ * Structured validation for `cirvix policy check`.
586
+ *
587
+ * Distinct from `parseRules`, which throws on the first structural problem
588
+ * because it is the load path and a half-parsed rule set must never reach the
589
+ * engine. This returns everything at once, including things that parse fine but
590
+ * are probably a mistake — an operator fixing a policy file wants the whole
591
+ * list, not one error per run.
592
+ *
593
+ * The severity split is the point: `error` means the file will not load or the
594
+ * rule cannot match; `warning` means it loads and does something the author
595
+ * likely did not intend.
596
+ *
597
+ * @returns {{ok:boolean, errors:Array, warnings:Array, rules:number}}
598
+ */
599
+ export function validateRules(json) {
600
+ const errors = [];
601
+ const warnings = [];
602
+ const at = (rule, i) => rule?.name ?? `rule #${i + 1}`;
603
+
604
+ const rules = Array.isArray(json) ? json : json?.rules;
605
+ if (!Array.isArray(rules)) {
606
+ return {
607
+ ok: false,
608
+ rules: 0,
609
+ errors: [{ rule: null, message: "Policy must be an array of rules, or { rules: [...] }." }],
610
+ warnings: [],
611
+ };
612
+ }
613
+
614
+ const seen = new Map();
615
+
616
+ rules.forEach((rule, i) => {
617
+ const where = at(rule, i);
618
+
619
+ if (!rule || typeof rule !== "object") {
620
+ errors.push({ rule: where, message: "Rule is not an object." });
621
+ return;
622
+ }
623
+ if (!rule.name) errors.push({ rule: where, message: "Rule has no name." });
624
+ if (!Object.values(EFFECT).includes(rule.effect)) {
625
+ errors.push({
626
+ rule: where,
627
+ message: `Unknown effect "${rule.effect}". Expected one of: ${Object.values(EFFECT).join(", ")}.`,
628
+ });
629
+ }
630
+
631
+ if (rule.name) {
632
+ if (seen.has(rule.name)) {
633
+ errors.push({
634
+ rule: where,
635
+ message: `Duplicate rule name — also defined at position ${seen.get(rule.name) + 1}. Names appear in decision records and must identify one rule.`,
636
+ });
637
+ } else seen.set(rule.name, i);
638
+ }
639
+
640
+ for (const cond of rule.when ?? []) {
641
+ if (!cond || typeof cond !== "object") {
642
+ errors.push({ rule: where, message: "Condition is not an object." });
643
+ continue;
644
+ }
645
+ if (!COMPARATORS[cond.op]) {
646
+ errors.push({ rule: where, message: `Unknown operator "${cond.op}".` });
647
+ }
648
+ if (typeof cond.path !== "string" || !cond.path) {
649
+ errors.push({ rule: where, message: "Condition has no path." });
650
+ }
651
+ if (["in", "nin"].includes(cond.op) && !Array.isArray(cond.value)) {
652
+ errors.push({
653
+ rule: where,
654
+ message: `Operator "${cond.op}" needs an array value; got ${typeof cond.value}. It would never match.`,
655
+ });
656
+ }
657
+ }
658
+
659
+ // Warnings: loads fine, probably not what was meant.
660
+ if (rule.effect === EFFECT.SANITIZE && !rule.sanitize) {
661
+ warnings.push({
662
+ rule: where,
663
+ message:
664
+ "Sanitize rule declares no `sanitize` block, so it defaults to cleaning both arguments and results with every applicable strategy.",
665
+ });
666
+ }
667
+ if (rule.effect === EFFECT.HOLD && !(rule.approvers ?? []).length) {
668
+ warnings.push({
669
+ rule: where,
670
+ message: "Hold rule names no approvers, so the call waits on nobody in particular.",
671
+ });
672
+ }
673
+ if (rule.effect === EFFECT.PERMIT && isUnbounded(rule)) {
674
+ warnings.push({
675
+ rule: where,
676
+ message:
677
+ "Permit rule matches every agent, action, and resource with no conditions. It makes the rest of the rule set decorative for anything a forbid does not catch.",
678
+ });
679
+ }
680
+ if (rule.effect === EFFECT.AUDIT_ONLY) {
681
+ warnings.push({
682
+ rule: where,
683
+ message:
684
+ "audit_only records matches and never authorizes. If this rule was meant to allow the call, it needs effect `permit`.",
685
+ });
686
+ }
687
+ if (rule.effect === EFFECT.FORBID && rule.approvers?.length) {
688
+ warnings.push({
689
+ rule: where,
690
+ message: "Forbid rule names approvers, but a forbid cannot be approved. Use `hold` instead.",
691
+ });
692
+ }
693
+ });
694
+
695
+ // A rule set with no forbid at all is legal and almost never intended.
696
+ if (rules.length && !rules.some((r) => r?.effect === EFFECT.FORBID)) {
697
+ warnings.push({
698
+ rule: null,
699
+ message:
700
+ "No forbid rule in this policy set. Default-deny still applies, but nothing is explicitly prohibited.",
701
+ });
702
+ }
703
+
704
+ return { ok: errors.length === 0, errors, warnings, rules: rules.length };
705
+ }
706
+
707
+ function isUnbounded(rule) {
708
+ const wild = (v) => v === undefined || v === "*" || (Array.isArray(v) && (!v.length || v.includes("*")));
709
+ return wild(rule.agents) && wild(rule.actions) && wild(rule.resources) && !(rule.when ?? []).length;
710
+ }