@cirvix_ai/agent-control 0.1.3 → 0.2.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 (81) hide show
  1. package/README.md +76 -17
  2. package/bin/cirvix.mjs +539 -85
  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/console.mjs +58 -0
  18. package/src/commands/demo.mjs +55 -124
  19. package/src/commands/doctor.mjs +235 -0
  20. package/src/commands/init.mjs +292 -30
  21. package/src/commands/interactive.mjs +690 -0
  22. package/src/commands/kill.mjs +74 -0
  23. package/src/commands/login.mjs +227 -0
  24. package/src/commands/onboard.mjs +52 -0
  25. package/src/commands/passport.mjs +149 -0
  26. package/src/commands/policy.mjs +10 -6
  27. package/src/commands/protect.mjs +293 -0
  28. package/src/commands/prove.mjs +209 -0
  29. package/src/commands/redteam.mjs +51 -0
  30. package/src/commands/scan.mjs +11 -9
  31. package/src/commands/shadow.mjs +62 -0
  32. package/src/commands/simulate.mjs +96 -0
  33. package/src/commands/status.mjs +122 -41
  34. package/src/commands/upgrade.mjs +11 -11
  35. package/src/commands/welcome.mjs +105 -0
  36. package/src/core/authority.mjs +909 -0
  37. package/src/core/baseline.mjs +97 -0
  38. package/src/core/config-store.mjs +280 -0
  39. package/src/core/cost.mjs +0 -0
  40. package/src/core/detect.mjs +4 -33
  41. package/src/core/entitlements.mjs +7 -24
  42. package/src/core/escape-benchmark.mjs +597 -0
  43. package/src/core/events.mjs +234 -0
  44. package/src/core/evidence.mjs +212 -0
  45. package/src/core/format.mjs +44 -18
  46. package/src/core/gateway.mjs +15 -211
  47. package/src/core/graph.mjs +270 -0
  48. package/src/core/guard.mjs +118 -4
  49. package/src/core/intent.mjs +166 -0
  50. package/src/core/journal.mjs +131 -40
  51. package/src/core/kill-switch.mjs +122 -0
  52. package/src/core/notices.mjs +22 -2
  53. package/src/core/packs.mjs +193 -0
  54. package/src/core/passport.mjs +555 -0
  55. package/src/core/pipeline.mjs +148 -6
  56. package/src/core/prompts.mjs +51 -0
  57. package/src/core/proof.mjs +440 -0
  58. package/src/core/redteam/index.mjs +185 -0
  59. package/src/core/referral.mjs +187 -0
  60. package/src/core/sandbox.mjs +139 -0
  61. package/src/core/session.mjs +172 -0
  62. package/src/core/shadow.mjs +95 -0
  63. package/src/core/theme.mjs +240 -0
  64. package/src/core/trifecta.mjs +321 -0
  65. package/src/core/ui/controller.mjs +192 -0
  66. package/src/core/ui/decisions.mjs +55 -0
  67. package/src/core/ui/index.mjs +49 -0
  68. package/src/core/ui/intercept.mjs +103 -0
  69. package/src/core/ui/live.mjs +51 -0
  70. package/src/core/ui/primitives.mjs +123 -0
  71. package/src/core/ui/theme.mjs +92 -0
  72. package/src/core/verified.mjs +108 -0
  73. package/src/core/windows.mjs +270 -0
  74. package/src/index.mjs +67 -0
  75. package/src/tui/activity.mjs +71 -0
  76. package/src/tui/app.mjs +292 -0
  77. package/src/tui/cards.mjs +235 -0
  78. package/src/tui/composer.mjs +88 -0
  79. package/src/tui/palette.mjs +48 -0
  80. package/src/tui/status.mjs +42 -0
  81. package/src/core/cinematic.mjs +0 -545
@@ -29,7 +29,9 @@ import { classify } from "./risk.mjs";
29
29
  import { classifyTool, extractCommand, publicToolName } from "./normalize.mjs";
30
30
  import { scan as scanSecrets } from "./secret-detect.mjs";
31
31
  import { applyDelegation } from "./delegation.mjs";
