@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,187 @@
1
+ /**
2
+ * Referrals.
3
+ *
4
+ * Separate from invitations on purpose. An invitation says "join MY
5
+ * organisation" and grants membership; a referral says "try this product" and
6
+ * grants nothing. Building them as one thing is how a referral link quietly
7
+ * becomes an access-control decision.
8
+ *
9
+ * DELIBERATELY BORING
10
+ * -------------------
11
+ * A code, a signup attributed to it, a qualification event, one reward. No
12
+ * tiers, no multi-level anything, no balances that can be spent. Every rewards
13
+ * economy starts simple and the complicated ones are complicated because
14
+ * somebody gamed the simple one; there is no reason to arrive there early.
15
+ *
16
+ * ABUSE IS THE DESIGN CONSTRAINT
17
+ * ------------------------------
18
+ * The three attacks that matter on a referral system, and what stops each:
19
+ *
20
+ * · self-referral — refused at attribution; the referrer cannot be the
21
+ * referred, checked on user AND on org, because one person owning two
22
+ * orgs is the cheap version of the same attack
23
+ * · replay — one referred party is attributed once, ever, even if the link
24
+ * is clicked a hundred times
25
+ * · reward-before-value — a signup earns nothing. Only a QUALIFYING event
26
+ * does, and qualification is defined by the product, not by the referrer
27
+ *
28
+ * None of that needs a fraud engine. It needs the rules to be in one place
29
+ * with tests on them, which is what this file is.
30
+ */
31
+
32
+ import { createHash, randomBytes } from "node:crypto";
33
+
34
+ export const REFERRAL_VERSION = 1;
35
+
36
+ /** What a referred org must do before anything is earned. */
37
+ export const QUALIFY = Object.freeze({
38
+ VERIFIED: "email_verified",
39
+ FIRST_DECISION: "first_decision_recorded",
40
+ SUBSCRIBED: "subscription_started",
41
+ });
42
+
43
+ /** The one reward. Usage, not money — it cannot be cashed out or transferred. */
44
+ export const REWARD = Object.freeze({
45
+ kind: "decision_allowance",
46
+ amount: 5_000,
47
+ note: "5,000 additional recorded decisions, one time, per qualified referral.",
48
+ });
49
+
50
+ /**
51
+ * A referral code for an org.
52
+ *
53
+ * Derived, not random, so the same org always presents the same code and a
54
+ * lost code is recoverable without a database lookup. Salted with the issuing
55
+ * secret so codes cannot be enumerated from org ids, which are not secret.
56
+ */
57
+ export function codeFor(orgId, { secret = "cirvix", length = 8 } = {}) {
58
+ if (!orgId) throw new Error("A referral code needs an org.");
59
+ const h = createHash("sha256").update(`${secret}:${orgId}`).digest("base64url");
60
+ return h.replace(/[^a-zA-Z0-9]/g, "").slice(0, length).toUpperCase();
61
+ }
62
+
63
+ export function linkFor(orgId, { base = "https://www.cirvix.com", secret } = {}) {
64
+ return `${base}/?ref=${codeFor(orgId, secret ? { secret } : {})}`;
65
+ }
66
+
67
+ /**
68
+ * The ledger.
69
+ *
70
+ * In-memory here; the control plane persists the same shape. Kept as a class
71
+ * so the rules live with the data rather than being re-implemented by whoever
72
+ * writes the SQL.
73
+ */
74
+ export class ReferralLedger {
75
+ #byReferred = new Map(); // referred org -> attribution
76
+ #records = [];
77
+ #events = [];
78
+
79
+ constructor({ secret = "cirvix", onEvent = () => {} } = {}) {
80
+ this.secret = secret;
81
+ this.onEvent = onEvent;
82
+ }
83
+
84
+ /**
85
+ * Attributes a new org to a referrer.
86
+ *
87
+ * Returns a reason on refusal rather than throwing: a bad referral code is
88
+ * an ordinary thing for a signup to carry, and a signup must never fail
89
+ * because the marketing attribution did not work out.
90
+ */
91
+ attribute({ code, referredOrg, referredUser = null, referrerOrg = null, referrerUser = null, at = new Date().toISOString() }) {
92
+ if (!referredOrg) return { ok: false, reason: "no_referred_org" };
93
+ if (!code) return { ok: false, reason: "no_code" };
94
+
95
+ if (this.#byReferred.has(referredOrg)) {
96
+ return { ok: false, reason: "already_attributed", existing: this.#byReferred.get(referredOrg).referrerOrg };
97
+ }
98
+ /*
99
+ * A code must resolve to a referrer, and the check is unconditional.
100
+ *
101
+ * It was guarded by `if (referrerOrg && …)`, so a caller that could not
102
+ * resolve the code simply omitted the referrer and every check below was
103
+ * skipped — an arbitrary string was attributed to nobody and counted as a
104
+ * referral. Attribution without a verifiable referrer is not attribution,
105
+ * so it is refused here rather than recorded as a fact.
106
+ */
107
+ if (!referrerOrg) return { ok: false, reason: "unresolved_code" };
108
+ if (codeFor(referrerOrg, { secret: this.secret }) !== String(code).toUpperCase()) {
109
+ return { ok: false, reason: "code_mismatch" };
110
+ }
111
+ /* Self-referral, on both axes. One person holding two orgs is the cheap
112
+ version of the same attack, so the user check is not optional. */
113
+ if (referrerOrg && referrerOrg === referredOrg) return { ok: false, reason: "self_referral" };
114
+ if (referrerUser && referredUser && referrerUser === referredUser) return { ok: false, reason: "self_referral" };
115
+
116
+ const rec = {
117
+ v: REFERRAL_VERSION, code: String(code).toUpperCase(), referrerOrg, referrerUser,
118
+ referredOrg, referredUser, attributedAt: at, qualifiedAt: null, qualifyingEvent: null,
119
+ rewarded: false, reward: null,
120
+ };
121
+ this.#byReferred.set(referredOrg, rec);
122
+ this.#records.push(rec);
123
+ this.#emit("referral.attributed", rec, at);
124
+ return { ok: true, record: rec };
125
+ }
126
+
127
+ /**
128
+ * Marks a referred org as qualified, which is what earns the reward.
129
+ *
130
+ * A signup earns nothing. That is the whole anti-abuse position: creating
131
+ * accounts is free, and anything rewarded on account creation will be.
132
+ */
133
+ qualify({ referredOrg, event, at = new Date().toISOString() }) {
134
+ const rec = this.#byReferred.get(referredOrg);
135
+ if (!rec) return { ok: false, reason: "not_attributed" };
136
+ if (!Object.values(QUALIFY).includes(event)) return { ok: false, reason: "unknown_qualifying_event" };
137
+ if (rec.qualifiedAt) return { ok: false, reason: "already_qualified" };
138
+
139
+ rec.qualifiedAt = at;
140
+ rec.qualifyingEvent = event;
141
+ rec.rewarded = true;
142
+ rec.reward = { ...REWARD };
143
+ this.#emit("referral.qualified", rec, at);
144
+ return { ok: true, record: rec, reward: rec.reward };
145
+ }
146
+
147
+ /** What one org has earned, and from whom. Never exposes the referred org's users. */
148
+ forReferrer(orgId) {
149
+ const mine = this.#records.filter((r) => r.referrerOrg === orgId);
150
+ return {
151
+ code: codeFor(orgId, { secret: this.secret }),
152
+ link: linkFor(orgId, { secret: this.secret }),
153
+ invited: mine.length,
154
+ qualified: mine.filter((r) => r.qualifiedAt).length,
155
+ rewardTotal: mine.filter((r) => r.rewarded).length * REWARD.amount,
156
+ rewardKind: REWARD.kind,
157
+ referrals: mine.map((r) => ({
158
+ referredOrg: r.referredOrg,
159
+ attributedAt: r.attributedAt,
160
+ qualifiedAt: r.qualifiedAt,
161
+ qualifyingEvent: r.qualifyingEvent,
162
+ rewarded: r.rewarded,
163
+ })),
164
+ };
165
+ }
166
+
167
+ /** Every referral event, for the audit trail. */
168
+ events() {
169
+ return [...this.#events];
170
+ }
171
+
172
+ #emit(kind, rec, at) {
173
+ const ev = {
174
+ kind, at,
175
+ referrerOrg: rec.referrerOrg, referredOrg: rec.referredOrg,
176
+ qualifyingEvent: rec.qualifyingEvent ?? null,
177
+ reward: rec.rewarded ? REWARD.kind : null,
178
+ };
179
+ this.#events.push(ev);
180
+ this.onEvent(ev);
181
+ }
182
+ }
183
+
184
+ /** A fresh secret for deriving codes. */
185
+ export function newReferralSecret() {
186
+ return randomBytes(32).toString("hex");
187
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Universal Agent Sandbox Abstraction.
3
+ *
4
+ * Provides isolation boundaries for agent executions across:
5
+ * - Filesystem root confinement
6
+ * - Process memory and timeout limits
7
+ * - Network destination allowlisting / link-local blocking
8
+ * - Adapter interfaces for Claude Code, Cursor, LangChain, CrewAI, MCP
9
+ */
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { resolve, relative, isAbsolute } from "node:path";
13
+
14
+ export const SANDBOX_ADAPTERS = {
15
+ CLAUDE_CODE: "claude_code",
16
+ CURSOR: "cursor",
17
+ LANGCHAIN: "langchain",
18
+ CREWAI: "crewai",
19
+ MCP: "mcp",
20
+ GENERIC_PROCESS: "generic_process",
21
+ };
22
+
23
+ export class AgentSandbox {
24
+ constructor({
25
+ adapter = SANDBOX_ADAPTERS.GENERIC_PROCESS,
26
+ fsRoot = process.cwd(),
27
+ timeoutMs = 30000,
28
+ maxMemoryMb = 512,
29
+ allowedDomains = [],
30
+ env = {},
31
+ } = {}) {
32
+ this.adapter = adapter;
33
+ this.fsRoot = resolve(fsRoot);
34
+ this.timeoutMs = timeoutMs;
35
+ this.maxMemoryMb = maxMemoryMb;
36
+ this.allowedDomains = new Set(allowedDomains.map((d) => d.toLowerCase()));
37
+ this.env = { ...env };
38
+ this.status = "ready";
39
+ }
40
+
41
+ /**
42
+ * Validates whether a file access is strictly within the sandbox filesystem boundary.
43
+ */
44
+ checkPathAccess(filePath) {
45
+ const target = isAbsolute(filePath) ? resolve(filePath) : resolve(this.fsRoot, filePath);
46
+ const rel = relative(this.fsRoot, target);
47
+ const isEscaped = rel.startsWith("..") || isAbsolute(rel);
48
+ return {
49
+ allowed: !isEscaped,
50
+ resolvedPath: target,
51
+ reason: isEscaped ? `Path '${filePath}' escapes sandbox boundary '${this.fsRoot}'` : "Path inside sandbox boundary",
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Validates whether a network destination is permitted.
57
+ */
58
+ checkNetworkAccess(destinationUrl) {
59
+ try {
60
+ const url = new URL(destinationUrl);
61
+ const host = url.hostname.toLowerCase();
62
+
63
+ // Block link-local and cloud metadata by default
64
+ if (
65
+ host === "169.254.169.254" ||
66
+ host === "metadata.google.internal" ||
67
+ host === "100.100.100.200"
68
+ ) {
69
+ return {
70
+ allowed: false,
71
+ reason: "Access to cloud metadata endpoints is strictly blocked in sandbox",
72
+ };
73
+ }
74
+
75
+ // If allowedDomains is specified, enforce allowlist
76
+ if (this.allowedDomains.size > 0 && !this.allowedDomains.has(host)) {
77
+ return {
78
+ allowed: false,
79
+ reason: `Host '${host}' is not in sandbox allowed network destinations`,
80
+ };
81
+ }
82
+
83
+ return { allowed: true, reason: "Network destination permitted" };
84
+ } catch {
85
+ return { allowed: false, reason: `Malformed destination URL '${destinationUrl}'` };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Executes a command within the configured sandbox bounds.
91
+ */
92
+ async execute(command, args = [], { cwd = this.fsRoot } = {}) {
93
+ const pathCheck = this.checkPathAccess(cwd);
94
+ if (!pathCheck.allowed) {
95
+ throw new Error(`Execution cwd rejected: ${pathCheck.reason}`);
96
+ }
97
+
98
+ return new Promise((resolveResult, reject) => {
99
+ let timedOut = false;
100
+ const timer = setTimeout(() => {
101
+ timedOut = true;
102
+ proc.kill("SIGKILL");
103
+ reject(new Error(`Sandbox execution exceeded timeout of ${this.timeoutMs}ms`));
104
+ }, this.timeoutMs);
105
+
106
+ const proc = spawn(command, args, {
107
+ cwd: pathCheck.resolvedPath,
108
+ env: {
109
+ ...process.env,
110
+ ...this.env,
111
+ CIRVIX_SANDBOX_ACTIVE: "1",
112
+ },
113
+ shell: false, // Prevents shell injection by default
114
+ });
115
+
116
+ let stdout = "";
117
+ let stderr = "";
118
+
119
+ proc.stdout?.on("data", (d) => (stdout += d.toString()));
120
+ proc.stderr?.on("data", (d) => (stderr += d.toString()));
121
+
122
+ proc.on("error", (err) => {
123
+ clearTimeout(timer);
124
+ reject(err);
125
+ });
126
+
127
+ proc.on("close", (code) => {
128
+ clearTimeout(timer);
129
+ if (timedOut) return;
130
+ resolveResult({
131
+ code,
132
+ stdout,
133
+ stderr,
134
+ ok: code === 0,
135
+ });
136
+ });
137
+ });
138
+ }
139
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Stateful Session Security & Action Chain Reasoning.
3
+ *
4
+ * Prevents multi-step attacks where each individual call may appear innocuous
5
+ * in isolation, but the sequence forms an exfiltration or reconnaissance attack.
6
+ *
7
+ * Example:
8
+ * READ customer.csv (allowed)
9
+ * ↓
10
+ * compress / encode data (allowed)
11
+ * ↓
12
+ * POST external endpoint (BLOCKED: Data Exfiltration Chain)
13
+ */
14
+
15
+ import { RISK } from "./risk.mjs";
16
+
17
+ export const CHAIN_TYPES = {
18
+ EXFILTRATION: "DATA_EXFILTRATION",
19
+ RECONNAISSANCE: "RECONNAISSANCE",
20
+ PRIVILEGE_ESCALATION: "PRIVILEGE_ESCALATION",
21
+ };
22
+
23
+ /**
24
+ * Tracks session state, action history, taint propagation, and risk accumulation.
25
+ */
26
+ export class SessionTracker {
27
+ constructor(sessionId, { agentId = null, maxHistory = 100 } = {}) {
28
+ this.sessionId = sessionId;
29
+ this.agentId = agentId;
30
+ this.history = [];
31
+ this.maxHistory = maxHistory;
32
+ this.cumulativeRisk = 0;
33
+ this.taint = {
34
+ readSensitiveData: false,
35
+ readSecrets: false,
36
+ encodedData: false,
37
+ accessedInternalNetwork: false,
38
+ };
39
+ this.status = "active"; // 'active' | 'quarantined' | 'terminated'
40
+ this.quarantineReason = null;
41
+ this.detectedChains = [];
42
+ }
43
+
44
+ /**
45
+ * Records a step in the session and analyzes emerging action chains.
46
+ *
47
+ * @param {Object} step
48
+ * @param {string} step.action - e.g. "fs:read", "net:post", "exec:run"
49
+ * @param {string} step.resource - Target path or URL
50
+ * @param {string} step.tool - Tool name
51
+ * @param {string} step.decision - ALLOW, DENY, etc.
52
+ * @param {string} step.risk - LOW, MEDIUM, HIGH, CRITICAL
53
+ * @returns {{ suspicious: boolean, chainDetected?: string, reason?: string, risk: number }}
54
+ */
55
+ recordStep({ action, resource = "", tool = "", decision = "ALLOW", risk = RISK.LOW }) {
56
+ const ts = new Date().toISOString();
57
+ const entry = { action, resource, tool, decision, risk, ts };
58
+
59
+ this.history.push(entry);
60
+ if (this.history.length > this.maxHistory) {
61
+ this.history.shift();
62
+ }
63
+
64
+ // Accumulate risk based on severity
65
+ const rLower = String(risk ?? "low").toLowerCase();
66
+ const riskPoints = {
67
+ low: 1,
68
+ medium: 5,
69
+ high: 15,
70
+ critical: 40,
71
+ }[rLower] ?? 1;
72
+
73
+ this.cumulativeRisk = Math.min(100, this.cumulativeRisk + riskPoints);
74
+
75
+ // Update taint flags
76
+ const resLower = String(resource).toLowerCase();
77
+ const actLower = String(action).toLowerCase();
78
+
79
+ if (
80
+ actLower.includes("read") &&
81
+ (resLower.includes("customer") ||
82
+ resLower.includes(".env") ||
83
+ resLower.includes("secret") ||
84
+ resLower.includes("token") ||
85
+ resLower.includes("credential") ||
86
+ resLower.includes("users") ||
87
+ resLower.includes("passwd"))
88
+ ) {
89
+ this.taint.readSensitiveData = true;
90
+ }
91
+
92
+ if (actLower.includes("secret") || resLower.includes("secret") || resLower.includes("key")) {
93
+ this.taint.readSecrets = true;
94
+ }
95
+
96
+ if (
97
+ actLower.includes("compress") ||
98
+ actLower.includes("encode") ||
99
+ resLower.includes(".tar") ||
100
+ resLower.includes(".zip") ||
101
+ resLower.includes("base64")
102
+ ) {
103
+ this.taint.encodedData = true;
104
+ }
105
+
106
+ if (actLower.includes("net") || resLower.startsWith("http://") || resLower.startsWith("https://")) {
107
+ if (resLower.includes("169.254.") || resLower.includes("10.") || resLower.includes("192.168.")) {
108
+ this.taint.accessedInternalNetwork = true;
109
+ }
110
+ }
111
+
112
+ // Check for exfiltration chain:
113
+ // Session previously read sensitive data/secrets -> now attempting external egress
114
+ const isEgress = actLower.includes("net") || actLower.includes("egress") || actLower.includes("post") || actLower.includes("curl");
115
+ const isExternalDest = resLower.startsWith("http://") || resLower.startsWith("https://");
116
+
117
+ if ((this.taint.readSensitiveData || this.taint.readSecrets) && isEgress && isExternalDest) {
118
+ const chain = {
119
+ type: CHAIN_TYPES.EXFILTRATION,
120
+ detectedAt: ts,
121
+ reason: `Exfiltration sequence detected: Agent read sensitive resources earlier in session and is now egressing to '${resource}'`,
122
+ };
123
+ this.detectedChains.push(chain);
124
+ return {
125
+ suspicious: true,
126
+ chainDetected: CHAIN_TYPES.EXFILTRATION,
127
+ reason: chain.reason,
128
+ risk: this.cumulativeRisk,
129
+ };
130
+ }
131
+
132
+ // Check for reconnaissance chain:
133
+ // Rapid multiple reads of environment/user tables followed by command execution
134
+ if (this.history.length >= 3) {
135
+ const last3 = this.history.slice(-3);
136
+ const isReconSequence =
137
+ last3[0].action.includes("read") &&
138
+ last3[1].action.includes("read") &&
139
+ last3[2].action.includes("exec");
140
+
141
+ if (isReconSequence && (this.taint.readSecrets || this.taint.readSensitiveData)) {
142
+ const chain = {
143
+ type: CHAIN_TYPES.RECONNAISSANCE,
144
+ detectedAt: ts,
145
+ reason: "Reconnaissance sequence: Probing sensitive files followed by shell command execution",
146
+ };
147
+ this.detectedChains.push(chain);
148
+ return {
149
+ suspicious: true,
150
+ chainDetected: CHAIN_TYPES.RECONNAISSANCE,
151
+ reason: chain.reason,
152
+ risk: this.cumulativeRisk,
153
+ };
154
+ }
155
+ }
156
+
157
+ return {
158
+ suspicious: false,
159
+ risk: this.cumulativeRisk,
160
+ };
161
+ }
162
+
163
+ quarantine(reason) {
164
+ this.status = "quarantined";
165
+ this.quarantineReason = reason;
166
+ }
167
+
168
+ terminate(reason) {
169
+ this.status = "terminated";
170
+ this.quarantineReason = reason;
171
+ }
172
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Shadow Mode Evaluator.
3
+ *
4
+ * Non-blocking policy evaluation allowing real agent actions to proceed
5
+ * while recording counterfactual decisions ("Would Allow", "Would Block",
6
+ * "Would Require Approval", "Would Quarantine") for safe policy rollout.
7
+ */
8
+
9
+ import { DECISION } from "./decisions.mjs";
10
+
11
+ export class ShadowEngine {
12
+ constructor({ logSink = () => {} } = {}) {
13
+ this.logSink = logSink;
14
+ this.metrics = {
15
+ total: 0,
16
+ wouldAllow: 0,
17
+ wouldBlock: 0,
18
+ wouldRequireApproval: 0,
19
+ wouldQuarantine: 0,
20
+ policiesTriggered: new Map(),
21
+ };
22
+ this.shadowLog = [];
23
+ }
24
+
25
+ /**
26
+ * Evaluates what Cirvix WOULD have decided without blocking execution.
27
+ *
28
+ * @param {Object} callParams - Request details
29
+ * @param {Function} policyEvaluator - Underlying evaluator function returning decision
30
+ * @returns {Object} Hypothetical decision record
31
+ */
32
+ evaluateShadow(callParams, policyEvaluator) {
33
+ const ts = new Date().toISOString();
34
+ let hypotheticalDecision = DECISION.ALLOW;
35
+ let ruleMatched = null;
36
+ let riskLevel = "LOW";
37
+ let reason = "Hypothetical shadow evaluation";
38
+
39
+ try {
40
+ const evaluation = policyEvaluator(callParams);
41
+ hypotheticalDecision = evaluation.verdict ?? evaluation.decision ?? DECISION.ALLOW;
42
+ ruleMatched = evaluation.rule ?? null;
43
+ riskLevel = evaluation.risk ?? "LOW";
44
+ reason = evaluation.reason ?? reason;
45
+ } catch (err) {
46
+ hypotheticalDecision = DECISION.DENY;
47
+ reason = `Shadow evaluation error: ${err.message}`;
48
+ }
49
+
50
+ this.metrics.total += 1;
51
+ if (hypotheticalDecision === DECISION.ALLOW) {
52
+ this.metrics.wouldAllow += 1;
53
+ } else if (hypotheticalDecision === DECISION.DENY) {
54
+ this.metrics.wouldBlock += 1;
55
+ } else if (hypotheticalDecision === DECISION.REQUIRE_APPROVAL) {
56
+ this.metrics.wouldRequireApproval += 1;
57
+ } else if (hypotheticalDecision === DECISION.QUARANTINE) {
58
+ this.metrics.wouldQuarantine += 1;
59
+ }
60
+
61
+ if (ruleMatched) {
62
+ const count = this.metrics.policiesTriggered.get(ruleMatched) ?? 0;
63
+ this.metrics.policiesTriggered.set(ruleMatched, count + 1);
64
+ }
65
+
66
+ const record = {
67
+ ts,
68
+ call: callParams,
69
+ hypotheticalDecision,
70
+ ruleMatched,
71
+ riskLevel,
72
+ reason,
73
+ };
74
+
75
+ this.shadowLog.push(record);
76
+ if (this.shadowLog.length > 500) {
77
+ this.shadowLog.shift();
78
+ }
79
+
80
+ this.logSink(record);
81
+ return record;
82
+ }
83
+
84
+ getSummary() {
85
+ return {
86
+ totalEvaluated: this.metrics.total,
87
+ wouldAllow: this.metrics.wouldAllow,
88
+ wouldBlock: this.metrics.wouldBlock,
89
+ wouldRequireApproval: this.metrics.wouldRequireApproval,
90
+ wouldQuarantine: this.metrics.wouldQuarantine,
91
+ triggeredPolicies: Object.fromEntries(this.metrics.policiesTriggered),
92
+ logSnippet: this.shadowLog.slice(-10),
93
+ };
94
+ }
95
+ }