@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 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 target = parseTarget("proxy", argv.slice(1), env);
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
- mode = { kind: "proxy", ...target.target };
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 callUpstream(upstream, name, args, signal, forwarding) {
714
- return async () => {
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
- pendingCalls.set(g.requestId, { name: toolName, args });
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.args,
1113
+ held.proposed,
1008
1114
  claimed,
1009
- callUpstream(upstream, held.name, held.args, signal, forwarding)
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, args, signal, forwarding)
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
- const proxy = createGovernedProxy({
1319
- client,
1320
- connectionId: config.connectionId,
1321
- autoRegister: config.autoRegister,
1322
- ...config.adapterId ? { adapterId: config.adapterId } : {},
1323
- log: (message) => process.stderr.write(`${message}
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
- upstream: clientUpstream(upstreamClient)
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.4",
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.3.9"
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.3.4",
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.3.4",
29
+ "version": "0.4.0",
30
30
  "transport": {
31
31
  "type": "stdio"
32
32
  },