@cirvix_ai/agent-control 0.1.2 → 0.1.5

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 (70) hide show
  1. package/README.md +76 -17
  2. package/bin/cirvix.mjs +488 -40
  3. package/bin/escape-benchmark.mjs +67 -0
  4. package/package.json +36 -16
  5. package/src/adapters/base.mjs +150 -0
  6. package/src/adapters/claude-code.mjs +161 -0
  7. package/src/adapters/cline.mjs +107 -0
  8. package/src/adapters/codex.mjs +104 -0
  9. package/src/adapters/cursor.mjs +104 -0
  10. package/src/adapters/frameworks.mjs +110 -0
  11. package/src/adapters/gemini-cli.mjs +104 -0
  12. package/src/adapters/generic-mcp.mjs +101 -0
  13. package/src/adapters/index.mjs +209 -0
  14. package/src/adapters/roo-code.mjs +106 -0
  15. package/src/adapters/vscode.mjs +104 -0
  16. package/src/adapters/windsurf.mjs +107 -0
  17. package/src/commands/demo.mjs +56 -70
  18. package/src/commands/doctor.mjs +235 -0
  19. package/src/commands/init.mjs +292 -30
  20. package/src/commands/interactive.mjs +690 -0
  21. package/src/commands/kill.mjs +74 -0
  22. package/src/commands/login.mjs +227 -0
  23. package/src/commands/passport.mjs +149 -0
  24. package/src/commands/policy.mjs +10 -6
  25. package/src/commands/protect.mjs +293 -0
  26. package/src/commands/prove.mjs +209 -0
  27. package/src/commands/redteam.mjs +51 -0
  28. package/src/commands/scan.mjs +6 -4
  29. package/src/commands/shadow.mjs +62 -0
  30. package/src/commands/simulate.mjs +96 -0
  31. package/src/commands/status.mjs +121 -36
  32. package/src/commands/upgrade.mjs +17 -9
  33. package/src/commands/welcome.mjs +105 -0
  34. package/src/core/authority.mjs +909 -0
  35. package/src/core/baseline.mjs +97 -0
  36. package/src/core/config-store.mjs +280 -0
  37. package/src/core/cost.mjs +0 -0
  38. package/src/core/detect.mjs +4 -33
  39. package/src/core/entitlements.mjs +6 -0
  40. package/src/core/escape-benchmark.mjs +597 -0
  41. package/src/core/evidence.mjs +212 -0
  42. package/src/core/format.mjs +27 -0
  43. package/src/core/gateway.mjs +15 -211
  44. package/src/core/graph.mjs +270 -0
  45. package/src/core/guard.mjs +118 -4
  46. package/src/core/intent.mjs +166 -0
  47. package/src/core/journal.mjs +131 -40
  48. package/src/core/kill-switch.mjs +122 -0
  49. package/src/core/notices.mjs +22 -2
  50. package/src/core/packs.mjs +193 -0
  51. package/src/core/passport.mjs +555 -0
  52. package/src/core/pipeline.mjs +148 -6
  53. package/src/core/prompts.mjs +51 -0
  54. package/src/core/proof.mjs +440 -0
  55. package/src/core/redteam/index.mjs +185 -0
  56. package/src/core/referral.mjs +187 -0
  57. package/src/core/sandbox.mjs +139 -0
  58. package/src/core/session.mjs +172 -0
  59. package/src/core/shadow.mjs +95 -0
  60. package/src/core/trifecta.mjs +321 -0
  61. package/src/core/ui/controller.mjs +192 -0
  62. package/src/core/ui/decisions.mjs +55 -0
  63. package/src/core/ui/index.mjs +49 -0
  64. package/src/core/ui/intercept.mjs +103 -0
  65. package/src/core/ui/live.mjs +51 -0
  66. package/src/core/ui/primitives.mjs +123 -0
  67. package/src/core/ui/theme.mjs +92 -0
  68. package/src/core/verified.mjs +108 -0
  69. package/src/core/windows.mjs +270 -0
  70. package/src/index.mjs +25 -0
@@ -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
+ }
@@ -26,6 +26,33 @@ export const red = wrap(31, 39);
26
26
  export const green = wrap(32, 39);
27
27
  export const amber = wrap(33, 39);
28
28
  export const blue = wrap(34, 39);
