@cirvix_ai/agent-control 0.1.2 → 0.1.5

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 (70) hide show
  1. package/README.md +76 -17
  2. package/bin/cirvix.mjs +488 -40
  3. package/bin/escape-benchmark.mjs +67 -0
  4. package/package.json +36 -16
  5. package/src/adapters/base.mjs +150 -0
  6. package/src/adapters/claude-code.mjs +161 -0
  7. package/src/adapters/cline.mjs +107 -0
  8. package/src/adapters/codex.mjs +104 -0
  9. package/src/adapters/cursor.mjs +104 -0
  10. package/src/adapters/frameworks.mjs +110 -0
  11. package/src/adapters/gemini-cli.mjs +104 -0
  12. package/src/adapters/generic-mcp.mjs +101 -0
  13. package/src/adapters/index.mjs +209 -0
  14. package/src/adapters/roo-code.mjs +106 -0
  15. package/src/adapters/vscode.mjs +104 -0
  16. package/src/adapters/windsurf.mjs +107 -0
  17. package/src/commands/demo.mjs +56 -70
  18. package/src/commands/doctor.mjs +235 -0
  19. package/src/commands/init.mjs +292 -30
  20. package/src/commands/interactive.mjs +690 -0
  21. package/src/commands/kill.mjs +74 -0
  22. package/src/commands/login.mjs +227 -0
  23. package/src/commands/passport.mjs +149 -0
  24. package/src/commands/policy.mjs +10 -6
  25. package/src/commands/protect.mjs +293 -0
  26. package/src/commands/prove.mjs +209 -0
  27. package/src/commands/redteam.mjs +51 -0
  28. package/src/commands/scan.mjs +6 -4
  29. package/src/commands/shadow.mjs +62 -0
  30. package/src/commands/simulate.mjs +96 -0
  31. package/src/commands/status.mjs +121 -36
  32. package/src/commands/upgrade.mjs +17 -9
  33. package/src/commands/welcome.mjs +105 -0
  34. package/src/core/authority.mjs +909 -0
  35. package/src/core/baseline.mjs +97 -0
  36. package/src/core/config-store.mjs +280 -0
  37. package/src/core/cost.mjs +0 -0
  38. package/src/core/detect.mjs +4 -33
  39. package/src/core/entitlements.mjs +6 -0
  40. package/src/core/escape-benchmark.mjs +597 -0
  41. package/src/core/evidence.mjs +212 -0
  42. package/src/core/format.mjs +27 -0
  43. package/src/core/gateway.mjs +15 -211
  44. package/src/core/graph.mjs +270 -0
  45. package/src/core/guard.mjs +118 -4
  46. package/src/core/intent.mjs +166 -0
  47. package/src/core/journal.mjs +131 -40
  48. package/src/core/kill-switch.mjs +122 -0
  49. package/src/core/notices.mjs +22 -2
  50. package/src/core/packs.mjs +193 -0
  51. package/src/core/passport.mjs +555 -0
  52. package/src/core/pipeline.mjs +148 -6
  53. package/src/core/prompts.mjs +51 -0
  54. package/src/core/proof.mjs +440 -0
  55. package/src/core/redteam/index.mjs +185 -0
  56. package/src/core/referral.mjs +187 -0
  57. package/src/core/sandbox.mjs +139 -0
  58. package/src/core/session.mjs +172 -0
  59. package/src/core/shadow.mjs +95 -0
  60. package/src/core/trifecta.mjs +321 -0
  61. package/src/core/ui/controller.mjs +192 -0
  62. package/src/core/ui/decisions.mjs +55 -0
  63. package/src/core/ui/index.mjs +49 -0
  64. package/src/core/ui/intercept.mjs +103 -0
  65. package/src/core/ui/live.mjs +51 -0
  66. package/src/core/ui/primitives.mjs +123 -0
  67. package/src/core/ui/theme.mjs +92 -0
  68. package/src/core/verified.mjs +108 -0
  69. package/src/core/windows.mjs +270 -0
  70. package/src/index.mjs +25 -0
@@ -198,23 +198,55 @@ function clock(ts) {
198
198
  return m ? m[1] : s.slice(0, 8).padEnd(8);
199
199
  }
200
200
 
