@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
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Cirvix semantic theme system.
3
+ *
4
+ * Every color in the product goes through here. No file outside this one
5
+ * (and its thin re-export in `format.mjs`) may hardcode an ANSI code or call
6
+ * a raw color helper for a product meaning.
7
+ *
8
+ * Why semantic, not literal:
9
+ * - `allow` always means "this proceeded". If it is green-on-dark today and
10
+ * bright-green-on-light tomorrow, every call site updates at once.
11
+ * - Themes become data: `cirvix theme light` swaps one table, not 40 files.
12
+ * - Accessibility (high-contrast, monochrome) is a theme, not a refactor.
13
+ * - Tests can assert meaning ("blocked renders with the block role") without
14
+ * asserting escape bytes.
15
+ *
16
+ * Zero dependencies. Works with NO_COLOR / dumb terminals / pipes by
17
+ * producing plain text (same contract `format.mjs` always had).
18
+ */
19
+
20
+ const ANSI = {
21
+ reset: "\x1b[0m",
22
+ bold: ["\x1b[1m", "\x1b[22m"],
23
+ dim: ["\x1b[2m", "\x1b[22m"],
24
+ };
25
+
26
+ /** Role names. Adding a role = adding a key here + every theme below. */
27
+ export const ROLES = [
28
+ "text",
29
+ "muted",
30
+ "accent",
31
+ "allow",
32
+ "sanitize",
33
+ "block",
34
+ "hold",
35
+ "info",
36
+ "warning",
37
+ "error",
38
+ "border",
39
+ "surface",
40
+ "selection",
41
+ ];
42
+
43
+ /**
44
+ * Themes are { role: [open, close] } ANSI pairs, or null for "no styling".
45
+ * Keep the numbers standard (30-37 / 90-97) so they survive SSH, tmux,
46
+ * Windows Terminal, and CI log renderers.
47
+ */
48
+ const THEMES = {
49
+ dark: {
50
+ text: [37, 39], // white
51
+ muted: [90, 39], // bright black (grey)
52
+ accent: [36, 39], // cyan
53
+ allow: [32, 39], // green
54
+ sanitize: [36, 39], // cyan
55
+ block: [31, 39], // red
56
+ hold: [33, 39], // yellow
57
+ info: [34, 39], // blue
58
+ warning: [33, 39], // yellow
59
+ error: [31, 39], // red
60
+ border: [90, 39],
61
+ surface: null,
62
+ selection: [36, 39],
63
+ },
64
+ light: {
65
+ text: [30, 39], // black
66
+ muted: [90, 39],
67
+ accent: [36, 39],
68
+ allow: [32, 39], // green reads on light bg; darker terminals vary but stay legible
69
+ sanitize: [36, 39],
70
+ block: [31, 39],
71
+ hold: [33, 39],
72
+ info: [34, 39],
73
+ warning: [33, 39],
74
+ error: [31, 39],
75
+ border: [90, 39],
76
+ surface: null,
77
+ selection: [36, 39],
78
+ },
79
+ midnight: {
80
+ text: [97, 39], // bright white
81
+ muted: [34, 39], // dim blue-grey feel
82
+ accent: [95, 39], // bright magenta
83
+ allow: [92, 39], // bright green
84
+ sanitize: [96, 39], // bright cyan
85
+ block: [91, 39], // bright red
86
+ hold: [93, 39], // bright yellow
87
+ info: [94, 39], // bright blue
88
+ warning: [93, 39],
89
+ error: [91, 39],
90
+ border: [35, 39], // magenta borders
91
+ surface: null,
92
+ selection: [95, 39],
93
+ },
94
+ "high-contrast": {
95
+ text: [97, 39],
96
+ muted: [37, 39], // no dim grey — everything legible
97
+ accent: [93, 39],
98
+ allow: [92, 39],
99
+ sanitize: [96, 39],
100
+ block: [91, 39],
101
+ hold: [93, 39],
102
+ info: [94, 39],
103
+ warning: [93, 39],
104
+ error: [91, 39],
105
+ border: [97, 39],
106
+ surface: null,
107
+ selection: [93, 39],
108
+ },
109
+ monochrome: {
110
+ text: null,
111
+ muted: null,
112
+ accent: null,
113
+ allow: null,
114
+ sanitize: null,
115
+ block: null,
116
+ hold: null,
117
+ info: null,
118
+ warning: null,
119
+ error: null,
120
+ border: null,
121
+ surface: null,
122
+ selection: null,
123
+ },
124
+ };
125
+
126
+ export const THEME_NAMES = Object.keys(THEMES);
127
+
128
+ let current = process.env.CIRVIX_THEME && THEMES[process.env.CIRVIX_THEME]
129
+ ? process.env.CIRVIX_THEME
130
+ : "dark";
131
+
132
+ function colorEnabled() {
133
+ if (process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true") return true;
134
+ if (process.env.NO_COLOR !== undefined) return false;
135
+ if (process.env.TERM === "dumb") return false;
136
+ if (process.env.CIRVIX_THEME === "monochrome") return false;
137
+ // Non-TTY (pipes, CI logs) → plain text. Same rule format.mjs always had.
138
+ if (!process.stdout.isTTY) return false;
139
+ return true;
140
+ }
141
+
142
+ function wrapAnsi(open, close) {
143
+ return (s) => (colorEnabled() ? `\x1b[${open}m${String(s)}\x1b[${close}m` : String(s));
144
+ }
145
+
146
+ /** Set the active theme. Returns the name set. Throws on unknown names. */
147
+ export function setTheme(name) {
148
+ if (!THEMES[name]) throw new Error(`Unknown theme "${name}". Available: ${THEME_NAMES.join(", ")}`);
149
+ current = name;
150
+ return current;
151
+ }
152
+
153
+ /** Active theme name. */
154
+ export function themeName() {
155
+ return current;
156
+ }
157
+
158
+ /** Raw role → ANSI pair for the active theme (or null). */
159
+ export function roleAnsi(role) {
160
+ return THEMES[current]?.[role] ?? null;
161
+ }
162
+
163
+ /**
164
+ * Style a string with a semantic role: `style("BLOCKED", "block")`.
165
+ * Unknown roles pass through unstyled rather than throwing — a theme
166
+ * must never break enforcement output.
167
+ */
168
+ export function style(text, role) {
169
+ const pair = THEMES[current]?.[role];
170
+ if (!pair || !colorEnabled()) return String(text);
171
+ return `\x1b[${pair[0]}m${String(text)}\x1b[${pair[1]}m`;
172
+ }
173
+
174
+ /** Bold / dim are emphasis, not color — they survive monochrome. */
175
+ export function bold(s) {
176
+ if (!colorEnabled()) return String(s);
177
+ return `${ANSI.bold[0]}${String(s)}${ANSI.bold[1]}`;
178
+ }
179
+
180
+ export function dim(s) {
181
+ if (process.env.CIRVIX_THEME === "high-contrast") return String(s); // contrast: never dim
182
+ if (!colorEnabled()) return String(s);
183
+ return `${ANSI.dim[0]}${String(s)}${ANSI.dim[1]}`;
184
+ }
185
+
186
+ /**
187
+ * The semantic palette. Prefer `colors.block("…")` over importing raw
188
+ * helpers — call sites name the meaning, this file owns the rendering.
189
+ */
190
+ export const colors = {
191
+ get text() { return (s) => style(s, "text"); },
192
+ get muted() { return (s) => style(s, "muted"); },
193
+ get accent() { return (s) => style(s, "accent"); },
194
+ get allow() { return (s) => style(s, "allow"); },
195
+ get sanitize() { return (s) => style(s, "sanitize"); },
196
+ get block() { return (s) => style(s, "block"); },
197
+ get hold() { return (s) => style(s, "hold"); },
198
+ get info() { return (s) => style(s, "info"); },
199
+ get warning() { return (s) => style(s, "warning"); },
200
+ get error() { return (s) => style(s, "error"); },
201
+ get border() { return (s) => style(s, "border"); },
202
+ get selection() { return (s) => style(s, "selection"); },
203
+ };
204
+
205
+ /** Decision → theme role. Single mapping, used by every renderer. */
206
+ export function roleForDecision(decision) {
207
+ switch (String(decision ?? "").toLowerCase()) {
208
+ case "allow": return "allow";
209
+ case "sanitize": return "sanitize";
210
+ case "deny": return "block";
211
+ case "require_approval": return "hold";
212
+ case "audit_only": return "muted";
213
+ default: return "muted";
214
+ }
215
+ }
216
+
217
+ /** Risk → theme role. */
218
+ export function roleForRisk(risk) {
219
+ switch (String(risk ?? "").toLowerCase()) {
220
+ case "critical": return "error";
221
+ case "high": return "warning";
222
+ case "medium": return "info";
223
+ case "low": return "muted";
224
+ default: return "muted";
225
+ }
226
+ }
227
+
228
+ /** Decision → icon + label. Icon is never the only signal (a11y). */
229
+ export function badgeForDecision(decision) {
230
+ switch (String(decision ?? "").toLowerCase()) {
231
+ case "allow": return { icon: "✓", label: "ALLOWED" };
232
+ case "sanitize": return { icon: "◇", label: "SANITIZED" };
233
+ case "deny": return { icon: "✕", label: "BLOCKED" };
234
+ case "require_approval": return { icon: "◷", label: "HELD FOR APPROVAL" };
235
+ case "audit_only": return { icon: "○", label: "AUDIT ONLY" };
236
+ default: return { icon: "?", label: String(decision ?? "UNKNOWN").toUpperCase() };
237
+ }
238
+ }
239
+
240
+ export { wrapAnsi };
@@ -0,0 +1,321 @@
1
+ /**
2
+ * The Lethal Trifecta — sequence-aware enforcement.
3
+ *
4
+ * Three conditions are each individually reasonable and jointly catastrophic:
5
+ *
6
+ * A. the agent has read sensitive material
7
+ * B. the agent has ingested content from outside the trust boundary
8
+ * C. the agent is now trying to act outbound
9
+ *
10
+ * Any one of those is ordinary work. A is `cat .env` during debugging. B is
11
+ * fetching a web page. C is a POST. A rule engine that only ever sees one call
12
+ * at a time cannot refuse any of them without refusing normal development, so
13
+ * it lets all three through and the exfiltration happens in the gaps between
14
+ * them.
15
+ *
16
+ * WHAT ALREADY EXISTED, AND WHY IT WAS NOT ENOUGH
17
+ * ----------------------------------------------
18
+ * risk.mjs carries `session-tainted-egress`: `touchedSecret && egress !== none`
19
+ * → HIGH. That is A + C, and it is a genuinely useful rule. Two gaps:
20
+ *
21
+ * · B was never tracked at all. The signal exists — sanitize.mjs already
22
+ * finds injected instructions in fetched results — but the finding was
23
+ * reported and then dropped on the floor rather than remembered.
24
+ * · It raises RISK, and risk alone does not refuse anything. It needs a
25
+ * separate policy rule to become a decision.
26
+ *
27
+ * So this module does not replace that rule; it completes it. A + C stays
28
+ * HIGH. A + B + C becomes a decision in its own right, because the presence of
29
+ * B is what turns "this agent is handling secrets near a network call" into
30
+ * "something outside the trust boundary has had the opportunity to steer this
31
+ * agent, and the agent is holding secrets, and it is now talking outward".
32
+ *
33
+ * WHY PROVENANCE, NOT BOOLEANS
34
+ * ----------------------------
35
+ * Each leg records when it was set and what set it. A refusal that says
36
+ * "blocked: trifecta" is indistinguishable from a bug, and a developer who
37
+ * cannot tell those apart turns the feature off. A refusal that says which
38
+ * three calls combined, in order, with timestamps, is a finding the developer
39
+ * can act on — and it is the artifact worth putting in a proof report.
40
+ *
41
+ * WHAT THIS DELIBERATELY DOES NOT DO
42
+ * ----------------------------------
43
+ * It does not attempt data-flow analysis. Knowing that the specific bytes read
44
+ * in call 1 reached the request body of call 3 would be stronger, and every
45
+ * approximation of it that fits in a synchronous hot path is guesswork wearing
46
+ * a proof's clothing. This tracks capability and opportunity, which is what
47
+ * can be established with certainty, and says so in its explanation.
48
+ */
49
+
50
+ import { RISK } from "./risk.mjs";
51
+ import { DECISION } from "./decisions.mjs";
52
+
53
+ /** The three legs, by the names used in explanations and audit records. */
54
+ export const LEG = Object.freeze({
55
+ SENSITIVE: "sensitive_data",
56
+ UNTRUSTED: "untrusted_content",
57
+ OUTBOUND: "outbound_action",
58
+ });
59
+
60
+ const LEG_LABEL = Object.freeze({
61
+ [LEG.SENSITIVE]: "private data",
62
+ [LEG.UNTRUSTED]: "untrusted content",
63
+ [LEG.OUTBOUND]: "an outbound action",
64
+ });
65
+
66
+ /**
67
+ * How a completed trifecta is answered, per environment.
68
+ *
69
+ * Production denies. Everywhere else holds for a human instead, because the
70
+ * combination is common and benign while a developer is exploring — reading a
71
+ * config, opening docs, pushing a branch — and a tool that refuses that
72
+ * outright gets uninstalled before it ever protects anything. The strictness
73
+ * follows the blast radius rather than being one global setting somebody has
74
+ * to remember to raise.
75
+ */
76
+ const DEFAULT_RESPONSE = Object.freeze({
77
+ production: DECISION.DENY,
78
+ staging: DECISION.REQUIRE_APPROVAL,
79
+ local: DECISION.REQUIRE_APPROVAL,
80
+ unknown: DECISION.REQUIRE_APPROVAL,
81
+ });
82
+
83
+ /* -------------------------------------------------------------- leg C ---- */
84
+
85
+ /**
86
+ * Is THIS call an outbound action?
87
+ *
88
+ * Leg C is a property of the call being judged, not of the session — which is
89
+ * the whole reason the trifecta can be refused *before* it completes rather
90
+ * than reported after. Legs A and B are history; C is the proposal.
91
+ */
92
+ export function outboundLegOf(call = {}) {
93
+ const egress = String(call.egress ?? "none");
94
+ if (egress === "external") {
95
+ return { at: call.timestamp, why: `Sends data to ${call.destination ?? "a destination outside your network"}.`, scope: "external" };
96
+ }
97
+ if (egress === "internal") {
98
+ return { at: call.timestamp, why: `Sends data to ${call.destination ?? "another host on your network"}.`, scope: "internal" };
99
+ }
100
+ // A write is outbound in the sense that matters here: it leaves an effect
101
+ // somewhere the agent does not own, whether or not a packet is involved.
102
+ const action = String(call.action ?? "");
103
+ if (/\.(write|create|update|delete|put|post|send|publish|deploy|push|upload)$/.test(action)) {
104
+ return { at: call.timestamp, why: `Writes to ${call.resource ?? "an external system"}.`, scope: "write" };
105
+ }
106
+ if (call.sql && /^\s*(insert|update|delete|drop|alter|truncate)/i.test(call.sql)) {
107
+ return { at: call.timestamp, why: "Mutates a database.", scope: "write" };
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /* ------------------------------------------------------- session taint ---- */
113
+
114
+ const SENSITIVE_RESOURCE = /secret|credential|token|password|api[-_]?key|\.env|\.pem|\.p12|id_rsa|\.aws|\.ssh|\.kube/i;
115
+
116
+ /**
117
+ * The per-session record of which legs have already been satisfied.
118
+ *
119
+ * Lives for the life of one agent session and holds no payload — only that a
120
+ * leg was satisfied, when, and by which call. Storing the sensitive value
121
+ * itself in order to reason about sensitive values would be its own incident.
122
+ */
123
+ export class SessionTaint {
124
+ constructor() {
125
+ this.legs = { [LEG.SENSITIVE]: null, [LEG.UNTRUSTED]: null };
126
+ }
127
+
128
+ /** Back-compatible with the boolean the pipeline and guard already pass. */
129
+ get touchedSecret() {
130
+ return this.legs[LEG.SENSITIVE] !== null;
131
+ }
132
+
133
+ set touchedSecret(value) {
134
+ if (value && !this.legs[LEG.SENSITIVE]) {
135
+ this.legs[LEG.SENSITIVE] = { at: new Date().toISOString(), why: "Read secret-shaped material.", action: null, resource: null };
136
+ } else if (!value) {
137
+ this.legs[LEG.SENSITIVE] = null;
138
+ }
139
+ }
140
+
141
+ get ingestedUntrusted() {
142
+ return this.legs[LEG.UNTRUSTED] !== null;
143
+ }
144
+
145
+ /**
146
+ * Leg A. A permitted read of secret-shaped material taints the session.
147
+ *
148
+ * A brokered substitution deliberately does not, which is the same rule the
149
+ * pipeline already applied: with a handle the agent never held the material,
150
+ * and that is the entire point of a handle.
151
+ */
152
+ observeCall(call = {}, forwarded = true) {
153
+ if (!forwarded) return this;
154
+ if (this.legs[LEG.SENSITIVE]) return this;
155
+ const resource = String(call.resource ?? "");
156
+ if (SENSITIVE_RESOURCE.test(resource) || Number(call.secretsDetected) > 0) {
157
+ this.legs[LEG.SENSITIVE] = {
158
+ at: call.timestamp ?? new Date().toISOString(),
159
+ action: call.action ?? null,
160
+ resource: resource || null,
161
+ why: `Read secret-shaped material from ${resource || "a tool result"}.`,
162
+ };
163
+ }
164
+ return this;
165
+ }
166
+
167
+ /**
168
+ * Leg B. Content came back from outside the trust boundary.
169
+ *
170
+ * Two independent triggers, because they fail in different directions.
171
+ * Injection findings are strong evidence and weak coverage — they only fire
172
+ * when the text looked like instructions. Provenance is weak evidence and
173
+ * strong coverage — anything fetched from outside is untrusted whether or
174
+ * not it happened to contain an obvious payload. A page that carries a
175
+ * cleverly-worded injection nobody's regex matched is exactly the case the
176
+ * provenance trigger is for.
177
+ */
178
+ observeResult(call = {}, findings = []) {
179
+ if (this.legs[LEG.UNTRUSTED]) return this;
180
+
181
+ const injected = findings.filter((f) => f?.kind === "injection");
182
+ if (injected.length) {
183
+ this.legs[LEG.UNTRUSTED] = {
184
+ at: call.timestamp ?? new Date().toISOString(),
185
+ action: call.action ?? null,
186
+ resource: call.resource ?? null,
187
+ why: `A tool result contained ${injected.length} injected instruction${injected.length === 1 ? "" : "s"} addressed to the model.`,
188
+ evidence: injected.map((f) => f.rule ?? f.detector).filter(Boolean).slice(0, 5),
189
+ };
190
+ return this;
191
+ }
192
+
193
+ if (call.egress === "external" || call.external === true) {
194
+ this.legs[LEG.UNTRUSTED] = {
195
+ at: call.timestamp ?? new Date().toISOString(),
196
+ action: call.action ?? null,
197
+ resource: call.resource ?? null,
198
+ why: `Content was ingested from ${call.destination ?? call.resource ?? "outside the trust boundary"}.`,
199
+ };
200
+ }
201
+ return this;
202
+ }
203
+
204
+ /** Structured state for the audit record. Never contains payload. */
205
+ snapshot() {
206
+ return {
207
+ [LEG.SENSITIVE]: this.legs[LEG.SENSITIVE],
208
+ [LEG.UNTRUSTED]: this.legs[LEG.UNTRUSTED],
209
+ };
210
+ }
211
+
212
+ reset() {
213
+ this.legs = { [LEG.SENSITIVE]: null, [LEG.UNTRUSTED]: null };
214
+ return this;
215
+ }
216
+ }
217
+
218
+ /* --------------------------------------------------------- assessment ---- */
219
+
220
+ /**
221
+ * Would this call complete the trifecta?
222
+ *
223
+ * Returns an assessment whether or not it does — `complete: false` with the
224
+ * legs that ARE satisfied is useful on its own, because it is what lets a
225
+ * dashboard show an agent one step away from the cliff rather than only
226
+ * telling anyone once it has gone over.
227
+ */
228
+ export function assessTrifecta(call = {}, taint = new SessionTaint(), options = {}) {
229
+ const outbound = outboundLegOf(call);
230
+ const legs = {
231
+ [LEG.SENSITIVE]: taint.legs?.[LEG.SENSITIVE] ?? null,
232
+ [LEG.UNTRUSTED]: taint.legs?.[LEG.UNTRUSTED] ?? null,
233
+ [LEG.OUTBOUND]: outbound,
234
+ };
235
+
236
+ const satisfied = Object.entries(legs).filter(([, v]) => v !== null).map(([k]) => k);
237
+ const missing = Object.keys(legs).filter((k) => legs[k] === null);
238
+ const complete = missing.length === 0;
239
+
240
+ const environment = String(call.environment ?? "unknown");
241
+ const configured = options.response ?? DEFAULT_RESPONSE[environment] ?? DEFAULT_RESPONSE.unknown;
242
+
243
+ return {
244
+ complete,
245
+ legs,
246
+ satisfied,
247
+ missing,
248
+ /* One short of the cliff. Worth surfacing before it matters. */
249
+ imminent: !complete && missing.length === 1,
250
+ risk: complete ? RISK.CRITICAL : satisfied.length >= 2 ? RISK.HIGH : RISK.LOW,
251
+ decision: complete ? configured : null,
252
+ environment,
253
+ explain: explainTrifecta({ complete, legs, missing, environment, decision: complete ? configured : null }),
254
+ };
255
+ }
256
+
257
+ /**
258
+ * The sentence a developer reads at 2am.
259
+ *
260
+ * Ordered by when each leg was satisfied rather than by leg name, because the
261
+ * order is the argument: this happened, then this happened, and now you are
262
+ * asking for the third thing.
263
+ */
264
+ export function explainTrifecta({ complete, legs, missing, environment, decision }) {
265
+ const ordered = Object.entries(legs)
266
+ .filter(([, v]) => v !== null)
267
+ .sort((a, b) => String(a[1].at ?? "").localeCompare(String(b[1].at ?? "")));
268
+
269
+ const steps = ordered.map(([leg, v], i) => ` ${i + 1}. ${LEG_LABEL[leg]} — ${v.why}${v.at ? ` (${v.at})` : ""}`);
270
+
271
+ if (!complete) {
272
+ const short = missing.map((m) => LEG_LABEL[m]).join(" and ");
273
+ return [
274
+ ordered.length
275
+ ? `${ordered.length} of 3 trifecta conditions are satisfied in this session:`
276
+ : "No trifecta conditions are satisfied in this session.",
277
+ ...steps,
278
+ ordered.length ? `\nStill missing: ${short}. This call is permitted on trifecta grounds.` : "",
279
+ ].filter(Boolean).join("\n");
280
+ }
281
+
282
+ return [
283
+ `This call would complete the Lethal Trifecta:`,
284
+ ...steps,
285
+ ``,
286
+ `Private data, untrusted content and an outbound action have now all`,
287
+ `occurred in one session. Anything that steered this agent through the`,
288
+ `untrusted content can reach the outside through this call.`,
289
+ ``,
290
+ `Cirvix does not claim the sensitive bytes are in this request — proving`,
291
+ `that would require data-flow analysis it does not do. It refuses on`,
292
+ `capability and opportunity, which are established.`,
293
+ ``,
294
+ `Environment is ${environment}, so the configured response is ${decision}.`,
295
+ ].join("\n");
296
+ }
297
+
298
+ /**
299
+ * Fold the assessment into a decision.
300
+ *
301
+ * Only ever tightens. A call already denied stays denied with its original
302
+ * reason, because the first refusal is the one the developer needs to fix and
303
+ * overwriting it with a second one buries the cause.
304
+ */
305
+ export function applyTrifecta(decision, assessment) {
306
+ if (!assessment?.complete) return decision;
307
+ if (decision.decision === DECISION.DENY) return decision;
308
+
309
+ const next = { ...decision };
310
+ next.decision = assessment.decision;
311
+ next.verdict = assessment.decision === DECISION.DENY ? "deny" : "hold";
312
+ next.rule = "lethal-trifecta";
313
+ next.reason = "private data + untrusted content + outbound action in one session";
314
+ next.trifecta = {
315
+ complete: true,
316
+ legs: assessment.legs,
317
+ explain: assessment.explain,
318
+ };
319
+ next.risk = RISK.CRITICAL;
320
+ return next;
321
+ }