@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.
Files changed (45) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +42 -0
  3. package/README.md +341 -0
  4. package/action/README.md +100 -0
  5. package/action/action.yml +134 -0
  6. package/action/report.mjs +144 -0
  7. package/bin/cirvix.mjs +1073 -0
  8. package/package.json +60 -0
  9. package/src/commands/demo.mjs +315 -0
  10. package/src/commands/init.mjs +558 -0
  11. package/src/commands/policy.mjs +345 -0
  12. package/src/commands/sarif.mjs +176 -0
  13. package/src/commands/scan.mjs +210 -0
  14. package/src/commands/status.mjs +208 -0
  15. package/src/commands/upgrade.mjs +162 -0
  16. package/src/core/approvals.mjs +388 -0
  17. package/src/core/audit.mjs +181 -0
  18. package/src/core/canonical.mjs +316 -0
  19. package/src/core/daemon.mjs +352 -0
  20. package/src/core/decisions.mjs +253 -0
  21. package/src/core/delegation.mjs +658 -0
  22. package/src/core/detect.mjs +337 -0
  23. package/src/core/entitlement-gate.mjs +100 -0
  24. package/src/core/entitlements.mjs +285 -0
  25. package/src/core/format.mjs +33 -0
  26. package/src/core/gateway.mjs +959 -0
  27. package/src/core/guard.mjs +568 -0
  28. package/src/core/http-transport.mjs +505 -0
  29. package/src/core/journal.mjs +419 -0
  30. package/src/core/jsonrpc.mjs +152 -0
  31. package/src/core/meter.mjs +225 -0
  32. package/src/core/normalize.mjs +516 -0
  33. package/src/core/notices.mjs +80 -0
  34. package/src/core/pipeline.mjs +629 -0
  35. package/src/core/policy-dsl.mjs +611 -0
  36. package/src/core/policy.mjs +710 -0
  37. package/src/core/prompts.mjs +146 -0
  38. package/src/core/risk.mjs +509 -0
  39. package/src/core/sanitize.mjs +279 -0
  40. package/src/core/secret-detect.mjs +533 -0
  41. package/src/core/secrets.mjs +312 -0
  42. package/src/core/uds.mjs +383 -0
  43. package/src/core/vault.mjs +530 -0
  44. package/src/index.mjs +143 -0
  45. 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
+ }