@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.
- package/README.md +76 -17
- package/bin/cirvix.mjs +539 -85
- package/bin/escape-benchmark.mjs +67 -0
- package/package.json +36 -16
- package/src/adapters/base.mjs +150 -0
- package/src/adapters/claude-code.mjs +161 -0
- package/src/adapters/cline.mjs +107 -0
- package/src/adapters/codex.mjs +104 -0
- package/src/adapters/cursor.mjs +104 -0
- package/src/adapters/frameworks.mjs +110 -0
- package/src/adapters/gemini-cli.mjs +104 -0
- package/src/adapters/generic-mcp.mjs +101 -0
- package/src/adapters/index.mjs +209 -0
- package/src/adapters/roo-code.mjs +106 -0
- package/src/adapters/vscode.mjs +104 -0
- package/src/adapters/windsurf.mjs +107 -0
- package/src/commands/console.mjs +58 -0
- package/src/commands/demo.mjs +55 -124
- package/src/commands/doctor.mjs +235 -0
- package/src/commands/init.mjs +292 -30
- package/src/commands/interactive.mjs +690 -0
- package/src/commands/kill.mjs +74 -0
- package/src/commands/login.mjs +227 -0
- package/src/commands/onboard.mjs +52 -0
- package/src/commands/passport.mjs +149 -0
- package/src/commands/policy.mjs +10 -6
- package/src/commands/protect.mjs +293 -0
- package/src/commands/prove.mjs +209 -0
- package/src/commands/redteam.mjs +51 -0
- package/src/commands/scan.mjs +11 -9
- package/src/commands/shadow.mjs +62 -0
- package/src/commands/simulate.mjs +96 -0
- package/src/commands/status.mjs +122 -41
- package/src/commands/upgrade.mjs +11 -11
- package/src/commands/welcome.mjs +105 -0
- package/src/core/authority.mjs +909 -0
- package/src/core/baseline.mjs +97 -0
- package/src/core/config-store.mjs +280 -0
- package/src/core/cost.mjs +0 -0
- package/src/core/detect.mjs +4 -33
- package/src/core/entitlements.mjs +7 -24
- package/src/core/escape-benchmark.mjs +597 -0
- package/src/core/events.mjs +234 -0
- package/src/core/evidence.mjs +212 -0
- package/src/core/format.mjs +44 -18
- package/src/core/gateway.mjs +15 -211
- package/src/core/graph.mjs +270 -0
- package/src/core/guard.mjs +118 -4
- package/src/core/intent.mjs +166 -0
- package/src/core/journal.mjs +131 -40
- package/src/core/kill-switch.mjs +122 -0
- package/src/core/notices.mjs +22 -2
- package/src/core/packs.mjs +193 -0
- package/src/core/passport.mjs +555 -0
- package/src/core/pipeline.mjs +148 -6
- package/src/core/prompts.mjs +51 -0
- package/src/core/proof.mjs +440 -0
- package/src/core/redteam/index.mjs +185 -0
- package/src/core/referral.mjs +187 -0
- package/src/core/sandbox.mjs +139 -0
- package/src/core/session.mjs +172 -0
- package/src/core/shadow.mjs +95 -0
- package/src/core/theme.mjs +240 -0
- package/src/core/trifecta.mjs +321 -0
- package/src/core/ui/controller.mjs +192 -0
- package/src/core/ui/decisions.mjs +55 -0
- package/src/core/ui/index.mjs +49 -0
- package/src/core/ui/intercept.mjs +103 -0
- package/src/core/ui/live.mjs +51 -0
- package/src/core/ui/primitives.mjs +123 -0
- package/src/core/ui/theme.mjs +92 -0
- package/src/core/verified.mjs +108 -0
- package/src/core/windows.mjs +270 -0
- package/src/index.mjs +67 -0
- package/src/tui/activity.mjs +71 -0
- package/src/tui/app.mjs +292 -0
- package/src/tui/cards.mjs +235 -0
- package/src/tui/composer.mjs +88 -0
- package/src/tui/palette.mjs +48 -0
- package/src/tui/status.mjs +42 -0
- 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
|
+
}
|