@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/guard.mjs
CHANGED
|
@@ -29,7 +29,9 @@ import { classify } from "./risk.mjs";
|
|
|
29
29
|
import { classifyTool, extractCommand, publicToolName } from "./normalize.mjs";
|
|
30
30
|
import { scan as scanSecrets } from "./secret-detect.mjs";
|
|
31
31
|
import { applyDelegation } from "./delegation.mjs";
|
|
32
|
+
import { assessAuthority, applyAuthority } from "./authority.mjs";
|
|
32
33
|
import { applyEntitlements } from "./entitlement-gate.mjs";
|
|
34
|
+
import { SessionTaint, assessTrifecta, applyTrifecta } from "./trifecta.mjs";
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* A refusal the agent can read and plan around.
|
|
@@ -176,6 +178,12 @@ export class Guard {
|
|
|
176
178
|
runId = null,
|
|
177
179
|
riskFloor = "high",
|
|
178
180
|
delegation = null,
|
|
181
|
+
/* Mission-scoped authority. Absent by default, and absent means INERT —
|
|
182
|
+
not "deny everything". Authority is subtractive: it can take away what
|
|
183
|
+
policy allows and can never add to it, so a Guard built without a
|
|
184
|
+
mission behaves exactly as before. See core/authority.mjs. */
|
|
185
|
+
missions = null,
|
|
186
|
+
mission = null,
|
|
179
187
|
/* Commercial enforcement. All three default to absent, so a Guard built
|
|
180
188
|
without them behaves exactly as before — which is what keeps the SDK's
|
|
181
189
|
library callers and the shared conformance fixture working unchanged.
|
|
@@ -192,6 +200,9 @@ export class Guard {
|
|
|
192
200
|
this.secrets = secrets;
|
|
193
201
|
/** DelegationBroker, when agent-to-agent delegation is in use. */
|
|
194
202
|
this.delegation = delegation;
|
|
203
|
+
/** MissionRegistry, and/or a single mission this Guard always acts under. */
|
|
204
|
+
this.missions = missions;
|
|
205
|
+
this.mission = mission;
|
|
195
206
|
this.licence = licence;
|
|
196
207
|
this.meter = meter;
|
|
197
208
|
this.agents = agents;
|
|
@@ -200,12 +211,20 @@ export class Guard {
|
|
|
200
211
|
this.runId = runId;
|
|
201
212
|
/** Risk level at or above which an unnamed call is escalated to approval. */
|
|
202
213
|
this.riskFloor = riskFloor;
|
|
203
|
-
/**
|
|
204
|
-
this.
|
|
214
|
+
/** Sequence taint tracking for Lethal Trifecta. */
|
|
215
|
+
this.taint = new SessionTaint();
|
|
205
216
|
this.stats = { calls: 0, permitted: 0, denied: 0, held: 0, leaks: 0, latencyTotal: 0 };
|
|
206
217
|
this.nextId = 1;
|
|
207
218
|
}
|
|
208
219
|
|
|
220
|
+
get touchedSecret() {
|
|
221
|
+
return this.taint.touchedSecret;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
set touchedSecret(value) {
|
|
225
|
+
this.taint.touchedSecret = value;
|
|
226
|
+
}
|
|
227
|
+
|
|
209
228
|
/**
|
|
210
229
|
* Decides one call, and brokers any secret handles it carries.
|
|
211
230
|
*
|
|
@@ -215,7 +234,8 @@ export class Guard {
|
|
|
215
234
|
*
|
|
216
235
|
* @returns {Promise<{decision:object, record:object, args:any}>}
|
|
217
236
|
*/
|
|
218
|
-
async authorize({ tool, server = null, args
|
|
237
|
+
async authorize({ tool, server = null, args, arguments: callArguments, delegation = null, agent = null }) {
|
|
238
|
+
args = args ?? callArguments ?? {};
|
|
219
239
|
const action = actionForTool(server, tool);
|
|
220
240
|
const resource = resourceForCall(args);
|
|
221
241
|
// A caller may act as a specific agent per call — a gateway serving several
|
|
@@ -271,7 +291,7 @@ export class Guard {
|
|
|
271
291
|
secrets: { detected: scanned.length },
|
|
272
292
|
};
|
|
273
293
|
|
|
274
|
-
|
|
294
|
+
let decision = evaluate(
|
|
275
295
|
{ agent: caller, action, resource, context },
|
|
276
296
|
this.rules,
|
|
277
297
|
{ cwd: this.cwd },
|
|
@@ -308,6 +328,88 @@ export class Guard {
|
|
|
308
328
|
resource: decision.resource ?? resource,
|
|
309
329
|
});
|
|
310
330
|
|
|
331
|
+
/*
|
|
332
|
+
* AUTHORITY RUNS HERE, ON THE SAME PATH AS EVERYTHING ELSE.
|
|
333
|
+
*
|
|
334
|
+
* Mission, capability, constraint and expiry are evaluated for every call,
|
|
335
|
+
* not only for the ones a caller remembers to check. Placing it beside
|
|
336
|
+
* delegation is deliberate: both answer "does this principal actually hold
|
|
337
|
+
* the authority it is exercising", both can only narrow, and both have to
|
|
338
|
+
* be on the ONE path that `guard.wrap()`, the MCP gateway and the socket
|
|
339
|
+
* all go through. The three bypasses documented above this line were all
|
|
340
|
+
* the same mistake — a check that lived on one surface and not the others —
|
|
341
|
+
* and an authority layer with that shape would be worse than none, because
|
|
342
|
+
* the console would show a boundary the runtime was not enforcing.
|
|
343
|
+
*
|
|
344
|
+
* `assessAuthority` is pure. It reads the mission and reports; it does not
|
|
345
|
+
* spend the budget. Usage is recorded below and only for a call that was
|
|
346
|
+
* actually permitted, so a refused call cannot exhaust the allowance it was
|
|
347
|
+
* refused under — otherwise every constraint doubles as a denial-of-service
|
|
348
|
+
* against the agent's real work.
|
|
349
|
+
*/
|
|
350
|
+
const activeMission =
|
|
351
|
+
this.mission ?? (this.missions ? this.missions.forAgent(caller) : null);
|
|
352
|
+
|
|
353
|
+
const authorityAssessment = assessAuthority(
|
|
354
|
+
{
|
|
355
|
+
agent: caller,
|
|
356
|
+
action,
|
|
357
|
+
resource: decision.resource ?? resource,
|
|
358
|
+
tool,
|
|
359
|
+
server,
|
|
360
|
+
destination: destinationFor(decision.resource ?? resource, args),
|
|
361
|
+
environment: this.environment,
|
|
362
|
+
costUsd: args?.costUsd ?? 0,
|
|
363
|
+
delegating: Boolean(delegation),
|
|
364
|
+
},
|
|
365
|
+
activeMission,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const authorityContext = applyAuthority(decision, authorityAssessment);
|
|
369
|
+
|
|
370
|
+
/*
|
|
371
|
+
* An attempt is recorded whether or not authority is what refused it.
|
|
372
|
+
*
|
|
373
|
+
* A call policy already denied is still an agent reaching outside its
|
|
374
|
+
* boundary, and if only authority-attributed refusals were counted an
|
|
375
|
+
* agent could probe the boundary for free by choosing actions policy
|
|
376
|
+
* denies anyway. The benchmark scores attempts, not attributions.
|
|
377
|
+
*/
|
|
378
|
+
if (this.missions && authorityAssessment.applicable && !authorityAssessment.authorized) {
|
|
379
|
+
this.missions.recordEscape({
|
|
380
|
+
missionId: activeMission?.id ?? null,
|
|
381
|
+
agent: caller,
|
|
382
|
+
kind: authorityAssessment.escape?.kind ?? null,
|
|
383
|
+
stage: authorityAssessment.stage,
|
|
384
|
+
code: authorityAssessment.code,
|
|
385
|
+
action,
|
|
386
|
+
resource: decision.resource ?? resource,
|
|
387
|
+
tool,
|
|
388
|
+
reason: authorityAssessment.reason,
|
|
389
|
+
blocked: decision.verdict === "deny" || decision.verdict === "hold",
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/*
|
|
394
|
+
* Sequence-aware enforcement (Lethal Trifecta) in Guard.
|
|
395
|
+
* Prevents untrusted content + sensitive data read + outbound egress.
|
|
396
|
+
*/
|
|
397
|
+
const trifectaCall = {
|
|
398
|
+
action,
|
|
399
|
+
resource: decision.resource ?? resource,
|
|
400
|
+
tool,
|
|
401
|
+
server,
|
|
402
|
+
destination: destinationFor(decision.resource ?? resource, args),
|
|
403
|
+
environment: this.environment,
|
|
404
|
+
egress: this.isExternal(decision.resource ?? resource) ? "external" : "none",
|
|
405
|
+
timestamp: new Date().toISOString(),
|
|
406
|
+
sql: typeof args?.sql === "string" ? args.sql : typeof args?.query === "string" ? args.query : null,
|
|
407
|
+
secretsDetected: scanned.length,
|
|
408
|
+
};
|
|
409
|
+
const trifecta = assessTrifecta(trifectaCall, this.taint);
|
|
410
|
+
decision = applyTrifecta(decision, trifecta);
|
|
411
|
+
decision.trifecta = { complete: trifecta.complete, satisfied: trifecta.satisfied, imminent: trifecta.imminent };
|
|
412
|
+
|
|
311
413
|
/*
|
|
312
414
|
* THE COMMERCIAL GATE RUNS HERE TOO, NOT ONLY IN THE PIPELINE.
|
|
313
415
|
*
|
|
@@ -391,7 +493,12 @@ export class Guard {
|
|
|
391
493
|
// Who authorized this must be answerable after the fact, on every surface
|
|
392
494
|
// — not only the one that happened to record it.
|
|
393
495
|
...(delegationContext ? { delegation: delegationContext } : {}),
|
|
496
|
+
// Authority is part of the record for the same reason delegation is:
|
|
497
|
+
// "who authorized this" must be answerable after the fact.
|
|
498
|
+
...(authorityContext ? { authority: authorityContext } : {}),
|
|
499
|
+
...(decision.escape ? { escape: decision.escape } : {}),
|
|
394
500
|
...(brokered.length ? { secrets: brokered, secrets_brokered: brokered } : {}),
|
|
501
|
+
...(decision.trifecta ? { trifecta: decision.trifecta } : {}),
|
|
395
502
|
// Findings never carry the value — see secret-detect.mjs.
|
|
396
503
|
...(scanned.length
|
|
397
504
|
? {
|
|
@@ -413,6 +520,13 @@ export class Guard {
|
|
|
413
520
|
else if (decision.verdict === "hold") this.stats.held++;
|
|
414
521
|
else {
|
|
415
522
|
this.stats.permitted++;
|
|
523
|
+
// Budget and rate are consumed by calls that HAPPEN. See the note above
|
|
524
|
+
// `assessAuthority`: charging a refused call would let a blocked agent
|
|
525
|
+
// exhaust its own mission.
|
|
526
|
+
if (this.missions && activeMission) {
|
|
527
|
+
this.missions.record(activeMission.id, { costUsd: args?.costUsd ?? 0 });
|
|
528
|
+
}
|
|
529
|
+
this.taint.observeCall(trifectaCall, true);
|
|
416
530
|
// Any successful read of secret-shaped material taints the session. A
|
|
417
531
|
// brokered substitution deliberately does not: the agent never held the
|
|
418
532
|
// material, which is the entire point of a handle.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent-Aware Agent Firewall.
|
|
3
|
+
*
|
|
4
|
+
* Implements: CAPABILITY + AUTHORITY + INTENT + CONTEXT = AUTHORIZED ACTION
|
|
5
|
+
*
|
|
6
|
+
* Rather than evaluating tools in isolation (`tool == allowed`), an agent's
|
|
7
|
+
* declared mission/purpose governs what it may reach for.
|
|
8
|
+
*
|
|
9
|
+
* Example:
|
|
10
|
+
* Mission: "Fix checkout test failures"
|
|
11
|
+
* Allowed: read source, run tests, modify checkout files
|
|
12
|
+
* Suspicious: read production credentials, drop database, upload code to external IP
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { RISK } from "./risk.mjs";
|
|
16
|
+
|
|
17
|
+
/** Semantic categories of intent. */
|
|
18
|
+
export const INTENT_CATEGORIES = {
|
|
19
|
+
TESTING: "testing",
|
|
20
|
+
DEVELOPMENT: "development",
|
|
21
|
+
MAINTENANCE: "maintenance",
|
|
22
|
+
REPORTING: "reporting",
|
|
23
|
+
DATABASE_ADMIN: "database_admin",
|
|
24
|
+
DEPLOYMENT: "deployment",
|
|
25
|
+
GENERAL: "general",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** High-risk actions that require explicit intent alignment. */
|
|
29
|
+
const RESTRICTED_ACTIONS = {
|
|
30
|
+
"secret:read": ["credential_management", "auth_setup"],
|
|
31
|
+
"db:drop": ["database_admin", "migration_rollback"],
|
|
32
|
+
"db:write": ["database_admin", "data_migration", "development"],
|
|
33
|
+
"shell:destructive": ["system_admin"],
|
|
34
|
+
"net:external_egress": ["data_sync", "api_integration", "deployment"],
|
|
35
|
+
"iam:modify": ["cloud_admin", "iam_setup"],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Classifies a declared intent string into dominant categories and allowed scopes.
|
|
40
|
+
*/
|
|
41
|
+
export function classifyIntent(intentText) {
|
|
42
|
+
if (!intentText || typeof intentText !== "string") {
|
|
43
|
+
return {
|
|
44
|
+
category: INTENT_CATEGORIES.GENERAL,
|
|
45
|
+
keywords: [],
|
|
46
|
+
sensitivityFloor: RISK.LOW,
|
|
47
|
+
allowedActions: ["*"],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const text = intentText.toLowerCase();
|
|
52
|
+
const keywords = text.match(/\b\w{3,}\b/g) ?? [];
|
|
53
|
+
|
|
54
|
+
if (/\b(test|spec|assert|coverage|jest|mocha|pytest|unit)\b/.test(text)) {
|
|
55
|
+
return {
|
|
56
|
+
category: INTENT_CATEGORIES.TESTING,
|
|
57
|
+
keywords,
|
|
58
|
+
sensitivityFloor: RISK.LOW,
|
|
59
|
+
disallowedActions: ["secret:read", "db:drop", "net:external_egress", "iam:modify"],
|
|
60
|
+
allowedActions: ["fs:read", "fs:write", "exec:test", "exec:dev"],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (/\b(fix|bug|refactor|feature|implement|code|frontend|backend)\b/.test(text)) {
|
|
65
|
+
return {
|
|
66
|
+
category: INTENT_CATEGORIES.DEVELOPMENT,
|
|
67
|
+
keywords,
|
|
68
|
+
sensitivityFloor: RISK.LOW,
|
|
69
|
+
disallowedActions: ["secret:read", "db:drop", "iam:modify"],
|
|
70
|
+
allowedActions: ["fs:read", "fs:write", "exec:dev", "net:fetch"],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (/\b(deploy|release|ship|staging|production|publish)\b/.test(text)) {
|
|
75
|
+
return {
|
|
76
|
+
category: INTENT_CATEGORIES.DEPLOYMENT,
|
|
77
|
+
keywords,
|
|
78
|
+
sensitivityFloor: RISK.HIGH,
|
|
79
|
+
disallowedActions: ["db:drop"],
|
|
80
|
+
allowedActions: ["fs:read", "net:external_egress", "exec:deploy"],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (/\b(migrate|schema|table|sql|database|query|seed)\b/.test(text)) {
|
|
85
|
+
return {
|
|
86
|
+
category: INTENT_CATEGORIES.DATABASE_ADMIN,
|
|
87
|
+
keywords,
|
|
88
|
+
sensitivityFloor: RISK.HIGH,
|
|
89
|
+
disallowedActions: ["iam:modify"],
|
|
90
|
+
allowedActions: ["db:read", "db:write", "fs:read"],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
category: INTENT_CATEGORIES.GENERAL,
|
|
96
|
+
keywords,
|
|
97
|
+
sensitivityFloor: RISK.LOW,
|
|
98
|
+
disallowedActions: ["db:drop", "iam:modify"],
|
|
99
|
+
allowedActions: ["*"],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Evaluates whether a requested action aligns with the agent's declared intent.
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} params
|
|
107
|
+
* @param {string} params.intent - Declared mission or task description
|
|
108
|
+
* @param {string} params.action - Canonical action identifier (e.g. "fs:read", "secret:read")
|
|
109
|
+
* @param {string} params.resource - Resource target (e.g. "/etc/passwd", "src/auth.ts")
|
|
110
|
+
* @param {string} params.tool - Tool name invoked
|
|
111
|
+
* @param {Object} params.context - Execution context
|
|
112
|
+
* @returns {{ aligned: boolean, intentScore: number, reason: string, category: string }}
|
|
113
|
+
*/
|
|
114
|
+
export function evaluateIntent({ intent, action, resource = "", tool = "", context = {} }) {
|
|
115
|
+
const classification = classifyIntent(intent);
|
|
116
|
+
|
|
117
|
+
// If action is explicitly restricted, verify if intent permits it
|
|
118
|
+
const restrictedFor = RESTRICTED_ACTIONS[action];
|
|
119
|
+
if (restrictedFor && !restrictedFor.includes(classification.category)) {
|
|
120
|
+
return {
|
|
121
|
+
aligned: false,
|
|
122
|
+
intentScore: 0.1,
|
|
123
|
+
reason: `Action '${action}' is restricted and outside declared mission '${intent}' (${classification.category})`,
|
|
124
|
+
category: classification.category,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check disallowed actions for this intent category
|
|
129
|
+
if (classification.disallowedActions?.includes(action)) {
|
|
130
|
+
return {
|
|
131
|
+
aligned: false,
|
|
132
|
+
intentScore: 0.2,
|
|
133
|
+
reason: `Action '${action}' conflicts with declared mission scope (${classification.category})`,
|
|
134
|
+
category: classification.category,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Resource sensitivity check against intent
|
|
139
|
+
const isCredentialTarget = /(credential|\.env|id_rsa|secret|token|api[_-]?key|password)/i.test(resource);
|
|
140
|
+
if (isCredentialTarget && classification.category === INTENT_CATEGORIES.TESTING) {
|
|
141
|
+
return {
|
|
142
|
+
aligned: false,
|
|
143
|
+
intentScore: 0.15,
|
|
144
|
+
reason: `Agent attempted to access credentials ('${resource}') while declared mission is testing only`,
|
|
145
|
+
category: classification.category,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Production database deletion check
|
|
150
|
+
const isDropTarget = /(drop\s+table|delete\s+from|truncate|rm\s+-rf\s+\/)/i.test(resource);
|
|
151
|
+
if (isDropTarget && classification.category !== INTENT_CATEGORIES.DATABASE_ADMIN) {
|
|
152
|
+
return {
|
|
153
|
+
aligned: false,
|
|
154
|
+
intentScore: 0.05,
|
|
155
|
+
reason: `Destructive action against '${resource}' requires explicit administrative mission`,
|
|
156
|
+
category: classification.category,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
aligned: true,
|
|
162
|
+
intentScore: 0.95,
|
|
163
|
+
reason: `Action '${action}' against '${resource || tool}' aligns with mission '${classification.category}'`,
|
|
164
|
+
category: classification.category,
|
|
165
|
+
};
|
|
166
|
+
}
|
package/src/core/journal.mjs
CHANGED
|
@@ -198,23 +198,55 @@ function clock(ts) {
|
|
|
198
198
|
return m ? m[1] : s.slice(0, 8).padEnd(8);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
/** One record,
|
|
201
|
+
/** One record, compact or expanded per risk/decision (spec: ALLOW compact, DENY expanded). */
|
|
202
202
|
export function renderLine(record) {
|
|
203
203
|
if (record.malformed) {
|
|
204
204
|
return ` ${dim(String(record.line).padStart(4))} ${red("malformed record")} ${dim(record.raw.slice(0, 60))}`;
|
|
205
205
|
}
|
|
206
206
|
const decision = record.decision ?? toDecision(record.verdict);
|
|
207
207
|
const tone = toneFor(record);
|
|
208
|
-
const
|
|
208
|
+
const riskTone = RISK_TONE[record.risk] ?? dim;
|
|
209
|
+
const isCritical = String(record.risk ?? "").toLowerCase() === "critical" || String(record.risk ?? "").toLowerCase() === "high";
|
|
210
|
+
const isDenied = decision === DECISION.DENY;
|
|
211
|
+
const isHeld = decision === DECISION.REQUIRE_APPROVAL;
|
|
212
|
+
const clockStr = dim(clock(record.ts ?? record.timestamp));
|
|
213
|
+
const icon = decision === DECISION.ALLOW ? "✓" : decision === DECISION.SANITIZE ? "◈" : isDenied ? "✕" : isHeld ? "⏸" : "·";
|
|
214
|
+
const label = String(decision).toUpperCase().replace(/_/g, " ");
|
|
215
|
+
const tool = String(record.tool ?? record.action ?? "—");
|
|
216
|
+
const resource = truncate(record.resource ?? record.command ?? "", 44);
|
|
217
|
+
const policy = String(record.policy ?? record.rule ?? "default-deny");
|
|
218
|
+
const latency = `${record.latency_ms ?? "—"}ms`;
|
|
219
|
+
|
|
220
|
+
// Expanded for DENY/CRITICAL/HOLD/HIGH — high visibility, spec layout:
|
|
221
|
+
// 22:38:53 ◇ SANITIZE HIGH network.request
|
|
222
|
+
// https://docs.example.com/deploy
|
|
223
|
+
// Policy: sanitize-fetched-content 16.82ms
|
|
224
|
+
if (isDenied || isHeld || isCritical) {
|
|
225
|
+
const iconLabel = isHeld ? "APPROVAL" : label; // REQUIRE_APPROVAL displays as APPROVAL per spec
|
|
226
|
+
const iconChar = isHeld ? "●" : icon;
|
|
227
|
+
const lines = [];
|
|
228
|
+
// First line: time + icon/verdict + risk + tool — columns aligned, decision strongest
|
|
229
|
+
lines.push(
|
|
230
|
+
` ${clockStr} ${tone(`${iconChar} ${iconLabel.padEnd(12)}`)} ${riskTone(String(record.risk ?? "—").toUpperCase().padEnd(9))} ${tool.padEnd(20)} ${dim(latency)}`,
|
|
231
|
+
);
|
|
232
|
+
if (resource) lines.push(` ${dim(resource)}`);
|
|
233
|
+
// Policy line — indented, secondary
|
|
234
|
+
lines.push(` ${dim(`Policy: ${policy}`)}`);
|
|
235
|
+
if (isHeld) {
|
|
236
|
+
lines.push(` ${amber("→ AWAITING APPROVAL")}`);
|
|
237
|
+
}
|
|
238
|
+
return lines.join("\n");
|
|
239
|
+
}
|
|
209
240
|
|
|
241
|
+
// Compact for ordinary ALLOW.
|
|
210
242
|
return [
|
|
211
|
-
` ${
|
|
212
|
-
tone(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
dim(
|
|
216
|
-
dim(
|
|
217
|
-
dim(
|
|
243
|
+
` ${clockStr}`,
|
|
244
|
+
tone(`${icon} ${label.padEnd(12)}`),
|
|
245
|
+
riskTone(String(record.risk ?? "—").toUpperCase().padEnd(8)),
|
|
246
|
+
tool.padEnd(20),
|
|
247
|
+
dim(resource.padEnd(44)),
|
|
248
|
+
dim(latency.padEnd(8)),
|
|
249
|
+
dim(policy),
|
|
218
250
|
].join(" ");
|
|
219
251
|
}
|
|
220
252
|
|
|
@@ -237,44 +269,103 @@ export function renderLine(record) {
|
|
|
237
269
|
export function renderTree(record, { indent = " " } = {}) {
|
|
238
270
|
const decision = record.decision ?? toDecision(record.verdict);
|
|
239
271
|
const tone = toneFor(record);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
272
|
+
const riskTone = RISK_TONE[record.risk] ?? dim;
|
|
273
|
+
const isDeny = decision === DECISION.DENY;
|
|
274
|
+
const isHold = decision === DECISION.REQUIRE_APPROVAL;
|
|
275
|
+
const isSanitize = decision === DECISION.SANITIZE;
|
|
276
|
+
|
|
277
|
+
// Header — spec: CIRVIX DECISION ANALYSIS
|
|
278
|
+
const lines = [];
|
|
279
|
+
lines.push(`${indent}${bold("CIRVIX DECISION ANALYSIS")}`);
|
|
280
|
+
lines.push("");
|
|
281
|
+
|
|
282
|
+
// Top: Decision + Risk — strongest visual
|
|
283
|
+
const decisionLabel = isDeny ? "✕ BLOCKED" : isHold ? "● AWAITING APPROVAL" : isSanitize ? "◇ SANITIZED" : "✓ " + String(decision).toUpperCase();
|
|
284
|
+
const decisionTone = isDeny ? red : isHold ? amber : isSanitize ? blue : green;
|
|
285
|
+
lines.push(`${indent}${dim("Decision".padEnd(12))} ${decisionTone(bold(decisionLabel))}${record.enforced === false ? dim(" (not enforced — audit mode)") : ""}`);
|
|
286
|
+
lines.push(`${indent}${dim("Risk".padEnd(12))} ${riskTone(bold(String(record.risk ?? "unknown").toUpperCase()))}${record.risk_signals?.length ? dim(` ${record.risk_signals.join(", ")}`) : ""}`);
|
|
287
|
+
lines.push("");
|
|
288
|
+
|
|
289
|
+
// Tool / Target / Policy / Latency / Request
|
|
290
|
+
lines.push(`${indent}${dim("Tool".padEnd(12))} ${bold(String(record.tool ?? record.action ?? "—"))}`);
|
|
291
|
+
if (record.resource || record.command) {
|
|
292
|
+
lines.push(`${indent}${dim("Target".padEnd(12))} ${truncate(record.resource || record.command || "", 70)}`);
|
|
252
293
|
}
|
|
253
|
-
if (record.
|
|
254
|
-
|
|
255
|
-
if (record.secrets_detected?.length) {
|
|
256
|
-
rows.push(["detected", record.secrets_detected.map((s) => `${s.detector} ${dim(s.masked)}`).join(", ")]);
|
|
294
|
+
if (record.destination) {
|
|
295
|
+
lines.push(`${indent}${dim("Destination".padEnd(12))} ${record.destination}`);
|
|
257
296
|
}
|
|
258
|
-
|
|
259
|
-
|
|
297
|
+
lines.push(`${indent}${dim("Policy".padEnd(12))} ${record.policy ?? record.rule ?? dim("— no rule matched (default deny)")}`);
|
|
298
|
+
lines.push(`${indent}${dim("Latency".padEnd(12))} ${record.latency_ms ?? "—"}ms`);
|
|
299
|
+
lines.push(`${indent}${dim("Request".padEnd(12))} ${dim(record.request_id ?? record.decision_id ?? "—")} ${dim(`agent ${record.agent ?? "—"}`)}`);
|
|
300
|
+
if (record.approval_id) {
|
|
301
|
+
lines.push(`${indent}${dim("Approval".padEnd(12))} ${blue(record.approval_id)}`);
|
|
260
302
|
}
|
|
261
|
-
|
|
262
|
-
rows.push(["result", decision === DECISION.DENY ? red("not forwarded") : decision === DECISION.REQUIRE_APPROVAL ? amber("held") : green("forwarded")]);
|
|
263
|
-
|
|
264
|
-
const width = Math.max(...rows.map(([k]) => k.length));
|
|
265
|
-
const lines = [
|
|
266
|
-
`${indent}${bold(record.agent ?? "agent")} ${dim(record.request_id ?? "")}`,
|
|
267
|
-
`${indent} └── ${bold(record.tool ?? record.action ?? "tool")}`,
|
|
268
|
-
];
|
|
269
|
-
rows.forEach(([key, value], i) => {
|
|
270
|
-
const branch = i === rows.length - 1 ? "└──" : "├──";
|
|
271
|
-
lines.push(`${indent} ${branch} ${dim(key.padEnd(width))} ${value}`);
|
|
272
|
-
});
|
|
303
|
+
lines.push("");
|
|
273
304
|
|
|
305
|
+
// Matched policy + Reason — if present
|
|
306
|
+
if (record.policy ?? record.rule) {
|
|
307
|
+
lines.push(`${indent}${dim("Matched policy")}`);
|
|
308
|
+
lines.push(`${indent} ${record.policy ?? record.rule}`);
|
|
309
|
+
lines.push("");
|
|
310
|
+
}
|
|
274
311
|
if (record.reason) {
|
|
312
|
+
lines.push(`${indent}${dim("Reason")}`);
|
|
313
|
+
lines.push(`${indent} ${wrap(record.reason, 70, `${indent} `)}`);
|
|
314
|
+
lines.push("");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Approval panel — for REQUIRE_APPROVAL, spec: HUMAN APPROVAL REQUIRED
|
|
318
|
+
if (isHold) {
|
|
319
|
+
const W = 48;
|
|
320
|
+
const top = `${indent}${dim(`╭─ HUMAN APPROVAL REQUIRED ${"─".repeat(Math.max(0, W - 26))}╮`)}`;
|
|
321
|
+
const bottom = `${indent}${dim(`╰${"─".repeat(W)}╯`)}`;
|
|
322
|
+
lines.push(top);
|
|
323
|
+
lines.push(`${indent}${dim("│")} ${dim("Agent".padEnd(10))} ${record.agent ?? "—"} ${dim("│")}`);
|
|
324
|
+
lines.push(`${indent}${dim("│")} ${dim("Action".padEnd(10))} ${record.action ?? record.tool ?? "—"} ${dim("│")}`);
|
|
325
|
+
lines.push(`${indent}${dim("│")} ${dim("Target".padEnd(10))} ${truncate(record.resource ?? "", 30)} ${dim("│")}`);
|
|
326
|
+
lines.push(`${indent}${dim("│")} ${"".padEnd(42)} ${dim("│")}`);
|
|
327
|
+
lines.push(`${indent}${dim("│")} ${dim("Risk".padEnd(10))} ${amber(String(record.risk ?? "").toUpperCase())} ${dim("│")}`);
|
|
328
|
+
lines.push(`${indent}${dim("│")} ${dim("Policy".padEnd(10))} ${record.policy ?? record.rule ?? "—"} ${dim("│")}`);
|
|
329
|
+
lines.push(`${indent}${dim("│")} ${"".padEnd(42)} ${dim("│")}`);
|
|
330
|
+
lines.push(`${indent}${dim("│")} ${dim("Reason")} ${dim("│")}`);
|
|
331
|
+
lines.push(`${indent}${dim("│")} ${wrap(record.reason ?? "Production database mutation requires human authorization.", 42, `${indent}${dim("│")} `)} ${dim("│")}`);
|
|
332
|
+
lines.push(`${indent}${dim("│")} ${"".padEnd(42)} ${dim("│")}`);
|
|
333
|
+
lines.push(`${indent}${dim("│")} ${blue("[A] Approve")} ${dim(" ")} ${red("[R] Reject")} ${dim("│")}`);
|
|
334
|
+
lines.push(bottom);
|
|
275
335
|
lines.push("");
|
|
276
|
-
lines.push(`${indent} ${dim(wrap(record.reason, 84, `${indent} `))}`);
|
|
277
336
|
}
|
|
337
|
+
|
|
338
|
+
// Decision path — spec: secret detection → risk classification → policy evaluation → BLOCK
|
|
339
|
+
lines.push(`${indent}${dim("Decision path")}`);
|
|
340
|
+
lines.push(`${indent} ${dim("secret detection")}`);
|
|
341
|
+
lines.push(`${indent} ${dim("↓")}`);
|
|
342
|
+
lines.push(`${indent} ${dim("risk classification")}`);
|
|
343
|
+
lines.push(`${indent} ${dim("↓")}`);
|
|
344
|
+
lines.push(`${indent} ${dim("policy evaluation")}`);
|
|
345
|
+
lines.push(`${indent} ${dim("↓")}`);
|
|
346
|
+
lines.push(`${indent} ${decisionTone(isDeny ? "BLOCK" : isHold ? "HOLD" : isSanitize ? "SANITIZE" : "ALLOW")}`);
|
|
347
|
+
|
|
348
|
+
// Low-level details — keep for debugging, muted
|
|
349
|
+
const extra = [];
|
|
350
|
+
if (record.secrets_brokered?.length) extra.push(`secrets brokered: ${record.secrets_brokered.join(", ")}`);
|
|
351
|
+
if (record.secrets_detected?.length) extra.push(`detected: ${record.secrets_detected.map((s) => `${s.detector} ${s.masked}`).join(", ")}`);
|
|
352
|
+
if (record.sanitized?.arguments?.length) extra.push(`sanitized: ${record.sanitized.arguments.length} value(s) stripped`);
|
|
353
|
+
if (record.would_have) extra.push(`would have: ${String(record.would_have.decision).toUpperCase()} by ${record.would_have.rule ?? "default-deny"}`);
|
|
354
|
+
if (extra.length) {
|
|
355
|
+
lines.push("");
|
|
356
|
+
lines.push(`${indent}${dim(extra.join(" · "))}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Considered tree — compact, for dead-rule discovery (keep, muted)
|
|
360
|
+
if (record.considered?.length) {
|
|
361
|
+
lines.push("");
|
|
362
|
+
lines.push(`${indent}${dim("considered")} ${dim(`${record.considered.filter((c) => c.matched).length} of ${record.considered.length} matched`)}`);
|
|
363
|
+
for (const c of record.considered.slice(0, 12)) {
|
|
364
|
+
const marker = c.matched ? bold("→") : dim(" ");
|
|
365
|
+
lines.push(`${indent} ${marker} ${dim(String(c.effect).padEnd(11))} ${c.matched ? c.rule : dim(c.rule)}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
278
369
|
return lines.join("\n");
|
|
279
370
|
}
|
|
280
371
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Multi-Scope Emergency Kill Switch.
|
|
3
|
+
*
|
|
4
|
+
* Provides emergency freezing across scopes:
|
|
5
|
+
* - single agent
|
|
6
|
+
* - agent family
|
|
7
|
+
* - organization
|
|
8
|
+
* - environment
|
|
9
|
+
* - MCP server
|
|
10
|
+
* - credential
|
|
11
|
+
* - session
|
|
12
|
+
* - model
|
|
13
|
+
* - tool
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { DECISION } from "./decisions.mjs";
|
|
17
|
+
|
|
18
|
+
export const KILL_SCOPES = {
|
|
19
|
+
AGENT: "agent",
|
|
20
|
+
FAMILY: "family",
|
|
21
|
+
ORG: "org",
|
|
22
|
+
ENVIRONMENT: "environment",
|
|
23
|
+
MCP: "mcp",
|
|
24
|
+
CREDENTIAL: "credential",
|
|
25
|
+
SESSION: "session",
|
|
26
|
+
MODEL: "model",
|
|
27
|
+
TOOL: "tool",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class KillSwitchEngine {
|
|
31
|
+
constructor() {
|
|
32
|
+
this.activeRules = new Map(); // id -> rule
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Arms a kill switch.
|
|
37
|
+
*/
|
|
38
|
+
arm({ scope, target, reason = "Emergency freeze activated", triggeredBy = "system" }) {
|
|
39
|
+
if (!Object.values(KILL_SCOPES).includes(scope)) {
|
|
40
|
+
throw new Error(`Invalid kill switch scope '${scope}'`);
|
|
41
|
+
}
|
|
42
|
+
const id = `ks_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
43
|
+
const rule = {
|
|
44
|
+
id,
|
|
45
|
+
scope,
|
|
46
|
+
target: String(target).toLowerCase(),
|
|
47
|
+
reason,
|
|
48
|
+
triggeredBy,
|
|
49
|
+
armedAt: new Date().toISOString(),
|
|
50
|
+
active: true,
|
|
51
|
+
};
|
|
52
|
+
this.activeRules.set(id, rule);
|
|
53
|
+
return rule;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Disarms a kill switch.
|
|
58
|
+
*/
|
|
59
|
+
disarm(id) {
|
|
60
|
+
return this.activeRules.delete(id);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Checks whether a request matches any active kill rule.
|
|
65
|
+
*
|
|
66
|
+
* @param {Object} context
|
|
67
|
+
* @returns {{ killed: boolean, decision: string, reason?: string, matchedRule?: Object }}
|
|
68
|
+
*/
|
|
69
|
+
evaluate({
|
|
70
|
+
agentId = null,
|
|
71
|
+
family = null,
|
|
72
|
+
orgId = null,
|
|
73
|
+
environment = null,
|
|
74
|
+
mcp = null,
|
|
75
|
+
credential = null,
|
|
76
|
+
session = null,
|
|
77
|
+
model = null,
|
|
78
|
+
tool = null,
|
|
79
|
+
} = {}) {
|
|
80
|
+
for (const rule of this.activeRules.values()) {
|
|
81
|
+
if (!rule.active) continue;
|
|
82
|
+
|
|
83
|
+
const target = rule.target;
|
|
84
|
+
|
|
85
|
+
if (rule.scope === KILL_SCOPES.ORG && orgId && orgId.toLowerCase() === target) {
|
|
86
|
+
return { killed: true, decision: DECISION.QUARANTINE, reason: `Organization under emergency freeze: ${rule.reason}`, matchedRule: rule };
|
|
87
|
+
}
|
|
88
|
+
if (rule.scope === KILL_SCOPES.AGENT && agentId && agentId.toLowerCase() === target) {
|
|
89
|
+
return { killed: true, decision: DECISION.QUARANTINE, reason: `Agent '${agentId}' is frozen: ${rule.reason}`, matchedRule: rule };
|
|
90
|
+
}
|
|
91
|
+
if (rule.scope === KILL_SCOPES.FAMILY && family && family.toLowerCase() === target) {
|
|
92
|
+
return { killed: true, decision: DECISION.QUARANTINE, reason: `Agent family '${family}' is frozen: ${rule.reason}`, matchedRule: rule };
|
|
93
|
+
}
|
|
94
|
+
if (rule.scope === KILL_SCOPES.ENVIRONMENT && environment && environment.toLowerCase() === target) {
|
|
95
|
+
return { killed: true, decision: DECISION.QUARANTINE, reason: `Environment '${environment}' is frozen: ${rule.reason}`, matchedRule: rule };
|
|
96
|
+
}
|
|
97
|
+
if (rule.scope === KILL_SCOPES.MCP && mcp && mcp.toLowerCase() === target) {
|
|
98
|
+
return { killed: true, decision: DECISION.DENY, reason: `MCP server '${mcp}' is disabled: ${rule.reason}`, matchedRule: rule };
|
|
99
|
+
}
|
|
100
|
+
if (rule.scope === KILL_SCOPES.TOOL && tool && tool.toLowerCase() === target) {
|
|
101
|
+
return { killed: true, decision: DECISION.DENY, reason: `Tool '${tool}' is disabled: ${rule.reason}`, matchedRule: rule };
|
|
102
|
+
}
|
|
103
|
+
if (rule.scope === KILL_SCOPES.SESSION && session && session.toLowerCase() === target) {
|
|
104
|
+
return { killed: true, decision: DECISION.QUARANTINE, reason: `Session '${session}' is terminated: ${rule.reason}`, matchedRule: rule };
|
|
105
|
+
}
|
|
106
|
+
if (rule.scope === KILL_SCOPES.MODEL && model && model.toLowerCase() === target) {
|
|
107
|
+
return { killed: true, decision: DECISION.DENY, reason: `Model '${model}' is suspended: ${rule.reason}`, matchedRule: rule };
|
|
108
|
+
}
|
|
109
|
+
if (rule.scope === KILL_SCOPES.CREDENTIAL && credential && credential.toLowerCase() === target) {
|
|
110
|
+
return { killed: true, decision: DECISION.DENY, reason: `Credential '${credential}' is revoked: ${rule.reason}`, matchedRule: rule };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { killed: false, decision: DECISION.ALLOW };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
list() {
|
|
118
|
+
return Array.from(this.activeRules.values());
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const globalKillSwitch = new KillSwitchEngine();
|