29
+ export const cyan = wrap(36, 39);
30
+ export const gray = wrap(90, 39);
31
+ export const white = wrap(97, 39);
32
+
33
+ /** Strip ANSI escape sequences for width calculation and secret checks. */
34
+ export function stripAnsi(s) {
35
+ return String(s).replace(/\u001b\[[0-9;]*m/g, "");
36
+ }
37
+
38
+ /** Visible character width, ignoring ANSI. */
39
+ export function visibleWidth(s) {
40
+ return stripAnsi(String(s)).length;
41
+ }
42
+
43
+ /** True when output should be decorated (TTY, not NO_COLOR, not dumb, not CI unless forced). */
44
+ export function isInteractive() {
45
+ if (disabled) return false;
46
+ if (process.env.CI !== undefined && !forced) return false;
47
+ return Boolean(process.stdout.isTTY);
48
+ }
49
+
50
+ /** Whether unicode box-drawing is safe. ASCII fallback when TERM=dumb or CIRVIX_ASCII=1. */
51
+ export function supportsUnicode() {
52
+ if (process.env.CIRVIX_ASCII === "1") return false;
53
+ if (process.env.TERM === "dumb") return false;
54
+ return true;
55
+ }
29
56
 
30
57
  /** "1 server" / "3 servers" — avoids the "1 servers" that reads as a bug. */
31
58
  export function plural(n, noun, pluralForm) {
@@ -42,17 +42,6 @@
42
42
  * the only point in the process where a credential exists inside a request,
43
43
  * and it sits downstream of the decision that authorized it. See
44
44
  * `./secrets.mjs`.
45
- *
46
- * WHAT THE GATEWAY DOES NOT GOVERN.
47
- *
48
- * The gateway governs traffic actually routed through it — every `tools/call`,
49
- * `resources/read`, `resources/subscribe`, `prompts/get`, `completion/complete`,
50
- * and unmodeled method crossing this process is evaluated before anything
51
- * executes, and unknown methods are default-denied. What never enters this
52
- * process is never evaluated: a direct MCP server entry in the agent's config,
53
- * the runtime's built-in tools, a subprocess the agent spawns, a socket the
54
- * agent opens itself. Those are routes around the boundary, not through it,
55
- * and no userspace gateway can interpose on them.
56
45
  */
57
46
 
58
47
  import { spawn } from "node:child_process";
@@ -66,6 +55,7 @@ const GATEWAY_VERSION = JSON.parse(
66
55
 
67
56
  import { Guard, actionForTool, destinationFor, resourceForCall } from "./guard.mjs";
68
57
  import { HttpUpstream } from "./http-transport.mjs";
58
+ import { prepareSpawn, killProcessTree } from "./windows.mjs";
69
59
  import { DECISION } from "./decisions.mjs";
70
60
  import {
71
61
  ERROR_CODE,
@@ -137,28 +127,12 @@ class Upstream {
137
127
 
138
128
  start() {
139
129
  const { command, args = [], env = {} } = this.spec;
140
- // Windows: Node >= 18.20 throws EINVAL when spawning .cmd/.bat shims
141
- // (npm, npx, pnpm) without a shell (CVE-2024-27980 mitigation). MCP configs
142
- // name such shims constantly. Going through the shell only for shims keeps
143
- // POSIX behaviour unchanged; with shell:true Node joins argv verbatim, so
144
- // every argument is quoted here to survive spaces and metacharacters.
145
- const isWindowsShim =
146
- process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
147
- const quoted = args.map((a) =>
148
- /[\s"^&|<>]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a,
149
- );
150
- this.proc = isWindowsShim
151
- ? spawn([command, ...quoted].join(" "), {
152
- stdio: ["pipe", "pipe", "pipe"],
153
- env: { ...process.env, ...env },
154
- shell: true,
155
- windowsHide: true,
156
- })
157
- : spawn(command, args, {
158
- stdio: ["pipe", "pipe", "pipe"],
159
- env: { ...process.env, ...env },
160
- shell: false,
161
- });
130
+ const prepared = prepareSpawn(command, args, {
131
+ stdio: ["pipe", "pipe", "pipe"],
132
+ env: { ...process.env, ...env },
133
+ shell: false,
134
+ });
135
+ this.proc = spawn(prepared.command, prepared.args, prepared.options);
162
136
 
163
137
  this.alive = true;
164
138
 
@@ -235,7 +209,7 @@ class Upstream {
235
209
  stop() {
236
210
  this.alive = false;
237
211
  try {
238
- this.proc?.kill();
212
+ if (this.proc) killProcessTree(this.proc);
239
213
  } catch {
240
214
  /* already gone */
241
215
  }
@@ -476,15 +450,10 @@ export class Gateway {
476
450
  /* ---------------------------------------------------------------------- */
477
451
 
478
452
  async handleClientMessage(message) {
479
- // Notifications carry no id and expect no answer, but they still reach
480
- // upstream processes — so they still cross the boundary. A small set of
481
- // lifecycle notifications is benign plumbing; anything else is evaluated
482
- // like any other call, and dropped unless permitted. Previously every
483
- // notification was broadcast to ALL upstreams unevaluated, so a
484
- // tools/call-shaped action framed as a notification bypassed the engine
485
- // entirely, with no decision and no audit record.
453
+ // Notifications are forwarded to every upstream and never answered.
486
454
  if (message.id === undefined && message.method) {
487
- return this.#handleNotification(message);
455
+ for (const up of this.upstreams.values()) up.send(message);
456
+ return;
488
457
  }
489
458
 
490
459
  switch (message.method) {
@@ -541,173 +510,11 @@ export class Gateway {
541
510
  this.write({ jsonrpc: "2.0", id: message.id, result: {} });
542
511
  return;
543
512
 
544
- /*
545
- * `prompts/get` returns server-authored text that enters the model's
546
- * context with instruction-level authority — the same reason tool
547
- * definitions are pinned. It was falling through to the default branch
548
- * and reaching the agent with no rule consulted and no decision
549
- * recorded. It is now evaluated as a read of the named prompt.
550
- */
551
- case "prompts/get":
552
- return this.#handlePromptsGet(message);
553
-
554
- /*
555
- * `completion/complete` asks an upstream to complete an argument value.
556
- * Low-risk content, but still upstream-influenced text entering the
557
- * agent loop — evaluated, then forwarded on permit.
558
- */
559
- case "completion/complete":
560
- return this.#handleCompletion(message);
561
-
562
- /*
563
- * `logging/setLevel` carries no content and executes nothing: it asks
564
- * upstreams to adjust log verbosity. Forwarded as benign plumbing, and
565
- * reported on the protocol sink (not the decision sink) so it can never
566
- * be mistaken for a policy decision.
567
- */
568
- case "logging/setLevel":
569
- this.onDecision({ kind: "protocol", method: message.method, action: "forward" });
570
- this.log(`forward ${message.method} (benign protocol plumbing)`);
571
- return this.#forwardToAny(message);
572
-
573
513
  default:
574
- // Default-deny applies to methods, not just tools. Anything the
575
- // gateway does not model is evaluated as `mcp.<method>` against the
576
- // active policy and forwarded only on an explicit permit — previously
577
- // this branch forwarded to the first live upstream unevaluated and
578
- // unrecorded, which made every unmodeled method a full bypass.
579
- return this.#rejectUnknown(message);
580
- }
581
- }
582
-
583
- /* Allowlisted lifecycle notifications: session plumbing with no content and
584
- * no upstream side effect beyond what the protocol requires. Reported on
585
- * the protocol sink, never the decision sink. */
586
- static #BENIGN_NOTIFICATIONS = new Set([
587
- "notifications/initialized",
588
- "notifications/cancelled",
589
- "notifications/progress",
590
- ]);
591
-
592
- async #handleNotification(message) {
593
- if (Gateway.#BENIGN_NOTIFICATIONS.has(message.method)) {
594
- this.onDecision({ kind: "protocol", method: message.method, action: "forward" });
595
- for (const up of this.upstreams.values()) up.send(message);
596
- return;
597
- }
598
- const { decision } = await this.guard.authorize({
599
- tool: `mcp.notification.${message.method}`,
600
- server: null,
601
- args: message.params ?? {},
602
- ...callerIdentity(message.params),
603
- });
604
- this.stats = this.guard.stats;
605
- if (decision.verdict !== "permit") {
606
- this.log(`DROP notification ${message.method} (${decision.rule ?? "default-deny"})`);
607
- return;
608
- }
609
- this.onDecision({ kind: "protocol", method: message.method, action: "forward", decision: decision.decisionId });
610
- for (const up of this.upstreams.values()) up.send(message);
611
- }
612
-
613
- async #handlePromptsGet(message) {
614
- const fullName = message.params?.name ?? "";
615
- const sep = fullName.indexOf(NS);
616
- const server = sep === -1 ? null : fullName.slice(0, sep);
617
- const promptName = sep === -1 ? fullName : fullName.slice(sep + NS.length);
618
- const up = server ? this.upstreams.get(server) : null;
619
-
620
- if (!up || !up.alive) {
621
- this.write(
622
- errorResponse(
623
- message.id,
624
- ERROR_CODE.UPSTREAM_UNAVAILABLE,
625
- `No registered server for prompt "${fullName}".`,
626
- ),
627
- );
628
- return;
629
- }
630
-
631
- const { agent: callerAgent, delegation } = callerIdentity(message.params);
632
- const { decision } = await this.guard.authorize({
633
- tool: "prompts.get",
634
- server,
635
- args: { name: promptName, ...(message.params?.arguments ?? {}) },
636
- agent: callerAgent,
637
- delegation,
638
- });
639
- this.stats = this.guard.stats;
640
-
641
- if (decision.verdict === "deny") {
642
- this.log(`DENY prompts/get ${promptName} (${decision.rule})`);
643
- this.write(deniedToolResult(message.id, decision));
644
- return;
645
- }
646
- if (decision.verdict === "hold") {
647
- decision.approvalId = `apr_${String(decision.decisionId).slice(4, 12)}`;
648
- this.log(`HOLD prompts/get ${promptName} (${decision.rule})`);
649
- this.write(heldToolResult(message.id, decision));
650
- return;
651
- }
652
-
653
- const gatewayId = `gw-${this.nextGatewayId++}`;
654
- this.inflight.set(gatewayId, { clientId: message.id, upstream: up, decision });
655
- up.send({
656
- jsonrpc: "2.0",
657
- id: gatewayId,
658
- method: "prompts/get",
659
- params: { ...message.params, name: promptName },
660
- });
661
- }
662
-
663
- async #handleCompletion(message) {
664
- const ref = message.params?.ref ?? {};
665
- const target = typeof ref.name === "string" && ref.name
666
- ? ref.name
667
- : typeof ref.uri === "string" ? ref.uri : "";
668
- const { agent: callerAgent, delegation } = callerIdentity(message.params);
669
- const { decision } = await this.guard.authorize({
670
- tool: "completion.complete",
671
- server: null,
672
- args: { ref: target, argument: message.params?.argument ?? {} },
673
- agent: callerAgent,
674
- delegation,
675
- });
676
- this.stats = this.guard.stats;
677
-
678
- if (decision.verdict === "deny") {
679
- this.log(`DENY completion/complete ${target} (${decision.rule})`);
680
- this.write(deniedToolResult(message.id, decision));
681
- return;
682
- }
683
- if (decision.verdict === "hold") {
684
- decision.approvalId = `apr_${String(decision.decisionId).slice(4, 12)}`;
685
- this.log(`HOLD completion/complete ${target} (${decision.rule})`);
686
- this.write(heldToolResult(message.id, decision));
687
- return;
688
- }
689
- return this.#forwardToAny(message);
690
- }
691
-
692
- async #rejectUnknown(message) {
693
- const method = message.method ?? "(missing)";
694
- const { agent: callerAgent, delegation } = callerIdentity(message.params);
695
- const { decision } = await this.guard.authorize({
696
- tool: `mcp.${method}`,
697
- server: null,
698
- args: message.params ?? {},
699
- agent: callerAgent,
700
- delegation,
701
- });
702
- this.stats = this.guard.stats;
703
-
704
- if (decision.verdict !== "permit") {
705
- this.log(`DENY ${method} (${decision.rule ?? "default-deny"}) — unmodeled method, no explicit permit`);
706
- this.write(deniedToolResult(message.id, decision));
707
- return;
514
+ // Anything else is broadcast to the first live upstream. The gateway
515
+ // deliberately does not invent behaviour for methods it doesn't model.
516
+ return this.#forwardToAny(message);
708
517
  }
709
- this.log(`PERMIT ${method} (${decision.rule}) — explicitly permitted unmodeled method`);
710
- return this.#forwardToAny(message);
711
518
  }
712
519
 
713
520
  #handleInitialize(message) {
@@ -853,11 +660,8 @@ export class Gateway {
853
660
  }
854
661
 
855
662
  // Unsubscribing is always permitted: refusing to let an agent stop
856
- // receiving something is not a security property. Reported on the
857
- // protocol sink so the forward is visible without fabricating a policy
858
- // decision that never happened.
663
+ // receiving something is not a security property.
859
664
  if (message.method === "resources/unsubscribe") {
860
- this.onDecision({ kind: "protocol", method: message.method, action: "forward" });
861
665
  const gatewayId = `gw-${this.nextGatewayId++}`;
862
666
  this.inflight.set(gatewayId, { clientId: message.id, upstream: up });
863
667
  up.send({ jsonrpc: "2.0", id: gatewayId, method: message.method, params: { ...message.params, uri } });