@metamynd/agentsafe-mcp-guard 0.3.6 → 0.6.1
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 +51 -2
- package/agentsafe-mcp-guard.mjs +41 -22
- package/key-providers.mjs +91 -0
- package/package.json +3 -2
- package/policy-core.mjs +77 -7
package/README.md
CHANGED
|
@@ -35,8 +35,8 @@ const guard = createMcpGuard({
|
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
// Responder side, over your HTTP transport:
|
|
38
|
-
const challenge = guard.handshakeChallenge(hello);
|
|
39
|
-
const ready = guard.handshakeVerify(prove);
|
|
38
|
+
const challenge = await guard.handshakeChallenge(hello); // POST /magp/handshake (HELLO → CHALLENGE)
|
|
39
|
+
const ready = guard.handshakeVerify(prove); // POST /magp/handshake (PROVE → READY, or throws)
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
The agent drives the initiator side with `createGuard(...).handshake()` from `agentsafe-guard`.
|
|
@@ -110,6 +110,55 @@ $5,000" protection never engaged, verifier configured or not. New `requireCapabi
|
|
|
110
110
|
an omitted capability a hard block (`CAPABILITY_REQUIRED`) instead of a silent pass-through. Both
|
|
111
111
|
off by default — existing embeds are unchanged.
|
|
112
112
|
|
|
113
|
+
**0.5.0 — the `keyProvider` seam
|
|
114
|
+
([docs/design/agent-key-custody-local-signer-daemon-plan.md](../../docs/design/agent-key-custody-local-signer-daemon-plan.md)).**
|
|
115
|
+
`serviceKey` no longer has to be a raw hex key living in this process. Pass
|
|
116
|
+
`keyProvider: 'daemon'` + `daemonSocketPath` instead, and `handshakeChallenge` gets its signature
|
|
117
|
+
from a separate `@metamynd/agentsafe-signer` daemon (`role: 'service'`) over a local socket — the
|
|
118
|
+
key never enters this process at all. `serviceKey` (unchanged) still works exactly as before and
|
|
119
|
+
remains the default. **Breaking, disclosed plainly**: `handshakeChallenge` and
|
|
120
|
+
`createHandshakeInitiator(...).prove()` are now `async` (a daemon-backed provider needs a socket
|
|
121
|
+
round trip); every caller needs an `await` added. New internal module `key-providers.mjs` — still
|
|
122
|
+
zero external dependencies.
|
|
123
|
+
|
|
124
|
+
**0.5.3 — `requireAuthorization`'s claim now actually enforces revoked authority and mandate
|
|
125
|
+
expiry, and the claim-binding check closes the action axis too.** A four-client readiness review's
|
|
126
|
+
P0 finding required a tested counterparty-verification pattern covering, by name, replay, wrong
|
|
127
|
+
identity, wrong action, expired mandate, and revoked authority. Building the end-to-end trial
|
|
128
|
+
against a real backend (not the mocked cases below) surfaced that two of those five were silently
|
|
129
|
+
unenforced at the claim step itself, not just untested:
|
|
130
|
+
|
|
131
|
+
- **Revoked authority.** Revoking a mandate (`POST /mandate/:ref/revoke`) only ever flipped the
|
|
132
|
+
hold's `mandate_event.status` to `voided` — it never touched the `effect_transition` chain, and
|
|
133
|
+
`markEffect`'s transition check only validated the state-machine shape (`authorized →
|
|
134
|
+
dispatching` is always syntactically legal), never the hold's own status. A resource relying on
|
|
135
|
+
`requireAuthorization` could successfully claim and execute a hold whose mandate had already been
|
|
136
|
+
revoked. Now refused with `AUTHORIZATION_VOIDED`.
|
|
137
|
+
- **Expired mandate.** A hold's expiry (`isWithinHoldWindow`/the TTL) is a *derived* property —
|
|
138
|
+
nothing writes it back to the row when time passes, so the same claim step had no way to see a
|
|
139
|
+
hold was stale. Now refused with `AUTHORIZATION_EXPIRED`.
|
|
140
|
+
- **Wrong action.** `claimAuthorization()`'s mismatch checks compared the claimed hold's
|
|
141
|
+
`agentDid`/`amount`/`currency`/`merchant` against the request being executed, but never `action`
|
|
142
|
+
— because the backend's claim response never carried it. A real, unclaimed authorization minted
|
|
143
|
+
for one action (e.g. `office-supplies-purchase`) could be claimed while executing a *different*
|
|
144
|
+
action at the identical agent/amount/currency/merchant — the same confused-deputy shape the 0.3.6
|
|
145
|
+
merchant check closed, one axis short. The backend now resolves and returns the hold's authorized
|
|
146
|
+
`action` on claim (backward-compatible — an older backend simply omits it, same
|
|
147
|
+
skip-when-absent tolerance as every other field here). Now refused with
|
|
148
|
+
`AUTHORIZATION_ACTION_MISMATCH`.
|
|
149
|
+
|
|
150
|
+
All three are backend-side (`MandateService.markEffect`) except the action check, which also
|
|
151
|
+
needed this guard to compare the new field. See
|
|
152
|
+
[`docs/design/mcp-reverification-quickstart.md`](../../docs/design/mcp-reverification-quickstart.md) (§7)
|
|
153
|
+
for the end-to-end trial these gaps were found and closed against.
|
|
154
|
+
|
|
155
|
+
**0.5.1 — `keyProvider: 'daemon'` retries a transient connect failure.** Same fix as
|
|
156
|
+
`@metamynd/agentsafe-guard` 0.9.1: on Windows the signer daemon's socket is a pool of independent
|
|
157
|
+
named-pipe instances, each consumed by one connection and replaced asynchronously, so two
|
|
158
|
+
signing requests close together could race that replacement window and fail with
|
|
159
|
+
`DAEMON_UNREACHABLE` even though the daemon was healthy. `key-providers.mjs` now retries a
|
|
160
|
+
connection that fails with `ENOENT` for up to 3 seconds before giving up. No API change.
|
|
161
|
+
|
|
113
162
|
### Replay, cumulative spend, rate limits, breakers, spend anomalies (`requireAuthorization`)
|
|
114
163
|
|
|
115
164
|
Re-evaluating policy per request (above) proves the request is well-formed and in-policy — it
|
package/agentsafe-mcp-guard.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from '
|
|
|
16
16
|
import { verifyDidSignature } from './magp-did.mjs';
|
|
17
17
|
import { buildPaymentRequirements, checkSettlementBinding } from './x402.mjs';
|
|
18
18
|
import { verifyBundle } from './magp-policy.mjs';
|
|
19
|
+
import { resolveKeyProvider } from './key-providers.mjs';
|
|
19
20
|
|
|
20
21
|
/** Freshness window for signed requests and handshake nonces (spec §7.7). How far `issuedAt`
|
|
21
22
|
* may be BEHIND server time — network/processing delay. */
|
|
@@ -57,19 +58,15 @@ const CLOCK_SKEW_TOLERANCE_MS = 30 * 1000;
|
|
|
57
58
|
* integrator's un-capability-aware callers must keep working); set true on any Service where
|
|
58
59
|
* capability binding is meant to be mandatory, not opt-in.
|
|
59
60
|
*/
|
|
60
|
-
export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle, policyPublicKey, settlementStore, verifyCapability, requireAuthorization = false, requireCapability = false } = {}) {
|
|
61
|
+
export function createMcpGuard({ serviceDid, serviceKey, keyProvider: keyProviderOpt, daemonSocketPath, issuerApi, fetchBundle, policyPublicKey, settlementStore, verifyCapability, requireAuthorization = false, requireCapability = false } = {}) {
|
|
61
62
|
if (!serviceDid) throw new Error('createMcpGuard requires { serviceDid }');
|
|
62
63
|
const base = issuerApi ? issuerApi.replace(/\/$/, '') : null;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
// keyProvider seam (docs/design/agent-key-custody-local-signer-daemon-plan.md): null when
|
|
65
|
+
// neither `serviceKey` nor `keyProvider` is configured — handshakeChallenge throws its own
|
|
66
|
+
// clear error only if actually called, matching the original lazy-throw behavior exactly.
|
|
67
|
+
const keyProvider = resolveKeyProvider({ keyProvider: keyProviderOpt, serviceKey, daemonSocketPath });
|
|
66
68
|
const pending = new Map(); // handshakeId -> { fromDid, nonceB, expiresAt }
|
|
67
69
|
|
|
68
|
-
function sign(message) {
|
|
69
|
-
if (!privateKey) throw new Error('serviceKey is required to sign handshake messages');
|
|
70
|
-
return crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
70
|
// --- Mutual handshake, RESPONDER side (spec §8.2) ---
|
|
74
71
|
// A → B HELLO { fromDid, nonceA }
|
|
75
72
|
// B → A CHALLENGE { toDid, nonceB, sigB(nonceA) } ← proves B controls toDid
|
|
@@ -77,12 +74,13 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
77
74
|
// B → A READY { channelId }
|
|
78
75
|
|
|
79
76
|
/** Step 1 (B): on HELLO, sign nonceA to prove control of serviceDid, issue nonceB. */
|
|
80
|
-
function handshakeChallenge({ fromDid, nonceA, protoVersion } = {}) {
|
|
77
|
+
async function handshakeChallenge({ fromDid, nonceA, protoVersion } = {}) {
|
|
81
78
|
if (!fromDid || !nonceA) throw new Error('HELLO requires { fromDid, nonceA }');
|
|
79
|
+
if (!keyProvider) throw new Error('serviceKey (or keyProvider) is required to sign handshake messages');
|
|
82
80
|
const handshakeId = crypto.randomUUID();
|
|
83
81
|
const nonceB = crypto.randomUUID();
|
|
84
82
|
pending.set(handshakeId, { fromDid, nonceB, expiresAt: Date.now() + FRESHNESS_MS });
|
|
85
|
-
return { handshakeId, toDid: serviceDid, nonceB, sigB:
|
|
83
|
+
return { handshakeId, toDid: serviceDid, nonceB, sigB: await keyProvider.signHandshakeNonce(nonceA), protoVersion: protoVersion ?? '1.0' };
|
|
86
84
|
}
|
|
87
85
|
|
|
88
86
|
/** Step 2 (B): on PROVE, verify sigA over nonceB against fromDid's key-in-DID. */
|
|
@@ -110,12 +108,12 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
110
108
|
* against the stateful issuer gate already checked both (agentsafe-guard.mjs's authorizeLocal()
|
|
111
109
|
* seals any value-bearing action through the real remote authorize() by default).
|
|
112
110
|
*
|
|
113
|
-
* On success, also returns the hold's OWN bound `agentDid`/`amount`/`currency
|
|
114
|
-
* caller MUST compare these to the request actually being executed. A claim
|
|
115
|
-
* "some real, unclaimed authorization exists"; without this check, a
|
|
116
|
-
* authorization for a small, honest transaction
|
|
117
|
-
*
|
|
118
|
-
* recurring one layer deeper.
|
|
111
|
+
* On success, also returns the hold's OWN bound `agentDid`/`action`/`amount`/`currency`/
|
|
112
|
+
* `merchant` — the caller MUST compare these to the request actually being executed. A claim
|
|
113
|
+
* alone only proves "some real, unclaimed authorization exists"; without this check, a
|
|
114
|
+
* legitimately-obtained authorization for a small, honest transaction (or a DIFFERENT action
|
|
115
|
+
* entirely) could be presented to unlock a completely different one — the same confused-deputy
|
|
116
|
+
* shape payload binding closes at the request layer, recurring one layer deeper.
|
|
119
117
|
*/
|
|
120
118
|
async function claimAuthorization({ authorizationId } = {}) {
|
|
121
119
|
if (!authorizationId) return { claimed: false, reasonCode: 'AUTHORIZATION_REQUIRED' };
|
|
@@ -124,7 +122,14 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
124
122
|
const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/dispatching`, { method: 'POST' });
|
|
125
123
|
const body = await res.json().catch(() => null);
|
|
126
124
|
if (!res.ok) return { claimed: false, reasonCode: body?.message ?? body?.data?.reasonCode ?? `AUTHORIZATION_CLAIM_HTTP_${res.status}` };
|
|
127
|
-
return {
|
|
125
|
+
return {
|
|
126
|
+
claimed: true,
|
|
127
|
+
agentDid: body?.data?.agentDid,
|
|
128
|
+
action: body?.data?.action,
|
|
129
|
+
amount: body?.data?.amount,
|
|
130
|
+
currency: body?.data?.currency,
|
|
131
|
+
merchant: body?.data?.merchant,
|
|
132
|
+
};
|
|
128
133
|
} catch (err) {
|
|
129
134
|
return { claimed: false, reasonCode: 'AUTHORIZATION_CLAIM_UNREACHABLE', error: String(err?.message ?? err) };
|
|
130
135
|
}
|
|
@@ -253,10 +258,12 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
253
258
|
// re-open the confused-deputy gap this claim exists to close, and nothing else here
|
|
254
259
|
// would notice.
|
|
255
260
|
if (claim.agentDid === undefined) console.warn('[mcp-guard] claim response omitted agentDid — binding degraded to "some valid unclaimed authorization exists"');
|
|
261
|
+
if (claim.action === undefined) console.warn('[mcp-guard] claim response omitted action — action binding degraded');
|
|
256
262
|
if (Number(amount) > 0 && claim.amount === undefined) console.warn('[mcp-guard] claim response omitted amount for a value-bearing request — amount binding degraded');
|
|
257
263
|
if (Number(amount) > 0 && claim.currency === undefined) console.warn('[mcp-guard] claim response omitted currency for a value-bearing request — currency binding degraded');
|
|
258
264
|
if (merchant && claim.merchant === undefined) console.warn('[mcp-guard] claim response omitted merchant for a request that signed one — merchant binding degraded');
|
|
259
265
|
if (claim.agentDid !== undefined && claim.agentDid !== agentDid) return { decision: 'block', reasonCode: 'AUTHORIZATION_AGENT_MISMATCH' };
|
|
266
|
+
if (claim.action !== undefined && claim.action !== action) return { decision: 'block', reasonCode: 'AUTHORIZATION_ACTION_MISMATCH' };
|
|
260
267
|
if (claim.amount !== undefined && Number(claim.amount) !== Number(amount)) return { decision: 'block', reasonCode: 'AUTHORIZATION_AMOUNT_MISMATCH' };
|
|
261
268
|
if (claim.currency !== undefined && claim.currency !== currency) return { decision: 'block', reasonCode: 'AUTHORIZATION_CURRENCY_MISMATCH' };
|
|
262
269
|
if (claim.merchant !== undefined && claim.merchant !== merchant) return { decision: 'block', reasonCode: 'AUTHORIZATION_MERCHANT_MISMATCH' };
|
|
@@ -274,7 +281,17 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
274
281
|
*/
|
|
275
282
|
function guardIncomingTool(action, handler) {
|
|
276
283
|
return async (signed, ...rest) => {
|
|
277
|
-
|
|
284
|
+
// The WRAPPED TOOL's own `action` is authoritative — never `signed?.action` (the caller's
|
|
285
|
+
// own claim). A Service that wraps more than one tool with ONE guard instance (the normal
|
|
286
|
+
// MCP-server shape: many tools, one guard) previously let a genuinely-valid signature for
|
|
287
|
+
// action A verify successfully — correctly, it really was valid for A — and then run
|
|
288
|
+
// action B's handler, because verifyRequest was asked to check whatever the SIGNED payload
|
|
289
|
+
// claimed instead of which wrapped function was actually being invoked. A caller with a
|
|
290
|
+
// real, cheap, in-policy authorization (e.g. a $0 read) could invoke ANY other tool sharing
|
|
291
|
+
// this guard (e.g. a wire transfer) and have it execute under that unrelated verification.
|
|
292
|
+
// Mirrors gateway.mjs's `route.action ?? signed.action` — "the route pins the action ...
|
|
293
|
+
// the client can't pick it" — for exactly the same reason, one layer down at the tool call.
|
|
294
|
+
const decision = await verifyRequest({ ...signed, action });
|
|
278
295
|
// allow/observe both PERMIT the tool call; observe is permit-but-flag (SAFR §11).
|
|
279
296
|
if (decision.decision !== 'allow' && decision.decision !== 'observe') {
|
|
280
297
|
const err = new Error(`MCP guard ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
|
|
@@ -389,7 +406,9 @@ export function createMcpGuard({ serviceDid, serviceKey, issuerApi, fetchBundle,
|
|
|
389
406
|
* then, given the responder's CHALLENGE, verifies the responder proved control of
|
|
390
407
|
* its DID before producing PROVE.
|
|
391
408
|
*
|
|
392
|
-
* @param {{fromDid:string, sign:(msg:string)=>string}} p sign() uses the
|
|
409
|
+
* @param {{fromDid:string, sign:(msg:string)=>(string|Promise<string>)}} p sign() uses the
|
|
410
|
+
* initiator's own key — may be sync (a raw local key) or async (e.g. a keyProvider backed by
|
|
411
|
+
* agentsafe-signer); `prove()` awaits it either way, see key-providers.mjs.
|
|
393
412
|
*/
|
|
394
413
|
export function createHandshakeInitiator({ fromDid, sign } = {}) {
|
|
395
414
|
if (!fromDid || typeof sign !== 'function') throw new Error('createHandshakeInitiator requires { fromDid, sign }');
|
|
@@ -400,7 +419,7 @@ export function createHandshakeInitiator({ fromDid, sign } = {}) {
|
|
|
400
419
|
return { nonceA, message: { fromDid, nonceA, protoVersion: '1.0' } };
|
|
401
420
|
},
|
|
402
421
|
/** Step 3 (A): verify CHALLENGE proves the responder controls toDid, then PROVE. */
|
|
403
|
-
prove({ nonceA, challenge } = {}) {
|
|
422
|
+
async prove({ nonceA, challenge } = {}) {
|
|
404
423
|
const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
|
|
405
424
|
if (!toDid || !nonceB || !sigB) throw new Error('malformed CHALLENGE');
|
|
406
425
|
if (!verifyDidSignature(toDid, nonceA, sigB)) {
|
|
@@ -408,7 +427,7 @@ export function createHandshakeInitiator({ fromDid, sign } = {}) {
|
|
|
408
427
|
e.name = 'HandshakeFailed';
|
|
409
428
|
throw e;
|
|
410
429
|
}
|
|
411
|
-
return { handshakeId, sigA: sign(nonceB), remoteDid: toDid };
|
|
430
|
+
return { handshakeId, sigA: await sign(nonceB), remoteDid: toDid };
|
|
412
431
|
},
|
|
413
432
|
};
|
|
414
433
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// key-providers.mjs — the keyProvider seam for the SERVICE side (docs/design/
|
|
2
|
+
// agent-key-custody-local-signer-daemon-plan.md). Much smaller than agentsafe-guard's own copy:
|
|
3
|
+
// serviceKey is only ever used for one thing (signing a handshake nonce, MAGP §8.2), so there is
|
|
4
|
+
// only one provider method here, not four.
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
export function createStaticKeyProvider(serviceKeyHex) {
|
|
10
|
+
const privateKey = crypto.createPrivateKey({ key: Buffer.from(serviceKeyHex, 'hex'), format: 'der', type: 'pkcs8' });
|
|
11
|
+
return {
|
|
12
|
+
async signHandshakeNonce(nonce) {
|
|
13
|
+
return crypto.sign(null, Buffer.from(nonce, 'utf8'), privateKey).toString('hex');
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Must match agentsafe-guard/key-providers.mjs's and agentsafe-signer/daemon.mjs's own copy — see either's own comment for why this isn't a shared import. */
|
|
19
|
+
function toPlatformSocketPath(logicalPath) {
|
|
20
|
+
if (process.platform !== 'win32') return logicalPath;
|
|
21
|
+
const name = crypto.createHash('sha256').update(path.resolve(logicalPath)).digest('hex').slice(0, 32);
|
|
22
|
+
return `\\\\.\\pipe\\agentsafe-signer-${name}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const PROTOCOL_VERSION = 1;
|
|
26
|
+
|
|
27
|
+
function daemonRequest(socketPath, op, params, { connectTimeoutMs = 3000 } = {}) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const deadline = Date.now() + connectTimeoutMs;
|
|
30
|
+
function attempt() {
|
|
31
|
+
const sock = net.connect(toPlatformSocketPath(socketPath));
|
|
32
|
+
const requestId = crypto.randomUUID();
|
|
33
|
+
let buf = '';
|
|
34
|
+
const cleanup = () => sock.destroy();
|
|
35
|
+
sock.once('error', (err) => {
|
|
36
|
+
cleanup();
|
|
37
|
+
// See agentsafe-guard/key-providers.mjs's own copy of this function for why: on Windows
|
|
38
|
+
// the signing socket is a pool of independent named-pipe instances, each consumed by one
|
|
39
|
+
// connection and replaced asynchronously, so a request can transiently race that
|
|
40
|
+
// replacement window (ENOENT) even though the daemon is healthy. Only ENOENT retries — a
|
|
41
|
+
// genuinely down daemon still fails fast.
|
|
42
|
+
if (err.code === 'ENOENT' && Date.now() < deadline) {
|
|
43
|
+
setTimeout(attempt, 20);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
reject(Object.assign(new Error(`agentsafe-signer daemon unreachable at ${socketPath}: ${err.message}`), { code: 'DAEMON_UNREACHABLE' }));
|
|
47
|
+
});
|
|
48
|
+
sock.once('connect', () => {
|
|
49
|
+
sock.write(JSON.stringify({ protocolVersion: PROTOCOL_VERSION, requestId, op, params }) + '\n');
|
|
50
|
+
});
|
|
51
|
+
sock.on('data', (chunk) => {
|
|
52
|
+
buf += chunk.toString('utf8');
|
|
53
|
+
const idx = buf.indexOf('\n');
|
|
54
|
+
if (idx === -1) return;
|
|
55
|
+
let res;
|
|
56
|
+
try {
|
|
57
|
+
res = JSON.parse(buf.slice(0, idx));
|
|
58
|
+
} catch (err) {
|
|
59
|
+
cleanup();
|
|
60
|
+
reject(err);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
cleanup();
|
|
64
|
+
if (res.ok) resolve(res.result);
|
|
65
|
+
else reject(Object.assign(new Error(res.error?.message || res.error?.code || 'daemon rejected request'), { code: res.error?.code }));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
attempt();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The key never enters this process — a service-role agentsafe-signer daemon signs instead. */
|
|
73
|
+
export function createDaemonKeyProvider({ socketPath }) {
|
|
74
|
+
if (!socketPath) throw new Error('createDaemonKeyProvider requires { socketPath }');
|
|
75
|
+
return {
|
|
76
|
+
async signHandshakeNonce(nonce) {
|
|
77
|
+
const { signature } = await daemonRequest(socketPath, 'sign-handshake-nonce', { nonce });
|
|
78
|
+
return signature;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resolves a keyProvider from createMcpGuard's opts — null (not thrown) when neither
|
|
84
|
+
* keyProvider nor serviceKey is configured, matching today's "handshakeChallenge throws only if
|
|
85
|
+
* actually called without one" behavior rather than failing construction eagerly. */
|
|
86
|
+
export function resolveKeyProvider({ keyProvider, serviceKey, daemonSocketPath } = {}) {
|
|
87
|
+
if (keyProvider && typeof keyProvider === 'object' && typeof keyProvider.signHandshakeNonce === 'function') return keyProvider;
|
|
88
|
+
if (keyProvider === 'daemon') return createDaemonKeyProvider({ socketPath: daemonSocketPath });
|
|
89
|
+
if (!serviceKey) return null;
|
|
90
|
+
return createStaticKeyProvider(serviceKey);
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-mcp-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Zero-dependency trustless governance for the SERVICE side. An MCP server or API re-verifies a calling agent's signed request against the agent's own published policy \u00e2\u20ac\u201d so an agent that ignores its own guard still cannot make your service act.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./agentsafe-mcp-guard.mjs",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"agentsafe-mcp-guard.mjs",
|
|
18
|
+
"key-providers.mjs",
|
|
18
19
|
"magp-policy.mjs",
|
|
19
20
|
"magp-did.mjs",
|
|
20
21
|
"policy-core.mjs",
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
25
26
|
"scripts": {
|
|
26
|
-
"test": "node mcp-guard.smoke.mjs && node pay.smoke.mjs && node decision-token.smoke.mjs && node claim-authorization.smoke.mjs"
|
|
27
|
+
"test": "node mcp-guard.smoke.mjs && node pay.smoke.mjs && node decision-token.smoke.mjs && node claim-authorization.smoke.mjs && node guard-incoming-tool.smoke.mjs"
|
|
27
28
|
},
|
|
28
29
|
"engines": {
|
|
29
30
|
"node": ">=18"
|
package/policy-core.mjs
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
// src/policy-core/atom-registry.ts
|
|
4
4
|
var RISK_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
5
|
+
function currencyOutOfScope(ctx, cfgCurrency) {
|
|
6
|
+
if (cfgCurrency === void 0 || cfgCurrency === null) return false;
|
|
7
|
+
const allowed = Array.isArray(cfgCurrency) ? cfgCurrency : [cfgCurrency];
|
|
8
|
+
if (allowed.length === 0) return false;
|
|
9
|
+
const currency = ctx.currency;
|
|
10
|
+
const matches = typeof currency === "string" && allowed.some((u) => typeof u === "string" && u.toUpperCase() === currency.toUpperCase());
|
|
11
|
+
return !matches;
|
|
12
|
+
}
|
|
5
13
|
var ATOM_REGISTRY = {
|
|
6
14
|
"data-source-not-approved": (c, cfg) => !!c.dataSourceId && !(cfg?.approved ?? []).includes(String(c.dataSourceId)),
|
|
7
15
|
"consent-missing": (c) => c.consent === false,
|
|
@@ -10,7 +18,11 @@ var ATOM_REGISTRY = {
|
|
|
10
18
|
const need = RISK_RANK[String(cfg?.level ?? "high")];
|
|
11
19
|
return have !== void 0 && need !== void 0 && have >= need;
|
|
12
20
|
},
|
|
13
|
-
"amount-over": (c, cfg) =>
|
|
21
|
+
"amount-over": (c, cfg) => {
|
|
22
|
+
if (typeof c.amount !== "number") return false;
|
|
23
|
+
if (currencyOutOfScope(c, cfg?.currency)) return true;
|
|
24
|
+
return c.amount > Number(cfg?.limit ?? 0);
|
|
25
|
+
},
|
|
14
26
|
// Deny-by-default primitive for value-moving actions. Fires on ABSENCE (like the
|
|
15
27
|
// evidence atoms below, and unlike `amount-over`) OR on a NEGATIVE amount: true when
|
|
16
28
|
// the context carries no usable amount, or one that cannot be trusted for capping —
|
|
@@ -31,7 +43,12 @@ var ATOM_REGISTRY = {
|
|
|
31
43
|
"amount-unknown": (c) => !(typeof c.amount === "number" && Number.isFinite(c.amount) && c.amount >= 0),
|
|
32
44
|
// Total budget: cumulativeSpend is a SERVER-derived, signed-last context field (never
|
|
33
45
|
// shadowable by the agent's itinerary), so this compares already-spent + this amount.
|
|
34
|
-
|
|
46
|
+
// See `currencyOutOfScope` above: a configured currency scope that this request's
|
|
47
|
+
// currency doesn't match fires the cap outright, same fail-closed reasoning as `amount-over`.
|
|
48
|
+
"cumulative-over": (c, cfg) => {
|
|
49
|
+
if (currencyOutOfScope(c, cfg?.currency)) return true;
|
|
50
|
+
return Number(c.cumulativeSpend ?? 0) + Number(c.amount ?? 0) > Number(cfg?.limit ?? 0);
|
|
51
|
+
},
|
|
35
52
|
// Fires if any configured term appears in the prompt and/or output text.
|
|
36
53
|
// Used to govern agent responses on content (prohibited claims, sensitive advice).
|
|
37
54
|
"text-matches": (c, cfg) => {
|
|
@@ -87,7 +104,25 @@ var ATOM_SPECS = [
|
|
|
87
104
|
predicate: "amount-over",
|
|
88
105
|
label: "Per-transaction amount over limit",
|
|
89
106
|
description: "Fires when a single action amount exceeds a configured limit (per-transaction cap).",
|
|
90
|
-
config: [
|
|
107
|
+
config: [
|
|
108
|
+
{ key: "limit", type: "number", required: true, description: "Maximum allowed amount for one transaction" },
|
|
109
|
+
{
|
|
110
|
+
key: "currency",
|
|
111
|
+
type: "string[]",
|
|
112
|
+
required: false,
|
|
113
|
+
description: `Optional currency scope for the limit (e.g. ['USD'], or ['USD','GBP'] for several). Leave empty to keep the limit currency-blind \u2014 the historical default: the raw number is compared regardless of currency. Once set, a request in a currency outside this list \u2014 or with none supplied at all \u2014 fires this atom regardless of amount (unverifiable is treated as unsafe, not as "smaller"), so the cap can't be cleared by naming a cheaper-looking currency (e.g. 200 JPY vs 200 USD).`
|
|
114
|
+
}
|
|
115
|
+
],
|
|
116
|
+
// `currency` is NOT listed here even though the executable atom conditionally reads it:
|
|
117
|
+
// unlike `limit`, the `currency` config is OPTIONAL per atom instance, so whether an agent
|
|
118
|
+
// needs to supply it depends on how a given molecule configures this atom — something
|
|
119
|
+
// `requiredContextFor`'s per-predicate (not per-instance) model can't express. Every
|
|
120
|
+
// authorize request already carries `currency` unconditionally regardless (see
|
|
121
|
+
// AuthorizeInput), so nothing is actually left unfed by omitting it here — this only
|
|
122
|
+
// controls the Scenario Bank simulate form / docs "context contract" surfacing, and
|
|
123
|
+
// forcing it onto every amount-over molecule would spuriously mark scenarios that never
|
|
124
|
+
// configure a currency scope as unexercised (see cumulative-over-atom.test.ts's sibling
|
|
125
|
+
// comment below for the same reasoning applied there).
|
|
91
126
|
requiredContext: ["amount"]
|
|
92
127
|
},
|
|
93
128
|
{
|
|
@@ -101,8 +136,32 @@ var ATOM_SPECS = [
|
|
|
101
136
|
predicate: "cumulative-over",
|
|
102
137
|
label: "Total budget over limit",
|
|
103
138
|
description: "Fires when cumulative spend (already-spent + this transaction) exceeds a configured total budget.",
|
|
104
|
-
config: [
|
|
105
|
-
|
|
139
|
+
config: [
|
|
140
|
+
{ key: "limit", type: "number", required: true, description: "Maximum total budget across all transactions" },
|
|
141
|
+
{
|
|
142
|
+
key: "currency",
|
|
143
|
+
type: "string[]",
|
|
144
|
+
required: false,
|
|
145
|
+
description: "Optional currency scope for the budget (e.g. ['USD'], or ['USD','GBP'] for several). Leave empty to keep it currency-blind \u2014 the historical default. Once set, a request in a currency outside this list \u2014 or with none supplied at all \u2014 fires this atom regardless of amount, same fail-closed design as amount-over's currency scope."
|
|
146
|
+
}
|
|
147
|
+
],
|
|
148
|
+
// The executable atom (atom-registry.ts) reads BOTH fields: `cumulativeSpend + amount >
|
|
149
|
+
// limit`. Omitting `cumulativeSpend` here silently broke two downstream consumers this
|
|
150
|
+
// catalog is the single source of truth for (see file header): the Scenario Bank's
|
|
151
|
+
// simulate form never rendered an "already spent" field for any set using this atom —
|
|
152
|
+
// including its own seeded preset, which supplied `cumulativeSpend` for a form field
|
|
153
|
+
// that didn't exist — so the control could never actually be exercised from the UI; and
|
|
154
|
+
// the integration docs' generated "context contract" told real SDK integrators this
|
|
155
|
+
// atom only needs `amount`, so an agent that never sends `cumulativeSpend` gets it
|
|
156
|
+
// silently treated as 0 and the total-budget cap never fires in production either.
|
|
157
|
+
//
|
|
158
|
+
// `currency`, by contrast, is deliberately NOT added here even though the executable atom
|
|
159
|
+
// conditionally reads it — see the sibling comment on `amount-over`'s currency config
|
|
160
|
+
// above: it is optional PER ATOM INSTANCE (only read when a molecule configures a
|
|
161
|
+
// currency scope), so unlike `cumulativeSpend` (always read), a static per-predicate
|
|
162
|
+
// requiredContext can't represent it without forcing every set using this atom to demand
|
|
163
|
+
// a currency it may never need.
|
|
164
|
+
requiredContext: ["amount", "cumulativeSpend"]
|
|
106
165
|
},
|
|
107
166
|
{
|
|
108
167
|
predicate: "risk-at-or-above",
|
|
@@ -357,7 +416,8 @@ function constraintSatisfied(c, req, strict) {
|
|
|
357
416
|
const left = Object.prototype.hasOwnProperty.call(req.values, c.leftOperand) ? req.values[c.leftOperand] : void 0;
|
|
358
417
|
if (!c.unit) return op(left, c.rightOperand);
|
|
359
418
|
const currency = req.values["mm:currency"];
|
|
360
|
-
const
|
|
419
|
+
const allowedUnits = Array.isArray(c.unit) ? c.unit : [c.unit];
|
|
420
|
+
const unitMatches = typeof currency === "string" && allowedUnits.some((u) => u.toUpperCase() === currency.toUpperCase());
|
|
361
421
|
return unitMatches ? op(left, c.rightOperand) : !strict;
|
|
362
422
|
}
|
|
363
423
|
function targetOf(rule, mandate) {
|
|
@@ -454,7 +514,15 @@ function escapeField(v) {
|
|
|
454
514
|
return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
455
515
|
}
|
|
456
516
|
function buildAuthMessage(f) {
|
|
457
|
-
return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
517
|
+
return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.resource ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
518
|
+
}
|
|
519
|
+
function buildLocalDecisionMessage(f) {
|
|
520
|
+
return [f.agentDid, f.action, f.decision, f.reasonCode, f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/policy-core/checkpoint-anchor.ts
|
|
524
|
+
function buildCheckpointAnchorMessage(f) {
|
|
525
|
+
return [f.agentDid, f.checkpointHash, f.previousCheckpointHash, f.entryCount, f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
458
526
|
}
|
|
459
527
|
|
|
460
528
|
// src/policy-core/context.ts
|
|
@@ -513,6 +581,8 @@ export {
|
|
|
513
581
|
asOperatingMode,
|
|
514
582
|
authorityFailure,
|
|
515
583
|
buildAuthMessage,
|
|
584
|
+
buildCheckpointAnchorMessage,
|
|
585
|
+
buildLocalDecisionMessage,
|
|
516
586
|
canAuthorize,
|
|
517
587
|
evaluate,
|
|
518
588
|
evaluateBoundStandards,
|