@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,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cirvix event model — the contract between the engine and every UI.
|
|
3
|
+
*
|
|
4
|
+
* The policy engine never prints. It emits typed events on an EventBus.
|
|
5
|
+
* Every UI (CLI cards, the interactive console, a future web dashboard or
|
|
6
|
+
* desktop app, CI reporters) is a renderer over this event stream:
|
|
7
|
+
*
|
|
8
|
+
* Policy engine → Event → State reducer → Component
|
|
9
|
+
*
|
|
10
|
+
* Event types:
|
|
11
|
+
* SESSION_STARTED / SESSION_ENDED
|
|
12
|
+
* USER_MESSAGE the human typed something into the console
|
|
13
|
+
* AGENT_MESSAGE a text answer to show in the conversation pane
|
|
14
|
+
* POLICY_EVALUATION_STARTED
|
|
15
|
+
* POLICY_DECISION one tool call decided (carries the audit event)
|
|
16
|
+
* TOOL_STARTED / TOOL_OUTPUT / TOOL_FINISHED
|
|
17
|
+
* APPROVAL_REQUESTED / APPROVAL_GRANTED / APPROVAL_DENIED
|
|
18
|
+
* AUDIT_COMPLETED
|
|
19
|
+
* RUNTIME_ERROR
|
|
20
|
+
* STATUS_SNAPSHOT rolling counters for the status bar
|
|
21
|
+
*
|
|
22
|
+
* Zero dependencies. Serializable (JSON-safe) by construction so the same
|
|
23
|
+
* events can cross the UDS socket or be recorded in tests.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const EVENT = {
|
|
27
|
+
SESSION_STARTED: "SESSION_STARTED",
|
|
28
|
+
SESSION_ENDED: "SESSION_ENDED",
|
|
29
|
+
USER_MESSAGE: "USER_MESSAGE",
|
|
30
|
+
AGENT_MESSAGE: "AGENT_MESSAGE",
|
|
31
|
+
POLICY_EVALUATION_STARTED: "POLICY_EVALUATION_STARTED",
|
|
32
|
+
POLICY_DECISION: "POLICY_DECISION",
|
|
33
|
+
TOOL_STARTED: "TOOL_STARTED",
|
|
34
|
+
TOOL_OUTPUT: "TOOL_OUTPUT",
|
|
35
|
+
TOOL_FINISHED: "TOOL_FINISHED",
|
|
36
|
+
APPROVAL_REQUESTED: "APPROVAL_REQUESTED",
|
|
37
|
+
APPROVAL_GRANTED: "APPROVAL_GRANTED",
|
|
38
|
+
APPROVAL_DENIED: "APPROVAL_DENIED",
|
|
39
|
+
AUDIT_COMPLETED: "AUDIT_COMPLETED",
|
|
40
|
+
RUNTIME_ERROR: "RUNTIME_ERROR",
|
|
41
|
+
STATUS_SNAPSHOT: "STATUS_SNAPSHOT",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
let seq = 0;
|
|
45
|
+
|
|
46
|
+
function base(type, payload = {}) {
|
|
47
|
+
return {
|
|
48
|
+
type,
|
|
49
|
+
id: `evt_${Date.now().toString(36)}_${(seq++).toString(36)}`,
|
|
50
|
+
ts: new Date().toISOString(),
|
|
51
|
+
...payload,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const createEvent = {
|
|
56
|
+
sessionStarted: (p = {}) => base(EVENT.SESSION_STARTED, p),
|
|
57
|
+
sessionEnded: (p = {}) => base(EVENT.SESSION_ENDED, p),
|
|
58
|
+
userMessage: (text, p = {}) => base(EVENT.USER_MESSAGE, { text, ...p }),
|
|
59
|
+
agentMessage: (text, p = {}) => base(EVENT.AGENT_MESSAGE, { text, ...p }),
|
|
60
|
+
evaluationStarted: (p = {}) => base(EVENT.POLICY_EVALUATION_STARTED, p),
|
|
61
|
+
policyDecision: (decision, p = {}) =>
|
|
62
|
+
base(EVENT.POLICY_DECISION, {
|
|
63
|
+
decision: decision?.decision ?? decision?.verdict ?? "deny",
|
|
64
|
+
tool: decision?.tool ?? decision?.action ?? "unknown",
|
|
65
|
+
resource: decision?.resource ?? "",
|
|
66
|
+
risk: decision?.risk ?? "low",
|
|
67
|
+
policy: decision?.policy ?? decision?.rule ?? null,
|
|
68
|
+
reason: decision?.reason ?? "",
|
|
69
|
+
latency_ms: decision?.latency_ms ?? 0,
|
|
70
|
+
raw: decision,
|
|
71
|
+
...p,
|
|
72
|
+
}),
|
|
73
|
+
toolStarted: (tool, p = {}) => base(EVENT.TOOL_STARTED, { tool, ...p }),
|
|
74
|
+
toolOutput: (text, p = {}) => base(EVENT.TOOL_OUTPUT, { text, ...p }),
|
|
75
|
+
toolFinished: (tool, p = {}) => base(EVENT.TOOL_FINISHED, { tool, ...p }),
|
|
76
|
+
approvalRequested: (p = {}) => base(EVENT.APPROVAL_REQUESTED, p),
|
|
77
|
+
approvalGranted: (p = {}) => base(EVENT.APPROVAL_GRANTED, p),
|
|
78
|
+
approvalDenied: (p = {}) => base(EVENT.APPROVAL_DENIED, p),
|
|
79
|
+
auditCompleted: (p = {}) => base(EVENT.AUDIT_COMPLETED, p),
|
|
80
|
+
runtimeError: (message, p = {}) => base(EVENT.RUNTIME_ERROR, { message, ...p }),
|
|
81
|
+
statusSnapshot: (p = {}) => base(EVENT.STATUS_SNAPSHOT, p),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Minimal pub/sub bus. Sync dispatch (the engine is sync-per-decision);
|
|
86
|
+
* subscribers never throw into the engine — a broken UI listener is
|
|
87
|
+
* isolated and reported via `onListenerError`.
|
|
88
|
+
*/
|
|
89
|
+
export class EventBus {
|
|
90
|
+
constructor({ onListenerError = () => {} } = {}) {
|
|
91
|
+
this.listeners = new Map();
|
|
92
|
+
this.onListenerError = onListenerError;
|
|
93
|
+
this.history = [];
|
|
94
|
+
this.capped = 2000;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
on(type, fn) {
|
|
98
|
+
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
|
99
|
+
this.listeners.get(type).add(fn);
|
|
100
|
+
return () => this.listeners.get(type)?.delete(fn);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onAny(fn) {
|
|
104
|
+
return this.on("*", fn);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
emit(event) {
|
|
108
|
+
this.history.push(event);
|
|
109
|
+
if (this.history.length > this.capped) this.history.shift();
|
|
110
|
+
const targets = [
|
|
111
|
+
...(this.listeners.get(event.type) ?? []),
|
|
112
|
+
...(this.listeners.get("*") ?? []),
|
|
113
|
+
];
|
|
114
|
+
for (const fn of targets) {
|
|
115
|
+
try {
|
|
116
|
+
fn(event);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
this.onListenerError(err, event);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return event;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
replay(types, fn) {
|
|
125
|
+
for (const e of this.history) {
|
|
126
|
+
if (!types || types.includes(e.type)) fn(e);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Adapt a live Pipeline to the bus: wraps `onEvent` so every decision the
|
|
133
|
+
* engine makes also becomes a POLICY_DECISION (+ approval/tool) event.
|
|
134
|
+
* Returns an unsubscribe function.
|
|
135
|
+
*/
|
|
136
|
+
export function attachPipeline(pipeline, bus) {
|
|
137
|
+
const prev = pipeline.onEvent;
|
|
138
|
+
pipeline.onEvent = (e) => {
|
|
139
|
+
try {
|
|
140
|
+
if (e?.kind === "decision") {
|
|
141
|
+
bus.emit(createEvent.policyDecision(e));
|
|
142
|
+
if (e.decision === "require_approval") {
|
|
143
|
+
bus.emit(createEvent.approvalRequested({ approval_id: e.approval_id, tool: e.tool, resource: e.resource }));
|
|
144
|
+
}
|
|
145
|
+
} else if (e?.kind === "scrub") {
|
|
146
|
+
bus.emit(createEvent.toolOutput(`scrubbed ${e.findings?.length ?? 0} finding(s)`, { findings: e.findings }));
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// event translation must never break enforcement
|
|
150
|
+
}
|
|
151
|
+
return prev?.(e);
|
|
152
|
+
};
|
|
153
|
+
return () => {
|
|
154
|
+
pipeline.onEvent = prev;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* ------------------------------------------------------------------ */
|
|
159
|
+
/* Reducer — event stream → renderable UI state */
|
|
160
|
+
/* ------------------------------------------------------------------ */
|
|
161
|
+
|
|
162
|
+
export function initialState() {
|
|
163
|
+
return {
|
|
164
|
+
session: null,
|
|
165
|
+
messages: [], // { role: 'user'|'cirvix', text, ts }
|
|
166
|
+
activity: [], // POLICY_DECISION payloads, newest last
|
|
167
|
+
approvals: [], // pending approval requests
|
|
168
|
+
errors: [],
|
|
169
|
+
status: {
|
|
170
|
+
mode: "enforce",
|
|
171
|
+
requests: 0,
|
|
172
|
+
allowed: 0,
|
|
173
|
+
sanitized: 0,
|
|
174
|
+
blocked: 0,
|
|
175
|
+
held: 0,
|
|
176
|
+
latencies: [],
|
|
177
|
+
},
|
|
178
|
+
evaluating: false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function reduce(state, event) {
|
|
183
|
+
switch (event.type) {
|
|
184
|
+
case EVENT.SESSION_STARTED:
|
|
185
|
+
return { ...state, session: { id: event.sessionId ?? event.id, startedAt: event.ts, agent: event.agent ?? "local" } };
|
|
186
|
+
case EVENT.SESSION_ENDED:
|
|
187
|
+
return { ...state, session: state.session ? { ...state.session, endedAt: event.ts } : null, evaluating: false };
|
|
188
|
+
case EVENT.USER_MESSAGE:
|
|
189
|
+
return { ...state, messages: [...state.messages, { role: "user", text: event.text, ts: event.ts }] };
|
|
190
|
+
case EVENT.AGENT_MESSAGE:
|
|
191
|
+
return { ...state, messages: [...state.messages, { role: "cirvix", text: event.text, ts: event.ts }] };
|
|
192
|
+
case EVENT.POLICY_EVALUATION_STARTED:
|
|
193
|
+
return { ...state, evaluating: true };
|
|
194
|
+
case EVENT.POLICY_DECISION: {
|
|
195
|
+
const d = event.decision;
|
|
196
|
+
const status = { ...state.status, requests: state.status.requests + 1 };
|
|
197
|
+
if (d === "allow") status.allowed++;
|
|
198
|
+
else if (d === "sanitize") status.sanitized++;
|
|
199
|
+
else if (d === "deny") status.blocked++;
|
|
200
|
+
else if (d === "require_approval") status.held++;
|
|
201
|
+
if (typeof event.latency_ms === "number") {
|
|
202
|
+
status.latencies = [...status.latencies.slice(-999), event.latency_ms];
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
...state,
|
|
206
|
+
evaluating: false,
|
|
207
|
+
activity: [...state.activity.slice(-499), event],
|
|
208
|
+
status,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
case EVENT.APPROVAL_REQUESTED:
|
|
212
|
+
return { ...state, approvals: [...state.approvals, event] };
|
|
213
|
+
case EVENT.APPROVAL_GRANTED:
|
|
214
|
+
case EVENT.APPROVAL_DENIED:
|
|
215
|
+
return {
|
|
216
|
+
...state,
|
|
217
|
+
approvals: state.approvals.filter((a) => a.approval_id !== event.approval_id),
|
|
218
|
+
};
|
|
219
|
+
case EVENT.RUNTIME_ERROR:
|
|
220
|
+
return { ...state, evaluating: false, errors: [...state.errors.slice(-49), event] };
|
|
221
|
+
case EVENT.STATUS_SNAPSHOT:
|
|
222
|
+
return { ...state, status: { ...state.status, ...event.snapshot } };
|
|
223
|
+
default:
|
|
224
|
+
return state;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** P50/P95 over the reducer's latency window. */
|
|
229
|
+
export function latencyStats(latencies) {
|
|
230
|
+
if (!latencies?.length) return { p50: 0, p95: 0, samples: 0 };
|
|
231
|
+
const s = [...latencies].sort((a, b) => a - b);
|
|
232
|
+
const at = (q) => s[Math.min(s.length - 1, Math.floor(q * s.length))];
|
|
233
|
+
return { p50: Number(at(0.5).toFixed(2)), p95: Number(at(0.95).toFixed(2)), samples: s.length };
|
|
234
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence packs.
|
|
3
|
+
*
|
|
4
|
+
* The primitives already existed — prove.mjs signs a decision, the audit chain
|
|
5
|
+
* verifies, buildPassport describes an agent. What did not exist was the thing
|
|
6
|
+
* a security reviewer actually asks for: one bundle, for one scope, that
|
|
7
|
+
* answers "show me this agent was controlled" without the reviewer having to
|
|
8
|
+
* know which four commands to run.
|
|
9
|
+
*
|
|
10
|
+
* THE ONE RULE THIS FILE EXISTS TO ENFORCE
|
|
11
|
+
* ---------------------------------------
|
|
12
|
+
* A pack maps controls. It never claims compliance.
|
|
13
|
+
*
|
|
14
|
+
* "SOC 2 CC6.1" appearing next to a decision means Cirvix believes this
|
|
15
|
+
* evidence is relevant to that control. It does not mean the control is met,
|
|
16
|
+
* that an auditor agreed, or that anybody is certified. The vocabulary below
|
|
17
|
+
* has no word for "compliant" and a test asserts it never acquires one —
|
|
18
|
+
* because the moment a generated PDF says "SOC 2 compliant", somebody forwards
|
|
19
|
+
* it to a customer and the claim is ours.
|
|
20
|
+
*
|
|
21
|
+
* WHAT NEVER GOES IN
|
|
22
|
+
* ------------------
|
|
23
|
+
* Arguments and results are excluded wholesale rather than redacted. A
|
|
24
|
+
* redactor is a filter that has to be right every time; an exclusion is right
|
|
25
|
+
* by construction. The pack carries what was decided, under which rule, and
|
|
26
|
+
* whether the chain verifies — none of which needs the payload.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import { canonicalJson } from "./audit.mjs";
|
|
31
|
+
|
|
32
|
+
export const EVIDENCE_VERSION = 1;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Coverage vocabulary. Deliberately has no "pass" and no "compliant".
|
|
36
|
+
*
|
|
37
|
+
* `mapped` is the strongest word available and it only means evidence exists.
|
|
38
|
+
* Adding a stronger term is the one change to this file that would turn a
|
|
39
|
+
* useful artifact into a liability.
|
|
40
|
+
*/
|
|
41
|
+
export const COVERAGE = Object.freeze({
|
|
42
|
+
MAPPED: "mapped", // evidence exists and is attached
|
|
43
|
+
PARTIAL: "partial", // some evidence, with a stated gap
|
|
44
|
+
NOT_COVERED: "not_covered", // in scope, nothing found
|
|
45
|
+
OUT_OF_SCOPE: "out_of_scope",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Control mappings. Intentionally few, and each says what it is evidenced BY.
|
|
50
|
+
*
|
|
51
|
+
* A long list of frameworks would look more impressive and mean less: every
|
|
52
|
+
* row Cirvix cannot actually evidence is a row a reviewer will find empty.
|
|
53
|
+
*/
|
|
54
|
+
const CONTROLS = [
|
|
55
|
+
{ id: "SOC2.CC6.1", framework: "SOC 2", title: "Logical access controls restrict access to protected resources",
|
|
56
|
+
evidencedBy: "decisions" },
|
|
57
|
+
{ id: "SOC2.CC6.3", framework: "SOC 2", title: "Access is removed or modified when no longer appropriate",
|
|
58
|
+
evidencedBy: "policyVersions" },
|
|
59
|
+
{ id: "SOC2.CC7.2", framework: "SOC 2", title: "Anomalies are identified and analysed",
|
|
60
|
+
evidencedBy: "denials" },
|
|
61
|
+
{ id: "SOC2.CC7.3", framework: "SOC 2", title: "Security events are evaluated and acted upon",
|
|
62
|
+
evidencedBy: "approvals" },
|
|
63
|
+
{ id: "ISO27001.A.8.16", framework: "ISO/IEC 27001:2022", title: "Monitoring activities",
|
|
64
|
+
evidencedBy: "decisions" },
|
|
65
|
+
{ id: "ISO27001.A.5.15", framework: "ISO/IEC 27001:2022", title: "Access control",
|
|
66
|
+
evidencedBy: "policyVersions" },
|
|
67
|
+
{ id: "NIST.AI.RMF.MEASURE.2.7", framework: "NIST AI RMF", title: "AI system security and resilience are evaluated",
|
|
68
|
+
evidencedBy: "denials" },
|
|
69
|
+
{ id: "NIST.AI.RMF.MANAGE.4.1", framework: "NIST AI RMF", title: "Post-deployment monitoring plans are implemented",
|
|
70
|
+
evidencedBy: "chain" },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/** Fields that may appear on a decision inside a pack. Everything else is dropped. */
|
|
74
|
+
const DECISION_FIELDS = [
|
|
75
|
+
"decision_id", "ts", "agent", "action", "tool", "resource", "destination",
|
|
76
|
+
"verdict", "decision", "rule", "reason", "risk", "environment", "run_id", "hash", "prev_hash",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
function slimDecision(record) {
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const f of DECISION_FIELDS) if (record[f] !== undefined) out[f] = record[f];
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Assembles a pack.
|
|
87
|
+
*
|
|
88
|
+
* `records` are audit records already read from the chain — this function does
|
|
89
|
+
* no I/O, so it is testable and so the caller decides what it is allowed to
|
|
90
|
+
* read.
|
|
91
|
+
*/
|
|
92
|
+
export function buildEvidencePack({
|
|
93
|
+
records = [],
|
|
94
|
+
agent = null,
|
|
95
|
+
org = null,
|
|
96
|
+
from = null,
|
|
97
|
+
to = null,
|
|
98
|
+
passport = null,
|
|
99
|
+
policyVersions = [],
|
|
100
|
+
proofs = [],
|
|
101
|
+
approvals = [],
|
|
102
|
+
chain = null,
|
|
103
|
+
now = () => new Date().toISOString(),
|
|
104
|
+
} = {}) {
|
|
105
|
+
const inWindow = (r) => {
|
|
106
|
+
const t = r.ts ?? r.timestamp;
|
|
107
|
+
if (from && t && t < from) return false;
|
|
108
|
+
if (to && t && t > to) return false;
|
|
109
|
+
return true;
|
|
110
|
+
};
|
|
111
|
+
const scoped = records
|
|
112
|
+
.filter((r) => (agent ? r.agent === agent : true))
|
|
113
|
+
.filter(inWindow);
|
|
114
|
+
|
|
115
|
+
const decisions = scoped.map(slimDecision);
|
|
116
|
+
const denials = decisions.filter((d) => d.verdict === "deny" || d.decision === "deny");
|
|
117
|
+
const held = decisions.filter((d) => d.decision === "require_approval" || d.verdict === "hold");
|
|
118
|
+
|
|
119
|
+
const evidence = {
|
|
120
|
+
decisions: decisions.length,
|
|
121
|
+
denials: denials.length,
|
|
122
|
+
approvals: approvals.length,
|
|
123
|
+
policyVersions: policyVersions.length,
|
|
124
|
+
chain: chain?.ok === true ? 1 : 0,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const coverage = CONTROLS.map((c) => {
|
|
128
|
+
const n = evidence[c.evidencedBy] ?? 0;
|
|
129
|
+
return {
|
|
130
|
+
control: c.id,
|
|
131
|
+
framework: c.framework,
|
|
132
|
+
title: c.title,
|
|
133
|
+
coverage: n > 0 ? COVERAGE.MAPPED : COVERAGE.NOT_COVERED,
|
|
134
|
+
evidence: `${n} ${c.evidencedBy}`,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const pack = {
|
|
139
|
+
v: EVIDENCE_VERSION,
|
|
140
|
+
kind: "evidence_pack",
|
|
141
|
+
generatedAt: now(),
|
|
142
|
+
scope: { agent, org, from, to },
|
|
143
|
+
summary: {
|
|
144
|
+
decisions: decisions.length,
|
|
145
|
+
denied: denials.length,
|
|
146
|
+
heldForApproval: held.length,
|
|
147
|
+
distinctRules: [...new Set(decisions.map((d) => d.rule).filter(Boolean))].length,
|
|
148
|
+
chainVerified: chain?.ok === true,
|
|
149
|
+
chainRecords: chain?.records ?? null,
|
|
150
|
+
chainHead: chain?.head ?? null,
|
|
151
|
+
},
|
|
152
|
+
passport: passport ?? null,
|
|
153
|
+
policyVersions: policyVersions.map((p) => ({ version: p.version ?? null, hash: p.hash ?? null, publishedAt: p.publishedAt ?? null })),
|
|
154
|
+
decisions,
|
|
155
|
+
approvals: approvals.map((a) => ({ id: a.id ?? null, decidedBy: a.decidedBy ?? null, decision: a.decision ?? null, at: a.at ?? null })),
|
|
156
|
+
proofs: proofs.map((p) => (typeof p === "string" ? { token: p } : { token: p.token ?? null, decisionId: p.decisionId ?? null })),
|
|
157
|
+
controlMapping: coverage,
|
|
158
|
+
/* Load-bearing. Read by humans who will forward this onward. */
|
|
159
|
+
disclaimer:
|
|
160
|
+
"This pack maps evidence to control identifiers. It is not an audit, a certification, " +
|
|
161
|
+
"or a statement of compliance. No control is asserted to be met, and no framework " +
|
|
162
|
+
"listed here has assessed this system. Coverage of 'mapped' means only that relevant " +
|
|
163
|
+
"evidence is attached.",
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
pack.digest = "sha256:" + createHash("sha256").update(canonicalJson({ ...pack, digest: undefined })).digest("hex");
|
|
167
|
+
return pack;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The human-readable report. Plain text so it survives every pipeline. */
|
|
171
|
+
export function renderEvidenceReport(pack) {
|
|
172
|
+
const s = pack.summary;
|
|
173
|
+
const lines = [
|
|
174
|
+
"CIRVIX EVIDENCE PACK",
|
|
175
|
+
"",
|
|
176
|
+
`Generated ${pack.generatedAt}`,
|
|
177
|
+
`Agent ${pack.scope.agent ?? "(all agents)"}`,
|
|
178
|
+
`Window ${pack.scope.from ?? "(open)"} → ${pack.scope.to ?? "(open)"}`,
|
|
179
|
+
`Digest ${pack.digest}`,
|
|
180
|
+
"",
|
|
181
|
+
"SUMMARY",
|
|
182
|
+
` Decisions recorded ${s.decisions}`,
|
|
183
|
+
` Denied ${s.denied}`,
|
|
184
|
+
` Held for approval ${s.heldForApproval}`,
|
|
185
|
+
` Distinct rules applied ${s.distinctRules}`,
|
|
186
|
+
` Audit chain ${s.chainVerified ? `verified, ${s.chainRecords} records` : "NOT VERIFIED"}`,
|
|
187
|
+
"",
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
if (pack.passport?.trust) {
|
|
191
|
+
const t = pack.passport.trust;
|
|
192
|
+
lines.push("TRUST", ` Score ${t.score ?? "unscored"}${t.band ? ` (${t.band})` : ""}`, "");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
lines.push("CONTROL MAPPING", "");
|
|
196
|
+
for (const c of pack.controlMapping) {
|
|
197
|
+
lines.push(` ${c.coverage === COVERAGE.MAPPED ? "▪" : "·"} ${c.control.padEnd(28)} ${c.coverage.padEnd(12)} ${c.evidence}`);
|
|
198
|
+
}
|
|
199
|
+
lines.push("", " " + pack.disclaimer.replace(/(.{1,72})(\s|$)/g, "$1\n ").trim(), "");
|
|
200
|
+
return lines.join("\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Re-derives the digest.
|
|
205
|
+
*
|
|
206
|
+
* A pack that has been edited after generation fails here. The digest is over
|
|
207
|
+
* the canonical form minus itself, so it is stable across serialisation.
|
|
208
|
+
*/
|
|
209
|
+
export function verifyEvidencePack(pack) {
|
|
210
|
+
const expected = "sha256:" + createHash("sha256").update(canonicalJson({ ...pack, digest: undefined })).digest("hex");
|
|
211
|
+
return { ok: expected === pack.digest, expected, actual: pack.digest };
|
|
212
|
+
}
|
package/src/core/format.mjs
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Terminal formatting.
|
|
2
|
+
* Terminal formatting — thin compatibility layer over the semantic theme.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* New code should import from `theme.mjs` and use `colors.*` / `style(text,
|
|
5
|
+
* role)` so meaning stays in one place. This module keeps the historic names
|
|
6
|
+
* (`green`, `red`, `amber`, `blue`, `cyan`, `gray`, `white`, `bold`, `dim`,
|
|
7
|
+
* `plural`) working for the existing CLI, gateway logs, the `core/ui`
|
|
8
|
+
* primitives, and every test that already asserts on them.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* Mapping (the product's chroma rule — green means permitted, red denied,
|
|
11
|
+
* amber held, blue/cyan sanitized or informational, gray muted):
|
|
12
|
+
* green → allow · red → block · amber → hold/warning · blue/cyan → sanitize
|
|
13
|
+
* gray → muted · white → text
|
|
11
14
|
*/
|
|
12
15
|
|
|
16
|
+
export { bold, dim, colors, style, setTheme, themeName, THEME_NAMES } from "./theme.mjs";
|
|
17
|
+
import { style } from "./theme.mjs";
|
|
18
|
+
|
|
19
|
+
export const green = (s) => style(s, "allow");
|
|
20
|
+
export const red = (s) => style(s, "block");
|
|
21
|
+
export const amber = (s) => style(s, "hold");
|
|
22
|
+
export const blue = (s) => style(s, "sanitize");
|
|
23
|
+
export const cyan = (s) => style(s, "sanitize");
|
|
24
|
+
export const gray = (s) => style(s, "muted");
|
|
25
|
+
export const white = (s) => style(s, "text");
|
|
26
|
+
|
|
13
27
|
const forced = process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true";
|
|
14
28
|
const disabled =
|
|
15
29
|
!forced &&
|
|
@@ -17,17 +31,29 @@ const disabled =
|
|
|
17
31
|
process.env.TERM === "dumb" ||
|
|
18
32
|
!process.stdout.isTTY);
|
|
19
33
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
export
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
/** Strip ANSI escape sequences for width calculation and secret checks. */
|
|
35
|
+
export function stripAnsi(s) {
|
|
36
|
+
return String(s).replace(/\[[0-9;]*m/g, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Visible character width, ignoring ANSI. */
|
|
40
|
+
export function visibleWidth(s) {
|
|
41
|
+
return stripAnsi(String(s)).length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True when output should be decorated (TTY, not NO_COLOR, not dumb, not CI unless forced). */
|
|
45
|
+
export function isInteractive() {
|
|
46
|
+
if (disabled) return false;
|
|
47
|
+
if (process.env.CI !== undefined && !forced) return false;
|
|
48
|
+
return Boolean(process.stdout.isTTY);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether unicode box-drawing is safe. ASCII fallback when TERM=dumb or CIRVIX_ASCII=1. */
|
|
52
|
+
export function supportsUnicode() {
|
|
53
|
+
if (process.env.CIRVIX_ASCII === "1") return false;
|
|
54
|
+
if (process.env.TERM === "dumb") return false;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
31
57
|
|
|
32
58
|
/** "1 server" / "3 servers" — avoids the "1 servers" that reads as a bug. */
|
|
33
59
|
export function plural(n, noun, pluralForm) {
|