@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,959 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP gateway — the interception layer.
|
|
3
|
+
*
|
|
4
|
+
* This is the product. Everything else describes what this does.
|
|
5
|
+
*
|
|
6
|
+
* The agent connects to Cirvix believing it is talking to an MCP server.
|
|
7
|
+
* Cirvix connects to the real servers. Every `tools/call` crossing the
|
|
8
|
+
* boundary is decoded, evaluated against policy, recorded, and then either
|
|
9
|
+
* forwarded, refused, or held.
|
|
10
|
+
*
|
|
11
|
+
* ARCHITECTURE
|
|
12
|
+
*
|
|
13
|
+
* agent ──stdio──▶ Gateway ──stdio──▶ upstream server A
|
|
14
|
+
* │ ──stdio──▶ upstream server B
|
|
15
|
+
* ├─ policy engine
|
|
16
|
+
* └─ audit chain
|
|
17
|
+
*
|
|
18
|
+
* Design decisions that matter:
|
|
19
|
+
*
|
|
20
|
+
* - TOOL NAMES ARE NAMESPACED (`server__tool`). Two servers may both expose
|
|
21
|
+
* `search`. Without namespacing the gateway cannot route the call and,
|
|
22
|
+
* worse, a policy written for one server silently governs the other.
|
|
23
|
+
*
|
|
24
|
+
* - IDs ARE REWRITTEN. The agent's request ids and each upstream's id space
|
|
25
|
+
* are independent. Forwarding an id unchanged means two servers can answer
|
|
26
|
+
* with the same id and the gateway mis-routes a response. Every in-flight
|
|
27
|
+
* request gets a gateway-owned id mapped back on the way out.
|
|
28
|
+
*
|
|
29
|
+
* - TOOL DEFINITIONS ARE PINNED. A tool's description is instruction text that
|
|
30
|
+
* enters the model's context with the authority of a system message, and it
|
|
31
|
+
* is supplied by the server, not by you. The gateway hashes each definition
|
|
32
|
+
* on first sight; a changed definition is withheld until re-approved.
|
|
33
|
+
*
|
|
34
|
+
* - DENIALS ARE TOOL RESULTS, NOT TRANSPORT ERRORS. See `deniedToolResult`.
|
|
35
|
+
*
|
|
36
|
+
* - ONE UPSTREAM FAILING MUST NOT TAKE DOWN THE SESSION. A dead server's
|
|
37
|
+
* tools disappear from `tools/list`; calls to it return a clean error.
|
|
38
|
+
*
|
|
39
|
+
* - SECRETS ARE SUBSTITUTED HERE, IF ANYWHERE. When a broker is attached, a
|
|
40
|
+
* permitted call's arguments are resolved from handles into real material
|
|
41
|
+
* on the way out, and scanned for that material on the way back. This is
|
|
42
|
+
* the only point in the process where a credential exists inside a request,
|
|
43
|
+
* and it sits downstream of the decision that authorized it. See
|
|
44
|
+
* `./secrets.mjs`.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { spawn } from "node:child_process";
|
|
48
|
+
import { createHash } from "node:crypto";
|
|
49
|
+
import { readFileSync } from "node:fs";
|
|
50
|
+
|
|
51
|
+
/** Read from the manifest rather than written down twice. See `cirvix --version`. */
|
|
52
|
+
const GATEWAY_VERSION = JSON.parse(
|
|
53
|
+
readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
|
|
54
|
+
).version;
|
|
55
|
+
|
|
56
|
+
import { Guard, actionForTool, destinationFor, resourceForCall } from "./guard.mjs";
|
|
57
|
+
import { HttpUpstream } from "./http-transport.mjs";
|
|
58
|
+
import { DECISION } from "./decisions.mjs";
|
|
59
|
+
import {
|
|
60
|
+
ERROR_CODE,
|
|
61
|
+
MessageFramer,
|
|
62
|
+
deniedToolResult,
|
|
63
|
+
errorResponse,
|
|
64
|
+
heldToolResult,
|
|
65
|
+
serialize,
|
|
66
|
+
} from "./jsonrpc.mjs";
|
|
67
|
+
|
|
68
|
+
// Re-exported so existing importers of the gateway keep working; the
|
|
69
|
+
// definitions live in guard.mjs because the SDK needs them too, and two
|
|
70
|
+
// implementations of "what action is this tool" is two policies.
|
|
71
|
+
export { actionForTool, destinationFor, resourceForCall };
|
|
72
|
+
|
|
73
|
+
const NS = "__";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Turns a `file://` URI into the path the filesystem rules are written against.
|
|
77
|
+
*
|
|
78
|
+
* Without this, `resources/read` with `file:///home/u/.aws/credentials` would be
|
|
79
|
+
* canonicalized as a URL — scheme, empty host, path — and a rule written
|
|
80
|
+
* `path = **\/.aws/**` would not match it, because the engine treats anything
|
|
81
|
+
* with a scheme as a URL rather than a path. The rule and the call would be
|
|
82
|
+
* about the same file and disagree about it.
|
|
83
|
+
*
|
|
84
|
+
* A non-file URI is returned unchanged and evaluated as a URL, which is
|
|
85
|
+
* correct: `https://…` as a resource really is an egress.
|
|
86
|
+
*/
|
|
87
|
+
export function fileUriToPath(uri) {
|
|
88
|
+
const value = String(uri ?? "");
|
|
89
|
+
if (!/^file:\/\//i.test(value)) return value;
|
|
90
|
+
try {
|
|
91
|
+
const url = new URL(value);
|
|
92
|
+
// `file:///C:/x` → `/C:/x`; drop the leading slash so it reads as a drive.
|
|
93
|
+
const decoded = decodeURIComponent(url.pathname);
|
|
94
|
+
return /^\/[A-Za-z]:/.test(decoded) ? decoded.slice(1) : decoded;
|
|
95
|
+
} catch {
|
|
96
|
+
return value.replace(/^file:\/\//i, "");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Stable fingerprint of a tool definition, used for drift detection. */
|
|
101
|
+
export function fingerprintTool(tool) {
|
|
102
|
+
const canonical = JSON.stringify({
|
|
103
|
+
name: tool.name,
|
|
104
|
+
description: tool.description ?? "",
|
|
105
|
+
inputSchema: tool.inputSchema ?? null,
|
|
106
|
+
});
|
|
107
|
+
return "sha256:" + createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* -------------------------------------------------------------------------- */
|
|
111
|
+
/* Upstream */
|
|
112
|
+
/* -------------------------------------------------------------------------- */
|
|
113
|
+
|
|
114
|
+
class Upstream {
|
|
115
|
+
constructor(name, spec, { onMessage, onExit, log }) {
|
|
116
|
+
this.name = name;
|
|
117
|
+
this.spec = spec;
|
|
118
|
+
this.alive = false;
|
|
119
|
+
this.tools = new Map();
|
|
120
|
+
this.log = log;
|
|
121
|
+
this.onMessage = onMessage;
|
|
122
|
+
this.onExit = onExit;
|
|
123
|
+
this.pending = new Map();
|
|
124
|
+
this.nextId = 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
start() {
|
|
128
|
+
const { command, args = [], env = {} } = this.spec;
|
|
129
|
+
// Windows: Node >= 18.20 throws EINVAL when spawning .cmd/.bat shims
|
|
130
|
+
// (npm, npx, pnpm) without a shell (CVE-2024-27980 mitigation). MCP configs
|
|
131
|
+
// name such shims constantly. Going through the shell only for shims keeps
|
|
132
|
+
// POSIX behaviour unchanged; with shell:true Node joins argv verbatim, so
|
|
133
|
+
// every argument is quoted here to survive spaces and metacharacters.
|
|
134
|
+
const isWindowsShim =
|
|
135
|
+
process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
|
|
136
|
+
const quoted = args.map((a) =>
|
|
137
|
+
/[\s"^&|<>]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a,
|
|
138
|
+
);
|
|
139
|
+
this.proc = isWindowsShim
|
|
140
|
+
? spawn([command, ...quoted].join(" "), {
|
|
141
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
142
|
+
env: { ...process.env, ...env },
|
|
143
|
+
shell: true,
|
|
144
|
+
windowsHide: true,
|
|
145
|
+
})
|
|
146
|
+
: spawn(command, args, {
|
|
147
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
148
|
+
env: { ...process.env, ...env },
|
|
149
|
+
shell: false,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
this.alive = true;
|
|
153
|
+
|
|
154
|
+
const framer = new MessageFramer({
|
|
155
|
+
onMessage: (m) => this.onMessage(this, m),
|
|
156
|
+
onInvalid: (line) => this.log(`upstream ${this.name} sent unparseable frame`, { line: line.slice(0, 200) }),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
this.proc.stdout.on("data", (c) => framer.push(c));
|
|
160
|
+
this.proc.stdout.on("end", () => framer.end());
|
|
161
|
+
// Upstream stderr is diagnostic, never protocol. Surfacing it on our own
|
|
162
|
+
// stderr keeps it out of the agent's stdout channel, which would corrupt
|
|
163
|
+
// the JSON-RPC stream.
|
|
164
|
+
this.proc.stderr.on("data", (c) =>
|
|
165
|
+
this.log(`upstream ${this.name}: ${String(c).trim().slice(0, 400)}`),
|
|
166
|
+
);
|
|
167
|
+
this.proc.on("exit", (code) => {
|
|
168
|
+
this.alive = false;
|
|
169
|
+
this.log(`upstream ${this.name} exited`, { code });
|
|
170
|
+
// Fail every in-flight request rather than leaving the agent hanging.
|
|
171
|
+
for (const [, entry] of this.pending) {
|
|
172
|
+
entry.reject(new Error(`upstream ${this.name} exited`));
|
|
173
|
+
}
|
|
174
|
+
this.pending.clear();
|
|
175
|
+
this.onExit(this);
|
|
176
|
+
});
|
|
177
|
+
this.proc.on("error", (err) => {
|
|
178
|
+
this.alive = false;
|
|
179
|
+
this.log(`upstream ${this.name} failed to start: ${err.message}`);
|
|
180
|
+
this.onExit(this);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return this;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
send(message) {
|
|
187
|
+
if (!this.alive || !this.proc?.stdin.writable) return false;
|
|
188
|
+
this.proc.stdin.write(serialize(message));
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Sends a request and resolves with the matching response. */
|
|
193
|
+
request(method, params, timeoutMs = 20000) {
|
|
194
|
+
return new Promise((resolve, reject) => {
|
|
195
|
+
if (!this.alive) return reject(new Error(`upstream ${this.name} is not running`));
|
|
196
|
+
const id = `cx-${this.name}-${this.nextId++}`;
|
|
197
|
+
const timer = setTimeout(() => {
|
|
198
|
+
this.pending.delete(id);
|
|
199
|
+
reject(new Error(`upstream ${this.name} timed out on ${method}`));
|
|
200
|
+
}, timeoutMs);
|
|
201
|
+
this.pending.set(id, {
|
|
202
|
+
resolve: (v) => {
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
resolve(v);
|
|
205
|
+
},
|
|
206
|
+
reject: (e) => {
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
reject(e);
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
this.send({ jsonrpc: "2.0", id, method, params });
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
settle(message) {
|
|
216
|
+
const entry = this.pending.get(message.id);
|
|
217
|
+
if (!entry) return false;
|
|
218
|
+
this.pending.delete(message.id);
|
|
219
|
+
if (message.error) entry.reject(Object.assign(new Error(message.error.message), { rpc: message.error }));
|
|
220
|
+
else entry.resolve(message.result);
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
stop() {
|
|
225
|
+
this.alive = false;
|
|
226
|
+
try {
|
|
227
|
+
this.proc?.kill();
|
|
228
|
+
} catch {
|
|
229
|
+
/* already gone */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* -------------------------------------------------------------------------- */
|
|
235
|
+
/* Gateway */
|
|
236
|
+
/* -------------------------------------------------------------------------- */
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The caller's claimed identity and delegation, read from MCP's `_meta`.
|
|
240
|
+
*
|
|
241
|
+
* params._meta.cirvix = { agent, delegation }
|
|
242
|
+
*
|
|
243
|
+
* `_meta` is the protocol's own extension point, so this rides the standard
|
|
244
|
+
* wire format rather than inventing a parallel one.
|
|
245
|
+
*
|
|
246
|
+
* ON TRUSTING AN UNAUTHENTICATED FIELD
|
|
247
|
+
*
|
|
248
|
+
* Both values are attacker-controlled, and neither is trusted:
|
|
249
|
+
*
|
|
250
|
+
* · `delegation` is HMAC-signed by the broker and bound to its subject.
|
|
251
|
+
* Forging one fails the signature; presenting somebody else's fails the
|
|
252
|
+
* subject check. Its only power is to REMOVE authority, so the worst a
|
|
253
|
+
* caller achieves by lying is refusing itself.
|
|
254
|
+
*
|
|
255
|
+
* · `agent` is a name, and a name proves nothing — the delegation suite says
|
|
256
|
+
* so at length. It selects which agent-scoped rules apply and which
|
|
257
|
+
* handles resolve. Claiming a privileged name grants nothing, because
|
|
258
|
+
* nothing is granted BY the name: a claimed name with no matching signed
|
|
259
|
+
* grant and no handle bound to it is strictly weaker than the default.
|
|
260
|
+
*
|
|
261
|
+
* The honest limit: without delegation configured, `agent` is a self-asserted
|
|
262
|
+
* label suitable for attribution, not for authorization. Rules that key on
|
|
263
|
+
* agent identity in a hostile multi-agent deployment need a grant behind them.
|
|
264
|
+
*/
|
|
265
|
+
export function callerIdentity(params) {
|
|
266
|
+
const meta = params?._meta?.cirvix;
|
|
267
|
+
if (!meta || typeof meta !== "object") return { agent: null, delegation: null };
|
|
268
|
+
return {
|
|
269
|
+
agent: typeof meta.agent === "string" && meta.agent ? meta.agent : null,
|
|
270
|
+
delegation: meta.delegation ?? null,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export class Gateway {
|
|
275
|
+
#agentName = "local";
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* @param {object} opts
|
|
279
|
+
* @param {Record<string, {command:string,args?:string[],env?:object}>} opts.servers
|
|
280
|
+
* @param {Array} opts.rules policy rule set
|
|
281
|
+
* @param {object} [opts.audit] AuditChain (optional)
|
|
282
|
+
* @param {(name:string)=>string[]|null} [opts.scopeFor] per-server tool allowlist
|
|
283
|
+
* @param {(msg:string,extra?:object)=>void} [opts.log]
|
|
284
|
+
* @param {(decision:object)=>void} [opts.onDecision] telemetry sink
|
|
285
|
+
* @param {import("./secrets.mjs").SecretsClient|null} [opts.secrets] handle broker
|
|
286
|
+
*/
|
|
287
|
+
constructor({
|
|
288
|
+
servers,
|
|
289
|
+
rules,
|
|
290
|
+
audit = null,
|
|
291
|
+
scopeFor = () => null,
|
|
292
|
+
log = () => {},
|
|
293
|
+
onDecision = () => {},
|
|
294
|
+
cwd = process.cwd(),
|
|
295
|
+
environment = "local",
|
|
296
|
+
pins = new Map(),
|
|
297
|
+
secrets = null,
|
|
298
|
+
delegation = null,
|
|
299
|
+
/* Forwarded straight to the Guard. Absent means unmetered, which is the
|
|
300
|
+
right default for an embedding caller and was the wrong one for the CLI:
|
|
301
|
+
the gateway is the path most Free-tier traffic takes, and it counted
|
|
302
|
+
nothing. */
|
|
303
|
+
licence = null,
|
|
304
|
+
meter = null,
|
|
305
|
+
agents = null,
|
|
306
|
+
}) {
|
|
307
|
+
this.serversSpec = servers;
|
|
308
|
+
this.audit = audit;
|
|
309
|
+
this.scopeFor = scopeFor;
|
|
310
|
+
this.log = log;
|
|
311
|
+
this.onDecision = onDecision;
|
|
312
|
+
this.cwd = cwd;
|
|
313
|
+
/** name → fingerprint captured at approval time. */
|
|
314
|
+
this.pins = pins;
|
|
315
|
+
|
|
316
|
+
this.upstreams = new Map();
|
|
317
|
+
/**
|
|
318
|
+
* The session this gateway is recording.
|
|
319
|
+
*
|
|
320
|
+
* Every decision carries it, so the control plane can reconstruct the run
|
|
321
|
+
* in order rather than holding a flat list of tool calls with no notion of
|
|
322
|
+
* what they belonged to. Set by the CLI once a run is opened; a gateway
|
|
323
|
+
* running without a control plane simply leaves it null and the decisions
|
|
324
|
+
* are still individually valid.
|
|
325
|
+
*/
|
|
326
|
+
/** gatewayId → { clientId, upstream } for response routing. */
|
|
327
|
+
this.inflight = new Map();
|
|
328
|
+
this.nextGatewayId = 1;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The shared decision core. Built here rather than inlined so the gateway
|
|
332
|
+
* and `guard.wrap()` cannot answer the same question differently.
|
|
333
|
+
*/
|
|
334
|
+
this.guard = new Guard({
|
|
335
|
+
rules,
|
|
336
|
+
agent: this.agentName ?? "local",
|
|
337
|
+
environment,
|
|
338
|
+
cwd,
|
|
339
|
+
audit,
|
|
340
|
+
secrets,
|
|
341
|
+
delegation,
|
|
342
|
+
licence,
|
|
343
|
+
meter,
|
|
344
|
+
agents,
|
|
345
|
+
onDecision,
|
|
346
|
+
log,
|
|
347
|
+
});
|
|
348
|
+
this.stats = this.guard.stats;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Session taint, owned by the core. */
|
|
352
|
+
get touchedSecret() {
|
|
353
|
+
return this.guard.touchedSecret;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
set touchedSecret(value) {
|
|
357
|
+
this.guard.touchedSecret = value;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The name this gateway reports for its agent.
|
|
362
|
+
*
|
|
363
|
+
* Assigned after construction by the CLI, so it has to reach the core —
|
|
364
|
+
* a decision attributed to "local" when the operator named the agent is a
|
|
365
|
+
* decision nobody can find later.
|
|
366
|
+
*/
|
|
367
|
+
get agentName() {
|
|
368
|
+
return this.guard?.agent ?? this.#agentName;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
set agentName(value) {
|
|
372
|
+
this.#agentName = value;
|
|
373
|
+
if (this.guard) this.guard.agent = value;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The run every decision in this session belongs to. */
|
|
377
|
+
get runId() {
|
|
378
|
+
return this.guard?.runId ?? null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
set runId(value) {
|
|
382
|
+
if (this.guard) this.guard.runId = value;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/*
|
|
386
|
+
* Everything a decision depends on is delegated to the core rather than
|
|
387
|
+
* mirrored on the gateway.
|
|
388
|
+
*
|
|
389
|
+
* These are assigned after construction by real callers — the CLI names the
|
|
390
|
+
* agent once the daemon has registered it, and a long-lived gateway adopts a
|
|
391
|
+
* newer rule set after a policy pull. A mirrored copy would mean the gateway
|
|
392
|
+
* reporting one thing and enforcing another, which is the worst available
|
|
393
|
+
* outcome for a field like `environment`.
|
|
394
|
+
*/
|
|
395
|
+
get rules() {
|
|
396
|
+
return this.guard?.rules ?? [];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
set rules(value) {
|
|
400
|
+
if (this.guard) this.guard.rules = value ?? [];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
get environment() {
|
|
404
|
+
return this.guard?.environment ?? "local";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
set environment(value) {
|
|
408
|
+
if (this.guard) this.guard.environment = value;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
get secrets() {
|
|
412
|
+
return this.guard?.secrets ?? null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
set secrets(value) {
|
|
416
|
+
if (this.guard) this.guard.secrets = value;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
start(write) {
|
|
420
|
+
this.write = write;
|
|
421
|
+
for (const [name, spec] of Object.entries(this.serversSpec)) {
|
|
422
|
+
const hooks = {
|
|
423
|
+
onMessage: (u, m) => this.#fromUpstream(u, m),
|
|
424
|
+
onExit: () => {},
|
|
425
|
+
log: this.log,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// Transport is chosen by the spec's shape, not by a flag: an editor's
|
|
429
|
+
// mcp.json names a `command` for stdio and a `url` for a hosted server,
|
|
430
|
+
// and the gateway reads the file the user already has.
|
|
431
|
+
if (spec?.url) {
|
|
432
|
+
let up;
|
|
433
|
+
try {
|
|
434
|
+
up = new HttpUpstream(name, spec, hooks);
|
|
435
|
+
} catch (err) {
|
|
436
|
+
// A rejected endpoint (link-local, metadata, bad URL) removes that
|
|
437
|
+
// one server and leaves the session running, matching how a dead
|
|
438
|
+
// stdio server behaves.
|
|
439
|
+
this.log(`upstream ${name} not started: ${err.message}`);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
this.upstreams.set(name, up);
|
|
443
|
+
// Started asynchronously so one slow HTTP handshake does not delay the
|
|
444
|
+
// gateway coming up; the upstream reports itself alive immediately and
|
|
445
|
+
// demotes itself if the first request fails.
|
|
446
|
+
void up.start().catch((err) => {
|
|
447
|
+
up.alive = false;
|
|
448
|
+
this.log(`upstream ${name} failed to start: ${err.message}`);
|
|
449
|
+
});
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const up = new Upstream(name, spec, hooks);
|
|
454
|
+
this.upstreams.set(name, up.start());
|
|
455
|
+
}
|
|
456
|
+
return this;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
stop() {
|
|
460
|
+
for (const up of this.upstreams.values()) up.stop();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/* ---------------------------------------------------------------------- */
|
|
464
|
+
/* Agent → gateway */
|
|
465
|
+
/* ---------------------------------------------------------------------- */
|
|
466
|
+
|
|
467
|
+
async handleClientMessage(message) {
|
|
468
|
+
// Notifications are forwarded to every upstream and never answered.
|
|
469
|
+
if (message.id === undefined && message.method) {
|
|
470
|
+
for (const up of this.upstreams.values()) up.send(message);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
switch (message.method) {
|
|
475
|
+
case "initialize":
|
|
476
|
+
return this.#handleInitialize(message);
|
|
477
|
+
case "tools/list":
|
|
478
|
+
return this.#handleToolsList(message);
|
|
479
|
+
case "tools/call":
|
|
480
|
+
return this.#handleToolsCall(message);
|
|
481
|
+
|
|
482
|
+
/*
|
|
483
|
+
* Resources are a read path, and a read path is a policy decision.
|
|
484
|
+
*
|
|
485
|
+
* These were previously handled by the `default` branch, which forwards
|
|
486
|
+
* to the first live upstream unevaluated. That made `resources/read` a
|
|
487
|
+
* complete bypass of the engine: an agent that could not call
|
|
488
|
+
* `filesystem.read` on `~/.aws/credentials` could ask for the same file
|
|
489
|
+
* as a *resource* and get it, with no rule consulted and no decision
|
|
490
|
+
* recorded. The refusal an operator saw in `cirvix logs` was real; the
|
|
491
|
+
* read that succeeded next to it was invisible.
|
|
492
|
+
*
|
|
493
|
+
* `resources/read` is evaluated exactly like a `tools/call` — same
|
|
494
|
+
* engine, same rules, same audit record — because it does the same
|
|
495
|
+
* thing.
|
|
496
|
+
*/
|
|
497
|
+
case "resources/read":
|
|
498
|
+
return this.#handleResourcesRead(message);
|
|
499
|
+
case "resources/list":
|
|
500
|
+
case "resources/templates/list":
|
|
501
|
+
return this.#handleResourcesList(message);
|
|
502
|
+
case "resources/subscribe":
|
|
503
|
+
case "resources/unsubscribe":
|
|
504
|
+
return this.#handleResourceSubscription(message);
|
|
505
|
+
|
|
506
|
+
case "prompts/list":
|
|
507
|
+
return this.#handlePromptsList(message);
|
|
508
|
+
|
|
509
|
+
/*
|
|
510
|
+
* `ping` is answered HERE, by the gateway, and never forwarded.
|
|
511
|
+
*
|
|
512
|
+
* The spec says the receiver must respond promptly with an empty result,
|
|
513
|
+
* and clients use it as a liveness probe. It was falling through to the
|
|
514
|
+
* default branch, which forwards to the first live upstream — and an
|
|
515
|
+
* upstream that does not implement `ping` answers `-32601 Method not
|
|
516
|
+
* found`. The client reads that as a dead server and tears down the
|
|
517
|
+
* session, so a perfectly healthy Cirvix presented as a crash.
|
|
518
|
+
*
|
|
519
|
+
* It is also the wrong question to forward. Ping asks "is the thing I am
|
|
520
|
+
* connected to alive", and the thing the client is connected to is this
|
|
521
|
+
* gateway. Upstream health is a separate concern, already handled by
|
|
522
|
+
* dropping a dead server's tools from `tools/list`.
|
|
523
|
+
*/
|
|
524
|
+
case "ping":
|
|
525
|
+
this.write({ jsonrpc: "2.0", id: message.id, result: {} });
|
|
526
|
+
return;
|
|
527
|
+
|
|
528
|
+
default:
|
|
529
|
+
// Anything else is broadcast to the first live upstream. The gateway
|
|
530
|
+
// deliberately does not invent behaviour for methods it doesn't model.
|
|
531
|
+
return this.#forwardToAny(message);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
#handleInitialize(message) {
|
|
536
|
+
this.write({
|
|
537
|
+
jsonrpc: "2.0",
|
|
538
|
+
id: message.id,
|
|
539
|
+
result: {
|
|
540
|
+
protocolVersion: message.params?.protocolVersion ?? "2024-11-05",
|
|
541
|
+
// Advertised because the gateway now governs all three, rather than
|
|
542
|
+
// passing two of them through untouched.
|
|
543
|
+
capabilities: { tools: {}, resources: { subscribe: true }, prompts: {} },
|
|
544
|
+
// Same manifest the CLI reports, so an MCP client and `cirvix --version`
|
|
545
|
+
// cannot disagree about which build is running.
|
|
546
|
+
serverInfo: { name: "cirvix-gateway", version: GATEWAY_VERSION },
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/* ---------------------------------------------------------------------- */
|
|
552
|
+
/* Resources */
|
|
553
|
+
/* ---------------------------------------------------------------------- */
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Aggregates resources from every live upstream.
|
|
557
|
+
*
|
|
558
|
+
* URIs are namespaced the same way tool names are (`server__uri`), and for
|
|
559
|
+
* the same two reasons: two servers may expose the same URI, and a policy
|
|
560
|
+
* written for one must not silently govern the other.
|
|
561
|
+
*/
|
|
562
|
+
async #handleResourcesList(message) {
|
|
563
|
+
const key = message.method === "resources/templates/list" ? "resourceTemplates" : "resources";
|
|
564
|
+
const collected = [];
|
|
565
|
+
|
|
566
|
+
for (const [name, up] of this.upstreams) {
|
|
567
|
+
if (!up.alive) continue;
|
|
568
|
+
let result;
|
|
569
|
+
try {
|
|
570
|
+
result = await up.request(message.method, message.params ?? {});
|
|
571
|
+
} catch (err) {
|
|
572
|
+
// A server that does not implement resources answers with an error.
|
|
573
|
+
// That is normal, not a failure — skip it and keep the others.
|
|
574
|
+
this.log(`${message.method} skipped for ${name}: ${err.message}`);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
for (const entry of result?.[key] ?? []) {
|
|
578
|
+
const original = entry.uri ?? entry.uriTemplate;
|
|
579
|
+
if (!original) continue;
|
|
580
|
+
collected.push({
|
|
581
|
+
...entry,
|
|
582
|
+
...(entry.uri ? { uri: `${name}${NS}${entry.uri}` } : {}),
|
|
583
|
+
...(entry.uriTemplate ? { uriTemplate: `${name}${NS}${entry.uriTemplate}` } : {}),
|
|
584
|
+
_meta: { ...(entry._meta ?? {}), "cirvix/server": name },
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
this.write({ jsonrpc: "2.0", id: message.id, result: { [key]: collected } });
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The second decision point.
|
|
594
|
+
*
|
|
595
|
+
* A resource URI is a resource in the policy sense: `file:///etc/passwd` and
|
|
596
|
+
* `file:///home/u/.aws/credentials` are exactly the things the filesystem
|
|
597
|
+
* rules exist to protect. The URI is unwrapped from its namespace, passed to
|
|
598
|
+
* the shared core as an `fs.read`, and the result is scrubbed on the way back
|
|
599
|
+
* like any other payload.
|
|
600
|
+
*/
|
|
601
|
+
async #handleResourcesRead(message) {
|
|
602
|
+
const fullUri = message.params?.uri ?? "";
|
|
603
|
+
const sep = fullUri.indexOf(NS);
|
|
604
|
+
const server = sep === -1 ? null : fullUri.slice(0, sep);
|
|
605
|
+
const uri = sep === -1 ? fullUri : fullUri.slice(sep + NS.length);
|
|
606
|
+
const up = server ? this.upstreams.get(server) : [...this.upstreams.values()].find((u) => u.alive);
|
|
607
|
+
|
|
608
|
+
if (!up || !up.alive) {
|
|
609
|
+
this.write(
|
|
610
|
+
errorResponse(
|
|
611
|
+
message.id,
|
|
612
|
+
ERROR_CODE.UPSTREAM_UNAVAILABLE,
|
|
613
|
+
`No registered server for resource "${fullUri}".`,
|
|
614
|
+
),
|
|
615
|
+
);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// `file://` URIs are unwrapped to their path so the same rules that govern
|
|
620
|
+
// `filesystem.read` govern this. A rule written `path = **/.aws/**` must
|
|
621
|
+
// match whether the agent asked for a file or for a resource that is a
|
|
622
|
+
// file — otherwise the policy has a spelling-dependent hole.
|
|
623
|
+
const resource = fileUriToPath(uri);
|
|
624
|
+
|
|
625
|
+
// Identity travels with a resource read for the same reason the rules do:
|
|
626
|
+
// it is the same operation wearing a different method name, and a
|
|
627
|
+
// delegation that binds `tools/call` and not this one is a hole shaped
|
|
628
|
+
// exactly like the ungoverned-`resources/read` bug above.
|
|
629
|
+
const { agent: callerAgent, delegation } = callerIdentity(message.params);
|
|
630
|
+
|
|
631
|
+
const { decision } = await this.guard.authorize({
|
|
632
|
+
tool: "resources.read",
|
|
633
|
+
server: server ?? up.name,
|
|
634
|
+
args: { uri, path: resource },
|
|
635
|
+
agent: callerAgent,
|
|
636
|
+
delegation,
|
|
637
|
+
});
|
|
638
|
+
this.stats = this.guard.stats;
|
|
639
|
+
|
|
640
|
+
if (decision.verdict === "deny") {
|
|
641
|
+
this.log(`DENY resources/read ${decision.resource} (${decision.rule})`);
|
|
642
|
+
this.write(deniedToolResult(message.id, decision));
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (decision.verdict === "hold") {
|
|
646
|
+
decision.approvalId = `apr_${String(decision.decisionId).slice(4, 12)}`;
|
|
647
|
+
this.log(`HOLD resources/read ${decision.resource} (${decision.rule})`);
|
|
648
|
+
this.write(heldToolResult(message.id, decision));
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const gatewayId = `gw-${this.nextGatewayId++}`;
|
|
653
|
+
this.inflight.set(gatewayId, { clientId: message.id, upstream: up });
|
|
654
|
+
up.send({ jsonrpc: "2.0", id: gatewayId, method: "resources/read", params: { ...message.params, uri } });
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* A subscription is a standing read.
|
|
659
|
+
*
|
|
660
|
+
* Governed with the same decision as a one-shot read, because a subscription
|
|
661
|
+
* to a resource an agent may not read is a slower version of reading it.
|
|
662
|
+
*/
|
|
663
|
+
async #handleResourceSubscription(message) {
|
|
664
|
+
const fullUri = message.params?.uri ?? "";
|
|
665
|
+
const sep = fullUri.indexOf(NS);
|
|
666
|
+
const server = sep === -1 ? null : fullUri.slice(0, sep);
|
|
667
|
+
const uri = sep === -1 ? fullUri : fullUri.slice(sep + NS.length);
|
|
668
|
+
const up = server ? this.upstreams.get(server) : [...this.upstreams.values()].find((u) => u.alive);
|
|
669
|
+
|
|
670
|
+
if (!up || !up.alive) {
|
|
671
|
+
this.write(
|
|
672
|
+
errorResponse(message.id, ERROR_CODE.UPSTREAM_UNAVAILABLE, `No registered server for "${fullUri}".`),
|
|
673
|
+
);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Unsubscribing is always permitted: refusing to let an agent stop
|
|
678
|
+
// receiving something is not a security property.
|
|
679
|
+
if (message.method === "resources/unsubscribe") {
|
|
680
|
+
const gatewayId = `gw-${this.nextGatewayId++}`;
|
|
681
|
+
this.inflight.set(gatewayId, { clientId: message.id, upstream: up });
|
|
682
|
+
up.send({ jsonrpc: "2.0", id: gatewayId, method: message.method, params: { ...message.params, uri } });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const { decision } = await this.guard.authorize({
|
|
687
|
+
tool: "resources.subscribe",
|
|
688
|
+
server: server ?? up.name,
|
|
689
|
+
args: { uri, path: fileUriToPath(uri) },
|
|
690
|
+
});
|
|
691
|
+
this.stats = this.guard.stats;
|
|
692
|
+
|
|
693
|
+
if (decision.verdict !== "permit") {
|
|
694
|
+
this.log(`${decision.verdict.toUpperCase()} resources/subscribe ${decision.resource} (${decision.rule})`);
|
|
695
|
+
this.write(
|
|
696
|
+
decision.verdict === "hold"
|
|
697
|
+
? heldToolResult(message.id, decision)
|
|
698
|
+
: deniedToolResult(message.id, decision),
|
|
699
|
+
);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const gatewayId = `gw-${this.nextGatewayId++}`;
|
|
704
|
+
this.inflight.set(gatewayId, { clientId: message.id, upstream: up });
|
|
705
|
+
up.send({ jsonrpc: "2.0", id: gatewayId, method: message.method, params: { ...message.params, uri } });
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Prompt templates are instruction text supplied by the server.
|
|
710
|
+
*
|
|
711
|
+
* Aggregated and namespaced rather than forwarded blind, for the same reason
|
|
712
|
+
* tool definitions are pinned: a prompt is text that enters the model's
|
|
713
|
+
* context with the authority of a system message, and it is written by
|
|
714
|
+
* whoever wrote the server.
|
|
715
|
+
*/
|
|
716
|
+
async #handlePromptsList(message) {
|
|
717
|
+
const prompts = [];
|
|
718
|
+
for (const [name, up] of this.upstreams) {
|
|
719
|
+
if (!up.alive) continue;
|
|
720
|
+
let result;
|
|
721
|
+
try {
|
|
722
|
+
result = await up.request("prompts/list", message.params ?? {});
|
|
723
|
+
} catch (err) {
|
|
724
|
+
this.log(`prompts/list skipped for ${name}: ${err.message}`);
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
for (const prompt of result?.prompts ?? []) {
|
|
728
|
+
prompts.push({ ...prompt, name: `${name}${NS}${prompt.name}` });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
this.write({ jsonrpc: "2.0", id: message.id, result: { prompts } });
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Aggregates tools from every live upstream, applies the per-server scope,
|
|
736
|
+
* and withholds any tool whose definition has drifted from its pin.
|
|
737
|
+
*/
|
|
738
|
+
async #handleToolsList(message) {
|
|
739
|
+
const tools = [];
|
|
740
|
+
|
|
741
|
+
for (const [name, up] of this.upstreams) {
|
|
742
|
+
if (!up.alive) continue;
|
|
743
|
+
let result;
|
|
744
|
+
try {
|
|
745
|
+
result = await up.request("tools/list", {});
|
|
746
|
+
} catch (err) {
|
|
747
|
+
this.log(`tools/list failed for ${name}: ${err.message}`);
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const scope = this.scopeFor(name);
|
|
752
|
+
for (const tool of result?.tools ?? []) {
|
|
753
|
+
const fingerprint = fingerprintTool(tool);
|
|
754
|
+
const key = `${name}${NS}${tool.name}`;
|
|
755
|
+
up.tools.set(tool.name, { tool, fingerprint });
|
|
756
|
+
|
|
757
|
+
if (scope && !scope.includes(tool.name)) continue;
|
|
758
|
+
|
|
759
|
+
const pin = this.pins.get(key);
|
|
760
|
+
if (pin && pin !== fingerprint) {
|
|
761
|
+
this.log(`tool withheld — definition drift: ${key}`, { pin, fingerprint });
|
|
762
|
+
this.onDecision({
|
|
763
|
+
kind: "drift",
|
|
764
|
+
server: name,
|
|
765
|
+
tool: tool.name,
|
|
766
|
+
expected: pin,
|
|
767
|
+
actual: fingerprint,
|
|
768
|
+
});
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (!pin) this.pins.set(key, fingerprint);
|
|
772
|
+
|
|
773
|
+
tools.push({
|
|
774
|
+
...tool,
|
|
775
|
+
name: key,
|
|
776
|
+
description: tool.description,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
this.write({ jsonrpc: "2.0", id: message.id, result: { tools } });
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** The decision point. */
|
|
785
|
+
async #handleToolsCall(message) {
|
|
786
|
+
const fullName = message.params?.name ?? "";
|
|
787
|
+
const sep = fullName.indexOf(NS);
|
|
788
|
+
const server = sep === -1 ? null : fullName.slice(0, sep);
|
|
789
|
+
const toolName = sep === -1 ? fullName : fullName.slice(sep + NS.length);
|
|
790
|
+
const up = server ? this.upstreams.get(server) : null;
|
|
791
|
+
|
|
792
|
+
if (!up || !up.alive) {
|
|
793
|
+
this.write(
|
|
794
|
+
errorResponse(
|
|
795
|
+
message.id,
|
|
796
|
+
ERROR_CODE.UPSTREAM_UNAVAILABLE,
|
|
797
|
+
`No registered server for tool "${fullName}".`,
|
|
798
|
+
),
|
|
799
|
+
);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const args = message.params?.arguments ?? {};
|
|
804
|
+
const { agent: callerAgent, delegation } = callerIdentity(message.params);
|
|
805
|
+
|
|
806
|
+
// The decision is made by the shared core, not here.
|
|
807
|
+
//
|
|
808
|
+
// `guard.wrap()` in the SDK runs this same call. If the gateway kept its
|
|
809
|
+
// own copy of the evaluation, the substitution ordering, and the taint
|
|
810
|
+
// rule, the two would drift — and a guard that permits what the gateway
|
|
811
|
+
// denies is a governance product with a documented bypass.
|
|
812
|
+
const { decision, args: outgoingArgs } = await this.guard.authorize({
|
|
813
|
+
tool: toolName,
|
|
814
|
+
server,
|
|
815
|
+
args,
|
|
816
|
+
agent: callerAgent,
|
|
817
|
+
delegation,
|
|
818
|
+
});
|
|
819
|
+
this.stats = this.guard.stats;
|
|
820
|
+
|
|
821
|
+
if (decision.verdict === "deny") {
|
|
822
|
+
this.log(`DENY ${decision.action ?? toolName} ${decision.resource} (${decision.rule})`);
|
|
823
|
+
this.write(deniedToolResult(message.id, decision));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (decision.verdict === "hold") {
|
|
828
|
+
decision.approvalId = `apr_${String(decision.decisionId).slice(4, 12)}`;
|
|
829
|
+
this.log(`HOLD ${decision.action ?? toolName} ${decision.resource} (${decision.rule})`);
|
|
830
|
+
this.write(heldToolResult(message.id, decision));
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Forward under a gateway-owned id, remembering how to route the answer —
|
|
835
|
+
// and what was decided about it, so the return path can say so.
|
|
836
|
+
const gatewayId = `gw-${this.nextGatewayId++}`;
|
|
837
|
+
this.inflight.set(gatewayId, { clientId: message.id, upstream: up, decision });
|
|
838
|
+
up.send({
|
|
839
|
+
jsonrpc: "2.0",
|
|
840
|
+
id: gatewayId,
|
|
841
|
+
method: "tools/call",
|
|
842
|
+
params: { name: toolName, arguments: outgoingArgs },
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
#forwardToAny(message) {
|
|
847
|
+
const up = [...this.upstreams.values()].find((u) => u.alive);
|
|
848
|
+
if (!up) {
|
|
849
|
+
this.write(
|
|
850
|
+
errorResponse(message.id, ERROR_CODE.UPSTREAM_UNAVAILABLE, "No upstream server available."),
|
|
851
|
+
);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
const gatewayId = `gw-${this.nextGatewayId++}`;
|
|
855
|
+
this.inflight.set(gatewayId, { clientId: message.id, upstream: up });
|
|
856
|
+
up.send({ ...message, id: gatewayId });
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/* ---------------------------------------------------------------------- */
|
|
860
|
+
/* Upstream → agent */
|
|
861
|
+
/* ---------------------------------------------------------------------- */
|
|
862
|
+
|
|
863
|
+
#fromUpstream(up, message) {
|
|
864
|
+
// Responses to the gateway's own internal requests (tools/list, etc.)
|
|
865
|
+
if (up.settle(message)) return;
|
|
866
|
+
|
|
867
|
+
const route = this.inflight.get(message.id);
|
|
868
|
+
if (route) {
|
|
869
|
+
this.inflight.delete(message.id);
|
|
870
|
+
this.write({ ...this.#annotate(this.#scrub(message), route.decision), id: route.clientId });
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Server-initiated notifications pass straight through — scrubbed, since
|
|
875
|
+
// a notification reaches the model's context exactly like a result does.
|
|
876
|
+
if (message.id === undefined) this.write(this.#scrub(message));
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* The return path.
|
|
881
|
+
*
|
|
882
|
+
* A well-behaved upstream never echoes a credential back. A compromised or
|
|
883
|
+
* merely careless one does, and if that reaches the model then the handle
|
|
884
|
+
* indirection bought nothing — the value is in the context window, the
|
|
885
|
+
* trace, and whatever the agent writes next.
|
|
886
|
+
*
|
|
887
|
+
* Only material this session resolved can be recognised. That is a real
|
|
888
|
+
* limit, it is the one the product's own documentation states, and it is
|
|
889
|
+
* why this is a backstop rather than a substitute for scoping handles.
|
|
890
|
+
*/
|
|
891
|
+
#scrub(message) {
|
|
892
|
+
return this.guard.scrub(message).payload;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Tells the agent what was done to its result.
|
|
897
|
+
*
|
|
898
|
+
* A SANITIZE decision forwards the call, so without this the agent receives
|
|
899
|
+
* an ordinary success and has no way to know the payload was modified — while
|
|
900
|
+
* the audit record says `sanitize`. The consistency oracle caught exactly
|
|
901
|
+
* that: Cirvix told the agent ALLOW and recorded SANITIZE, which is the
|
|
902
|
+
* product disagreeing with itself about the same call.
|
|
903
|
+
*
|
|
904
|
+
* It also matters on its own terms. The sanitizer's whole design principle is
|
|
905
|
+
* replacement over deletion, because an agent acting on quietly truncated
|
|
906
|
+
* content makes worse decisions than one told a paragraph was withheld. That
|
|
907
|
+
* principle only holds if the *decision* is surfaced too, not just the marker
|
|
908
|
+
* buried in the text.
|
|
909
|
+
*/
|
|
910
|
+
#annotate(message, decision) {
|
|
911
|
+
if (!decision || decision.decision !== DECISION.SANITIZE) return message;
|
|
912
|
+
if (!message?.result || typeof message.result !== "object") return message;
|
|
913
|
+
|
|
914
|
+
return {
|
|
915
|
+
...message,
|
|
916
|
+
result: {
|
|
917
|
+
...message.result,
|
|
918
|
+
_meta: {
|
|
919
|
+
...(message.result._meta ?? {}),
|
|
920
|
+
"cirvix/verdict": "sanitize",
|
|
921
|
+
"cirvix/rule": decision.rule ?? null,
|
|
922
|
+
"cirvix/decision_id": decision.decisionId ?? null,
|
|
923
|
+
// Named so an agent can distinguish "cleaned" from "refused" without
|
|
924
|
+
// parsing prose: this call succeeded, and its payload is not verbatim.
|
|
925
|
+
"cirvix/sanitized": true,
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/* ---------------------------------------------------------------------- */
|
|
932
|
+
|
|
933
|
+
#insideWorkspace(resource) {
|
|
934
|
+
if (!resource) return true;
|
|
935
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(resource)) return false;
|
|
936
|
+
const norm = (s) => s.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
937
|
+
const abs = /^([A-Za-z]:|\/)/.test(resource)
|
|
938
|
+
? resource
|
|
939
|
+
: `${this.cwd}/${resource}`;
|
|
940
|
+
const parts = [];
|
|
941
|
+
for (const seg of norm(abs).split("/")) {
|
|
942
|
+
if (seg === "..") parts.pop();
|
|
943
|
+
else if (seg !== ".") parts.push(seg);
|
|
944
|
+
}
|
|
945
|
+
const flat = parts.join("/");
|
|
946
|
+
const root = norm(this.cwd);
|
|
947
|
+
return flat === root || flat.startsWith(root + "/");
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
#isExternal(resource) {
|
|
951
|
+
if (!/^https?:\/\//i.test(resource)) return false;
|
|
952
|
+
try {
|
|
953
|
+
const host = new URL(resource).hostname;
|
|
954
|
+
return !/^(localhost|127\.|::1|0\.0\.0\.0|.*\.internal|.*\.local)$/i.test(host);
|
|
955
|
+
} catch {
|
|
956
|
+
return true;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|