@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.
- package/README.md +76 -17
- package/bin/cirvix.mjs +488 -40
- 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/demo.mjs +56 -70
- 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/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 +6 -4
- package/src/commands/shadow.mjs +62 -0
- package/src/commands/simulate.mjs +96 -0
- package/src/commands/status.mjs +121 -36
- package/src/commands/upgrade.mjs +17 -9
- 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 +6 -0
- package/src/core/escape-benchmark.mjs +597 -0
- package/src/core/evidence.mjs +212 -0
- package/src/core/format.mjs +27 -0
- 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/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 +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
|
+
}
|
package/src/core/format.mjs
CHANGED
|
@@ -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) {
|
package/src/core/gateway.mjs
CHANGED
|
@@ -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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
575
|
-
//
|
|
576
|
-
|
|
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.
|
|
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 } });
|