201
- /** One record, one line. The shape `cirvix logs` prints. */
201
+ /** One record, compact or expanded per risk/decision (spec: ALLOW compact, DENY expanded). */
202
202
  export function renderLine(record) {
203
203
  if (record.malformed) {
204
204
  return ` ${dim(String(record.line).padStart(4))} ${red("malformed record")} ${dim(record.raw.slice(0, 60))}`;
205
205
  }
206
206
  const decision = record.decision ?? toDecision(record.verdict);
207
207
  const tone = toneFor(record);
208
- const risk = RISK_TONE[record.risk] ?? dim;
208
+ const riskTone = RISK_TONE[record.risk] ?? dim;
209
+ const isCritical = String(record.risk ?? "").toLowerCase() === "critical" || String(record.risk ?? "").toLowerCase() === "high";
210
+ const isDenied = decision === DECISION.DENY;
211
+ const isHeld = decision === DECISION.REQUIRE_APPROVAL;
212
+ const clockStr = dim(clock(record.ts ?? record.timestamp));
213
+ const icon = decision === DECISION.ALLOW ? "✓" : decision === DECISION.SANITIZE ? "◈" : isDenied ? "✕" : isHeld ? "⏸" : "·";
214
+ const label = String(decision).toUpperCase().replace(/_/g, " ");
215
+ const tool = String(record.tool ?? record.action ?? "—");
216
+ const resource = truncate(record.resource ?? record.command ?? "", 44);
217
+ const policy = String(record.policy ?? record.rule ?? "default-deny");
218
+ const latency = `${record.latency_ms ?? "—"}ms`;
219
+
220
+ // Expanded for DENY/CRITICAL/HOLD/HIGH — high visibility, spec layout:
221
+ // 22:38:53 ◇ SANITIZE HIGH network.request
222
+ // https://docs.example.com/deploy
223
+ // Policy: sanitize-fetched-content 16.82ms
224
+ if (isDenied || isHeld || isCritical) {
225
+ const iconLabel = isHeld ? "APPROVAL" : label; // REQUIRE_APPROVAL displays as APPROVAL per spec
226
+ const iconChar = isHeld ? "●" : icon;
227
+ const lines = [];
228
+ // First line: time + icon/verdict + risk + tool — columns aligned, decision strongest
229
+ lines.push(
230
+ ` ${clockStr} ${tone(`${iconChar} ${iconLabel.padEnd(12)}`)} ${riskTone(String(record.risk ?? "—").toUpperCase().padEnd(9))} ${tool.padEnd(20)} ${dim(latency)}`,
231
+ );
232
+ if (resource) lines.push(` ${dim(resource)}`);
233
+ // Policy line — indented, secondary
234
+ lines.push(` ${dim(`Policy: ${policy}`)}`);
235
+ if (isHeld) {
236
+ lines.push(` ${amber("→ AWAITING APPROVAL")}`);
237
+ }
238
+ return lines.join("\n");
239
+ }
209
240
 
241
+ // Compact for ordinary ALLOW.
210
242
  return [
211
- ` ${dim(clock(record.ts ?? record.timestamp))}`,
212
- tone(String(decision).toUpperCase().padEnd(16)),
213
- risk(String(record.risk ?? "—").toUpperCase().padEnd(8)),
214
- String(record.tool ?? record.action ?? "—").padEnd(20),
215
- dim(truncate(record.resource ?? record.command ?? "", 44).padEnd(44)),
216
- dim(String(record.policy ?? record.rule ?? "default-deny").padEnd(24)),
217
- dim(`${record.latency_ms ?? "—"}ms`),
243
+ ` ${clockStr}`,
244
+ tone(`${icon} ${label.padEnd(12)}`),
245
+ riskTone(String(record.risk ?? "—").toUpperCase().padEnd(8)),
246
+ tool.padEnd(20),
247
+ dim(resource.padEnd(44)),
248
+ dim(latency.padEnd(8)),
249
+ dim(policy),
218
250
  ].join(" ");
219
251
  }
220
252
 
