@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
package/src/core/pipeline.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
130
|
-
|
|
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
|
-
|
|
497
|
-
|
|
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({
|
package/src/core/prompts.mjs
CHANGED
|
@@ -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.
|