@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
@@ -61,6 +61,11 @@ import { approvalFingerprint } from "./approvals.mjs";
61
61
  import { applyDelegation } from "./delegation.mjs";
62
62
  import { redact as redactSecrets, scan as scanSecrets } from "./secret-detect.mjs";
63
63
  import { stripInjection } from "./sanitize.mjs";
64
+ import { SessionTaint, assessTrifecta, applyTrifecta } from "./trifecta.mjs";
65
+ import { evaluateIntent } from "./intent.mjs";
66
+ import { SessionTracker } from "./session.mjs";
67
+ import { BehavioralBaseline } from "./baseline.mjs";
68
+ import { globalKillSwitch } from "./kill-switch.mjs";
64
69
 
65
70
  /** Wall-clock for one stage, in fractional milliseconds. */
66
71
  function timer() {
@@ -105,6 +110,10 @@ export class Pipeline {
105
110
  agents = null,
106
111
  riskFloor = "high",
107
112
  runId = null,
113
+ killSwitch = null,
114
+ sessionTracker = null,
115
+ baseline = null,
116
+ intent = null,
108
117
  onEvent = () => {},
109
118
  log = () => {},
110
119
  } = {}) {
@@ -123,11 +132,21 @@ export class Pipeline {
123
132
  this.agents = agents;
124
133
  this.riskFloor = riskFloor;
125
134
  this.runId = runId;
135
+ this.killSwitch = killSwitch;
136
+ this.sessionTracker = sessionTracker;
137
+ this.baseline = baseline;
138
+ this.intent = intent;
126
139
  this.onEvent = onEvent;
127
140
  this.log = log;
128
141
 
129
- /** Set once this session reads secret-shaped material. */
130
- this.touchedSecret = false;
142
+ /**
143
+ * Sequence state for this session.
144
+ *
145
+ * Was a bare `touchedSecret` boolean. It is now the full three-leg record
146
+ * the Lethal Trifecta needs, with `touchedSecret` kept as an accessor so
147
+ * the gateway and every existing caller are unaffected.
148
+ */
149
+ this.taint = new SessionTaint();
131
150
 
132
151
  this.stats = {
133
152
  calls: 0,
@@ -143,6 +162,19 @@ export class Pipeline {
143
162
  };
144
163
  }
145
164
 
165
+ /**
166
+ * Kept so the gateway, the guard and any embedder that set or read this
167
+ * boolean keep working unchanged. Leg A of the trifecta and the old
168
+ * "this session touched a secret" flag are the same fact.
169
+ */
170
+ get touchedSecret() {
171
+ return this.taint.touchedSecret;
172
+ }
173
+
174
+ set touchedSecret(value) {
175
+ this.taint.touchedSecret = value;
176
+ }
177
+
146
178
  /**
147
179
  * Runs one call through every stage.
148
180
  *
@@ -225,6 +257,18 @@ export class Pipeline {
225
257
  if (context) call.delegation = context;
226
258
  }
227
259
 
260
+ /*
261
+ * Sequence-aware enforcement, after policy and after mode.
262
+ *
263
+ * It runs last among the tightening steps so that a call policy already
264
+ * refused keeps policy's reason — the first refusal is the actionable one.
265
+ * `applyTrifecta` is a no-op unless all three legs are satisfied, so the
266
+ * common path costs one object comparison.
267
+ */
268
+ const trifecta = assessTrifecta(call, this.taint, { response: this.trifectaResponse });
269
+ decision = applyTrifecta(decision, trifecta);
270
+ call.trifecta = { complete: trifecta.complete, satisfied: trifecta.satisfied, imminent: trifecta.imminent };
271
+
228
272
  decision = applyMode(decision, this.mode);
229
273
  decision.decision_id = `dec_${id.slice(4)}`;
230
274
 
@@ -243,6 +287,86 @@ export class Pipeline {
243
287
  agent: call.agent,
244
288
  });
245
289
 
290
+ /* ------------------------------------------- kill switch evaluation ---- */
291
+ const ksEngine = this.killSwitch ?? globalKillSwitch;
292
+ const killCheck = ksEngine.evaluate({
293
+ agentId: call.agent,
294
+ tool: call.tool,
295
+ resource: call.resource,
296
+ session: this.runId,
297
+ environment: this.environment,
298
+ });
299
+ if (killCheck.killed) {
300
+ decision = {
301
+ decision: killCheck.decision ?? DECISION.QUARANTINE,
302
+ verdict: killCheck.decision ?? DECISION.QUARANTINE,
303
+ rule: "emergency-kill-switch",
304
+ reason: killCheck.reason,
305
+ risk: "critical",
306
+ };
307
+ call.killSwitchTriggered = true;
308
+ }
309
+
310
+ /* -------------------------------------------------- intent firewall ---- */
311
+ const declaredIntent = ctx.intent ?? call.intent ?? this.intent;
312
+ if (declaredIntent && decision.decision === DECISION.ALLOW) {
313
+ const intentEval = evaluateIntent({
314
+ intent: declaredIntent,
315
+ action: call.action,
316
+ resource: call.resource,
317
+ tool: call.tool,
318
+ context: ctx,
319
+ });
320
+ call.intentEvaluation = intentEval;
321
+ if (!intentEval.aligned) {
322
+ decision = {
323
+ decision: DECISION.DENY,
324
+ verdict: "deny",
325
+ rule: "intent-firewall-boundary",
326
+ reason: intentEval.reason,
327
+ risk: "high",
328
+ };
329
+ }
330
+ }
331
+
332
+ /* ------------------------------------- stateful session security ---- */
333
+ if (this.sessionTracker) {
334
+ const chainCheck = this.sessionTracker.recordStep({
335
+ action: call.action,
336
+ resource: call.resource,
337
+ tool: call.tool,
338
+ decision: decision.decision,
339
+ risk: call.risk,
340
+ });
341
+ call.sessionRisk = chainCheck.risk;
342
+ if (chainCheck.suspicious && decision.decision === DECISION.ALLOW) {
343
+ decision = {
344
+ decision: DECISION.DENY,
345
+ verdict: "deny",
346
+ rule: "stateful-exfiltration-chain",
347
+ reason: chainCheck.reason,
348
+ risk: "critical",
349
+ };
350
+ }
351
+ }
352
+
353
+ /* --------------------------------------- behavioral baseline check ---- */
354
+ if (this.baseline) {
355
+ const deviation = this.baseline.scoreDeviation({
356
+ tool: call.tool,
357
+ action: call.action,
358
+ resource: call.resource,
359
+ });
360
+ call.anomalyScore = deviation.anomalyScore;
361
+ if (deviation.isDeviation && decision.decision === DECISION.ALLOW) {
362
+ decision = escalateForRisk(
363
+ decision,
364
+ { level: "HIGH", signals: ["behavioral_anomaly"], reason: deviation.reasons[0] },
365
+ { floor: this.riskFloor }
366
+ );
367
+ }
368
+ }
369
+
246
370
  stages.policy = t();
247
371
 
248
372
  /* ---------------------------------------------------------- 6. approval */
@@ -487,15 +611,29 @@ export class Pipeline {
487
611
  stages.audit = t();
488
612
  event.stages.audit = Number(stages.audit.toFixed(3));
489
613
 
614
+ /* Attach only when there is something to record. A key present with an
615
+ undefined value and an absent key are identical to JSON.stringify but
616
+ not to the canonicaliser the audit chain hashes with, and setting one
617
+ unconditionally broke chain verification for every call. */
618
+ if (trifecta.complete) {
619
+ event.trifecta = { complete: true, legs: trifecta.legs, response: trifecta.decision };
620
+ } else if (trifecta.satisfied.length) {
621
+ event.trifecta = { complete: false, satisfied: trifecta.satisfied, imminent: trifecta.imminent };
622
+ }
623
+
490
624
  this.#count(decision, latency);
491
625
  this.onEvent({ kind: "decision", ...event });
492
626
 
493
627
  // Any permitted read of secret-shaped material taints the session. A
494
628
  // brokered substitution deliberately does not: the agent never held the
495
629
  // material, which is the entire point of a handle.
496
- if (isForwarded(decision.decision) && /secret|credential|token|password|\.env/i.test(call.resource)) {
497
- this.touchedSecret = true;
498
- }
630
+ this.taint.observeCall(call, isForwarded(decision.decision));
631
+
632
+ /* The result comes back through scrubResult(), which the transport calls
633
+ with only the decision — it has no call in hand. Holding the last call
634
+ here is what lets leg B attribute an ingested page to the fetch that
635
+ asked for it, instead of recording an anonymous taint. */
636
+ this.lastCall = call;
499
637
 
500
638
  return { event, call, decision, arguments: outgoing };
501
639
  }
@@ -513,7 +651,7 @@ export class Pipeline {
513
651
  * default would corrupt legitimate content that merely discusses
514
652
  * prompts.
515
653
  */
516
- scrubResult(payload, decision = {}) {
654
+ scrubResult(payload, decision = {}, call = null) {
517
655
  let out = payload;
518
656
  const findings = [];
519
657
 
@@ -542,6 +680,10 @@ export class Pipeline {
542
680
  for (const f of stripped.findings) findings.push({ ...f, kind: "injection" });
543
681
  }
544
682
 
683
+ /* Leg B. The injection findings were already computed here and then
684
+ discarded; remembering them is what makes the trifecta detectable. */
685
+ this.taint.observeResult(call ?? decision.call ?? this.lastCall ?? {}, findings);
686
+
545
687
  if (findings.length) {
546
688
  this.stats.leaks += findings.filter((f) => f.kind === "credential").length;
547
689
  this.onEvent({
@@ -110,6 +110,57 @@ export function ephemeralSecret(licence = {}, handle = "sec_handle_…") {
110
110
 
111
111
  /** The fraction of the allowance that triggers the one soft nudge. */
112
112
  export const NUDGE_AT = 0.7;
113
+ /**
114
+ * The egress-class DENY rules that carry a third-party destination.
115
+ *
116
+ * A denial in this class is the moment someone learns what Cirvix is for:
117
+ * an agent they run tried to send something somewhere, and the engine said
118
+ * no. That is also the story worth telling other people — which is why the
119
+ * prompt that fires on it offers to share it, and why the share offer lives
120
+ * here rather than in a place it would fire on every mundane refusal.
121
+ */
122
+ export const EGRESS_DENY_RULES = new Set([
123
+ "deny-external-egress-after-secret",
124
+ "deny-unlisted-egress",
125
+ "deny-credential-files",
126
+ "deny-dotenv-production",
127
+ ]);
128
+
129
+ export const SHARE_URL = "https://www.cirvix.com/share.html";
130
+
131
+ /**
132
+ * The domain-signal prompt, or null.
133
+ *
134
+ * FIRES ON THE EGRESS-CLASS DENIALS ONLY, AND AT MOST ONCE PER PROCESS. A
135
+ * blocked credential file or a blocked exfiltration-shaped egress is the
136
+ * strongest evidence that the person behind the terminal is running agents
137
+ * against real infrastructure — exactly who Team exists for. The prompt
138
+ * states what was seen (a rule name and a destination, never a secret value),
139
+ * names the tier's own limits as proof it read them, and offers one next
140
+ * step. It does not fire on every refusal: a denial of a workspace write is
141
+ * housekeeping, not a signal.
142
+ *
143
+ * Like everything else in this file it decides nothing: the caller owns when,
144
+ * `notices.mjs` owns once-per-process, and this only shapes the sentence.
145
+ */
146
+ export function domainSignal(licence = {}, { rule = "", destination = "" } = {}) {
147
+ const tier = tierFor(licence.tier);
148
+ if (!EGRESS_DENY_RULES.has(rule)) return null;
149
+
150
+ const seen = [rule, destination ? `→ ${destination}` : ""].filter(Boolean).join(" ");
151
+
152
+ return [
153
+ `[cirvix] Blocked an outbound action on ${tier.name} (${seen}).`,
154
+ tier.agents !== null
155
+ ? `${tier.name} watches 1 agent; Team (${n(TIERS.team.seatsIncluded)}+ seats) shares one policy across all of them.`
156
+ : "",
157
+ "Seen something worth showing? Share the blocked action — no secrets, your call:",
158
+ `→ ${SHARE_URL}`,
159
+ ]
160
+ .filter(Boolean)
161
+ .join("\n");
162
+ }
163
+
113
164
 
114
165
  /**
115
166
  * The single mid-day nudge, or null.