@@ -237,44 +269,103 @@ export function renderLine(record) {
237
269
  export function renderTree(record, { indent = " " } = {}) {
238
270
  const decision = record.decision ?? toDecision(record.verdict);
239
271
  const tone = toneFor(record);
240
- const risk = RISK_TONE[record.risk] ?? dim;
241
-
242
- const rows = [
243
- ["input", truncate(record.resource || record.command || "(no resource)", 70)],
244
- ["risk", risk(String(record.risk ?? "unknown").toUpperCase()) + (record.risk_signals?.length ? dim(` ${record.risk_signals.join(", ")}`) : "")],
245
- ["policy", record.policy ?? record.rule ?? dim("no rule matched (default deny)")],
246
- ["decision", tone(String(decision).toUpperCase()) + (record.enforced === false ? dim(" (not enforced audit mode)") : "")],
247
- ["latency", `${record.latency_ms ?? "—"}ms`],
248
- ];
249
-
250
- if (record.would_have) {
251
- rows.push(["would have", red(String(record.would_have.decision).toUpperCase()) + dim(` by ${record.would_have.rule ?? "default-deny"}`)]);
272
+ const riskTone = RISK_TONE[record.risk] ?? dim;
273
+ const isDeny = decision === DECISION.DENY;
274
+ const isHold = decision === DECISION.REQUIRE_APPROVAL;
275
+ const isSanitize = decision === DECISION.SANITIZE;
276
+
277
+ // Headerspec: CIRVIX DECISION ANALYSIS
278
+ const lines = [];
279
+ lines.push(`${indent}${bold("CIRVIX DECISION ANALYSIS")}`);
280
+ lines.push("");
281
+
282
+ // Top: Decision + Risk — strongest visual
283
+ const decisionLabel = isDeny ? " BLOCKED" : isHold ? "● AWAITING APPROVAL" : isSanitize ? "◇ SANITIZED" : "✓ " + String(decision).toUpperCase();
284
+ const decisionTone = isDeny ? red : isHold ? amber : isSanitize ? blue : green;
285
+ lines.push(`${indent}${dim("Decision".padEnd(12))} ${decisionTone(bold(decisionLabel))}${record.enforced === false ? dim(" (not enforced — audit mode)") : ""}`);
286
+ lines.push(`${indent}${dim("Risk".padEnd(12))} ${riskTone(bold(String(record.risk ?? "unknown").toUpperCase()))}${record.risk_signals?.length ? dim(` ${record.risk_signals.join(", ")}`) : ""}`);
287
+ lines.push("");
288
+
289
+ // Tool / Target / Policy / Latency / Request
290
+ lines.push(`${indent}${dim("Tool".padEnd(12))} ${bold(String(record.tool ?? record.action ?? "—"))}`);
291
+ if (record.resource || record.command) {
292
+ lines.push(`${indent}${dim("Target".padEnd(12))} ${truncate(record.resource || record.command || "", 70)}`);
252
293
  }
253
- if (record.approval_id) rows.push(["approval", blue(record.approval_id)]);
254
- if (record.secrets_brokered?.length) rows.push(["secrets", `${record.secrets_brokered.join(", ")} ${dim("(brokered — the agent never held the value)")}`]);
255
- if (record.secrets_detected?.length) {
256
- rows.push(["detected", record.secrets_detected.map((s) => `${s.detector} ${dim(s.masked)}`).join(", ")]);
294
+ if (record.destination) {
295
+ lines.push(`${indent}${dim("Destination".padEnd(12))} ${record.destination}`);
257
296
  }
258
- if (record.sanitized?.arguments?.length) {
259
- rows.push(["sanitized", `${record.sanitized.arguments.length} value(s) stripped from arguments`]);
297
+ lines.push(`${indent}${dim("Policy".padEnd(12))} ${record.policy ?? record.rule ?? dim("— no rule matched (default deny)")}`);
298
+ lines.push(`${indent}${dim("Latency".padEnd(12))} ${record.latency_ms ?? "—"}ms`);
299
+ lines.push(`${indent}${dim("Request".padEnd(12))} ${dim(record.request_id ?? record.decision_id ?? "—")} ${dim(`agent ${record.agent ?? "—"}`)}`);
300
+ if (record.approval_id) {
301
+ lines.push(`${indent}${dim("Approval".padEnd(12))} ${blue(record.approval_id)}`);
260
302
  }
261
- if (record.observed_by?.length) rows.push(["observed", dim(record.observed_by.join(", "))]);
262
- rows.push(["result", decision === DECISION.DENY ? red("not forwarded") : decision === DECISION.REQUIRE_APPROVAL ? amber("held") : green("forwarded")]);
263
-
264
- const width = Math.max(...rows.map(([k]) => k.length));
265
- const lines = [
266
- `${indent}${bold(record.agent ?? "agent")} ${dim(record.request_id ?? "")}`,
267
- `${indent} └── ${bold(record.tool ?? record.action ?? "tool")}`,
268
- ];
269
- rows.forEach(([key, value], i) => {
270
- const branch = i === rows.length - 1 ? "└──" : "├──";
271
- lines.push(`${indent} ${branch} ${dim(key.padEnd(width))} ${value}`);
272
- });
303
+ lines.push("");
273
304
 
305
+ // Matched policy + Reason — if present
306
+ if (record.policy ?? record.rule) {
307
+ lines.push(`${indent}${dim("Matched policy")}`);
308
+ lines.push(`${indent} ${record.policy ?? record.rule}`);
309
+ lines.push("");
310
+ }
274
311
  if (record.reason) {
312
+ lines.push(`${indent}${dim("Reason")}`);
313
+ lines.push(`${indent} ${wrap(record.reason, 70, `${indent} `)}`);
314
+ lines.push("");
315
+ }
316
+
317
+ // Approval panel — for REQUIRE_APPROVAL, spec: HUMAN APPROVAL REQUIRED
318
+ if (isHold) {
319
+ const W = 48;
320
+ const top = `${indent}${dim(`╭─ HUMAN APPROVAL REQUIRED ${"─".repeat(Math.max(0, W - 26))}╮`)}`;
321
+ const bottom = `${indent}${dim(`╰${"─".repeat(W)}╯`)}`;
322
+ lines.push(top);
323
+ lines.push(`${indent}${dim("│")} ${dim("Agent".padEnd(10))} ${record.agent ?? "—"} ${dim("│")}`);
324
+ lines.push(`${indent}${dim("│")} ${dim("Action".padEnd(10))} ${record.action ?? record.tool ?? "—"} ${dim("│")}`);
325
+ lines.push(`${indent}${dim("│")} ${dim("Target".padEnd(10))} ${truncate(record.resource ?? "", 30)} ${dim("│")}`);
326
+ lines.push(`${indent}${dim("│")} ${"".padEnd(42)} ${dim("│")}`);
327
+ lines.push(`${indent}${dim("│")} ${dim("Risk".padEnd(10))} ${amber(String(record.risk ?? "").toUpperCase())} ${dim("│")}`);
328
+ lines.push(`${indent}${dim("│")} ${dim("Policy".padEnd(10))} ${record.policy ?? record.rule ?? "—"} ${dim("│")}`);
329
+ lines.push(`${indent}${dim("│")} ${"".padEnd(42)} ${dim("│")}`);
330
+ lines.push(`${indent}${dim("│")} ${dim("Reason")} ${dim("│")}`);
331
+ lines.push(`${indent}${dim("│")} ${wrap(record.reason ?? "Production database mutation requires human authorization.", 42, `${indent}${dim("│")} `)} ${dim("│")}`);
332
+ lines.push(`${indent}${dim("│")} ${"".padEnd(42)} ${dim("│")}`);
333
+ lines.push(`${indent}${dim("│")} ${blue("[A] Approve")} ${dim(" ")} ${red("[R] Reject")} ${dim("│")}`);
334
+ lines.push(bottom);
275
335
  lines.push("");
276
- lines.push(`${indent} ${dim(wrap(record.reason, 84, `${indent} `))}`);
277
336
  }
337
+
338
+ // Decision path — spec: secret detection → risk classification → policy evaluation → BLOCK
339
+ lines.push(`${indent}${dim("Decision path")}`);
340
+ lines.push(`${indent} ${dim("secret detection")}`);
341
+ lines.push(`${indent} ${dim("↓")}`);
342
+ lines.push(`${indent} ${dim("risk classification")}`);
343
+ lines.push(`${indent} ${dim("↓")}`);
344
+ lines.push(`${indent} ${dim("policy evaluation")}`);
345
+ lines.push(`${indent} ${dim("↓")}`);
346
+ lines.push(`${indent} ${decisionTone(isDeny ? "BLOCK" : isHold ? "HOLD" : isSanitize ? "SANITIZE" : "ALLOW")}`);
347
+
348
+ // Low-level details — keep for debugging, muted
349
+ const extra = [];
350
+ if (record.secrets_brokered?.length) extra.push(`secrets brokered: ${record.secrets_brokered.join(", ")}`);
351
+ if (record.secrets_detected?.length) extra.push(`detected: ${record.secrets_detected.map((s) => `${s.detector} ${s.masked}`).join(", ")}`);
352
+ if (record.sanitized?.arguments?.length) extra.push(`sanitized: ${record.sanitized.arguments.length} value(s) stripped`);
353
+ if (record.would_have) extra.push(`would have: ${String(record.would_have.decision).toUpperCase()} by ${record.would_have.rule ?? "default-deny"}`);
354
+ if (extra.length) {
355
+ lines.push("");
356
+ lines.push(`${indent}${dim(extra.join(" · "))}`);
357
+ }
358
+
359
+ // Considered tree — compact, for dead-rule discovery (keep, muted)
360
+ if (record.considered?.length) {
361
+ lines.push("");
362
+ lines.push(`${indent}${dim("considered")} ${dim(`${record.considered.filter((c) => c.matched).length} of ${record.considered.length} matched`)}`);
363
+ for (const c of record.considered.slice(0, 12)) {
364
+ const marker = c.matched ? bold("→") : dim(" ");
365
+ lines.push(`${indent} ${marker} ${dim(String(c.effect).padEnd(11))} ${c.matched ? c.rule : dim(c.rule)}`);
366
+ }
367
+ }
368
+
278
369
  return lines.join("\n");
279
370
  }
280
371
 
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Unified Multi-Scope Emergency Kill Switch.
3
+ *
4
+ * Provides emergency freezing across scopes:
5
+ * - single agent
6
+ * - agent family
7
+ * - organization
8
+ * - environment
9
+ * - MCP server
10
+ * - credential
11
+ * - session
12
+ * - model
13
+ * - tool
14
+ */
15
+
16
+ import { DECISION } from "./decisions.mjs";
17
+
18
+ export const KILL_SCOPES = {
19
+ AGENT: "agent",
20
+ FAMILY: "family",
21
+ ORG: "org",
22
+ ENVIRONMENT: "environment",
23
+ MCP: "mcp",
24
+ CREDENTIAL: "credential",
25
+ SESSION: "session",
26
+ MODEL: "model",
27
+ TOOL: "tool",
28
+ };
29
+
30
+ export class KillSwitchEngine {
31
+ constructor() {
32
+ this.activeRules = new Map(); // id -> rule
33
+ }
34
+
35
+ /**
36
+ * Arms a kill switch.
37
+ */
38
+ arm({ scope, target, reason = "Emergency freeze activated", triggeredBy = "system" }) {
39
+ if (!Object.values(KILL_SCOPES).includes(scope)) {
40
+ throw new Error(`Invalid kill switch scope '${scope}'`);
41
+ }
42
+ const id = `ks_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
43
+ const rule = {
44
+ id,
45
+ scope,
46
+ target: String(target).toLowerCase(),
47
+ reason,
48
+ triggeredBy,
49
+ armedAt: new Date().toISOString(),
50
+ active: true,
51
+ };
52
+ this.activeRules.set(id, rule);
53
+ return rule;
54
+ }
55
+
56
+ /**
57
+ * Disarms a kill switch.
58
+ */
59
+ disarm(id) {
60
+ return this.activeRules.delete(id);
61
+ }
62
+
63
+ /**
64
+ * Checks whether a request matches any active kill rule.
65
+ *
66
+ * @param {Object} context
67
+ * @returns {{ killed: boolean, decision: string, reason?: string, matchedRule?: Object }}
68
+ */
69
+ evaluate({
70
+ agentId = null,
71
+ family = null,
72
+ orgId = null,
73
+ environment = null,
74
+ mcp = null,
75
+ credential = null,
76
+ session = null,
77
+ model = null,
78
+ tool = null,
79
+ } = {}) {
80
+ for (const rule of this.activeRules.values()) {
81
+ if (!rule.active) continue;
82
+
83
+ const target = rule.target;
84
+
85
+ if (rule.scope === KILL_SCOPES.ORG && orgId && orgId.toLowerCase() === target) {
86
+ return { killed: true, decision: DECISION.QUARANTINE, reason: `Organization under emergency freeze: ${rule.reason}`, matchedRule: rule };
87
+ }
88
+ if (rule.scope === KILL_SCOPES.AGENT && agentId && agentId.toLowerCase() === target) {
89
+ return { killed: true, decision: DECISION.QUARANTINE, reason: `Agent '${agentId}' is frozen: ${rule.reason}`, matchedRule: rule };
90
+ }
91
+ if (rule.scope === KILL_SCOPES.FAMILY && family && family.toLowerCase() === target) {
92
+ return { killed: true, decision: DECISION.QUARANTINE, reason: `Agent family '${family}' is frozen: ${rule.reason}`, matchedRule: rule };
93
+ }
94
+ if (rule.scope === KILL_SCOPES.ENVIRONMENT && environment && environment.toLowerCase() === target) {
95
+ return { killed: true, decision: DECISION.QUARANTINE, reason: `Environment '${environment}' is frozen: ${rule.reason}`, matchedRule: rule };
96
+ }
97
+ if (rule.scope === KILL_SCOPES.MCP && mcp && mcp.toLowerCase() === target) {
98
+ return { killed: true, decision: DECISION.DENY, reason: `MCP server '${mcp}' is disabled: ${rule.reason}`, matchedRule: rule };
99
+ }
100
+ if (rule.scope === KILL_SCOPES.TOOL && tool && tool.toLowerCase() === target) {
101
+ return { killed: true, decision: DECISION.DENY, reason: `Tool '${tool}' is disabled: ${rule.reason}`, matchedRule: rule };
102
+ }
103
+ if (rule.scope === KILL_SCOPES.SESSION && session && session.toLowerCase() === target) {
104
+ return { killed: true, decision: DECISION.QUARANTINE, reason: `Session '${session}' is terminated: ${rule.reason}`, matchedRule: rule };
105
+ }
106
+ if (rule.scope === KILL_SCOPES.MODEL && model && model.toLowerCase() === target) {
107
+ return { killed: true, decision: DECISION.DENY, reason: `Model '${model}' is suspended: ${rule.reason}`, matchedRule: rule };
108
+ }
109
+ if (rule.scope === KILL_SCOPES.CREDENTIAL && credential && credential.toLowerCase() === target) {
110
+ return { killed: true, decision: DECISION.DENY, reason: `Credential '${credential}' is revoked: ${rule.reason}`, matchedRule: rule };
111
+ }
112
+ }
113
+
114
+ return { killed: false, decision: DECISION.ALLOW };
115
+ }
116
+
117
+ list() {
118
+ return Array.from(this.activeRules.values());
119
+ }
120
+ }
121
+
122
+ export const globalKillSwitch = new KillSwitchEngine();
@@ -24,10 +24,18 @@
24
24
  * The limit notice fires on the transition into the limit, not on every
25
25
  * refused call after it — a process that keeps calling past its quota would
26
26
  * otherwise print the same paragraph hundreds of times. The nudge is once per
27
- * day and `Meter` owns that flag.
27
+ * day and `Meter` owns that flag. The domain-signal prompt is once per
28
+ * process, on the first egress-class denial: the story it offers to share is
29
+ * worth telling once, not every time an agent reaches for a credential file.
28
30
  */
29
31
 
30
- import { agentLimitReached, quotaReached, softNudge } from "./prompts.mjs";
32
+ import {
33
+ agentLimitReached,
34
+ domainSignal,
35
+ quotaReached,
36
+ softNudge,
37
+ EGRESS_DENY_RULES,
38
+ } from "./prompts.mjs";
31
39
 
32
40
  /**
33
41
  * Builds the per-decision notice hook.
@@ -43,6 +51,7 @@ export function commercialNotices({ licence, meter, write }) {
43
51
 
44
52
  let saidQuota = false;
45
53
  let saidAgents = false;
54
+ let saidDomain = false;
46
55
 
47
56
  return function notice(decision) {
48
57
  if (!decision) return;
@@ -54,6 +63,17 @@ export function commercialNotices({ licence, meter, write }) {
54
63
  // this whole file exists to correct.
55
64
  const rule = decision.rule ?? decision.policy;
56
65
 
66
+ if (decision.verdict === "deny" && EGRESS_DENY_RULES.has(rule)) {
67
+ if (saidDomain) return;
68
+ saidDomain = true;
69
+ const text = domainSignal(licence, {
70
+ rule,
71
+ destination: decision.destination ?? "",
72
+ });
73
+ if (text) write(`\n${text}\n`);
74
+ return;
75
+ }
76
+
57
77
  if (rule === "quota-exhausted") {
58
78
  if (saidQuota) return;
59
79
  saidQuota = true;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Policy packs — installable, forkable, provenanced rule sets.
3
+ *
4
+ * The four files in `policies/` were already packs in everything but name: a
5
+ * curated set of rules with a stated purpose. What they lacked was the
6
+ * metadata that makes a pack shareable — who wrote it, what version it is,
7
+ * what it is for, what it was forked from, and whether the rules still match
8
+ * the hash somebody reviewed.
9
+ *
10
+ * WHY THE MANIFEST LIVES IN COMMENTS
11
+ * ----------------------------------
12
+ * A pack is a `.policy` file and nothing else. The manifest is carried on
13
+ * `#@ key: value` lines, which the DSL parser already skips as comments.
14
+ *
15
+ * The alternative — a sidecar `.pack.json` — was rejected because it splits a
16
+ * pack into two files that can drift apart, and the failure mode of that drift
17
+ * is a manifest describing rules that are no longer there. One file cannot
18
+ * disagree with itself about what it contains.
19
+ *
20
+ * WHAT THE HASH COVERS
21
+ * --------------------
22
+ * The rules, not the manifest. Bumping a description or adding an author must
23
+ * not invalidate a review of the rules, and editing a single `deny` must
24
+ * invalidate it. Hashing the whole file would get both of those backwards.
25
+ */
26
+
27
+ import { createHash } from "node:crypto";
28
+
29
+ /** Manifest fields a pack may declare. Anything else is ignored, not an error:
30
+ * a pack written against a newer Cirvix must still install on an older one. */
31
+ const FIELDS = new Set([
32
+ "id", "name", "version", "description", "author", "homepage",
33
+ "risk", "targets", "requires", "forked-from", "forked-at", "official",
34
+ ]);
35
+
36
+ const MANIFEST_LINE = /^#@\s*([a-z-]+)\s*:\s*(.*)$/i;
37
+
38
+ /**
39
+ * Split a pack file into its manifest and its rule text.
40
+ *
41
+ * The rule text is returned verbatim, including comments that are not manifest
42
+ * lines, because those comments are the author explaining their reasoning and
43
+ * dropping them would make an installed pack less useful than the one on disk.
44
+ */
45
+ export function parsePack(text, { source = null } = {}) {
46
+ const manifest = {};
47
+ const ruleLines = [];
48
+
49
+ for (const line of String(text ?? "").split(/\r?\n/)) {
50
+ const m = line.match(MANIFEST_LINE);
51
+ if (m && FIELDS.has(m[1].toLowerCase())) {
52
+ const key = m[1].toLowerCase();
53
+ const value = m[2].trim();
54
+ if (key === "targets" || key === "requires") {
55
+ manifest[key] = value.split(/[,\s]+/).filter(Boolean);
56
+ } else if (key === "official") {
57
+ manifest[key] = value === "true";
58
+ } else {
59
+ manifest[key] = value;
60
+ }
61
+ continue;
62
+ }
63
+ ruleLines.push(line);
64
+ }
65
+
66
+ const rules = ruleLines.join("\n");
67
+ return {
68
+ manifest: {
69
+ id: manifest.id ?? null,
70
+ name: manifest.name ?? manifest.id ?? "untitled pack",
71
+ version: manifest.version ?? "0.0.0",
72
+ description: manifest.description ?? "",
73
+ author: manifest.author ?? "unknown",
74
+ risk: manifest.risk ?? "unspecified",
75
+ targets: manifest.targets ?? [],
76
+ requires: manifest.requires ?? [],
77
+ official: manifest.official ?? false,
78
+ forkedFrom: manifest["forked-from"] ?? null,
79
+ forkedAt: manifest["forked-at"] ?? null,
80
+ homepage: manifest.homepage ?? null,
81
+ source,
82
+ },
83
+ rules,
84
+ hash: hashRules(rules),
85
+ };
86
+ }
87
+
88
+ /** sha256 over the rule text with trailing whitespace normalised, so a pack
89
+ * that survives a round trip through an editor still verifies. */
90
+ export function hashRules(rules) {
91
+ const normalised = String(rules ?? "")
92
+ .split(/\r?\n/)
93
+ .map((l) => l.replace(/\s+$/, ""))
94
+ .join("\n")
95
+ .replace(/\n+$/, "\n");
96
+ return "sha256:" + createHash("sha256").update(normalised, "utf8").digest("hex");
97
+ }
98
+
99
+ /** Does this pack still contain the rules somebody reviewed? */
100
+ export function verifyPack(pack, expectedHash) {
101
+ const actual = hashRules(pack.rules);
102
+ return { ok: actual === expectedHash, expected: expectedHash, actual };
103
+ }
104
+
105
+ /**
106
+ * Render a pack back to a file.
107
+ *
108
+ * Manifest first, then the rules exactly as they were. Round-tripping a pack
109
+ * through parse → render must not change its hash, which the tests assert.
110
+ */
111
+ export function renderPack(pack) {
112
+ const m = pack.manifest;
113
+ const head = [
114
+ `#@ id: ${m.id}`,
115
+ `#@ name: ${m.name}`,
116
+ `#@ version: ${m.version}`,
117
+ m.description ? `#@ description: ${m.description}` : null,
118
+ `#@ author: ${m.author}`,
119
+ `#@ risk: ${m.risk}`,
120
+ m.targets?.length ? `#@ targets: ${m.targets.join(", ")}` : null,
121
+ m.requires?.length ? `#@ requires: ${m.requires.join(", ")}` : null,
122
+ m.official ? `#@ official: true` : null,
123
+ m.forkedFrom ? `#@ forked-from: ${m.forkedFrom}` : null,
124
+ m.forkedAt ? `#@ forked-at: ${m.forkedAt}` : null,
125
+ m.homepage ? `#@ homepage: ${m.homepage}` : null,
126
+ ].filter(Boolean);
127
+ return head.join("\n") + "\n" + pack.rules.replace(/^\n+/, "\n");
128
+ }
129
+
130
+ /**
131
+ * Fork a pack under a new identity, keeping provenance.
132
+ *
133
+ * The fork records the parent's id AND the parent's rule hash. The id alone
134
+ * would be a claim; the hash is what lets anyone check which version of the
135
+ * parent this actually came from, including after the parent has moved on.
136
+ */
137
+ export function forkPack(pack, { id, name, author = "unknown", now = new Date() } = {}) {
138
+ if (!id) throw new Error("A fork needs an id.");
139
+ return {
140
+ manifest: {
141
+ ...pack.manifest,
142
+ id,
143
+ name: name ?? `${pack.manifest.name} (fork)`,
144
+ version: "0.1.0",
145
+ author,
146
+ official: false,
147
+ forkedFrom: `${pack.manifest.id ?? "unknown"}@${hashRules(pack.rules)}`,
148
+ forkedAt: now.toISOString(),
149
+ },
150
+ rules: pack.rules,
151
+ hash: hashRules(pack.rules),
152
+ };
153
+ }
154
+
155
+ /**
156
+ * The difference between two packs, as rule names.
157
+ *
158
+ * Reported by name rather than by line, because a pack update that reorders
159
+ * rules or rewrites a comment is not a change anyone needs to review, and a
160
+ * line diff would present it as though it were.
161
+ */
162
+ export function diffPacks(before, after) {
163
+ const names = (p) => {
164
+ const out = [];
165
+ for (const m of String(p?.rules ?? "").matchAll(/^\s*name\s*=\s*(\S+)/gm)) out.push(m[1]);
166
+ return out;
167
+ };
168
+ const a = new Set(names(before));
169
+ const b = new Set(names(after));
170
+ return {
171
+ added: [...b].filter((n) => !a.has(n)),
172
+ removed: [...a].filter((n) => !b.has(n)),
173
+ kept: [...a].filter((n) => b.has(n)),
174
+ changed: hashRules(before?.rules) !== hashRules(after?.rules),
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Sort packs for a listing.
180
+ *
181
+ * Official first, then by risk posture descending, then by name. A developer
182
+ * scanning this list is looking for "the safe default written by the vendor",
183
+ * and that should not be somewhere in the middle alphabetically.
184
+ */
185
+ const RISK_WEIGHT = { strict: 3, balanced: 2, permissive: 1, unspecified: 0 };
186
+ export function sortPacks(packs) {
187
+ return [...packs].sort((x, y) => {
188
+ if (x.manifest.official !== y.manifest.official) return x.manifest.official ? -1 : 1;
189
+ const rw = (RISK_WEIGHT[y.manifest.risk] ?? 0) - (RISK_WEIGHT[x.manifest.risk] ?? 0);
190
+ if (rw) return rw;
191
+ return String(x.manifest.name).localeCompare(String(y.manifest.name));
192
+ });
193
+ }