@odingard/guardian-mcp 0.3.3 → 0.4.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/README.md +71 -17
- package/dist/index.js +562 -85
- package/package.json +4 -3
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -39,6 +39,18 @@ npm i -g @odingard/guardian-mcp
|
|
|
39
39
|
|
|
40
40
|
Nothing executes without a Guardian capability — the model's output is a proposal, never an action.
|
|
41
41
|
|
|
42
|
+
Only a call whose receipt status is `sent-verified` is returned as a plain
|
|
43
|
+
result. Every other outcome is an MCP tool error (`isError: true`) that says
|
|
44
|
+
what happened:
|
|
45
|
+
|
|
46
|
+
- DENY, or Guardian could not be reached: nothing was done. A denial says not
|
|
47
|
+
to retry the same call; an outage says to try again later.
|
|
48
|
+
- Receipt status `not-sent`: `outcome: "not-done"`, the call was not done.
|
|
49
|
+
- Receipt status `outcome-pending` or `outcome-unconfirmed`, or an execute
|
|
50
|
+
answer that was refused or lost: `outcome: "unknown"`. The call may have
|
|
51
|
+
been done, so check (`guardian_receipt`, or the target system) before
|
|
52
|
+
retrying.
|
|
53
|
+
|
|
42
54
|
## Govern an existing MCP server: `guardian-mcp proxy` (0.3)
|
|
43
55
|
|
|
44
56
|
Put Guardian in front of any stdio MCP server you already use, such as
|
|
@@ -62,11 +74,12 @@ upstream server's tools unchanged, and every call is decided first:
|
|
|
62
74
|
}
|
|
63
75
|
```
|
|
64
76
|
|
|
65
|
-
| Decision
|
|
66
|
-
|
|
|
67
|
-
| **ALLOW**
|
|
68
|
-
| **ASK**
|
|
69
|
-
| **DENY**
|
|
77
|
+
| Decision | What happens |
|
|
78
|
+
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
79
|
+
| **ALLOW** | The call is forwarded to the upstream server, then the capability is redeemed and the receipt recorded. The upstream result is returned unchanged, with Guardian's `requestId` under `_meta["com.odingard/guardian"]`, so the client can fetch the receipt with `guardian_receipt`. |
|
|
80
|
+
| **ASK** | Nothing is forwarded. The exact call is held under a `requestId`; after a human approves, `guardian_claim` forwards exactly that call, once. |
|
|
81
|
+
| **DENY** | Nothing is forwarded, ever. The result is an MCP tool error (`isError: true`) with the reason code and, when Guardian made a record of it, the `requestId`. |
|
|
82
|
+
| **Guardian unreachable** | Nothing is forwarded. The result is a tool error (`isError: true`) whose JSON keeps `decision: "DENY"` with `reasonCode: "SERVICE_UNAVAILABLE"`, and its message says Guardian could not be reached and the call can be tried again later. A held call stays held, so `guardian_claim` can be retried. |
|
|
70
83
|
|
|
71
84
|
- `guardian_claim` and `guardian_receipt` are added to the tool list.
|
|
72
85
|
- The upstream server never receives `GUARDIAN_*` settings, so it never sees
|
|
@@ -79,20 +92,59 @@ upstream server's tools unchanged, and every call is decided first:
|
|
|
79
92
|
for the agent.
|
|
80
93
|
- Since 0.3.1 the proxy does not try to register tools itself.
|
|
81
94
|
- `GUARDIAN_AUTO_REGISTER=true` turns that back on, but it is denied unless
|
|
82
|
-
the agent's grant pins `mcp.install
|
|
95
|
+
the agent's grant pins `mcp.install`, and it only works for a tool that
|
|
96
|
+
has no entry yet. An "already registered" answer is refused
|
|
97
|
+
(`RECORD_TRANSPORT_UNVERIFIED`) and nothing is forwarded, including after
|
|
98
|
+
a proxy restart for an entry the proxy created itself. Once the entries
|
|
99
|
+
exist, run the proxy without it.
|
|
83
100
|
- When Guardian refuses, the result says what to fix.
|
|
84
101
|
- Held calls live in the proxy process. Restarting the proxy drops them, and
|
|
85
102
|
nothing is forwarded.
|
|
103
|
+
- **Cancelled requests are not forwarded.** If the client cancels a
|
|
104
|
+
`tools/call` (or it times out) before the call is sent upstream, for
|
|
105
|
+
example while Guardian is still deciding, the call is not forwarded and the
|
|
106
|
+
capability is not redeemed. A `guardian_claim` cancelled that way keeps the
|
|
107
|
+
call held with its approval, so the next `guardian_claim` forwards it
|
|
108
|
+
without asking the person again. Once a call has been sent upstream it runs
|
|
109
|
+
to its real outcome, which is recorded.
|
|
110
|
+
- **A second `guardian_claim` of the same `requestId`** says the call was
|
|
111
|
+
already forwarded and must not be proposed again. If the first claim has
|
|
112
|
+
finished, its result is returned again after that notice; if it is still
|
|
113
|
+
running, the answer says to check `guardian_receipt`. The proxy remembers
|
|
114
|
+
the last 1000 claimed requests. A `requestId` it does not hold is answered
|
|
115
|
+
with "this claim forwarded nothing", and a reminder to check
|
|
116
|
+
`guardian_receipt` if it was claimed before.
|
|
117
|
+
- An unexpected proxy error after a call was forwarded says the call may
|
|
118
|
+
have run, never that it was not forwarded.
|
|
86
119
|
|
|
87
120
|
`guardian-mcp --help` prints usage. A missing setting prints a one-line
|
|
88
121
|
message naming it instead of a stack trace.
|
|
89
122
|
|
|
90
|
-
|
|
123
|
+
### Pin the contents of file arguments: `--pin-file` (0.4)
|
|
124
|
+
|
|
125
|
+
Approval binds the exact arguments. A file path is an argument; the file's contents are not, unless you declare the argument with `--pin-file`.
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
guardian-mcp proxy --pin-file upload_file:path --pin-file send_email:attachment -- npx -y my-mcp-server
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
For each declared `<tool>:<argument>` the proxy (the SDK on your machine) reads the file the path names, puts its SHA-256 in the approved arguments, and checks it again just before the call is forwarded. Guardian's servers never see the file. If the file changed, nothing is forwarded, the capability is not redeemed, and the result says `CONTENT_CHANGED_SINCE_APPROVAL`; propose the call again for a new approval. A call held for approval keeps the arguments that were proposed, pins included, and `guardian_claim` checks the file against them. After a proxy restart a claim finds nothing; call the tool again, which pins the file again. The upstream server receives the approved arguments without the reserved key `guardianContentPins`. In code: `createGovernedProxy({ ..., pinFiles: { upload_file: ["path"] } })`.
|
|
132
|
+
|
|
133
|
+
- The mode is always `recheck`: the upstream server opens the path itself, so a short window remains between the check and its open.
|
|
134
|
+
- Stdio upstreams only. With `--url` (HTTP or SSE) the path names a file on another machine, so `--pin-file` is refused at start (`CONTENT_PIN_REMOTE_UPSTREAM`). Paths on a remote MCP server are not pinned.
|
|
135
|
+
- File paths must be absolute. The proxy does not resolve relative paths or `~`.
|
|
136
|
+
- Content pinning is supported on Linux and macOS. On Windows a declared file argument is refused (`CONTENT_PIN_UNSUPPORTED_PLATFORM`).
|
|
137
|
+
- **Digest privacy:** the SHA-256 of each pinned file is sent to Guardian, shown to the approver and kept in receipts. For a small or low-entropy file (a token, a PIN, a short config value), anyone who sees the digest can guess the contents offline by hashing candidates. Do not pin files like that, or treat the digest as sensitive as the file.
|
|
138
|
+
- This stops a changed file from running under an approval given for different contents. It does not protect against code that bypasses the proxy, and files a tool opens without naming them in a declared argument are not pinned.
|
|
139
|
+
|
|
140
|
+
## Govern a remote MCP server: `guardian-mcp proxy --url` (0.3.3 and later)
|
|
91
141
|
|
|
92
|
-
**Status:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
142
|
+
**Status: tested against a real Guardian; production proof pending.** It ran
|
|
143
|
+
end to end over Streamable HTTP against the real Guardian server code (allow,
|
|
144
|
+
ask then approve, deny, outage), not yet on hosted production. SSE is tested
|
|
145
|
+
against local servers and a stand-in Guardian only, and no real hosted server
|
|
146
|
+
such as Composio has been tried. Composio's `x-api-key` header is
|
|
147
|
+
unconfirmed.
|
|
96
148
|
|
|
97
149
|
The proxy can also front an MCP server that runs online, such as Composio.
|
|
98
150
|
The agent still starts `guardian-mcp` over stdio like any
|
|
@@ -157,7 +209,8 @@ A server that takes a Bearer key: `--url https://mcp.example.com/mcp --header
|
|
|
157
209
|
unavailable" error. The upstream's error text (which can quote its URL
|
|
158
210
|
path, response body or headers) is not shown to the agent or logged;
|
|
159
211
|
`guardian-mcp tools --url` prints it with the URL, its path identifiers,
|
|
160
|
-
query values and header values scrubbed.
|
|
212
|
+
query values and header values scrubbed. Each query value is scrubbed as
|
|
213
|
+
typed (a `+` stays a `+`), percent-decoded, and with `+` read as a space.
|
|
161
214
|
|
|
162
215
|
What is **not** governed: a remote server the agent connects to directly
|
|
163
216
|
(for example an MCP client's own `"url"` entry, or a model provider's hosted
|
|
@@ -175,9 +228,8 @@ MCP connector), and anything the agent does without MCP. Also not covered:
|
|
|
175
228
|
[ZeroClaw](../../examples/zeroclaw/README.md) and
|
|
176
229
|
[your own container (CrewAI, LangGraph, AutoGen)](../../examples/byo/README.md).
|
|
177
230
|
Runnable example: `examples/remote-mcp.mjs` (SIMULATED by default, LIVE with
|
|
178
|
-
Guardian settings).
|
|
179
|
-
|
|
180
|
-
0.3.2 does not have `--url` yet.
|
|
231
|
+
Guardian settings). `--url` is in the published `@odingard/guardian-mcp`
|
|
232
|
+
from 0.3.3.
|
|
181
233
|
|
|
182
234
|
## See what you are about to govern: `guardian-mcp tools`
|
|
183
235
|
|
|
@@ -197,8 +249,10 @@ createIssue Ask me first "create" may change something ⚠ name not accept
|
|
|
197
249
|
|
|
198
250
|
How the suggestions are made:
|
|
199
251
|
|
|
200
|
-
-
|
|
201
|
-
-
|
|
252
|
+
- A tool the server marks destructive (`destructiveHint`) suggests Ask.
|
|
253
|
+
- So does a tool whose name contains a verb that changes something or sends data out, anywhere in the name (`create`, `update`, `delete`, `replace`, `send`, `run`, `write` and similar), even when the server marks it read-only. `get_or_create_customer`, `find_and_replace` and `list_and_delete_old_files` suggest Ask.
|
|
254
|
+
- Otherwise a tool the server marks read-only (`readOnlyHint`) suggests Allow, and so does a name that starts with a read verb (`get`, `list`, `search`, `read`, `describe`, `show`, `view`, `find`, `lookup`, `count`, `inspect`).
|
|
255
|
+
- Everything else suggests Ask, including `fetch` (it can send data to any URL) and `status`.
|
|
202
256
|
|
|
203
257
|
They are suggestions only: you set the real modes in the console. Tool names that break Guardian's naming rule are flagged, because every call to them would be denied. Add `--json` for machine-readable output.
|
|
204
258
|
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,11 @@ var USAGE = `guardian-mcp \u2014 route an agent's tool calls through Guardian (A
|
|
|
16
16
|
Usage:
|
|
17
17
|
guardian-mcp Guardian tools (guardian_invoke_tool, guardian_claim, guardian_receipt)
|
|
18
18
|
guardian-mcp proxy -- <command> [args...] Govern every tool of an existing stdio MCP server
|
|
19
|
+
guardian-mcp proxy --pin-file <tool>:<arg> [--pin-file ...] -- <command> [args...]
|
|
20
|
+
Also pin the file a path argument names: its SHA-256
|
|
21
|
+
goes into the approved arguments and is checked again
|
|
22
|
+
just before the call is forwarded (stdio upstreams
|
|
23
|
+
only; the path must be absolute)
|
|
19
24
|
guardian-mcp proxy --url <https-url> [--transport streamable-http|sse] [--header "Name: value"]...
|
|
20
25
|
Govern every tool of a remote MCP server (Streamable
|
|
21
26
|
HTTP by default, or SSE). The agent still talks stdio.
|
|
@@ -64,9 +69,24 @@ function parseCli(argv, env) {
|
|
|
64
69
|
};
|
|
65
70
|
}
|
|
66
71
|
if (argv[0] === "proxy") {
|
|
67
|
-
const
|
|
72
|
+
const pins = extractPinFiles(argv.slice(1));
|
|
73
|
+
if (!pins.ok) return pins;
|
|
74
|
+
const target = parseTarget("proxy", pins.rest, env);
|
|
68
75
|
if (!target.ok) return target;
|
|
69
|
-
|
|
76
|
+
const declared = Object.keys(pins.pinFiles).length > 0;
|
|
77
|
+
if (declared && "remote" in target.target) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
message: `guardian-mcp proxy: --pin-file is supported only for a stdio upstream (CONTENT_PIN_REMOTE_UPSTREAM). A remote MCP server opens paths on another machine, so the proxy cannot check the file it will read.
|
|
81
|
+
|
|
82
|
+
${USAGE}`
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
mode = {
|
|
86
|
+
kind: "proxy",
|
|
87
|
+
...target.target,
|
|
88
|
+
...declared ? { pinFiles: pins.pinFiles } : {}
|
|
89
|
+
};
|
|
70
90
|
} else if (argv.length > 0) {
|
|
71
91
|
return {
|
|
72
92
|
ok: false,
|
|
@@ -135,6 +155,42 @@ function expandEnv(cmd, value, env, where) {
|
|
|
135
155
|
value: value.replace(ENV_REF, (_, name) => env[name])
|
|
136
156
|
};
|
|
137
157
|
}
|
|
158
|
+
function extractPinFiles(argv) {
|
|
159
|
+
const pinFiles = {};
|
|
160
|
+
const rest = [];
|
|
161
|
+
const fail = (message) => ({
|
|
162
|
+
ok: false,
|
|
163
|
+
message: `guardian-mcp proxy: ${message}
|
|
164
|
+
|
|
165
|
+
${USAGE}`
|
|
166
|
+
});
|
|
167
|
+
let i = 0;
|
|
168
|
+
for (; i < argv.length; i++) {
|
|
169
|
+
const arg = argv[i];
|
|
170
|
+
if (arg === "--") break;
|
|
171
|
+
let value;
|
|
172
|
+
if (arg === "--pin-file") {
|
|
173
|
+
value = argv[++i];
|
|
174
|
+
} else if (arg.startsWith("--pin-file=")) {
|
|
175
|
+
value = arg.slice("--pin-file=".length);
|
|
176
|
+
} else {
|
|
177
|
+
rest.push(arg);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const colon = value?.lastIndexOf(":") ?? -1;
|
|
181
|
+
const tool = colon > 0 ? value.slice(0, colon) : "";
|
|
182
|
+
const name = colon > 0 ? value.slice(colon + 1) : "";
|
|
183
|
+
if (!tool || !name || name.includes(".")) {
|
|
184
|
+
return fail(
|
|
185
|
+
'--pin-file needs "<tool>:<argument>", naming a top-level argument.'
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const list = pinFiles[tool] ??= [];
|
|
189
|
+
if (!list.includes(name)) list.push(name);
|
|
190
|
+
}
|
|
191
|
+
rest.push(...argv.slice(i));
|
|
192
|
+
return { ok: true, pinFiles, rest };
|
|
193
|
+
}
|
|
138
194
|
function parseTarget(cmd, argv, env) {
|
|
139
195
|
const fail = (message) => ({
|
|
140
196
|
ok: false,
|
|
@@ -235,6 +291,18 @@ function secretsOf(target) {
|
|
|
235
291
|
const parsed2 = new URL(url);
|
|
236
292
|
if (parsed2.search) out.add(parsed2.search.slice(1));
|
|
237
293
|
for (const v of parsed2.searchParams.values()) if (v.length >= 4) out.add(v);
|
|
294
|
+
for (const pair of parsed2.search.slice(1).split("&")) {
|
|
295
|
+
const raw = pair.slice(pair.indexOf("=") + 1);
|
|
296
|
+
if (pair.indexOf("=") < 0 || raw.length < 4) continue;
|
|
297
|
+
const forms = [raw, raw.replace(/\+/g, " ")];
|
|
298
|
+
for (const form of [...forms]) {
|
|
299
|
+
try {
|
|
300
|
+
forms.push(decodeURIComponent(form));
|
|
301
|
+
} catch {
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
for (const form of forms) if (form.length >= 4) out.add(form);
|
|
305
|
+
}
|
|
238
306
|
for (const segment of parsed2.pathname.split("/")) {
|
|
239
307
|
if (segment.length >= 16 || segment.length >= 8 && /\d/.test(segment)) {
|
|
240
308
|
out.add(segment);
|
|
@@ -308,11 +376,26 @@ var TOOLS = [
|
|
|
308
376
|
}
|
|
309
377
|
];
|
|
310
378
|
function jsonResult(payload) {
|
|
311
|
-
return {
|
|
379
|
+
return {
|
|
380
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
|
|
381
|
+
};
|
|
312
382
|
}
|
|
313
383
|
function errorResult(message) {
|
|
314
384
|
return { isError: true, content: [{ type: "text", text: message }] };
|
|
315
385
|
}
|
|
386
|
+
function jsonError(payload) {
|
|
387
|
+
return { ...jsonResult(payload), isError: true };
|
|
388
|
+
}
|
|
389
|
+
var CHECK_FIRST = "Call guardian_receipt with this requestId, or check the target system, before retrying.";
|
|
390
|
+
function denied(reasonCode, requestId) {
|
|
391
|
+
const code = reasonCode ?? "DENIED";
|
|
392
|
+
return jsonError({
|
|
393
|
+
decision: "DENY",
|
|
394
|
+
reasonCode: code,
|
|
395
|
+
...requestId ? { requestId } : {},
|
|
396
|
+
message: code === "SERVICE_UNAVAILABLE" ? "Guardian could not be reached, so nothing was done. Try again later." : "Guardian denied this call. Nothing was done. Do not retry the same call."
|
|
397
|
+
});
|
|
398
|
+
}
|
|
316
399
|
async function requestAndExecute(client2, result) {
|
|
317
400
|
if (!result.ok) {
|
|
318
401
|
const heldRequestId = result.requestId;
|
|
@@ -324,33 +407,62 @@ async function requestAndExecute(client2, result) {
|
|
|
324
407
|
next: "Held for human approval. Call guardian_claim with this requestId after approval."
|
|
325
408
|
});
|
|
326
409
|
}
|
|
327
|
-
return
|
|
410
|
+
return denied(
|
|
411
|
+
result.reasonCode,
|
|
412
|
+
typeof heldRequestId === "string" && heldRequestId.length > 0 ? heldRequestId : void 0
|
|
413
|
+
);
|
|
328
414
|
}
|
|
329
415
|
if (result.decision === "ALLOW" && result.capabilityToken) {
|
|
330
416
|
const executed = await client2.execute({
|
|
331
417
|
requestId: result.requestId,
|
|
332
418
|
capabilityToken: result.capabilityToken
|
|
333
419
|
});
|
|
334
|
-
|
|
335
|
-
|
|
420
|
+
if (!executed.ok) {
|
|
421
|
+
return jsonError({
|
|
422
|
+
decision: "ALLOW",
|
|
423
|
+
requestId: result.requestId,
|
|
424
|
+
outcome: "unknown",
|
|
425
|
+
executionFailed: executed.reasonCode,
|
|
426
|
+
message: `Guardian could not confirm the outcome (${executed.reasonCode}). The call may have been done. ${CHECK_FIRST}`
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
const status = executed.receipt?.status;
|
|
430
|
+
if (status === "sent-verified") {
|
|
431
|
+
return jsonResult({
|
|
336
432
|
decision: "ALLOW",
|
|
337
433
|
requestId: result.requestId,
|
|
338
434
|
receipt: executed.receipt
|
|
339
|
-
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
if (status === "not-sent") {
|
|
438
|
+
return jsonError({
|
|
340
439
|
decision: "ALLOW",
|
|
341
440
|
requestId: result.requestId,
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
441
|
+
outcome: "not-done",
|
|
442
|
+
receipt: executed.receipt,
|
|
443
|
+
message: "Guardian did not send the call (receipt status not-sent). It was not done."
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return jsonError({
|
|
447
|
+
decision: "ALLOW",
|
|
448
|
+
requestId: result.requestId,
|
|
449
|
+
outcome: "unknown",
|
|
450
|
+
receipt: executed.receipt,
|
|
451
|
+
message: `The outcome is unknown (receipt status ${typeof status === "string" ? status : "missing"}). The call may have been done. ${CHECK_FIRST}`
|
|
452
|
+
});
|
|
345
453
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
454
|
+
if (result.decision === "ASK") {
|
|
455
|
+
return jsonResult({
|
|
456
|
+
decision: "ASK",
|
|
457
|
+
requestId: result.requestId,
|
|
458
|
+
...result.reasonCode ? { reasonCode: result.reasonCode } : {},
|
|
351
459
|
message: "Held for human approval. Call guardian_claim with this requestId after approval."
|
|
352
|
-
}
|
|
353
|
-
}
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return denied(
|
|
463
|
+
result.reasonCode ?? (result.decision === "DENY" ? void 0 : "MISSING_CAPABILITY"),
|
|
464
|
+
result.requestId
|
|
465
|
+
);
|
|
354
466
|
}
|
|
355
467
|
async function handleToolCall(options, name, arguments_) {
|
|
356
468
|
const { client: client2, defaultConnectionId, adapterId } = options;
|
|
@@ -410,12 +522,85 @@ var READ_VERBS = /* @__PURE__ */ new Set([
|
|
|
410
522
|
"find",
|
|
411
523
|
"lookup",
|
|
412
524
|
"count",
|
|
413
|
-
"inspect"
|
|
414
|
-
|
|
415
|
-
|
|
525
|
+
"inspect"
|
|
526
|
+
]);
|
|
527
|
+
var WRITE_VERBS = /* @__PURE__ */ new Set([
|
|
528
|
+
"add",
|
|
529
|
+
"append",
|
|
530
|
+
"apply",
|
|
531
|
+
"approve",
|
|
532
|
+
"archive",
|
|
533
|
+
"assign",
|
|
534
|
+
"buy",
|
|
535
|
+
"cancel",
|
|
536
|
+
"charge",
|
|
537
|
+
"clear",
|
|
538
|
+
"close",
|
|
539
|
+
"commit",
|
|
540
|
+
"copy",
|
|
541
|
+
"create",
|
|
542
|
+
"delete",
|
|
543
|
+
"deploy",
|
|
544
|
+
"destroy",
|
|
545
|
+
"disable",
|
|
546
|
+
"drop",
|
|
547
|
+
"edit",
|
|
548
|
+
"enable",
|
|
549
|
+
"exec",
|
|
550
|
+
"execute",
|
|
551
|
+
"fork",
|
|
552
|
+
"grant",
|
|
553
|
+
"import",
|
|
554
|
+
"insert",
|
|
555
|
+
"install",
|
|
556
|
+
"invite",
|
|
557
|
+
"kill",
|
|
558
|
+
"lock",
|
|
559
|
+
"merge",
|
|
560
|
+
"modify",
|
|
561
|
+
"move",
|
|
562
|
+
"notify",
|
|
563
|
+
"patch",
|
|
564
|
+
"pay",
|
|
565
|
+
"post",
|
|
566
|
+
"publish",
|
|
567
|
+
"purge",
|
|
568
|
+
"push",
|
|
569
|
+
"put",
|
|
570
|
+
"refund",
|
|
571
|
+
"remove",
|
|
572
|
+
"rename",
|
|
573
|
+
"replace",
|
|
574
|
+
"reply",
|
|
575
|
+
"reset",
|
|
576
|
+
"restart",
|
|
577
|
+
"restore",
|
|
578
|
+
"revoke",
|
|
579
|
+
"rm",
|
|
580
|
+
"run",
|
|
581
|
+
"save",
|
|
582
|
+
"schedule",
|
|
583
|
+
"sell",
|
|
584
|
+
"send",
|
|
585
|
+
"set",
|
|
586
|
+
"share",
|
|
587
|
+
"sign",
|
|
588
|
+
"start",
|
|
589
|
+
"stop",
|
|
590
|
+
"submit",
|
|
591
|
+
"sync",
|
|
592
|
+
"transfer",
|
|
593
|
+
"trigger",
|
|
594
|
+
"truncate",
|
|
595
|
+
"uninstall",
|
|
596
|
+
"unlock",
|
|
597
|
+
"update",
|
|
598
|
+
"upload",
|
|
599
|
+
"upsert",
|
|
600
|
+
"write"
|
|
416
601
|
]);
|
|
417
|
-
function
|
|
418
|
-
return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).
|
|
602
|
+
function words(name) {
|
|
603
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
419
604
|
}
|
|
420
605
|
function suggestTool(tool) {
|
|
421
606
|
const governable = GUARDIAN_TOOL_NAME.test(tool.name) && tool.name.length <= 64;
|
|
@@ -428,6 +613,16 @@ function suggestTool(tool) {
|
|
|
428
613
|
governable
|
|
429
614
|
};
|
|
430
615
|
}
|
|
616
|
+
const tokens = words(tool.name);
|
|
617
|
+
const writeVerb = tokens.find((token) => WRITE_VERBS.has(token));
|
|
618
|
+
if (writeVerb) {
|
|
619
|
+
return {
|
|
620
|
+
name: tool.name,
|
|
621
|
+
suggestedMode: "ask",
|
|
622
|
+
why: `"${writeVerb}" may change something`,
|
|
623
|
+
governable
|
|
624
|
+
};
|
|
625
|
+
}
|
|
431
626
|
if (annotations.readOnlyHint === true) {
|
|
432
627
|
return {
|
|
433
628
|
name: tool.name,
|
|
@@ -436,7 +631,7 @@ function suggestTool(tool) {
|
|
|
436
631
|
governable
|
|
437
632
|
};
|
|
438
633
|
}
|
|
439
|
-
const verb =
|
|
634
|
+
const verb = tokens[0] ?? "";
|
|
440
635
|
if (READ_VERBS.has(verb)) {
|
|
441
636
|
return {
|
|
442
637
|
name: tool.name,
|
|
@@ -482,6 +677,8 @@ function renderSuggestions(serverCommand, tools, remote = false) {
|
|
|
482
677
|
|
|
483
678
|
// src/proxy.ts
|
|
484
679
|
import {
|
|
680
|
+
CONTENT_PIN_KEY,
|
|
681
|
+
CONTENT_PIN_REMOTE_UPSTREAM,
|
|
485
682
|
createGuardianContext,
|
|
486
683
|
runThroughGuardian,
|
|
487
684
|
settle,
|
|
@@ -490,8 +687,40 @@ import {
|
|
|
490
687
|
GuardianExecutionFailed,
|
|
491
688
|
GuardianRecordTransportRequired
|
|
492
689
|
} from "@odingard/guardian-sdk";
|
|
690
|
+
var ContentPinConfigError = class extends Error {
|
|
691
|
+
constructor(reasonCode, message) {
|
|
692
|
+
super(message);
|
|
693
|
+
this.reasonCode = reasonCode;
|
|
694
|
+
this.name = "ContentPinConfigError";
|
|
695
|
+
}
|
|
696
|
+
reasonCode;
|
|
697
|
+
};
|
|
698
|
+
function pinFilesToFileArgs(pinFiles) {
|
|
699
|
+
const fileArgs = {};
|
|
700
|
+
for (const [toolName, args] of Object.entries(pinFiles ?? {})) {
|
|
701
|
+
if (args.length === 0) continue;
|
|
702
|
+
fileArgs[toolName] = Object.fromEntries(
|
|
703
|
+
args.map((arg) => [arg, "recheck"])
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
return fileArgs;
|
|
707
|
+
}
|
|
493
708
|
var CLAIM_TOOL = "guardian_claim";
|
|
494
709
|
var RECEIPT_TOOL = "guardian_receipt";
|
|
710
|
+
var GUARDIAN_RESULT_META = "com.odingard/guardian";
|
|
711
|
+
var UNAVAILABLE = "SERVICE_UNAVAILABLE";
|
|
712
|
+
function unreachableResult(toolName, requestId) {
|
|
713
|
+
return jsonResult2(
|
|
714
|
+
{
|
|
715
|
+
decision: "DENY",
|
|
716
|
+
toolName,
|
|
717
|
+
reasonCode: UNAVAILABLE,
|
|
718
|
+
...requestId ? { requestId } : {},
|
|
719
|
+
message: `Guardian could not be reached, so ${toolName} was not checked and nothing was forwarded. Try again later.`
|
|
720
|
+
},
|
|
721
|
+
true
|
|
722
|
+
);
|
|
723
|
+
}
|
|
495
724
|
var PROXY_GOVERNANCE_TOOLS = [
|
|
496
725
|
{
|
|
497
726
|
name: CLAIM_TOOL,
|
|
@@ -518,6 +747,14 @@ function jsonResult2(payload, isError = false) {
|
|
|
518
747
|
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
|
|
519
748
|
};
|
|
520
749
|
}
|
|
750
|
+
function withRequestId(result, requestId) {
|
|
751
|
+
if (!requestId) return result;
|
|
752
|
+
const meta = typeof result._meta === "object" && result._meta !== null ? result._meta : {};
|
|
753
|
+
return {
|
|
754
|
+
...result,
|
|
755
|
+
_meta: { ...meta, [GUARDIAN_RESULT_META]: { requestId } }
|
|
756
|
+
};
|
|
757
|
+
}
|
|
521
758
|
function isDecisionResult(value) {
|
|
522
759
|
return typeof value === "object" && value !== null && "guardian" in value && typeof value.guardian === "object";
|
|
523
760
|
}
|
|
@@ -533,6 +770,14 @@ var UPSTREAM_RESULT = /* @__PURE__ */ Symbol("guardian-mcp.upstreamResult");
|
|
|
533
770
|
function upstreamRan(value) {
|
|
534
771
|
return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, UPSTREAM_RESULT);
|
|
535
772
|
}
|
|
773
|
+
var UpstreamCallCancelled = class extends Error {
|
|
774
|
+
constructor(toolName) {
|
|
775
|
+
super("the call was cancelled before it was forwarded");
|
|
776
|
+
this.toolName = toolName;
|
|
777
|
+
this.name = "UpstreamCallCancelled";
|
|
778
|
+
}
|
|
779
|
+
toolName;
|
|
780
|
+
};
|
|
536
781
|
var UpstreamListFailed = class extends Error {
|
|
537
782
|
constructor() {
|
|
538
783
|
super(
|
|
@@ -541,16 +786,50 @@ var UpstreamListFailed = class extends Error {
|
|
|
541
786
|
this.name = "UpstreamListFailed";
|
|
542
787
|
}
|
|
543
788
|
};
|
|
544
|
-
function
|
|
545
|
-
|
|
789
|
+
function forwardable(approved) {
|
|
790
|
+
const record = approved !== null && typeof approved === "object" && !Array.isArray(approved) ? { ...approved } : {};
|
|
791
|
+
delete record[CONTENT_PIN_KEY];
|
|
792
|
+
return record;
|
|
793
|
+
}
|
|
794
|
+
function callUpstream(upstream, name, signal, forwarding) {
|
|
795
|
+
return async (approved) => {
|
|
796
|
+
const args = forwardable(approved);
|
|
797
|
+
if (signal?.aborted) {
|
|
798
|
+
throw new UpstreamCallCancelled(name);
|
|
799
|
+
}
|
|
800
|
+
forwarding.sent = true;
|
|
546
801
|
try {
|
|
547
|
-
return {
|
|
548
|
-
|
|
802
|
+
return {
|
|
803
|
+
[UPSTREAM_RESULT]: await upstream.callTool(
|
|
804
|
+
name,
|
|
805
|
+
args,
|
|
806
|
+
signal ? { signal } : void 0
|
|
807
|
+
)
|
|
808
|
+
};
|
|
809
|
+
} catch (err) {
|
|
810
|
+
if (err instanceof UpstreamCallCancelled) {
|
|
811
|
+
forwarding.sent = false;
|
|
812
|
+
throw err;
|
|
813
|
+
}
|
|
549
814
|
throw new UpstreamCallFailed(name);
|
|
550
815
|
}
|
|
551
816
|
};
|
|
552
817
|
}
|
|
553
|
-
function
|
|
818
|
+
function cancelledResult(toolName, requestId) {
|
|
819
|
+
return jsonResult2(
|
|
820
|
+
{
|
|
821
|
+
toolName,
|
|
822
|
+
...requestId ? { requestId } : {},
|
|
823
|
+
cancelled: true,
|
|
824
|
+
message: requestId ? `The request was cancelled before ${toolName} was forwarded. Nothing was forwarded; the approved call is still held, so call ${CLAIM_TOOL} with this requestId to forward it.` : `The request was cancelled before ${toolName} was forwarded. Nothing was forwarded.`
|
|
825
|
+
},
|
|
826
|
+
true
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
function failure(err, toolName, forwarding) {
|
|
830
|
+
if (err instanceof UpstreamCallCancelled) {
|
|
831
|
+
return cancelledResult(toolName);
|
|
832
|
+
}
|
|
554
833
|
if (err instanceof UpstreamCallFailed) {
|
|
555
834
|
return jsonResult2(
|
|
556
835
|
{
|
|
@@ -561,6 +840,19 @@ function failure(err, toolName) {
|
|
|
561
840
|
true
|
|
562
841
|
);
|
|
563
842
|
}
|
|
843
|
+
if (err instanceof GuardianExecutionFailed && !err.localEffectRan && (err.reasonCode === "CONTENT_CHANGED_SINCE_APPROVAL" || err.reasonCode.startsWith("CONTENT_PIN_"))) {
|
|
844
|
+
return jsonResult2(
|
|
845
|
+
{
|
|
846
|
+
decision: "ALLOW",
|
|
847
|
+
toolName,
|
|
848
|
+
requestId: err.requestId,
|
|
849
|
+
executionFailed: err.reasonCode,
|
|
850
|
+
localEffectRan: false,
|
|
851
|
+
message: err.reasonCode === "CONTENT_CHANGED_SINCE_APPROVAL" ? "A file named in the approved call changed since it was approved. Nothing was forwarded and the capability was not redeemed. Propose the call again; it needs a new approval." : "A file named in the approved call could not be checked against its approval. Nothing was forwarded and the capability was not redeemed."
|
|
852
|
+
},
|
|
853
|
+
true
|
|
854
|
+
);
|
|
855
|
+
}
|
|
564
856
|
if (err instanceof GuardianExecutionFailed) {
|
|
565
857
|
return jsonResult2(
|
|
566
858
|
{
|
|
@@ -569,12 +861,16 @@ function failure(err, toolName) {
|
|
|
569
861
|
requestId: err.requestId,
|
|
570
862
|
executionFailed: err.reasonCode,
|
|
571
863
|
localEffectRan: err.localEffectRan,
|
|
572
|
-
|
|
864
|
+
...err.outcomeUnknown ? { outcomeUnknown: true } : {},
|
|
865
|
+
message: err.outcomeUnknown ? err.localEffectRan ? `The upstream call ran, but Guardian could not confirm that it was recorded. Do not call it again; check this requestId with ${RECEIPT_TOOL}.` : `Guardian could not confirm whether the call completed. It may have run. Do not call it again; check this requestId with ${RECEIPT_TOOL}.` : err.localEffectRan ? "The upstream call ran but Guardian refused to redeem the capability. Do not call it again. Treat this as an incident, not a success." : "Guardian could not redeem the capability; the call was not completed."
|
|
573
866
|
},
|
|
574
867
|
true
|
|
575
868
|
);
|
|
576
869
|
}
|
|
577
870
|
if (err instanceof GuardianDenied) {
|
|
871
|
+
if (err.reasonCode === UNAVAILABLE) {
|
|
872
|
+
return unreachableResult(toolName, err.requestId);
|
|
873
|
+
}
|
|
578
874
|
return jsonResult2(
|
|
579
875
|
{
|
|
580
876
|
decision: "DENY",
|
|
@@ -599,17 +895,35 @@ function failure(err, toolName) {
|
|
|
599
895
|
return jsonResult2(
|
|
600
896
|
{
|
|
601
897
|
toolName,
|
|
602
|
-
error: "Guardian proxy error; the call was not forwarded."
|
|
898
|
+
error: forwarding.sent ? "Guardian proxy error after the call was forwarded. It may have run; check before retrying." : "Guardian proxy error; the call was not forwarded."
|
|
603
899
|
},
|
|
604
900
|
true
|
|
605
901
|
);
|
|
606
902
|
}
|
|
903
|
+
var CLAIMED_REQUESTS_LIMIT = 1e3;
|
|
607
904
|
function createGovernedProxy(options) {
|
|
608
905
|
const { upstream, log = () => {
|
|
609
906
|
} } = options;
|
|
907
|
+
const fileArgs = pinFilesToFileArgs(options.pinFiles);
|
|
908
|
+
if (Object.keys(fileArgs).length > 0 && options.upstreamTransport === "remote") {
|
|
909
|
+
throw new ContentPinConfigError(
|
|
910
|
+
CONTENT_PIN_REMOTE_UPSTREAM,
|
|
911
|
+
"guardian-mcp proxy: --pin-file is supported only for a stdio upstream. A remote (HTTP/SSE) MCP server opens paths on another machine, so the proxy cannot check the file it will read."
|
|
912
|
+
);
|
|
913
|
+
}
|
|
610
914
|
const toolInvokeRequestIds = /* @__PURE__ */ new Set();
|
|
611
915
|
const pendingCalls = /* @__PURE__ */ new Map();
|
|
612
916
|
const pendingInstalls = /* @__PURE__ */ new Map();
|
|
917
|
+
const claimedCapabilities = /* @__PURE__ */ new Map();
|
|
918
|
+
const claimedRequests = /* @__PURE__ */ new Map();
|
|
919
|
+
const rememberClaim = (requestId, entry) => {
|
|
920
|
+
claimedRequests.delete(requestId);
|
|
921
|
+
claimedRequests.set(requestId, entry);
|
|
922
|
+
while (claimedRequests.size > CLAIMED_REQUESTS_LIMIT) {
|
|
923
|
+
const oldest = claimedRequests.keys().next().value;
|
|
924
|
+
claimedRequests.delete(oldest);
|
|
925
|
+
}
|
|
926
|
+
};
|
|
613
927
|
const recordingClient = {
|
|
614
928
|
...options.client,
|
|
615
929
|
async request(invocation) {
|
|
@@ -629,8 +943,30 @@ function createGovernedProxy(options) {
|
|
|
629
943
|
onDeny: "result",
|
|
630
944
|
installRecordTransport: options.autoRegister ?? false,
|
|
631
945
|
...options.adapterId ? { adapterId: options.adapterId } : {},
|
|
632
|
-
...options.agentName ? { agentName: options.agentName } : {}
|
|
946
|
+
...options.agentName ? { agentName: options.agentName } : {},
|
|
947
|
+
...Object.keys(fileArgs).length > 0 ? { fileArgs } : {}
|
|
633
948
|
});
|
|
949
|
+
ctx.installed ??= /* @__PURE__ */ new Set();
|
|
950
|
+
ctx.proposedInputs = /* @__PURE__ */ new Map();
|
|
951
|
+
function heldSetupStep(toolName, requestId) {
|
|
952
|
+
const held = ctx.heldSetup?.get(`${options.connectionId}:${toolName}`);
|
|
953
|
+
return held?.requestId === requestId ? held.step : void 0;
|
|
954
|
+
}
|
|
955
|
+
function tracked() {
|
|
956
|
+
let allowed;
|
|
957
|
+
return {
|
|
958
|
+
ctx: {
|
|
959
|
+
...ctx,
|
|
960
|
+
options: {
|
|
961
|
+
...ctx.options,
|
|
962
|
+
onReceipt: (event) => {
|
|
963
|
+
allowed = event.requestId;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
},
|
|
967
|
+
allowedRequestId: () => allowed
|
|
968
|
+
};
|
|
969
|
+
}
|
|
634
970
|
let upstreamTools = null;
|
|
635
971
|
async function loadTools() {
|
|
636
972
|
if (upstreamTools) return upstreamTools;
|
|
@@ -647,8 +983,10 @@ function createGovernedProxy(options) {
|
|
|
647
983
|
upstreamTools = tools;
|
|
648
984
|
return tools;
|
|
649
985
|
}
|
|
650
|
-
function toCallResult(toolName, args, output) {
|
|
651
|
-
if (upstreamRan(output))
|
|
986
|
+
function toCallResult(toolName, args, output, allowedRequestId) {
|
|
987
|
+
if (upstreamRan(output)) {
|
|
988
|
+
return withRequestId(output[UPSTREAM_RESULT], allowedRequestId);
|
|
989
|
+
}
|
|
652
990
|
if (!isDecisionResult(output)) {
|
|
653
991
|
return jsonResult2(
|
|
654
992
|
{
|
|
@@ -661,7 +999,9 @@ function createGovernedProxy(options) {
|
|
|
661
999
|
const g = output.guardian;
|
|
662
1000
|
if (g.decision === "ASK") {
|
|
663
1001
|
if (toolInvokeRequestIds.has(g.requestId)) {
|
|
664
|
-
|
|
1002
|
+
const proposed = ctx.proposedInputs?.get(g.requestId) ?? args;
|
|
1003
|
+
ctx.proposedInputs?.delete(g.requestId);
|
|
1004
|
+
pendingCalls.set(g.requestId, { name: toolName, args, proposed });
|
|
665
1005
|
return jsonResult2({
|
|
666
1006
|
decision: "ASK",
|
|
667
1007
|
toolName,
|
|
@@ -670,40 +1010,92 @@ function createGovernedProxy(options) {
|
|
|
670
1010
|
});
|
|
671
1011
|
}
|
|
672
1012
|
pendingInstalls.set(g.requestId, toolName);
|
|
1013
|
+
const step = heldSetupStep(toolName, g.requestId);
|
|
673
1014
|
return jsonResult2({
|
|
674
1015
|
decision: "ASK",
|
|
675
1016
|
toolName,
|
|
676
1017
|
requestId: g.requestId,
|
|
677
|
-
message: `First use of ${toolName} needs a human to approve registering it with Guardian. After approval call ${CLAIM_TOOL} with this requestId, then retry the original call.`
|
|
1018
|
+
message: step === "enable" ? `Switching on ${toolName} for this agent needs a human to approve it in Guardian. After approval call ${CLAIM_TOOL} with this requestId (or retry the original call).` : `First use of ${toolName} needs a human to approve registering it with Guardian. After approval call ${CLAIM_TOOL} with this requestId, then retry the original call.`
|
|
678
1019
|
});
|
|
679
1020
|
}
|
|
680
1021
|
if (g.decision === "DENY") {
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
1022
|
+
if (g.reasonCode === UNAVAILABLE) {
|
|
1023
|
+
return unreachableResult(toolName, g.requestId);
|
|
1024
|
+
}
|
|
1025
|
+
return jsonResult2(
|
|
1026
|
+
{
|
|
1027
|
+
decision: "DENY",
|
|
1028
|
+
toolName,
|
|
1029
|
+
reasonCode: g.reasonCode,
|
|
1030
|
+
...g.requestId ? { requestId: g.requestId } : {},
|
|
1031
|
+
message: g.message
|
|
1032
|
+
},
|
|
1033
|
+
true
|
|
1034
|
+
);
|
|
688
1035
|
}
|
|
689
1036
|
return jsonResult2({ decision: "ALLOW", toolName, requestId: g.requestId });
|
|
690
1037
|
}
|
|
691
|
-
|
|
1038
|
+
function alreadyClaimed(requestId) {
|
|
1039
|
+
const seen = claimedRequests.get(requestId);
|
|
1040
|
+
if (!seen) return void 0;
|
|
1041
|
+
if (seen.state === "forwarding" || !seen.result) {
|
|
1042
|
+
return jsonResult2(
|
|
1043
|
+
{
|
|
1044
|
+
toolName: seen.toolName,
|
|
1045
|
+
requestId,
|
|
1046
|
+
alreadyClaimed: true,
|
|
1047
|
+
message: `This approved call is already being forwarded by an earlier ${CLAIM_TOOL}. Do not propose ${seen.toolName} again; wait, then call ${RECEIPT_TOOL} with this requestId to see what happened.`
|
|
1048
|
+
},
|
|
1049
|
+
true
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
const notice = {
|
|
1053
|
+
type: "text",
|
|
1054
|
+
text: `This requestId was already claimed and ${seen.toolName} was already forwarded. Do not propose it again. The earlier result follows.`
|
|
1055
|
+
};
|
|
1056
|
+
const earlier = seen.result;
|
|
1057
|
+
return {
|
|
1058
|
+
...earlier,
|
|
1059
|
+
isError: true,
|
|
1060
|
+
content: [
|
|
1061
|
+
notice,
|
|
1062
|
+
...Array.isArray(earlier.content) ? earlier.content : []
|
|
1063
|
+
]
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
async function claim(requestId, signal) {
|
|
1067
|
+
const repeat = alreadyClaimed(requestId);
|
|
1068
|
+
if (repeat) return repeat;
|
|
692
1069
|
const held = pendingCalls.get(requestId);
|
|
693
1070
|
if (held) {
|
|
1071
|
+
if (signal?.aborted) {
|
|
1072
|
+
return cancelledResult(held.name, requestId);
|
|
1073
|
+
}
|
|
694
1074
|
pendingCalls.delete(requestId);
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
claimed = await options.client.claim(requestId);
|
|
698
|
-
} catch {
|
|
1075
|
+
const putBack = () => {
|
|
1076
|
+
claimedRequests.delete(requestId);
|
|
699
1077
|
pendingCalls.set(requestId, held);
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1078
|
+
};
|
|
1079
|
+
rememberClaim(requestId, { toolName: held.name, state: "forwarding" });
|
|
1080
|
+
let claimed;
|
|
1081
|
+
const reused = claimedCapabilities.get(requestId);
|
|
1082
|
+
if (reused) {
|
|
1083
|
+
claimedCapabilities.delete(requestId);
|
|
1084
|
+
claimed = reused;
|
|
1085
|
+
} else {
|
|
1086
|
+
try {
|
|
1087
|
+
claimed = await options.client.claim(requestId);
|
|
1088
|
+
} catch {
|
|
1089
|
+
putBack();
|
|
1090
|
+
return unreachableResult(held.name, requestId);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (!claimed.ok && claimed.reasonCode === UNAVAILABLE) {
|
|
1094
|
+
putBack();
|
|
1095
|
+
return unreachableResult(held.name, requestId);
|
|
704
1096
|
}
|
|
705
1097
|
if (claimed.ok && claimed.decision === "ASK" || !claimed.ok && (claimed.reasonCode === "APPROVAL_PENDING" || claimed.reasonCode === "APPROVAL_REQUIRED")) {
|
|
706
|
-
|
|
1098
|
+
putBack();
|
|
707
1099
|
return jsonResult2({
|
|
708
1100
|
decision: "ASK",
|
|
709
1101
|
toolName: held.name,
|
|
@@ -711,39 +1103,95 @@ function createGovernedProxy(options) {
|
|
|
711
1103
|
message: "Still awaiting human approval. Nothing was sent."
|
|
712
1104
|
});
|
|
713
1105
|
}
|
|
1106
|
+
let result;
|
|
1107
|
+
const forwarding = { sent: false };
|
|
714
1108
|
try {
|
|
1109
|
+
const call = tracked();
|
|
715
1110
|
const output = await settle(
|
|
716
|
-
ctx,
|
|
1111
|
+
call.ctx,
|
|
717
1112
|
held.name,
|
|
718
|
-
held.
|
|
1113
|
+
held.proposed,
|
|
719
1114
|
claimed,
|
|
720
|
-
callUpstream(upstream, held.name,
|
|
1115
|
+
callUpstream(upstream, held.name, signal, forwarding)
|
|
1116
|
+
);
|
|
1117
|
+
result = toCallResult(
|
|
1118
|
+
held.name,
|
|
1119
|
+
held.args,
|
|
1120
|
+
output,
|
|
1121
|
+
call.allowedRequestId()
|
|
721
1122
|
);
|
|
722
|
-
return toCallResult(held.name, held.args, output);
|
|
723
1123
|
} catch (err) {
|
|
724
|
-
|
|
1124
|
+
if (err instanceof UpstreamCallCancelled) {
|
|
1125
|
+
putBack();
|
|
1126
|
+
if (claimed.ok && claimed.decision === "ALLOW") {
|
|
1127
|
+
claimedCapabilities.set(requestId, claimed);
|
|
1128
|
+
}
|
|
1129
|
+
return cancelledResult(held.name, requestId);
|
|
1130
|
+
}
|
|
1131
|
+
result = failure(err, held.name, forwarding);
|
|
725
1132
|
}
|
|
1133
|
+
if (!claimed.ok || claimed.decision !== "ALLOW" || !claimed.capabilityToken) {
|
|
1134
|
+
claimedRequests.delete(requestId);
|
|
1135
|
+
} else {
|
|
1136
|
+
rememberClaim(requestId, {
|
|
1137
|
+
toolName: held.name,
|
|
1138
|
+
state: "done",
|
|
1139
|
+
result
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
return result;
|
|
726
1143
|
}
|
|
727
1144
|
const installFor = pendingInstalls.get(requestId);
|
|
728
1145
|
if (installFor) {
|
|
729
|
-
|
|
1146
|
+
if (signal?.aborted) {
|
|
1147
|
+
return jsonResult2(
|
|
1148
|
+
{
|
|
1149
|
+
toolName: installFor,
|
|
1150
|
+
requestId,
|
|
1151
|
+
cancelled: true,
|
|
1152
|
+
message: `The request was cancelled. Nothing was claimed; call ${CLAIM_TOOL} with this requestId again.`
|
|
1153
|
+
},
|
|
1154
|
+
true
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
const key = `${options.connectionId}:${installFor}`;
|
|
1158
|
+
const step = heldSetupStep(installFor, requestId) ?? "install";
|
|
1159
|
+
const forgetHeld = () => {
|
|
1160
|
+
if (ctx.heldSetup?.get(key)?.requestId === requestId) {
|
|
1161
|
+
ctx.heldSetup.delete(key);
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
let claimed;
|
|
1165
|
+
try {
|
|
1166
|
+
claimed = await options.client.claim(requestId);
|
|
1167
|
+
} catch {
|
|
1168
|
+
return unreachableResult(installFor, requestId);
|
|
1169
|
+
}
|
|
1170
|
+
if (!claimed.ok && claimed.reasonCode === UNAVAILABLE) {
|
|
1171
|
+
return unreachableResult(installFor, requestId);
|
|
1172
|
+
}
|
|
730
1173
|
if (claimed.ok && claimed.decision === "ALLOW" && claimed.capabilityToken) {
|
|
1174
|
+
forgetHeld();
|
|
1175
|
+
pendingInstalls.delete(requestId);
|
|
731
1176
|
const executed = await options.client.execute({
|
|
732
1177
|
requestId: claimed.requestId,
|
|
733
1178
|
capabilityToken: claimed.capabilityToken
|
|
734
1179
|
});
|
|
735
|
-
|
|
736
|
-
|
|
1180
|
+
if (executed.ok && step === "enable") {
|
|
1181
|
+
ctx.ensured.add(key);
|
|
1182
|
+
} else if (executed.ok) {
|
|
1183
|
+
ctx.installed?.add(key);
|
|
1184
|
+
}
|
|
737
1185
|
return jsonResult2(
|
|
738
1186
|
executed.ok ? {
|
|
739
1187
|
decision: "ALLOW",
|
|
740
1188
|
toolName: installFor,
|
|
741
|
-
message: `${installFor} is registered with Guardian. Retry the original call.`
|
|
1189
|
+
message: step === "enable" ? `${installFor} is switched on for this agent. Retry the original call.` : `${installFor} is registered with Guardian. Retry the original call; switching it on for this agent may need one more approval.`
|
|
742
1190
|
} : { toolName: installFor, executionFailed: executed.reasonCode },
|
|
743
1191
|
!executed.ok
|
|
744
1192
|
);
|
|
745
1193
|
}
|
|
746
|
-
if (claimed.ok && claimed.decision === "ASK") {
|
|
1194
|
+
if (claimed.ok && claimed.decision === "ASK" || !claimed.ok && (claimed.reasonCode === "APPROVAL_PENDING" || claimed.reasonCode === "APPROVAL_REQUIRED")) {
|
|
747
1195
|
return jsonResult2({
|
|
748
1196
|
decision: "ASK",
|
|
749
1197
|
toolName: installFor,
|
|
@@ -751,17 +1199,21 @@ function createGovernedProxy(options) {
|
|
|
751
1199
|
message: "Still awaiting human approval."
|
|
752
1200
|
});
|
|
753
1201
|
}
|
|
1202
|
+
forgetHeld();
|
|
754
1203
|
pendingInstalls.delete(requestId);
|
|
755
|
-
return jsonResult2(
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1204
|
+
return jsonResult2(
|
|
1205
|
+
{
|
|
1206
|
+
decision: "DENY",
|
|
1207
|
+
toolName: installFor,
|
|
1208
|
+
reasonCode: claimed.ok ? claimed.reasonCode ?? "MISSING_CAPABILITY" : claimed.reasonCode
|
|
1209
|
+
},
|
|
1210
|
+
true
|
|
1211
|
+
);
|
|
760
1212
|
}
|
|
761
1213
|
return jsonResult2(
|
|
762
1214
|
{
|
|
763
1215
|
requestId,
|
|
764
|
-
error:
|
|
1216
|
+
error: `This proxy holds no call with this requestId, so this claim forwarded nothing. If it was claimed before (in this or an earlier proxy session), the call may already have been forwarded: call ${RECEIPT_TOOL} with this requestId before proposing it again.`
|
|
765
1217
|
},
|
|
766
1218
|
true
|
|
767
1219
|
);
|
|
@@ -785,13 +1237,14 @@ function createGovernedProxy(options) {
|
|
|
785
1237
|
...PROXY_GOVERNANCE_TOOLS
|
|
786
1238
|
];
|
|
787
1239
|
},
|
|
788
|
-
async callTool(name, args) {
|
|
1240
|
+
async callTool(name, args, callOptions) {
|
|
1241
|
+
const signal = callOptions?.signal;
|
|
789
1242
|
if (name === CLAIM_TOOL) {
|
|
790
1243
|
const requestId = args.requestId;
|
|
791
1244
|
if (typeof requestId !== "string" || requestId.length === 0) {
|
|
792
1245
|
return jsonResult2({ error: "requestId is required" }, true);
|
|
793
1246
|
}
|
|
794
|
-
return claim(requestId);
|
|
1247
|
+
return claim(requestId, signal);
|
|
795
1248
|
}
|
|
796
1249
|
if (name === RECEIPT_TOOL) {
|
|
797
1250
|
const requestId = args.requestId;
|
|
@@ -819,16 +1272,21 @@ function createGovernedProxy(options) {
|
|
|
819
1272
|
if (!tools.has(name)) {
|
|
820
1273
|
return jsonResult2({ error: `unknown tool: ${name}` }, true);
|
|
821
1274
|
}
|
|
1275
|
+
if (signal?.aborted) {
|
|
1276
|
+
return cancelledResult(name);
|
|
1277
|
+
}
|
|
1278
|
+
const forwarding = { sent: false };
|
|
822
1279
|
try {
|
|
1280
|
+
const call = tracked();
|
|
823
1281
|
const output = await runThroughGuardian(
|
|
824
|
-
ctx,
|
|
1282
|
+
call.ctx,
|
|
825
1283
|
name,
|
|
826
1284
|
args,
|
|
827
|
-
callUpstream(upstream, name,
|
|
1285
|
+
callUpstream(upstream, name, signal, forwarding)
|
|
828
1286
|
);
|
|
829
|
-
return toCallResult(name, args, output);
|
|
1287
|
+
return toCallResult(name, args, output, call.allowedRequestId());
|
|
830
1288
|
} catch (err) {
|
|
831
|
-
return failure(err, name);
|
|
1289
|
+
return failure(err, name, forwarding);
|
|
832
1290
|
}
|
|
833
1291
|
}
|
|
834
1292
|
};
|
|
@@ -892,7 +1350,10 @@ function toolListFailure(target, err) {
|
|
|
892
1350
|
function clientUpstream(client2) {
|
|
893
1351
|
return {
|
|
894
1352
|
listTools: () => listAllTools(client2),
|
|
895
|
-
async callTool(name, args) {
|
|
1353
|
+
async callTool(name, args, options) {
|
|
1354
|
+
if (options?.signal?.aborted) {
|
|
1355
|
+
throw new UpstreamCallCancelled(name);
|
|
1356
|
+
}
|
|
896
1357
|
return await client2.callTool({
|
|
897
1358
|
name,
|
|
898
1359
|
arguments: args
|
|
@@ -960,23 +1421,39 @@ if (config.mode.kind === "proxy") {
|
|
|
960
1421
|
name: "guardian-mcp-proxy",
|
|
961
1422
|
version: VERSION
|
|
962
1423
|
});
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1424
|
+
let proxy;
|
|
1425
|
+
try {
|
|
1426
|
+
proxy = createGovernedProxy({
|
|
1427
|
+
client,
|
|
1428
|
+
connectionId: config.connectionId,
|
|
1429
|
+
autoRegister: config.autoRegister,
|
|
1430
|
+
upstreamTransport: "remote" in config.mode ? "remote" : "stdio",
|
|
1431
|
+
...config.mode.pinFiles ? { pinFiles: config.mode.pinFiles } : {},
|
|
1432
|
+
...config.adapterId ? { adapterId: config.adapterId } : {},
|
|
1433
|
+
log: (message) => process.stderr.write(`${message}
|
|
969
1434
|
`),
|
|
970
|
-
|
|
971
|
-
|
|
1435
|
+
upstream: clientUpstream(upstreamClient)
|
|
1436
|
+
});
|
|
1437
|
+
} catch (err) {
|
|
1438
|
+
process.stderr.write(
|
|
1439
|
+
`${err instanceof Error ? err.message : "guardian-mcp proxy: invalid configuration."}
|
|
1440
|
+
`
|
|
1441
|
+
);
|
|
1442
|
+
await upstreamClient.close().catch(() => {
|
|
1443
|
+
});
|
|
1444
|
+
process.exit(2);
|
|
1445
|
+
}
|
|
972
1446
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
973
1447
|
tools: await proxy.listTools()
|
|
974
1448
|
}));
|
|
975
1449
|
server.setRequestHandler(
|
|
976
1450
|
CallToolRequestSchema,
|
|
977
|
-
async (request) => await proxy.callTool(
|
|
1451
|
+
async (request, extra) => await proxy.callTool(
|
|
978
1452
|
request.params.name,
|
|
979
|
-
request.params.arguments ?? {}
|
|
1453
|
+
request.params.arguments ?? {},
|
|
1454
|
+
// Cancelled (notifications/cancelled) or timed-out requests are
|
|
1455
|
+
// not forwarded if the signal fires before the call is sent.
|
|
1456
|
+
{ signal: extra.signal }
|
|
980
1457
|
)
|
|
981
1458
|
);
|
|
982
1459
|
} else {
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odingard/guardian-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"mcpName": "io.github.Odingard/guardian-mcp",
|
|
5
|
-
"description": "MCP server that routes an agent's tool calls through Guardian — the human authority layer for autonomous AI. Every tool call gets a deterministic ALLOW/ASK/DENY decision before anything executes; ASK holds for human approval, DENY never runs, every
|
|
5
|
+
"description": "MCP server that routes an agent's tool calls through Guardian — the human authority layer for autonomous AI. Every tool call gets a deterministic ALLOW/ASK/DENY decision before anything executes; ASK holds for human approval, DENY never runs, and every allowed call that completes carries a verifiable receipt.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
|
+
"author": "Odingard Security LLC",
|
|
7
8
|
"type": "module",
|
|
8
9
|
"main": "dist/index.js",
|
|
9
10
|
"bin": {
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
27
28
|
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
28
|
-
"@odingard/guardian-sdk": "^0.
|
|
29
|
+
"@odingard/guardian-sdk": "^0.4.0"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
32
|
"@types/node": "^22",
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
|
|
3
3
|
"name": "io.github.Odingard/guardian-mcp",
|
|
4
4
|
"description": "Routes agent tool calls through Guardian for ALLOW/ASK/DENY before execution.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.4.0",
|
|
6
6
|
"tools": [
|
|
7
7
|
{
|
|
8
8
|
"name": "guardian_invoke_tool",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
{
|
|
27
27
|
"registryType": "npm",
|
|
28
28
|
"identifier": "@odingard/guardian-mcp",
|
|
29
|
-
"version": "0.
|
|
29
|
+
"version": "0.4.0",
|
|
30
30
|
"transport": {
|
|
31
31
|
"type": "stdio"
|
|
32
32
|
},
|