32
+ import { assessAuthority, applyAuthority } from "./authority.mjs";
32
33
  import { applyEntitlements } from "./entitlement-gate.mjs";
34
+ import { SessionTaint, assessTrifecta, applyTrifecta } from "./trifecta.mjs";
33
35
 
34
36
  /**
35
37
  * A refusal the agent can read and plan around.
@@ -176,6 +178,12 @@ export class Guard {
176
178
  runId = null,
177
179
  riskFloor = "high",
178
180
  delegation = null,
181
+ /* Mission-scoped authority. Absent by default, and absent means INERT —
182
+ not "deny everything". Authority is subtractive: it can take away what
183
+ policy allows and can never add to it, so a Guard built without a
184
+ mission behaves exactly as before. See core/authority.mjs. */
185
+ missions = null,
186
+ mission = null,
179
187
  /* Commercial enforcement. All three default to absent, so a Guard built
180
188
  without them behaves exactly as before — which is what keeps the SDK's
181
189
  library callers and the shared conformance fixture working unchanged.
@@ -192,6 +200,9 @@ export class Guard {
192
200
  this.secrets = secrets;
193
201
  /** DelegationBroker, when agent-to-agent delegation is in use. */
194
202
  this.delegation = delegation;
203
+ /** MissionRegistry, and/or a single mission this Guard always acts under. */
204
+ this.missions = missions;
205
+ this.mission = mission;
195
206
  this.licence = licence;
196
207
  this.meter = meter;
197
208
  this.agents = agents;
@@ -200,12 +211,20 @@ export class Guard {
200
211
  this.runId = runId;
201
212
  /** Risk level at or above which an unnamed call is escalated to approval. */
202
213
  this.riskFloor = riskFloor;
203
- /** Set once this session reads secret-shaped material. */
204
- this.touchedSecret = false;
214
+ /** Sequence taint tracking for Lethal Trifecta. */
215
+ this.taint = new SessionTaint();
205
216
  this.stats = { calls: 0, permitted: 0, denied: 0, held: 0, leaks: 0, latencyTotal: 0 };
206
217
  this.nextId = 1;
207
218
  }
208
219
 
220
+ get touchedSecret() {
221
+ return this.taint.touchedSecret;
222
+ }
223
+
224
+ set touchedSecret(value) {
225
+ this.taint.touchedSecret = value;
226
+ }
227
+
209
228
  /**
210
229
  * Decides one call, and brokers any secret handles it carries.
211
230
  *
@@ -215,7 +234,8 @@ export class Guard {
215
234
  *
216
235
  * @returns {Promise<{decision:object, record:object, args:any}>}
217
236
  */
