@cirvix_ai/agent-control 0.1.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/LICENSE +202 -0
- package/NOTICE +42 -0
- package/README.md +341 -0
- package/action/README.md +100 -0
- package/action/action.yml +134 -0
- package/action/report.mjs +144 -0
- package/bin/cirvix.mjs +1073 -0
- package/package.json +60 -0
- package/src/commands/demo.mjs +315 -0
- package/src/commands/init.mjs +558 -0
- package/src/commands/policy.mjs +345 -0
- package/src/commands/sarif.mjs +176 -0
- package/src/commands/scan.mjs +210 -0
- package/src/commands/status.mjs +208 -0
- package/src/commands/upgrade.mjs +162 -0
- package/src/core/approvals.mjs +388 -0
- package/src/core/audit.mjs +181 -0
- package/src/core/canonical.mjs +316 -0
- package/src/core/daemon.mjs +352 -0
- package/src/core/decisions.mjs +253 -0
- package/src/core/delegation.mjs +658 -0
- package/src/core/detect.mjs +337 -0
- package/src/core/entitlement-gate.mjs +100 -0
- package/src/core/entitlements.mjs +285 -0
- package/src/core/format.mjs +33 -0
- package/src/core/gateway.mjs +959 -0
- package/src/core/guard.mjs +568 -0
- package/src/core/http-transport.mjs +505 -0
- package/src/core/journal.mjs +419 -0
- package/src/core/jsonrpc.mjs +152 -0
- package/src/core/meter.mjs +225 -0
- package/src/core/normalize.mjs +516 -0
- package/src/core/notices.mjs +80 -0
- package/src/core/pipeline.mjs +629 -0
- package/src/core/policy-dsl.mjs +611 -0
- package/src/core/policy.mjs +710 -0
- package/src/core/prompts.mjs +146 -0
- package/src/core/risk.mjs +509 -0
- package/src/core/sanitize.mjs +279 -0
- package/src/core/secret-detect.mjs +533 -0
- package/src/core/secrets.mjs +312 -0
- package/src/core/uds.mjs +383 -0
- package/src/core/vault.mjs +530 -0
- package/src/index.mjs +143 -0
- package/src/testing.mjs +145 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The execution journal — reading the audit chain back.
|
|
3
|
+
*
|
|
4
|
+
* `audit.mjs` writes an immutable, hash-linked record of every decision. This
|
|
5
|
+
* reads it: filter, group into runs, render as an execution tree, and re-decide
|
|
6
|
+
* a recorded call under a candidate policy.
|
|
7
|
+
*
|
|
8
|
+
* cirvix logs
|
|
9
|
+
* cirvix logs --last 50
|
|
10
|
+
* cirvix logs --risk high
|
|
11
|
+
* cirvix replay req_8a91
|
|
12
|
+
*
|
|
13
|
+
* IMMUTABLE HISTORY FIRST, TIME TRAVEL NEVER
|
|
14
|
+
*
|
|
15
|
+
* `replay` re-evaluates and does not re-execute. It answers "what would today's
|
|
16
|
+
* rules have decided about that call" — the question you have at 2am — and it
|
|
17
|
+
* cannot undo the call's effects, because nothing in a userspace policy engine
|
|
18
|
+
* can un-send an HTTP request or un-drop a table.
|
|
19
|
+
*
|
|
20
|
+
* That distinction is stated in the output of the command itself, not only
|
|
21
|
+
* here, because "time-travel rollback" is a thing security products claim and
|
|
22
|
+
* an operator who believes it will not take the backup.
|
|
23
|
+
*
|
|
24
|
+
* WHY THIS READS THE FILE EVERY TIME
|
|
25
|
+
*
|
|
26
|
+
* No index, no cache, no daemon. The journal is a JSONL file that a developer
|
|
27
|
+
* can `tail`, `grep`, and diff — and at the volume one machine produces, a
|
|
28
|
+
* linear scan of a few megabytes is faster than the code that would avoid it.
|
|
29
|
+
* When the file gets large enough to matter, it belongs in the control plane,
|
|
30
|
+
* which has a database.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readFile } from "node:fs/promises";
|
|
34
|
+
|
|
35
|
+
import { evaluate } from "./policy.mjs";
|
|
36
|
+
import { DECISION, toDecision } from "./decisions.mjs";
|
|
37
|
+
import { RISK_ORDER, riskRank } from "./risk.mjs";
|
|
38
|
+
import { normalize, policyRequest } from "./normalize.mjs";
|
|
39
|
+
import { amber, blue, bold, dim, green, red } from "./format.mjs";
|
|
40
|
+
|
|
41
|
+
/* -------------------------------------------------------------------------- */
|
|
42
|
+
/* Reading */
|
|
43
|
+
/* -------------------------------------------------------------------------- */
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reads every record.
|
|
47
|
+
*
|
|
48
|
+
* A malformed line is returned as `{ malformed: true }` rather than skipped.
|
|
49
|
+
* Silently dropping it would let someone corrupt one line to make a record
|
|
50
|
+
* disappear from `cirvix logs` while the chain still verifies over the rest.
|
|
51
|
+
*/
|
|
52
|
+
export async function read(path) {
|
|
53
|
+
let text = "";
|
|
54
|
+
try {
|
|
55
|
+
text = await readFile(path, "utf8");
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
return text
|
|
60
|
+
.split("\n")
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.map((line, i) => {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(line);
|
|
65
|
+
} catch {
|
|
66
|
+
return { malformed: true, line: i + 1, raw: line.slice(0, 200) };
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @typedef {object} Query
|
|
73
|
+
* @property {number} [last] most recent N
|
|
74
|
+
* @property {string} [risk] minimum risk level
|
|
75
|
+
* @property {string} [decision] exact decision
|
|
76
|
+
* @property {string} [agent]
|
|
77
|
+
* @property {string} [tool] substring match
|
|
78
|
+
* @property {string} [run] run id
|
|
79
|
+
* @property {string} [since] ISO timestamp
|
|
80
|
+
* @property {boolean} [deniedOnly]
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/** Filters records. Ordering is preserved; `last` is applied at the end. */
|
|
84
|
+
export function query(records, q = {}) {
|
|
85
|
+
let out = records.filter((r) => !r.malformed);
|
|
86
|
+
|
|
87
|
+
if (q.risk) {
|
|
88
|
+
const floor = riskRank(q.risk);
|
|
89
|
+
out = out.filter((r) => riskRank(r.risk) >= floor);
|
|
90
|
+
}
|
|
91
|
+
if (q.decision) {
|
|
92
|
+
const want = String(q.decision).toLowerCase();
|
|
93
|
+
out = out.filter((r) => (r.decision ?? toDecision(r.verdict)) === want);
|
|
94
|
+
}
|
|
95
|
+
if (q.deniedOnly) {
|
|
96
|
+
out = out.filter((r) => (r.decision ?? toDecision(r.verdict)) === DECISION.DENY);
|
|
97
|
+
}
|
|
98
|
+
if (q.agent) out = out.filter((r) => r.agent === q.agent);
|
|
99
|
+
if (q.tool) {
|
|
100
|
+
const needle = String(q.tool).toLowerCase();
|
|
101
|
+
out = out.filter((r) => `${r.tool ?? ""} ${r.action ?? ""}`.toLowerCase().includes(needle));
|
|
102
|
+
}
|
|
103
|
+
if (q.run) out = out.filter((r) => r.run_id === q.run || r.runId === q.run);
|
|
104
|
+
if (q.since) {
|
|
105
|
+
const t = new Date(q.since).getTime();
|
|
106
|
+
out = out.filter((r) => new Date(r.ts ?? r.timestamp ?? 0).getTime() >= t);
|
|
107
|
+
}
|
|
108
|
+
if (q.last) out = out.slice(-Number(q.last));
|
|
109
|
+
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One record by request id or decision id. */
|
|
114
|
+
export function find(records, id) {
|
|
115
|
+
return (
|
|
116
|
+
records.find((r) => r.request_id === id || r.decision_id === id || r.decisionId === id) ?? null
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* -------------------------------------------------------------------------- */
|
|
121
|
+
/* Summaries */
|
|
122
|
+
/* -------------------------------------------------------------------------- */
|
|
123
|
+
|
|
124
|
+
/** Counts and latency percentiles over a record set. */
|
|
125
|
+
export function summarize(records) {
|
|
126
|
+
const counts = { allow: 0, deny: 0, require_approval: 0, sanitize: 0, audit_only: 0 };
|
|
127
|
+
const risks = { low: 0, medium: 0, high: 0, critical: 0 };
|
|
128
|
+
const latencies = [];
|
|
129
|
+
const agents = new Set();
|
|
130
|
+
const rules = new Map();
|
|
131
|
+
|
|
132
|
+
for (const r of records) {
|
|
133
|
+
if (r.malformed) continue;
|
|
134
|
+
const d = r.decision ?? toDecision(r.verdict);
|
|
135
|
+
if (d in counts) counts[d]++;
|
|
136
|
+
if (r.risk in risks) risks[r.risk]++;
|
|
137
|
+
if (typeof r.latency_ms === "number") latencies.push(r.latency_ms);
|
|
138
|
+
if (r.agent) agents.add(r.agent);
|
|
139
|
+
if (r.policy ?? r.rule) {
|
|
140
|
+
const key = r.policy ?? r.rule;
|
|
141
|
+
rules.set(key, (rules.get(key) ?? 0) + 1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
latencies.sort((a, b) => a - b);
|
|
146
|
+
const at = (q) => (latencies.length ? latencies[Math.min(latencies.length - 1, Math.floor(q * latencies.length))] : 0);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
records: records.length,
|
|
150
|
+
counts,
|
|
151
|
+
risks,
|
|
152
|
+
agents: [...agents],
|
|
153
|
+
topRules: [...rules.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10),
|
|
154
|
+
latency: {
|
|
155
|
+
p50: Number(at(0.5).toFixed(3)),
|
|
156
|
+
p95: Number(at(0.95).toFixed(3)),
|
|
157
|
+
p99: Number(at(0.99).toFixed(3)),
|
|
158
|
+
max: Number((latencies[latencies.length - 1] ?? 0).toFixed(3)),
|
|
159
|
+
samples: latencies.length,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Groups records into the runs they belong to. Loose records get one bucket. */
|
|
165
|
+
export function byRun(records) {
|
|
166
|
+
const runs = new Map();
|
|
167
|
+
for (const r of records) {
|
|
168
|
+
if (r.malformed) continue;
|
|
169
|
+
const key = r.run_id ?? r.runId ?? "(no run)";
|
|
170
|
+
if (!runs.has(key)) runs.set(key, []);
|
|
171
|
+
runs.get(key).push(r);
|
|
172
|
+
}
|
|
173
|
+
return runs;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* -------------------------------------------------------------------------- */
|
|
177
|
+
/* Rendering */
|
|
178
|
+
/* -------------------------------------------------------------------------- */
|
|
179
|
+
|
|
180
|
+
const TONE = {
|
|
181
|
+
[DECISION.ALLOW]: green,
|
|
182
|
+
[DECISION.SANITIZE]: blue,
|
|
183
|
+
[DECISION.AUDIT_ONLY]: dim,
|
|
184
|
+
[DECISION.REQUIRE_APPROVAL]: amber,
|
|
185
|
+
[DECISION.DENY]: red,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const RISK_TONE = { low: dim, medium: blue, high: amber, critical: red };
|
|
189
|
+
|
|
190
|
+
function toneFor(record) {
|
|
191
|
+
return TONE[record.decision ?? toDecision(record.verdict)] ?? dim;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** `2026-08-12T09:14:02.113Z` → `09:14:02`. */
|
|
195
|
+
function clock(ts) {
|
|
196
|
+
const s = String(ts ?? "");
|
|
197
|
+
const m = s.match(/T(\d{2}:\d{2}:\d{2})/);
|
|
198
|
+
return m ? m[1] : s.slice(0, 8).padEnd(8);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** One record, one line. The shape `cirvix logs` prints. */
|
|
202
|
+
export function renderLine(record) {
|
|
203
|
+
if (record.malformed) {
|
|
204
|
+
return ` ${dim(String(record.line).padStart(4))} ${red("malformed record")} ${dim(record.raw.slice(0, 60))}`;
|
|
205
|
+
}
|
|
206
|
+
const decision = record.decision ?? toDecision(record.verdict);
|
|
207
|
+
const tone = toneFor(record);
|
|
208
|
+
const risk = RISK_TONE[record.risk] ?? dim;
|
|
209
|
+
|
|
210
|
+
return [
|
|
211
|
+
` ${dim(clock(record.ts ?? record.timestamp))}`,
|
|
212
|
+
tone(String(decision).toUpperCase().padEnd(16)),
|
|
213
|
+
risk(String(record.risk ?? "—").toUpperCase().padEnd(8)),
|
|
214
|
+
String(record.tool ?? record.action ?? "—").padEnd(20),
|
|
215
|
+
dim(truncate(record.resource ?? record.command ?? "", 44).padEnd(44)),
|
|
216
|
+
dim(String(record.policy ?? record.rule ?? "default-deny").padEnd(24)),
|
|
217
|
+
dim(`${record.latency_ms ?? "—"}ms`),
|
|
218
|
+
].join(" ");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The execution tree.
|
|
223
|
+
*
|
|
224
|
+
* claude-code
|
|
225
|
+
* └── filesystem.read
|
|
226
|
+
* ├── input ./src/app.ts
|
|
227
|
+
* ├── risk LOW
|
|
228
|
+
* ├── policy allow-workspace-read
|
|
229
|
+
* ├── decision ALLOW
|
|
230
|
+
* ├── latency 0.41ms
|
|
231
|
+
* └── result forwarded
|
|
232
|
+
*
|
|
233
|
+
* Deliberately one call per tree rather than a whole run in one: an operator
|
|
234
|
+
* reading this is looking at a specific decision, and a hundred-node tree
|
|
235
|
+
* scrolls the interesting node off the screen.
|
|
236
|
+
*/
|
|
237
|
+
export function renderTree(record, { indent = " " } = {}) {
|
|
238
|
+
const decision = record.decision ?? toDecision(record.verdict);
|
|
239
|
+
const tone = toneFor(record);
|
|
240
|
+
const risk = RISK_TONE[record.risk] ?? dim;
|
|
241
|
+
|
|
242
|
+
const rows = [
|
|
243
|
+
["input", truncate(record.resource || record.command || "(no resource)", 70)],
|
|
244
|
+
["risk", risk(String(record.risk ?? "unknown").toUpperCase()) + (record.risk_signals?.length ? dim(` ${record.risk_signals.join(", ")}`) : "")],
|
|
245
|
+
["policy", record.policy ?? record.rule ?? dim("— no rule matched (default deny)")],
|
|
246
|
+
["decision", tone(String(decision).toUpperCase()) + (record.enforced === false ? dim(" (not enforced — audit mode)") : "")],
|
|
247
|
+
["latency", `${record.latency_ms ?? "—"}ms`],
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
if (record.would_have) {
|
|
251
|
+
rows.push(["would have", red(String(record.would_have.decision).toUpperCase()) + dim(` by ${record.would_have.rule ?? "default-deny"}`)]);
|
|
252
|
+
}
|
|
253
|
+
if (record.approval_id) rows.push(["approval", blue(record.approval_id)]);
|
|
254
|
+
if (record.secrets_brokered?.length) rows.push(["secrets", `${record.secrets_brokered.join(", ")} ${dim("(brokered — the agent never held the value)")}`]);
|
|
255
|
+
if (record.secrets_detected?.length) {
|
|
256
|
+
rows.push(["detected", record.secrets_detected.map((s) => `${s.detector} ${dim(s.masked)}`).join(", ")]);
|
|
257
|
+
}
|
|
258
|
+
if (record.sanitized?.arguments?.length) {
|
|
259
|
+
rows.push(["sanitized", `${record.sanitized.arguments.length} value(s) stripped from arguments`]);
|
|
260
|
+
}
|
|
261
|
+
if (record.observed_by?.length) rows.push(["observed", dim(record.observed_by.join(", "))]);
|
|
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
|
+
});
|
|
273
|
+
|
|
274
|
+
if (record.reason) {
|
|
275
|
+
lines.push("");
|
|
276
|
+
lines.push(`${indent} ${dim(wrap(record.reason, 84, `${indent} `))}`);
|
|
277
|
+
}
|
|
278
|
+
return lines.join("\n");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function truncate(value, n) {
|
|
282
|
+
const s = String(value ?? "");
|
|
283
|
+
return s.length <= n ? s : `…${s.slice(-(n - 1))}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function wrap(text, width, prefix) {
|
|
287
|
+
const words = String(text).split(/\s+/);
|
|
288
|
+
const lines = [];
|
|
289
|
+
let line = "";
|
|
290
|
+
for (const w of words) {
|
|
291
|
+
if ((line + " " + w).trim().length > width) {
|
|
292
|
+
lines.push(line.trim());
|
|
293
|
+
line = w;
|
|
294
|
+
} else line += " " + w;
|
|
295
|
+
}
|
|
296
|
+
if (line.trim()) lines.push(line.trim());
|
|
297
|
+
return lines.join("\n" + prefix);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* -------------------------------------------------------------------------- */
|
|
301
|
+
/* Replay */
|
|
302
|
+
/* -------------------------------------------------------------------------- */
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Re-decides recorded calls under a candidate rule set.
|
|
306
|
+
*
|
|
307
|
+
* A record is replayable when it carries enough to reconstruct the request:
|
|
308
|
+
* an action and a resource. Older records, and records from a version that did
|
|
309
|
+
* not write those fields, are reported as not replayable rather than silently
|
|
310
|
+
* counted as unchanged — a diff that quietly omits what it could not evaluate
|
|
311
|
+
* is a diff that says "no change" when it means "did not look".
|
|
312
|
+
*
|
|
313
|
+
* NOTHING IS EXECUTED. Not the permitted calls, not the previously denied ones.
|
|
314
|
+
*
|
|
315
|
+
* @returns {{steps:Array, changed:number, replayable:number, caveat:string}}
|
|
316
|
+
*/
|
|
317
|
+
export function replay(records, rules, { cwd = process.cwd() } = {}) {
|
|
318
|
+
const steps = [];
|
|
319
|
+
let changed = 0;
|
|
320
|
+
let replayable = 0;
|
|
321
|
+
|
|
322
|
+
for (const record of records) {
|
|
323
|
+
if (record.malformed) continue;
|
|
324
|
+
|
|
325
|
+
const before = {
|
|
326
|
+
decision: record.decision ?? toDecision(record.verdict),
|
|
327
|
+
rule: record.policy ?? record.rule ?? null,
|
|
328
|
+
risk: record.risk ?? null,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
if (!record.action && !record.tool) {
|
|
332
|
+
steps.push({
|
|
333
|
+
request_id: record.request_id ?? null,
|
|
334
|
+
ts: record.ts ?? record.timestamp,
|
|
335
|
+
replayable: false,
|
|
336
|
+
reason: "Record carries no action; there is nothing to re-evaluate.",
|
|
337
|
+
before,
|
|
338
|
+
});
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
replayable++;
|
|
343
|
+
|
|
344
|
+
// Rebuilt from the record rather than re-derived from arguments: the
|
|
345
|
+
// arguments are not retained (they can contain secret material), so the
|
|
346
|
+
// recorded action and canonicalized resource are the inputs.
|
|
347
|
+
const request = {
|
|
348
|
+
agent: record.agent ?? "unknown",
|
|
349
|
+
action: record.action ?? record.tool,
|
|
350
|
+
resource: record.resource ?? "",
|
|
351
|
+
context: {
|
|
352
|
+
environment: record.environment ?? "local",
|
|
353
|
+
path: { insideWorkspace: record.inside_workspace ?? true },
|
|
354
|
+
egress: {
|
|
355
|
+
external: record.egress === "external",
|
|
356
|
+
allowlisted: false,
|
|
357
|
+
destination: record.destination ?? null,
|
|
358
|
+
},
|
|
359
|
+
session: { touchedSecret: false },
|
|
360
|
+
mcp: { server: record.server ?? null, tool: record.tool ?? null },
|
|
361
|
+
risk: record.risk ?? null,
|
|
362
|
+
command: record.command ?? null,
|
|
363
|
+
secrets: { detected: record.secrets_detected?.length ?? 0 },
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const result = evaluate(request, rules, { cwd });
|
|
368
|
+
const after = {
|
|
369
|
+
decision: result.decision ?? toDecision(result.verdict),
|
|
370
|
+
rule: result.rule,
|
|
371
|
+
reason: result.reason,
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const didChange = after.decision !== before.decision || after.rule !== before.rule;
|
|
375
|
+
if (didChange) changed++;
|
|
376
|
+
|
|
377
|
+
steps.push({
|
|
378
|
+
request_id: record.request_id ?? record.decision_id ?? null,
|
|
379
|
+
ts: record.ts ?? record.timestamp,
|
|
380
|
+
action: record.action ?? record.tool,
|
|
381
|
+
resource: record.resource ?? "",
|
|
382
|
+
risk: record.risk,
|
|
383
|
+
replayable: true,
|
|
384
|
+
changed: didChange,
|
|
385
|
+
before,
|
|
386
|
+
after,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
steps,
|
|
392
|
+
changed,
|
|
393
|
+
replayable,
|
|
394
|
+
caveat:
|
|
395
|
+
"Replay re-evaluates recorded decisions. It does not re-execute calls and cannot undo their effects. Session-dependent context (secret taint, allowlists) is not reconstructed, so a decision that depended on it may differ here.",
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** A single call, re-decided. Used by `cirvix replay <request-id>`. */
|
|
400
|
+
export function replayOne(record, rules, options) {
|
|
401
|
+
const result = replay([record], rules, options);
|
|
402
|
+
return result.steps[0] ?? null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Re-runs a *live* call description through normalization and policy.
|
|
407
|
+
*
|
|
408
|
+
* This is what `cirvix check` uses: it takes a tool and arguments rather than a
|
|
409
|
+
* historical record, so the risk classification is recomputed from the real
|
|
410
|
+
* inputs instead of read back from the log.
|
|
411
|
+
*/
|
|
412
|
+
export function decideNow({ tool, server = null, args = {}, agent = "local", environment = "local", rules, cwd = process.cwd() }) {
|
|
413
|
+
const call = normalize({ tool, server, arguments: args }, { agent, environment, cwd });
|
|
414
|
+
const decision = evaluate(policyRequest(call), rules, { cwd });
|
|
415
|
+
decision.decision = decision.decision ?? toDecision(decision.verdict);
|
|
416
|
+
return { call, decision };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export { RISK_ORDER };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-RPC 2.0 framing for MCP over stdio.
|
|
3
|
+
*
|
|
4
|
+
* MCP stdio transport is newline-delimited JSON. The subtlety that breaks
|
|
5
|
+
* naive implementations: a chunk from a pipe is NOT a message. A single read
|
|
6
|
+
* can deliver half a message, three messages, or a message split mid-UTF-8
|
|
7
|
+
* character. This buffers until a newline and decodes incrementally, which is
|
|
8
|
+
* the difference between a proxy that works and one that corrupts payloads
|
|
9
|
+
* under load.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { StringDecoder } from "node:string_decoder";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Splits a byte stream into JSON-RPC messages.
|
|
16
|
+
*
|
|
17
|
+
* `onMessage` receives parsed objects. Lines that fail to parse are passed to
|
|
18
|
+
* `onInvalid` rather than thrown — a proxy that dies on one malformed frame
|
|
19
|
+
* takes the agent down with it, and a hostile upstream could do that
|
|
20
|
+
* deliberately.
|
|
21
|
+
*/
|
|
22
|
+
export class MessageFramer {
|
|
23
|
+
#buffer = "";
|
|
24
|
+
#decoder = new StringDecoder("utf8");
|
|
25
|
+
#onMessage;
|
|
26
|
+
#onInvalid;
|
|
27
|
+
|
|
28
|
+
constructor({ onMessage, onInvalid = () => {} }) {
|
|
29
|
+
this.#onMessage = onMessage;
|
|
30
|
+
this.#onInvalid = onInvalid;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
push(chunk) {
|
|
34
|
+
this.#buffer += this.#decoder.write(chunk);
|
|
35
|
+
|
|
36
|
+
let index;
|
|
37
|
+
while ((index = this.#buffer.indexOf("\n")) !== -1) {
|
|
38
|
+
const line = this.#buffer.slice(0, index).trim();
|
|
39
|
+
this.#buffer = this.#buffer.slice(index + 1);
|
|
40
|
+
if (!line) continue;
|
|
41
|
+
try {
|
|
42
|
+
this.#onMessage(JSON.parse(line));
|
|
43
|
+
} catch (err) {
|
|
44
|
+
this.#onInvalid(line, err);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Flush any trailing partial character at end-of-stream. */
|
|
50
|
+
end() {
|
|
51
|
+
this.#buffer += this.#decoder.end();
|
|
52
|
+
const line = this.#buffer.trim();
|
|
53
|
+
this.#buffer = "";
|
|
54
|
+
if (!line) return;
|
|
55
|
+
try {
|
|
56
|
+
this.#onMessage(JSON.parse(line));
|
|
57
|
+
} catch (err) {
|
|
58
|
+
this.#onInvalid(line, err);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function serialize(message) {
|
|
64
|
+
return JSON.stringify(message) + "\n";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* -------------------------------------------------------------------------- */
|
|
68
|
+
/* Message shapes */
|
|
69
|
+
/* -------------------------------------------------------------------------- */
|
|
70
|
+
|
|
71
|
+
export const isRequest = (m) => m && m.jsonrpc === "2.0" && m.method && m.id !== undefined;
|
|
72
|
+
export const isNotification = (m) => m && m.jsonrpc === "2.0" && m.method && m.id === undefined;
|
|
73
|
+
export const isResponse = (m) => m && m.jsonrpc === "2.0" && m.id !== undefined && !m.method;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* JSON-RPC application error codes. -32000..-32099 is the reserved
|
|
77
|
+
* implementation-defined range; MCP leaves it to the server.
|
|
78
|
+
*/
|
|
79
|
+
export const ERROR_CODE = {
|
|
80
|
+
PARSE: -32700,
|
|
81
|
+
INVALID_REQUEST: -32600,
|
|
82
|
+
METHOD_NOT_FOUND: -32601,
|
|
83
|
+
INVALID_PARAMS: -32602,
|
|
84
|
+
INTERNAL: -32603,
|
|
85
|
+
/** Cirvix: the policy set denied this call. */
|
|
86
|
+
POLICY_DENIED: -32001,
|
|
87
|
+
/** Cirvix: the call is held awaiting human approval. */
|
|
88
|
+
POLICY_HOLD: -32002,
|
|
89
|
+
/** Cirvix: the upstream server is not registered or is quarantined. */
|
|
90
|
+
UPSTREAM_UNAVAILABLE: -32003,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export function errorResponse(id, code, message, data) {
|
|
94
|
+
return { jsonrpc: "2.0", id, error: { code, message, ...(data ? { data } : {}) } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A denial rendered as a *tool result* rather than a protocol error.
|
|
99
|
+
*
|
|
100
|
+
* This matters more than it looks. A JSON-RPC error is a transport failure —
|
|
101
|
+
* many agent runtimes surface it as a crash and abort the run. A tool result
|
|
102
|
+
* with `isError: true` is data the model reads, so the agent sees the refusal,
|
|
103
|
+
* the policy that caused it, and the suggested alternative, and re-plans.
|
|
104
|
+
* That single choice is the difference between a control plane and a kill
|
|
105
|
+
* switch.
|
|
106
|
+
*/
|
|
107
|
+
export function deniedToolResult(id, decision) {
|
|
108
|
+
const lines = [
|
|
109
|
+
`Denied by policy: ${decision.rule ?? "default-deny"}`,
|
|
110
|
+
decision.reason,
|
|
111
|
+
decision.remediation ? `Try instead: ${decision.remediation}` : null,
|
|
112
|
+
`Decision id: ${decision.decisionId ?? "—"}`,
|
|
113
|
+
].filter(Boolean);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
jsonrpc: "2.0",
|
|
117
|
+
id,
|
|
118
|
+
result: {
|
|
119
|
+
isError: true,
|
|
120
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
121
|
+
_meta: {
|
|
122
|
+
"cirvix/verdict": "deny",
|
|
123
|
+
"cirvix/rule": decision.rule,
|
|
124
|
+
"cirvix/decision_id": decision.decisionId,
|
|
125
|
+
"cirvix/appealable": true,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function heldToolResult(id, decision) {
|
|
132
|
+
const lines = [
|
|
133
|
+
`Held for human approval: ${decision.rule}`,
|
|
134
|
+
decision.reason,
|
|
135
|
+
decision.approvers?.length ? `Waiting on: ${decision.approvers.join(", ")}` : null,
|
|
136
|
+
`Approval id: ${decision.approvalId ?? "—"}`,
|
|
137
|
+
].filter(Boolean);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
jsonrpc: "2.0",
|
|
141
|
+
id,
|
|
142
|
+
result: {
|
|
143
|
+
isError: true,
|
|
144
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
145
|
+
_meta: {
|
|
146
|
+
"cirvix/verdict": "hold",
|
|
147
|
+
"cirvix/rule": decision.rule,
|
|
148
|
+
"cirvix/approval_id": decision.approvalId,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|