@guardion/guardion 0.3.0 → 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/LICENSE +21 -0
- package/README.md +202 -0
- package/dist/bin/cli.d.ts.map +1 -0
- package/dist/bin/cli.js +590 -0
- package/dist/bin/cli.js.map +1 -0
- package/dist/connectors/claude-code/hooks/enforce.cjs +58 -0
- package/{hooks → dist/connectors/claude-code/hooks}/guardion-hook.cjs +123 -1
- package/dist/connectors/claude-code/hooks/tool-scanner.cjs +272 -0
- package/dist/connectors/claude-code/src/collect.d.ts +5 -0
- package/dist/connectors/claude-code/src/collect.d.ts.map +1 -0
- package/dist/connectors/claude-code/src/collect.js +17 -0
- package/dist/connectors/claude-code/src/collect.js.map +1 -0
- package/dist/{installer.d.ts → connectors/claude-code/src/installer.d.ts} +1 -1
- package/dist/connectors/claude-code/src/installer.d.ts.map +1 -0
- package/dist/{installer.js → connectors/claude-code/src/installer.js} +2 -2
- package/dist/connectors/claude-code/src/installer.js.map +1 -0
- package/dist/connectors/claude-code/src/scanner.d.ts.map +1 -0
- package/dist/{scanner.js → connectors/claude-code/src/scanner.js} +1 -1
- package/dist/connectors/claude-code/src/scanner.js.map +1 -0
- package/dist/{config.d.ts → core/config.d.ts} +96 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/{config.js → core/config.js} +44 -0
- package/dist/core/config.js.map +1 -0
- package/dist/{constants.d.ts → core/constants.d.ts} +1 -1
- package/dist/core/constants.d.ts.map +1 -0
- package/dist/{constants.js → core/constants.js} +1 -1
- package/dist/core/constants.js.map +1 -0
- package/dist/core/discover.d.ts +36 -0
- package/dist/core/discover.d.ts.map +1 -0
- package/dist/core/discover.js +154 -0
- package/dist/core/discover.js.map +1 -0
- package/dist/core/fingerprint.cjs +84 -0
- package/dist/core/inventory.d.ts +35 -0
- package/dist/core/inventory.d.ts.map +1 -0
- package/dist/core/inventory.js +69 -0
- package/dist/core/inventory.js.map +1 -0
- package/dist/core/keychain.d.ts.map +1 -0
- package/dist/core/keychain.js.map +1 -0
- package/dist/core/mcp/guard-client.cjs +86 -0
- package/dist/core/mcp/interceptor.cjs +238 -0
- package/dist/core/mcp/jsonrpc.cjs +194 -0
- package/dist/core/mcp/transport/http-server-side.cjs +89 -0
- package/dist/core/mcp/transport/http-upstream.cjs +111 -0
- package/dist/core/mcp/transport/http_forward.cjs +40 -0
- package/dist/core/mcp/transport/http_input.cjs +46 -0
- package/dist/core/mcp/transport/http_reverse.cjs +33 -0
- package/dist/core/mcp/transport/index.cjs +32 -0
- package/dist/core/mcp/transport/sse_bridge.cjs +101 -0
- package/dist/core/mcp/transport/stdio.cjs +60 -0
- package/dist/core/mcp-interpose.cjs +141 -0
- package/dist/core/mcp-protect.d.ts +69 -0
- package/dist/core/mcp-protect.d.ts.map +1 -0
- package/dist/core/mcp-protect.js +205 -0
- package/dist/core/mcp-protect.js.map +1 -0
- package/dist/core/mcp-scan.d.ts +40 -0
- package/dist/core/mcp-scan.d.ts.map +1 -0
- package/dist/core/mcp-scan.js +201 -0
- package/dist/core/mcp-scan.js.map +1 -0
- package/dist/core/mock-server.d.ts.map +1 -0
- package/dist/{mock-server.js → core/mock-server.js} +41 -0
- package/dist/core/mock-server.js.map +1 -0
- package/package.json +9 -10
- package/config.yaml.example +0 -84
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -298
- package/dist/cli.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/constants.d.ts.map +0 -1
- package/dist/constants.js.map +0 -1
- package/dist/installer.d.ts.map +0 -1
- package/dist/installer.js.map +0 -1
- package/dist/keychain.d.ts.map +0 -1
- package/dist/keychain.js.map +0 -1
- package/dist/mock-server.d.ts.map +0 -1
- package/dist/mock-server.js.map +0 -1
- package/dist/scanner.d.ts.map +0 -1
- package/dist/scanner.js.map +0 -1
- /package/dist/{cli.d.ts → bin/cli.d.ts} +0 -0
- /package/dist/{scanner.d.ts → connectors/claude-code/src/scanner.d.ts} +0 -0
- /package/dist/{keychain.d.ts → core/keychain.d.ts} +0 -0
- /package/dist/{keychain.js → core/keychain.js} +0 -0
- /package/{hooks → dist/core}/metadata.cjs +0 -0
- /package/dist/{mock-server.d.ts → core/mock-server.d.ts} +0 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// core/inventory — UNIVERSAL tool-inventory types + Guard submission. The
|
|
2
|
+
// connector-specific local discovery (collectInventory) lives in each connector
|
|
3
|
+
// (e.g. connectors/claude-code/src/collect.ts); this module only defines the
|
|
4
|
+
// shared ScannedTool shape, the rug-pull pin, and the /v1/guard submit.
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import http from 'node:http';
|
|
8
|
+
import https from 'node:https';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
/** Attach prev_* fingerprints from the local pin (~/.guardion/fingerprints.json)
|
|
12
|
+
* so the server can flag a changed tool as a rug-pull. Best-effort; never throws. */
|
|
13
|
+
export function pinInventory(tools) {
|
|
14
|
+
try {
|
|
15
|
+
const requireCjs = createRequire(import.meta.url);
|
|
16
|
+
// fingerprint.cjs lives beside this module (core/); dist mirrors source.
|
|
17
|
+
const fpPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'fingerprint.cjs');
|
|
18
|
+
const { pinAndEnrich } = requireCjs(fpPath);
|
|
19
|
+
return pinAndEnrich(tools, path.join(os.homedir(), '.guardion', 'fingerprints.json'));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return tools;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** POST the inventory to Guard API /v1/guard, tagged snapshot_source=plugin_scan. */
|
|
26
|
+
export function submitInventory(opts) {
|
|
27
|
+
const { apiUrl, token, policy, application } = opts;
|
|
28
|
+
const tools = pinInventory(opts.tools); // attach rug-pull prev_* fingerprints
|
|
29
|
+
const body = JSON.stringify({
|
|
30
|
+
tools,
|
|
31
|
+
policy: policy || undefined,
|
|
32
|
+
application: application || 'claude-code',
|
|
33
|
+
log: true,
|
|
34
|
+
snapshot_source: 'plugin_scan',
|
|
35
|
+
});
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
let url;
|
|
38
|
+
try {
|
|
39
|
+
url = new URL('/v1/guard', apiUrl);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
return reject(e);
|
|
43
|
+
}
|
|
44
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
45
|
+
const req = transport.request({
|
|
46
|
+
hostname: url.hostname,
|
|
47
|
+
port: url.port,
|
|
48
|
+
path: url.pathname,
|
|
49
|
+
method: 'POST',
|
|
50
|
+
timeout: 8000,
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/json',
|
|
53
|
+
Authorization: `Bearer ${token}`,
|
|
54
|
+
'Content-Length': Buffer.byteLength(body),
|
|
55
|
+
},
|
|
56
|
+
}, (res) => {
|
|
57
|
+
res.resume();
|
|
58
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0 }));
|
|
59
|
+
});
|
|
60
|
+
req.on('error', reject);
|
|
61
|
+
req.on('timeout', () => {
|
|
62
|
+
req.destroy();
|
|
63
|
+
reject(new Error('timeout'));
|
|
64
|
+
});
|
|
65
|
+
req.write(body);
|
|
66
|
+
req.end();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=inventory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inventory.js","sourceRoot":"","sources":["../../core/inventory.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keychain.d.ts","sourceRoot":"","sources":["../../core/keychain.ts"],"names":[],"mappings":"AAaA,wBAAgB,QAAQ,IAAI,MAAM,CAiCjC;AAID,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAsB5C;AAED,wBAAgB,UAAU,IAAI,IAAI,CAwBjC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keychain.js","sourceRoot":"","sources":["../../core/keychain.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Guard control-plane client for the interposer. We delegate ALL detection to
|
|
4
|
+
* Guard (POST /v1/guard) — never re-implement it here. This module only does the
|
|
5
|
+
* HTTP round-trip and interprets the response (block / sanitize / reason).
|
|
6
|
+
*
|
|
7
|
+
* Decision mapping (AARM → MCP):
|
|
8
|
+
* res.deny === true → BLOCK (JSON-RPC error)
|
|
9
|
+
* res.correction / res.redacted → SANITIZE (replace content with redacted text)
|
|
10
|
+
* else → ALLOW (forward)
|
|
11
|
+
* Unreachable Guard (null) → fail-open unless failClosed.
|
|
12
|
+
*/
|
|
13
|
+
const http = require('http');
|
|
14
|
+
const https = require('https');
|
|
15
|
+
|
|
16
|
+
function createGuardClient(opts) {
|
|
17
|
+
const { apiUrl, token, policy, application, timeout, failClosed } = opts;
|
|
18
|
+
|
|
19
|
+
// `message` is a single message object OR an ordered array of messages (one per
|
|
20
|
+
// redactable leaf). Correction choices come back 1:1 in the same order.
|
|
21
|
+
function evaluate(message, tools) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
if (!token) return resolve(null);
|
|
24
|
+
let url; try { url = new URL('/v1/guard', apiUrl); } catch { return resolve(null); }
|
|
25
|
+
const tr = url.protocol === 'https:' ? https : http;
|
|
26
|
+
const payload = { application: application || 'mcp-interpose', policy, log: true };
|
|
27
|
+
if (message) payload.messages = Array.isArray(message) ? message : [message];
|
|
28
|
+
if (tools) payload.tools = tools;
|
|
29
|
+
const body = JSON.stringify(payload);
|
|
30
|
+
const req = tr.request({
|
|
31
|
+
hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
32
|
+
path: url.pathname, method: 'POST', timeout: timeout || 3000,
|
|
33
|
+
headers: {
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
'Authorization': `Bearer ${token}`,
|
|
36
|
+
'Content-Length': Buffer.byteLength(body),
|
|
37
|
+
},
|
|
38
|
+
}, (r) => {
|
|
39
|
+
let d = ''; r.setEncoding('utf8');
|
|
40
|
+
r.on('data', (c) => { d += c; });
|
|
41
|
+
r.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve(null); } });
|
|
42
|
+
});
|
|
43
|
+
req.on('error', () => resolve(null));
|
|
44
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
45
|
+
req.write(body); req.end();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// null (unreachable) ⇒ fail-open unless failClosed. res.deny ⇒ block.
|
|
50
|
+
function shouldBlock(res) {
|
|
51
|
+
if (!res) return !!failClosed;
|
|
52
|
+
return res.deny === true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function reasonOf(res) {
|
|
56
|
+
try {
|
|
57
|
+
const b = res && Array.isArray(res.breakdown) ? res.breakdown[0] : null;
|
|
58
|
+
const l = b && (b.top_label || b.label);
|
|
59
|
+
return l ? `Guardion: ${l}` : 'Guardion governance';
|
|
60
|
+
} catch { return 'Guardion governance'; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Sanitized text from a correction (the redacted message content), or null.
|
|
64
|
+
function correctionText(res) {
|
|
65
|
+
try {
|
|
66
|
+
if (!res) return null;
|
|
67
|
+
const ch = res.correction && Array.isArray(res.correction.choices) ? res.correction.choices : null;
|
|
68
|
+
if (ch && ch.length && typeof ch[0].content === 'string') return ch[0].content;
|
|
69
|
+
} catch { /* ignore */ }
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Redacted content per submitted message, in order (choices are 1:1 with the
|
|
74
|
+
// messages sent). Entry is null when that choice carried no string content.
|
|
75
|
+
function corrections(res) {
|
|
76
|
+
try {
|
|
77
|
+
const ch = res && res.correction && Array.isArray(res.correction.choices) ? res.correction.choices : null;
|
|
78
|
+
if (!ch) return [];
|
|
79
|
+
return ch.map((c) => (c && typeof c.content === 'string' ? c.content : null));
|
|
80
|
+
} catch { return []; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { evaluate, shouldBlock, reasonOf, correctionText, corrections };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { createGuardClient };
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Transport-agnostic interception brain (Layer 2, AGT MCP-Security-Gateway-1.0).
|
|
4
|
+
*
|
|
5
|
+
* Given injected `sendClient` / `sendServer` and a Guard client, it returns the
|
|
6
|
+
* two relay handlers a transport drives:
|
|
7
|
+
* onClient(msg) — host → server (requests we scan as INPUT)
|
|
8
|
+
* onServer(msg) — server → host (results we scan as OUTPUT; server-initiated
|
|
9
|
+
* sampling requests we scan as INPUT; tools/list integrity)
|
|
10
|
+
*
|
|
11
|
+
* It is the same logic for stdio / http / sse — only the I/O differs (see
|
|
12
|
+
* transport/*). Detection is delegated to Guard; this file only decides
|
|
13
|
+
* block / sanitize / forward and tracks request↔response pairing.
|
|
14
|
+
*
|
|
15
|
+
* Improvements over the fire-and-forget v1:
|
|
16
|
+
* #1 broaden scanned methods: resources/read, prompts/get, sampling/createMessage
|
|
17
|
+
* #5 tool-list integrity AT CONNECT: local rug-pull (fingerprint drift) strips
|
|
18
|
+
* the changed tools; a Guard deny on the toolset refuses the poisoned server
|
|
19
|
+
* (synchronous when enforcing) — no longer fire-and-forget
|
|
20
|
+
* #6 in-proxy redaction: a Guard correction rewrites the result content (SANITIZE)
|
|
21
|
+
*/
|
|
22
|
+
const J = require('./jsonrpc.cjs');
|
|
23
|
+
|
|
24
|
+
// host → server requests whose ARGUMENTS we scan as tool input.
|
|
25
|
+
const INPUT_REQUESTS = new Set(['tools/call', 'resources/read', 'prompts/get']);
|
|
26
|
+
// server → host requests we scan as input (the server feeding the host LLM).
|
|
27
|
+
const SERVER_INPUT_REQUESTS = new Set(['sampling/createMessage']);
|
|
28
|
+
// results (matched via the pending request) whose payload we scan as tool output.
|
|
29
|
+
const OUTPUT_METHODS = new Set(['tools/call', 'resources/read', 'prompts/get']);
|
|
30
|
+
|
|
31
|
+
function _inputMessage(serverName, method, params) {
|
|
32
|
+
const ns = serverName ? serverName + '/' : '';
|
|
33
|
+
if (method === 'tools/call') {
|
|
34
|
+
const name = (params && params.name) || '';
|
|
35
|
+
return { role: 'tool_input', name: ns + name,
|
|
36
|
+
content: `${name} ${JSON.stringify((params && params.arguments) || {})}` };
|
|
37
|
+
}
|
|
38
|
+
if (method === 'resources/read') {
|
|
39
|
+
const uri = (params && params.uri) || '';
|
|
40
|
+
return { role: 'tool_input', name: ns + 'resources/read', content: uri };
|
|
41
|
+
}
|
|
42
|
+
if (method === 'prompts/get') {
|
|
43
|
+
const name = (params && params.name) || '';
|
|
44
|
+
return { role: 'tool_input', name: ns + 'prompts/get:' + name,
|
|
45
|
+
content: `${name} ${JSON.stringify((params && params.arguments) || {})}` };
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createInterceptor(opts) {
|
|
51
|
+
const {
|
|
52
|
+
guard, enforce, dlp = false, integrity = true, redact = true,
|
|
53
|
+
fingerprint, pinPath, serverName, log = () => {},
|
|
54
|
+
sendClient, sendServer,
|
|
55
|
+
} = opts;
|
|
56
|
+
|
|
57
|
+
// Synchronous scan path (await Guard before forwarding) runs for hard
|
|
58
|
+
// enforcement OR standalone DLP. Observe-only stays fire-and-forget. Blocking
|
|
59
|
+
// is gated by `enforce`; anonymization by `redact` — so `dlp` = anonymize,
|
|
60
|
+
// never block.
|
|
61
|
+
const sync = enforce || dlp;
|
|
62
|
+
|
|
63
|
+
const pending = new Map(); // request id → { method, toolName }
|
|
64
|
+
|
|
65
|
+
// ── #5: tool-list integrity ────────────────────────────────────────────────
|
|
66
|
+
// Local rug-pull via the fingerprint pin (deterministic, no Guard round-trip):
|
|
67
|
+
// a tool whose description/schema changed from the pinned baseline is drift.
|
|
68
|
+
function driftedNames(tools) {
|
|
69
|
+
const out = new Set();
|
|
70
|
+
try {
|
|
71
|
+
let defs = tools.map((t) => ({
|
|
72
|
+
name: t.name, description: t.description || '', server: serverName || 'mcp',
|
|
73
|
+
source: 'mcp', snapshot_source: 'mcp_interpose', parameters: J.paramsToList(t.inputSchema),
|
|
74
|
+
}));
|
|
75
|
+
if (fingerprint && fingerprint.pinAndEnrich) {
|
|
76
|
+
defs = fingerprint.pinAndEnrich(defs, pinPath); // attaches prev_* + updates pin
|
|
77
|
+
for (const d of defs) {
|
|
78
|
+
const dh = fingerprint.descriptionHash(d.description);
|
|
79
|
+
const sh = fingerprint.schemaHash(d.parameters);
|
|
80
|
+
if ((d.prev_description_hash && d.prev_description_hash !== dh)
|
|
81
|
+
|| (d.prev_schema_hash && d.prev_schema_hash !== sh)) {
|
|
82
|
+
out.add(d.name);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch { /* best-effort */ }
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function toolDefs(tools) {
|
|
91
|
+
let defs = tools.map((t) => ({
|
|
92
|
+
name: t.name, description: t.description || '', server: serverName || 'mcp',
|
|
93
|
+
source: 'mcp', snapshot_source: 'mcp_interpose', parameters: J.paramsToList(t.inputSchema),
|
|
94
|
+
}));
|
|
95
|
+
try { if (fingerprint && fingerprint.pinAndEnrich) defs = fingerprint.pinAndEnrich(defs, pinPath); } catch { /* pin best-effort */ }
|
|
96
|
+
return defs;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function handleToolsList(msg) {
|
|
100
|
+
const tools = msg.result.tools;
|
|
101
|
+
const drifted = (enforce && integrity) ? driftedNames(tools) : new Set();
|
|
102
|
+
|
|
103
|
+
if (!enforce) { // observe-only: fire-and-forget + pin
|
|
104
|
+
try { guard.evaluate(null, toolDefs(tools)); } catch { /* ignore */ }
|
|
105
|
+
return sendClient(msg);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Synchronous poisoning verdict on the toolset (connect-time). A deny means
|
|
109
|
+
// the server is poisoned → refuse to expose ANY of its tools.
|
|
110
|
+
let res = null;
|
|
111
|
+
if (integrity) { try { res = await guard.evaluate(null, toolDefs(tools)); } catch { res = null; } }
|
|
112
|
+
if (integrity && guard.shouldBlock(res)) {
|
|
113
|
+
log(`tools/list blocked (poisoned server): ${guard.reasonOf(res)}`);
|
|
114
|
+
return sendClient(J.rpcError(msg.id, `[guardion] blocked — ${guard.reasonOf(res)}`));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (drifted.size) { // rug-pull: strip changed tools
|
|
118
|
+
const safe = tools.filter((t) => !drifted.has(t.name));
|
|
119
|
+
log(`tools/list rug-pull: stripped ${[...drifted].join(', ')}`);
|
|
120
|
+
msg.result.tools = safe;
|
|
121
|
+
}
|
|
122
|
+
return sendClient(msg);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── #6: output redaction / block (leaf-level, structure-preserving) ──────────
|
|
126
|
+
async function handleResult(msg, method, toolName) {
|
|
127
|
+
const name = toolName || method;
|
|
128
|
+
if (!sync) { // observe-only: fire-and-forget
|
|
129
|
+
try { guard.evaluate({ role: J.ROLES.output, name, content: J.resultText(msg.result) }); } catch { /* ignore */ }
|
|
130
|
+
return sendClient(msg);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Scan each redactable string leaf; fall back to a flat message for shapes
|
|
134
|
+
// with no text leaves (e.g. image-only) so a deny is still enforced.
|
|
135
|
+
const leaves = J.collectOutputLeaves(method, msg.result);
|
|
136
|
+
const messages = leaves.length
|
|
137
|
+
? leaves.map((l) => ({ role: J.ROLES.output, name, content: l.text }))
|
|
138
|
+
: { role: J.ROLES.output, name, content: J.resultText(msg.result) };
|
|
139
|
+
const res = await guard.evaluate(messages);
|
|
140
|
+
|
|
141
|
+
if (enforce && guard.shouldBlock(res)) {
|
|
142
|
+
log(`${method} result blocked: ${guard.reasonOf(res)}`);
|
|
143
|
+
return sendClient(J.rpcError(msg.id, `[guardion] blocked — ${guard.reasonOf(res)}`));
|
|
144
|
+
}
|
|
145
|
+
if (redact && res && res.redacted && leaves.length) {
|
|
146
|
+
const corr = guard.corrections(res);
|
|
147
|
+
const applied = leaves.map((l, i) => ({ path: l.path, text: corr[i] != null ? corr[i] : l.text }));
|
|
148
|
+
J.applyLeaves(msg.result, applied); // write back in place; structure preserved
|
|
149
|
+
log(`${method} result sanitized (redaction)`);
|
|
150
|
+
}
|
|
151
|
+
return sendClient(msg);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Input scan messages: one per redactable argument leaf (for redaction
|
|
155
|
+
// write-back) PLUS a trailing identity envelope (tool/prompt name) so
|
|
156
|
+
// tool-name threat detection and fail-closed still fire when arguments are
|
|
157
|
+
// empty. Corrections map 1:1 to `leaves` by index; the envelope is last and
|
|
158
|
+
// ignored for redaction.
|
|
159
|
+
function _inputScan(method, params) {
|
|
160
|
+
const toolName = (params && params.name) || '';
|
|
161
|
+
const scanName = serverName ? serverName + '/' + toolName : toolName;
|
|
162
|
+
const leaves = J.collectInputLeaves(method, params);
|
|
163
|
+
const messages = leaves.map((l) => ({ role: J.ROLES.input, name: scanName, content: l.text }));
|
|
164
|
+
if (method === 'tools/call' && toolName) {
|
|
165
|
+
messages.push({ role: J.ROLES.input, name: scanName, content: toolName });
|
|
166
|
+
} else if (method === 'prompts/get') {
|
|
167
|
+
const pn = serverName ? serverName + '/prompts/get' : 'prompts/get';
|
|
168
|
+
messages.push({ role: J.ROLES.input, name: pn, content: toolName || 'prompts/get' });
|
|
169
|
+
}
|
|
170
|
+
return { leaves, messages };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── host → server ──────────────────────────────────────────────────────────
|
|
174
|
+
async function onClient(msg) {
|
|
175
|
+
if (J.isRequest(msg) && INPUT_REQUESTS.has(msg.method)) {
|
|
176
|
+
const toolName = (msg.params && msg.params.name) || '';
|
|
177
|
+
pending.set(msg.id, { method: msg.method, toolName });
|
|
178
|
+
if (sync) {
|
|
179
|
+
const { leaves, messages } = _inputScan(msg.method, msg.params);
|
|
180
|
+
if (messages.length) {
|
|
181
|
+
const res = await guard.evaluate(messages);
|
|
182
|
+
if (enforce && guard.shouldBlock(res)) {
|
|
183
|
+
pending.delete(msg.id);
|
|
184
|
+
log(`${msg.method} blocked (input): ${guard.reasonOf(res)}`);
|
|
185
|
+
return sendClient(J.rpcError(msg.id, `[guardion] blocked — ${guard.reasonOf(res)}`));
|
|
186
|
+
}
|
|
187
|
+
if (redact && res && res.redacted && leaves.length) {
|
|
188
|
+
const corr = guard.corrections(res);
|
|
189
|
+
const applied = leaves.map((l, i) => ({ path: l.path, text: corr[i] != null ? corr[i] : l.text }));
|
|
190
|
+
J.applyLeaves(msg.params, applied); // anonymize args in place before forwarding
|
|
191
|
+
log(`${msg.method} input anonymized (redaction)`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
const message = _inputMessage(serverName, msg.method, msg.params);
|
|
196
|
+
if (message) { try { guard.evaluate(message); } catch { /* observe-only */ } }
|
|
197
|
+
}
|
|
198
|
+
return sendServer(msg);
|
|
199
|
+
}
|
|
200
|
+
if (J.isRequest(msg)) pending.set(msg.id, { method: msg.method });
|
|
201
|
+
return sendServer(msg);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── server → host ────────────────────────────────────────────────────────────
|
|
205
|
+
async function onServer(msg) {
|
|
206
|
+
// server-initiated request (sampling/createMessage): scan what the server is
|
|
207
|
+
// feeding into the host LLM; a deny is returned to the SERVER (it asked).
|
|
208
|
+
if (J.isRequest(msg) && SERVER_INPUT_REQUESTS.has(msg.method)) {
|
|
209
|
+
const content = J.samplingText(msg.params);
|
|
210
|
+
const message = { role: J.ROLES.input, name: (serverName ? serverName + '/' : '') + msg.method, content };
|
|
211
|
+
if (enforce) {
|
|
212
|
+
const res = await guard.evaluate(message);
|
|
213
|
+
if (guard.shouldBlock(res)) {
|
|
214
|
+
log(`${msg.method} blocked (server sampling): ${guard.reasonOf(res)}`);
|
|
215
|
+
return sendServer(J.rpcError(msg.id, `[guardion] blocked — ${guard.reasonOf(res)}`));
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
try { guard.evaluate(message); } catch { /* observe-only */ }
|
|
219
|
+
}
|
|
220
|
+
return sendClient(msg);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const info = J.isResponse(msg) ? pending.get(msg.id) : null;
|
|
224
|
+
if (info) pending.delete(msg.id);
|
|
225
|
+
|
|
226
|
+
if (info && info.method === 'tools/list' && msg.result && Array.isArray(msg.result.tools)) {
|
|
227
|
+
return handleToolsList(msg);
|
|
228
|
+
}
|
|
229
|
+
if (info && OUTPUT_METHODS.has(info.method) && msg.result) {
|
|
230
|
+
return handleResult(msg, info.method, info.toolName);
|
|
231
|
+
}
|
|
232
|
+
return sendClient(msg);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { onClient, onServer };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = { createInterceptor, INPUT_REQUESTS, SERVER_INPUT_REQUESTS, OUTPUT_METHODS };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* JSON-RPC / MCP framing + helpers shared by every transport.
|
|
4
|
+
*
|
|
5
|
+
* Zero-dep (node builtins only) so the interposer runs when launched by arbitrary
|
|
6
|
+
* hosts without node_modules. Pure functions — unit-testable standalone.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Canonical Guard message roles for the two MCP scan directions. These MUST match
|
|
10
|
+
// Guard's MessagesRole enum (guard/guard/core/schemas.py) — `tool_response`, NOT
|
|
11
|
+
// `tool_output` (which is a Target name, not a role). A mismatch makes /v1/guard
|
|
12
|
+
// 422 and silently fail-open, so both directions are pinned here once.
|
|
13
|
+
const ROLES = { input: 'tool_input', output: 'tool_response' };
|
|
14
|
+
|
|
15
|
+
// Keys that hold base64 binary — never sent for detection, never rewritten.
|
|
16
|
+
const BINARY_KEYS = new Set(['data', 'blob']);
|
|
17
|
+
// MCP structural / metadata keys carried alongside text in result shapes. Skipped
|
|
18
|
+
// when walking result content so we only redact human-readable text leaves.
|
|
19
|
+
const META_KEYS = new Set(['type', 'mimeType', 'annotations', '_meta', 'role', 'isError', 'size']);
|
|
20
|
+
|
|
21
|
+
/** Incremental newline-delimited JSON-RPC framer (stdio / SSE data lines). */
|
|
22
|
+
class LineFramer {
|
|
23
|
+
constructor() { this.buf = ''; }
|
|
24
|
+
/** Feed a chunk; returns the complete lines it produced (trailing partial kept). */
|
|
25
|
+
push(chunk) {
|
|
26
|
+
this.buf += chunk;
|
|
27
|
+
const out = [];
|
|
28
|
+
let nl;
|
|
29
|
+
while ((nl = this.buf.indexOf('\n')) >= 0) {
|
|
30
|
+
const line = this.buf.slice(0, nl);
|
|
31
|
+
this.buf = this.buf.slice(nl + 1);
|
|
32
|
+
if (line.trim()) out.push(line);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parse(line) {
|
|
39
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function serialize(obj) {
|
|
43
|
+
return JSON.stringify(obj);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isRequest(msg) {
|
|
47
|
+
return !!(msg && typeof msg === 'object' && msg.method && msg.id != null);
|
|
48
|
+
}
|
|
49
|
+
function isNotification(msg) {
|
|
50
|
+
return !!(msg && typeof msg === 'object' && msg.method && msg.id == null);
|
|
51
|
+
}
|
|
52
|
+
function isResponse(msg) {
|
|
53
|
+
return !!(msg && typeof msg === 'object' && msg.id != null && !msg.method
|
|
54
|
+
&& (Object.prototype.hasOwnProperty.call(msg, 'result')
|
|
55
|
+
|| Object.prototype.hasOwnProperty.call(msg, 'error')));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rpcError(id, message, code) {
|
|
59
|
+
return { jsonrpc: '2.0', id: id == null ? null : id, error: { code: code || -32000, message } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Flatten an MCP tool/resource/prompt result into scannable text. */
|
|
63
|
+
function resultText(result) {
|
|
64
|
+
try {
|
|
65
|
+
if (result && Array.isArray(result.content)) { // tools/call
|
|
66
|
+
return result.content.map((c) => (c && typeof c.text === 'string' ? c.text : '')).join('\n');
|
|
67
|
+
}
|
|
68
|
+
if (result && Array.isArray(result.contents)) { // resources/read
|
|
69
|
+
return result.contents.map((c) => (c && typeof c.text === 'string' ? c.text : '')).join('\n');
|
|
70
|
+
}
|
|
71
|
+
if (result && Array.isArray(result.messages)) { // prompts/get
|
|
72
|
+
return result.messages.map((m) => {
|
|
73
|
+
const c = m && m.content;
|
|
74
|
+
if (c && typeof c.text === 'string') return c.text;
|
|
75
|
+
return typeof c === 'string' ? c : '';
|
|
76
|
+
}).join('\n');
|
|
77
|
+
}
|
|
78
|
+
} catch { /* ignore */ }
|
|
79
|
+
return typeof result === 'string' ? result : JSON.stringify(result || '');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Replace the textual payload of a result in place with `text` (best-effort SANITIZE). */
|
|
83
|
+
function replaceResultText(result, text) {
|
|
84
|
+
if (!result || typeof result !== 'object') return result;
|
|
85
|
+
if (Array.isArray(result.content)) { result.content = [{ type: 'text', text }]; return result; }
|
|
86
|
+
if (Array.isArray(result.contents)) {
|
|
87
|
+
const uri = (result.contents[0] && result.contents[0].uri) || '';
|
|
88
|
+
result.contents = [{ uri, mimeType: 'text/plain', text }];
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(result.messages)) {
|
|
92
|
+
result.messages = [{ role: 'user', content: { type: 'text', text } }];
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Extract the text a server-initiated sampling/createMessage wants the host LLM to run. */
|
|
99
|
+
function samplingText(params) {
|
|
100
|
+
if (!params || typeof params !== 'object') return '';
|
|
101
|
+
const parts = [];
|
|
102
|
+
if (typeof params.systemPrompt === 'string') parts.push(params.systemPrompt);
|
|
103
|
+
for (const m of (Array.isArray(params.messages) ? params.messages : [])) {
|
|
104
|
+
const c = m && m.content;
|
|
105
|
+
if (c && typeof c.text === 'string') parts.push(c.text);
|
|
106
|
+
else if (typeof c === 'string') parts.push(c);
|
|
107
|
+
}
|
|
108
|
+
return parts.join('\n');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── leaf-level, structure-preserving redaction ───────────────────────────────
|
|
112
|
+
// Detection/redaction is a string transform: we extract every human-readable
|
|
113
|
+
// string leaf (with its JSON path), let Guard redact each, then write the result
|
|
114
|
+
// back IN PLACE — so multiple content blocks, structuredContent, images/audio,
|
|
115
|
+
// blobs, isError, _meta and mimeType all survive untouched. Guard never has to
|
|
116
|
+
// reserialize an MCP payload.
|
|
117
|
+
|
|
118
|
+
/** Recursively collect string leaves of `node` as { path, text }. Skips base64
|
|
119
|
+
* binary keys always; skips MCP metadata keys when `skipMeta` (result shapes). */
|
|
120
|
+
function collectStringLeaves(node, basePath, out, skipMeta) {
|
|
121
|
+
if (node == null) return out;
|
|
122
|
+
if (typeof node === 'string') { out.push({ path: basePath.slice(), text: node }); return out; }
|
|
123
|
+
if (Array.isArray(node)) {
|
|
124
|
+
for (let i = 0; i < node.length; i++) collectStringLeaves(node[i], basePath.concat(i), out, skipMeta);
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
if (typeof node === 'object') {
|
|
128
|
+
for (const k of Object.keys(node)) {
|
|
129
|
+
// Skip MCP binary (base64) + structural/metadata keys ONLY in result shapes
|
|
130
|
+
// (skipMeta). Tool-input arguments are arbitrary tool-defined keys — a field
|
|
131
|
+
// named `data`/`blob`/`type` there can be redactable text, so scan it.
|
|
132
|
+
if (skipMeta && (BINARY_KEYS.has(k) || META_KEYS.has(k))) continue;
|
|
133
|
+
collectStringLeaves(node[k], basePath.concat(k), out, skipMeta);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out; // numbers / booleans are not redactable text
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Redactable string leaves of a tool-call INPUT (relative to `params`). */
|
|
140
|
+
function collectInputLeaves(method, params) {
|
|
141
|
+
const out = [];
|
|
142
|
+
if (!params || typeof params !== 'object') return out;
|
|
143
|
+
if (method === 'tools/call' || method === 'prompts/get') {
|
|
144
|
+
collectStringLeaves(params.arguments, ['arguments'], out, false); // arbitrary tool keys
|
|
145
|
+
} else if (method === 'resources/read') {
|
|
146
|
+
if (typeof params.uri === 'string') out.push({ path: ['uri'], text: params.uri });
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Redactable string leaves of a tool/resource/prompt OUTPUT (relative to `result`). */
|
|
152
|
+
function collectOutputLeaves(method, result) {
|
|
153
|
+
const out = [];
|
|
154
|
+
if (!result || typeof result !== 'object') return out;
|
|
155
|
+
if (method === 'tools/call') {
|
|
156
|
+
collectStringLeaves(result.content, ['content'], out, true); // text / resource_link / embedded resource text
|
|
157
|
+
if (result.structuredContent != null) collectStringLeaves(result.structuredContent, ['structuredContent'], out, false);
|
|
158
|
+
} else if (method === 'resources/read') {
|
|
159
|
+
collectStringLeaves(result.contents, ['contents'], out, true); // TextResourceContents.text / .uri (blob skipped)
|
|
160
|
+
} else if (method === 'prompts/get') {
|
|
161
|
+
collectStringLeaves(result.messages, ['messages'], out, true);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function setByPath(root, p, value) {
|
|
167
|
+
let n = root;
|
|
168
|
+
for (let i = 0; i < p.length - 1; i++) { if (n == null) return; n = n[p[i]]; }
|
|
169
|
+
if (n != null && p.length) n[p[p.length - 1]] = value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Write redacted strings back into `root` by path, preserving everything else. */
|
|
173
|
+
function applyLeaves(root, leaves) {
|
|
174
|
+
for (const l of (leaves || [])) {
|
|
175
|
+
if (l && Array.isArray(l.path) && typeof l.text === 'string') setByPath(root, l.path, l.text);
|
|
176
|
+
}
|
|
177
|
+
return root;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function paramsToList(inputSchema) {
|
|
181
|
+
const props = inputSchema && inputSchema.properties ? inputSchema.properties : {};
|
|
182
|
+
return Object.keys(props).map((k) => ({
|
|
183
|
+
name: k,
|
|
184
|
+
type: (props[k] && props[k].type) || '',
|
|
185
|
+
description: (props[k] && props[k].description) || '',
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
ROLES,
|
|
191
|
+
LineFramer, parse, serialize, isRequest, isNotification, isResponse,
|
|
192
|
+
rpcError, resultText, replaceResultText, samplingText, paramsToList,
|
|
193
|
+
collectStringLeaves, collectInputLeaves, collectOutputLeaves, applyLeaves, setByPath,
|
|
194
|
+
};
|