@metamynd/agentsafe-mcp-guard 0.4.0 → 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 +11 -1
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
|
@@ -514,7 +514,15 @@ function escapeField(v) {
|
|
|
514
514
|
return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
515
515
|
}
|
|
516
516
|
function buildAuthMessage(f) {
|
|
517
|
-
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("|");
|
|
518
526
|
}
|
|
519
527
|
|
|
520
528
|
// src/policy-core/context.ts
|
|
@@ -573,6 +581,8 @@ export {
|
|
|
573
581
|
asOperatingMode,
|
|
574
582
|
authorityFailure,
|
|
575
583
|
buildAuthMessage,
|
|
584
|
+
buildCheckpointAnchorMessage,
|
|
585
|
+
buildLocalDecisionMessage,
|
|
576
586
|
canAuthorize,
|
|
577
587
|
evaluate,
|
|
578
588
|
evaluateBoundStandards,
|