@odingard/guardian-mcp 0.3.4 → 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 +17 -0
- package/dist/index.js +136 -17
- package/package.json +2 -2
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -120,6 +120,23 @@ upstream server's tools unchanged, and every call is decided first:
|
|
|
120
120
|
`guardian-mcp --help` prints usage. A missing setting prints a one-line
|
|
121
121
|
message naming it instead of a stack trace.
|
|
122
122
|
|
|
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
|
+
|
|
123
140
|
## Govern a remote MCP server: `guardian-mcp proxy --url` (0.3.3 and later)
|
|
124
141
|
|
|
125
142
|
**Status: tested against a real Guardian; production proof pending.** It ran
|
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,
|
|
@@ -621,6 +677,8 @@ function renderSuggestions(serverCommand, tools, remote = false) {
|
|
|
621
677
|
|
|
622
678
|
// src/proxy.ts
|
|
623
679
|
import {
|
|
680
|
+
CONTENT_PIN_KEY,
|
|
681
|
+
CONTENT_PIN_REMOTE_UPSTREAM,
|
|
624
682
|
createGuardianContext,
|
|
625
683
|
runThroughGuardian,
|
|
626
684
|
settle,
|
|
@@ -629,6 +687,24 @@ import {
|
|
|
629
687
|
GuardianExecutionFailed,
|
|
630
688
|
GuardianRecordTransportRequired
|
|
631
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
|
+
}
|
|
632
708
|
var CLAIM_TOOL = "guardian_claim";
|
|
633
709
|
var RECEIPT_TOOL = "guardian_receipt";
|
|
634
710
|
var GUARDIAN_RESULT_META = "com.odingard/guardian";
|
|
@@ -710,8 +786,14 @@ var UpstreamListFailed = class extends Error {
|
|
|
710
786
|
this.name = "UpstreamListFailed";
|
|
711
787
|
}
|
|
712
788
|
};
|
|
713
|
-
function
|
|
714
|
-
|
|
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);
|
|
715
797
|
if (signal?.aborted) {
|
|
716
798
|
throw new UpstreamCallCancelled(name);
|
|
717
799
|
}
|
|
@@ -758,6 +840,19 @@ function failure(err, toolName, forwarding) {
|
|
|
758
840
|
true
|
|
759
841
|
);
|
|
760
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
|
+
}
|
|
761
856
|
if (err instanceof GuardianExecutionFailed) {
|
|
762
857
|
return jsonResult2(
|
|
763
858
|
{
|
|
@@ -809,6 +904,13 @@ var CLAIMED_REQUESTS_LIMIT = 1e3;
|
|
|
809
904
|
function createGovernedProxy(options) {
|
|
810
905
|
const { upstream, log = () => {
|
|
811
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
|
+
}
|
|
812
914
|
const toolInvokeRequestIds = /* @__PURE__ */ new Set();
|
|
813
915
|
const pendingCalls = /* @__PURE__ */ new Map();
|
|
814
916
|
const pendingInstalls = /* @__PURE__ */ new Map();
|
|
@@ -841,9 +943,11 @@ function createGovernedProxy(options) {
|
|
|
841
943
|
onDeny: "result",
|
|
842
944
|
installRecordTransport: options.autoRegister ?? false,
|
|
843
945
|
...options.adapterId ? { adapterId: options.adapterId } : {},
|
|
844
|
-
...options.agentName ? { agentName: options.agentName } : {}
|
|
946
|
+
...options.agentName ? { agentName: options.agentName } : {},
|
|
947
|
+
...Object.keys(fileArgs).length > 0 ? { fileArgs } : {}
|
|
845
948
|
});
|
|
846
949
|
ctx.installed ??= /* @__PURE__ */ new Set();
|
|
950
|
+
ctx.proposedInputs = /* @__PURE__ */ new Map();
|
|
847
951
|
function heldSetupStep(toolName, requestId) {
|
|
848
952
|
const held = ctx.heldSetup?.get(`${options.connectionId}:${toolName}`);
|
|
849
953
|
return held?.requestId === requestId ? held.step : void 0;
|
|
@@ -895,7 +999,9 @@ function createGovernedProxy(options) {
|
|
|
895
999
|
const g = output.guardian;
|
|
896
1000
|
if (g.decision === "ASK") {
|
|
897
1001
|
if (toolInvokeRequestIds.has(g.requestId)) {
|
|
898
|
-
|
|
1002
|
+
const proposed = ctx.proposedInputs?.get(g.requestId) ?? args;
|
|
1003
|
+
ctx.proposedInputs?.delete(g.requestId);
|
|
1004
|
+
pendingCalls.set(g.requestId, { name: toolName, args, proposed });
|
|
899
1005
|
return jsonResult2({
|
|
900
1006
|
decision: "ASK",
|
|
901
1007
|
toolName,
|
|
@@ -1004,9 +1110,9 @@ function createGovernedProxy(options) {
|
|
|
1004
1110
|
const output = await settle(
|
|
1005
1111
|
call.ctx,
|
|
1006
1112
|
held.name,
|
|
1007
|
-
held.
|
|
1113
|
+
held.proposed,
|
|
1008
1114
|
claimed,
|
|
1009
|
-
callUpstream(upstream, held.name,
|
|
1115
|
+
callUpstream(upstream, held.name, signal, forwarding)
|
|
1010
1116
|
);
|
|
1011
1117
|
result = toCallResult(
|
|
1012
1118
|
held.name,
|
|
@@ -1176,7 +1282,7 @@ function createGovernedProxy(options) {
|
|
|
1176
1282
|
call.ctx,
|
|
1177
1283
|
name,
|
|
1178
1284
|
args,
|
|
1179
|
-
callUpstream(upstream, name,
|
|
1285
|
+
callUpstream(upstream, name, signal, forwarding)
|
|
1180
1286
|
);
|
|
1181
1287
|
return toCallResult(name, args, output, call.allowedRequestId());
|
|
1182
1288
|
} catch (err) {
|
|
@@ -1315,15 +1421,28 @@ if (config.mode.kind === "proxy") {
|
|
|
1315
1421
|
name: "guardian-mcp-proxy",
|
|
1316
1422
|
version: VERSION
|
|
1317
1423
|
});
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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}
|
|
1324
1434
|
`),
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
+
}
|
|
1327
1446
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1328
1447
|
tools: await proxy.listTools()
|
|
1329
1448
|
}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
29
|
-
"@odingard/guardian-sdk": "^0.
|
|
29
|
+
"@odingard/guardian-sdk": "^0.4.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
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
|
},
|