218
- async authorize({ tool, server = null, args = {}, delegation = null, agent = null }) {
237
+ async authorize({ tool, server = null, args, arguments: callArguments, delegation = null, agent = null }) {
238
+ args = args ?? callArguments ?? {};
219
239
  const action = actionForTool(server, tool);
220
240
  const resource = resourceForCall(args);
221
241
  // A caller may act as a specific agent per call — a gateway serving several
@@ -271,7 +291,7 @@ export class Guard {
271
291
  secrets: { detected: scanned.length },
272
292
  };
273
293
 
274
- const decision = evaluate(
294
+ let decision = evaluate(
275
295
  { agent: caller, action, resource, context },
276
296
  this.rules,
277
297
  { cwd: this.cwd },
@@ -308,6 +328,88 @@ export class Guard {
308
328
  resource: decision.resource ?? resource,
309
329
  });
310
330
 
331
+ /*
332
+ * AUTHORITY RUNS HERE, ON THE SAME PATH AS EVERYTHING ELSE.
333
+ *
334
+ * Mission, capability, constraint and expiry are evaluated for every call,
335
+ * not only for the ones a caller remembers to check. Placing it beside
336
+ * delegation is deliberate: both answer "does this principal actually hold
337
+ * the authority it is exercising", both can only narrow, and both have to
338
+ * be on the ONE path that `guard.wrap()`, the MCP gateway and the socket
339
+ * all go through. The three bypasses documented above this line were all
340
+ * the same mistake — a check that lived on one surface and not the others —
341
+ * and an authority layer with that shape would be worse than none, because
342
+ * the console would show a boundary the runtime was not enforcing.
343
+ *
344
+ * `assessAuthority` is pure. It reads the mission and reports; it does not
345
+ * spend the budget. Usage is recorded below and only for a call that was
346
+ * actually permitted, so a refused call cannot exhaust the allowance it was
347
+ * refused under — otherwise every constraint doubles as a denial-of-service
348
+ * against the agent's real work.
349
+ */
350
+ const activeMission =
351
+ this.mission ?? (this.missions ? this.missions.forAgent(caller) : null);
352
+
353
+ const authorityAssessment = assessAuthority(
354
+ {
355
+ agent: caller,
356
+ action,
357
+ resource: decision.resource ?? resource,
358
+ tool,
359
+ server,
360
+ destination: destinationFor(decision.resource ?? resource, args),
361
+ environment: this.environment,
362
+ costUsd: args?.costUsd ?? 0,
363
+ delegating: Boolean(delegation),
364
+ },
365
+ activeMission,
366
+ );
367
+
368
+ const authorityContext = applyAuthority(decision, authorityAssessment);
369
+
370
+ /*
371
+ * An attempt is recorded whether or not authority is what refused it.
372
+ *
373
+ * A call policy already denied is still an agent reaching outside its
374
+ * boundary, and if only authority-attributed refusals were counted an
375
+ * agent could probe the boundary for free by choosing actions policy
376
+ * denies anyway. The benchmark scores attempts, not attributions.
377
+ */
378
+ if (this.missions && authorityAssessment.applicable && !authorityAssessment.authorized) {
379
+ this.missions.recordEscape({
380
+ missionId: activeMission?.id ?? null,
381
+ agent: caller,
382
+ kind: authorityAssessment.escape?.kind ?? null,
383
+ stage: authorityAssessment.stage,
384
+ code: authorityAssessment.code,
385
+ action,
386
+ resource: decision.resource ?? resource,
387
+ tool,
388
+ reason: authorityAssessment.reason,
389
+ blocked: decision.verdict === "deny" || decision.verdict === "hold",
390
+ });
391
+ }
392
+
393
+ /*
394
+ * Sequence-aware enforcement (Lethal Trifecta) in Guard.
395
+ * Prevents untrusted content + sensitive data read + outbound egress.
396
+ */
397
+ const trifectaCall = {
398
+ action,
399
+ resource: decision.resource ?? resource,
400
+ tool,
401
+ server,
402
+ destination: destinationFor(decision.resource ?? resource, args),
403
+ environment: this.environment,
404
+ egress: this.isExternal(decision.resource ?? resource) ? "external" : "none",
405
+ timestamp: new Date().toISOString(),
406
+ sql: typeof args?.sql === "string" ? args.sql : typeof args?.query === "string" ? args.query : null,
407
+ secretsDetected: scanned.length,
408
+ };
409
+ const trifecta = assessTrifecta(trifectaCall, this.taint);
410
+ decision = applyTrifecta(decision, trifecta);
411
+ decision.trifecta = { complete: trifecta.complete, satisfied: trifecta.satisfied, imminent: trifecta.imminent };
412
+
311
413
  /*
312
414
  * THE COMMERCIAL GATE RUNS HERE TOO, NOT ONLY IN THE PIPELINE.
313
415
  *
@@ -391,7 +493,12 @@ export class Guard {
391
493
  // Who authorized this must be answerable after the fact, on every surface
392
494
  // — not only the one that happened to record it.
393
495
  ...(delegationContext ? { delegation: delegationContext } : {}),
496
+ // Authority is part of the record for the same reason delegation is:
497
+ // "who authorized this" must be answerable after the fact.
498
+ ...(authorityContext ? { authority: authorityContext } : {}),
499
+ ...(decision.escape ? { escape: decision.escape } : {}),
394
500
  ...(brokered.length ? { secrets: brokered, secrets_brokered: brokered } : {}),
501
+ ...(decision.trifecta ? { trifecta: decision.trifecta } : {}),
395
502
  // Findings never carry the value — see secret-detect.mjs.
396
503
  ...(scanned.length
397
504
  ? {
@@ -413,6 +520,13 @@ export class Guard {
413
520
  else if (decision.verdict === "hold") this.stats.held++;
414
521
  else {
415
522
  this.stats.permitted++;
523
+ // Budget and rate are consumed by calls that HAPPEN. See the note above
524
+ // `assessAuthority`: charging a refused call would let a blocked agent
525
+ // exhaust its own mission.
526
+ if (this.missions && activeMission) {
527
+ this.missions.record(activeMission.id, { costUsd: args?.costUsd ?? 0 });
528
+ }
529
+ this.taint.observeCall(trifectaCall, true);
416
530
  // Any successful read of secret-shaped material taints the session. A
417
531
  // brokered substitution deliberately does not: the agent never held the
418
532
  // material, which is the entire point of a handle.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Intent-Aware Agent Firewall.
3
+ *
4
+ * Implements: CAPABILITY + AUTHORITY + INTENT + CONTEXT = AUTHORIZED ACTION
5
+ *
6
+ * Rather than evaluating tools in isolation (`tool == allowed`), an agent's
7
+ * declared mission/purpose governs what it may reach for.
8
+ *
9
+ * Example:
10
+ * Mission: "Fix checkout test failures"
11
+ * Allowed: read source, run tests, modify checkout files
12
+ * Suspicious: read production credentials, drop database, upload code to external IP
13
+ */
14
+
15
+ import { RISK } from "./risk.mjs";
16
+
17
+ /** Semantic categories of intent. */
18
+ export const INTENT_CATEGORIES = {
19
+ TESTING: "testing",
20
+ DEVELOPMENT: "development",
21
+ MAINTENANCE: "maintenance",
22
+ REPORTING: "reporting",
23
+ DATABASE_ADMIN: "database_admin",
24
+ DEPLOYMENT: "deployment",
25
+ GENERAL: "general",
26
+ };
27
+
28
+ /** High-risk actions that require explicit intent alignment. */
29
+ const RESTRICTED_ACTIONS = {
30
+ "secret:read": ["credential_management", "auth_setup"],
31
+ "db:drop": ["database_admin", "migration_rollback"],
32
+ "db:write": ["database_admin", "data_migration", "development"],
33
+ "shell:destructive": ["system_admin"],
34
+ "net:external_egress": ["data_sync", "api_integration", "deployment"],
35
+ "iam:modify": ["cloud_admin", "iam_setup"],
36
+ };
37
+
38
+ /**
39
+ * Classifies a declared intent string into dominant categories and allowed scopes.
40
+ */
41
+ export function classifyIntent(intentText) {
42
+ if (!intentText || typeof intentText !== "string") {
43
+ return {
44
+ category: INTENT_CATEGORIES.GENERAL,
45
+ keywords: [],
46
+ sensitivityFloor: RISK.LOW,
47
+ allowedActions: ["*"],
48
+ };
49
+ }
50
+
51
+ const text = intentText.toLowerCase();
52
+ const keywords = text.match(/\b\w{3,}\b/g) ?? [];
53
+
54
+ if (/\b(test|spec|assert|coverage|jest|mocha|pytest|unit)\b/.test(text)) {
55
+ return {
56
+ category: INTENT_CATEGORIES.TESTING,
57
+ keywords,
58
+ sensitivityFloor: RISK.LOW,
59
+ disallowedActions: ["secret:read", "db:drop", "net:external_egress", "iam:modify"],
60
+ allowedActions: ["fs:read", "fs:write", "exec:test", "exec:dev"],
61
+ };
62
+ }
63
+
64
+ if (/\b(fix|bug|refactor|feature|implement|code|frontend|backend)\b/.test(text)) {
65
+ return {
66
+ category: INTENT_CATEGORIES.DEVELOPMENT,
67
+ keywords,
68
+ sensitivityFloor: RISK.LOW,
69
+ disallowedActions: ["secret:read", "db:drop", "iam:modify"],
70
+ allowedActions: ["fs:read", "fs:write", "exec:dev", "net:fetch"],
71
+ };
72
+ }
73
+
74
+ if (/\b(deploy|release|ship|staging|production|publish)\b/.test(text)) {
75
+ return {
76
+ category: INTENT_CATEGORIES.DEPLOYMENT,
77
+ keywords,
78
+ sensitivityFloor: RISK.HIGH,
79
+ disallowedActions: ["db:drop"],
80
+ allowedActions: ["fs:read", "net:external_egress", "exec:deploy"],
81
+ };
82
+ }
83
+
84
+ if (/\b(migrate|schema|table|sql|database|query|seed)\b/.test(text)) {
85
+ return {
86
+ category: INTENT_CATEGORIES.DATABASE_ADMIN,
87
+ keywords,
88
+ sensitivityFloor: RISK.HIGH,
89
+ disallowedActions: ["iam:modify"],
90
+ allowedActions: ["db:read", "db:write", "fs:read"],
91
+ };
92
+ }
93
+
94
+ return {
95
+ category: INTENT_CATEGORIES.GENERAL,
96
+ keywords,
97
+ sensitivityFloor: RISK.LOW,
98
+ disallowedActions: ["db:drop", "iam:modify"],
99
+ allowedActions: ["*"],
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Evaluates whether a requested action aligns with the agent's declared intent.
105
+ *
106
+ * @param {Object} params
107
+ * @param {string} params.intent - Declared mission or task description
108
+ * @param {string} params.action - Canonical action identifier (e.g. "fs:read", "secret:read")
109
+ * @param {string} params.resource - Resource target (e.g. "/etc/passwd", "src/auth.ts")
110
+ * @param {string} params.tool - Tool name invoked
111
+ * @param {Object} params.context - Execution context
112
+ * @returns {{ aligned: boolean, intentScore: number, reason: string, category: string }}
113
+ */
114
+ export function evaluateIntent({ intent, action, resource = "", tool = "", context = {} }) {
115
+ const classification = classifyIntent(intent);
116
+
117
+ // If action is explicitly restricted, verify if intent permits it
118
+ const restrictedFor = RESTRICTED_ACTIONS[action];
119
+ if (restrictedFor && !restrictedFor.includes(classification.category)) {
120
+ return {
121
+ aligned: false,
122
+ intentScore: 0.1,
123
+ reason: `Action '${action}' is restricted and outside declared mission '${intent}' (${classification.category})`,
124
+ category: classification.category,
125
+ };
126
+ }
127
+
128
+ // Check disallowed actions for this intent category
129
+ if (classification.disallowedActions?.includes(action)) {
130
+ return {
131
+ aligned: false,
132
+ intentScore: 0.2,
133
+ reason: `Action '${action}' conflicts with declared mission scope (${classification.category})`,
134
+ category: classification.category,
135
+ };
136
+ }
137
+
138
+ // Resource sensitivity check against intent
139
+ const isCredentialTarget = /(credential|\.env|id_rsa|secret|token|api[_-]?key|password)/i.test(resource);
140
+ if (isCredentialTarget && classification.category === INTENT_CATEGORIES.TESTING) {
141
+ return {
142
+ aligned: false,
143
+ intentScore: 0.15,
144
+ reason: `Agent attempted to access credentials ('${resource}') while declared mission is testing only`,
145
+ category: classification.category,
146
+ };
147
+ }
148
+
149
+ // Production database deletion check
150
+ const isDropTarget = /(drop\s+table|delete\s+from|truncate|rm\s+-rf\s+\/)/i.test(resource);
151
+ if (isDropTarget && classification.category !== INTENT_CATEGORIES.DATABASE_ADMIN) {
152
+ return {
153
+ aligned: false,
154
+ intentScore: 0.05,
155
+ reason: `Destructive action against '${resource}' requires explicit administrative mission`,
156
+ category: classification.category,
157
+ };
158
+ }
159
+
160
+ return {
161
+ aligned: true,
162
+ intentScore: 0.95,
163
+ reason: `Action '${action}' against '${resource || tool}' aligns with mission '${classification.category}'`,
164
+ category: classification.category,
165
+ };
166
+ }
@@ -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();