@metamynd/agentsafe-guard 0.12.4 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/agentsafe-guard.mjs +51 -9
- package/key-providers.mjs +13 -0
- package/package.json +3 -2
- package/payload-binding.mjs +110 -0
- package/policy-core.mjs +205 -9
package/README.md
CHANGED
|
@@ -153,6 +153,45 @@ the guard — see "Passphrase-encrypted managed key" above. New export from `key
|
|
|
153
153
|
backend's `encryptWithPassword`/`decryptWithPassword`, cross-verified against it). Fully
|
|
154
154
|
additive: a config without `agentKeyEncrypted` is loaded exactly as before, no passphrase needed.
|
|
155
155
|
|
|
156
|
+
**0.14.0 — sign the WHOLE payload, not just eight fields (MAGP §8.3.9).** The signed authorize message covers the
|
|
157
|
+
agent, action, amount, currency, merchant and resource — not a payee, an account number or a passenger list. Pass
|
|
158
|
+
`payload` to `authorize()` / `buildSignedRequest()` and the guard also signs a digest of it (RFC 8785 canonical JSON,
|
|
159
|
+
`sha256:`), bound to that one authorization:
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
await guard.authorize({ action: 'wire', amount: 250, currency: 'USD', merchant: 'skyward-air',
|
|
163
|
+
payload: { payee: { iban: 'NL91ABNA0417164300' }, reference: 'INV-1042' } });
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The gate stores the digest with the hold, and the service that executes it (an `agentsafe-mcp-guard` with
|
|
167
|
+
`bindPayload`, or an `agentsafe-http-gateway`, both 0.11.0) must present the digest of what it is about to run: a claim
|
|
168
|
+
with a different payload is refused and the hold stays claimable. Pass the payload exactly as the service will receive
|
|
169
|
+
it as JSON. A payload JSON cannot carry (`NaN`, a function, a lone surrogate) or a `keyProvider` that cannot sign a
|
|
170
|
+
binding (the signer daemon, for now) **blocks** with `PAYLOAD_NOT_CANONICALIZABLE` / `PAYLOAD_BINDING_UNSUPPORTED` —
|
|
171
|
+
it is never sent unbound. Off unless you pass `payload`; the wire body is otherwise unchanged. Also from `guardTool`: have `mapArgs`
|
|
172
|
+
return `payload`. The gate echoes the digest it stored, and an agent that sent one **refuses a permit that does not echo
|
|
173
|
+
it** (`PAYLOAD_BINDING_NOT_CONFIRMED`, releasing the hold) — so a proxy that strips the fields, or a backend that predates
|
|
174
|
+
MAGP §8.3.9, is a loud failure, not a silently unbound request.
|
|
175
|
+
|
|
176
|
+
**0.13.0 — a risk rule can no longer be skipped by staying silent about risk (D-03).** The rule layer
|
|
177
|
+
now labels every context field with where it came from (`agent_asserted`, `agent_signed`,
|
|
178
|
+
`gateway_derived`, `authoritative`, `attested`; MAGP §6.3) and judges `riskLevel` accordingly:
|
|
179
|
+
|
|
180
|
+
- **A missing or unrecognised `riskLevel` escalates** (`CONTEXT_UNVERIFIABLE`) for any rule that uses the
|
|
181
|
+
risk atom, instead of reading as "not risky". `"HIGH"` and `" high "` are read as `high`. **If your requests
|
|
182
|
+
do not send a `riskLevel`, they will now escalate** — send one (`context: { riskLevel: 'low' }`), or have
|
|
183
|
+
your mandate's owner set a `riskTier` (below) so the risk does not depend on you.
|
|
184
|
+
- **`riskTier` on a mandate permission** is the owner's classification of the action: a floor the agent's own
|
|
185
|
+
claim can never lower (the effective risk is the *maximum* of the tier and the claim — an agent may raise
|
|
186
|
+
its risk, never lower it). It travels in the signed mandate, so `evaluateLocally` and the issuer's gate agree.
|
|
187
|
+
- **`requireProvenance`** on a rule (`{ riskLevel: 'authoritative' }`) makes it demand a trusted source: the
|
|
188
|
+
agent's own honest "low" is then not enough.
|
|
189
|
+
- `evaluateLocally` builds its context the same way, and judges the SUPERVISED-mode high-risk escalation on
|
|
190
|
+
the effective risk.
|
|
191
|
+
|
|
192
|
+
The claim itself is still the agent's word when no owner tier, gateway derivation or `requireProvenance`
|
|
193
|
+
exists: this closes hiding and garbling risk, and gives owners the means to close understating it.
|
|
194
|
+
|
|
156
195
|
```yaml
|
|
157
196
|
# .github/workflows/governance.yml
|
|
158
197
|
name: Governance
|
package/agentsafe-guard.mjs
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
import crypto from 'node:crypto';
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
13
|
import { resolve as resolvePath } from 'node:path';
|
|
14
|
-
import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from './policy-core.mjs';
|
|
14
|
+
import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate, buildRuleContext, riskFloorFor, maxRisk, normalizeRiskLevel } from './policy-core.mjs';
|
|
15
15
|
import { envelopeHashFor } from './governance-envelope.mjs';
|
|
16
|
+
import { payloadDigestOf, toWireJson } from './payload-binding.mjs';
|
|
16
17
|
import { verifyDidSignature } from './magp-did.mjs';
|
|
17
18
|
import { checkSettlementBinding } from './x402.mjs';
|
|
18
19
|
import { resolveKeyProvider, decryptAgentKeyWithPassword } from './key-providers.mjs';
|
|
@@ -182,6 +183,27 @@ export function createGuard(opts = {}) {
|
|
|
182
183
|
return keyProvider.signEnvelope({ agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt });
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
// Payload binding (spec §8.3.9): sign a digest of the COMPLETE payload the caller will execute, bound to THIS authorization
|
|
187
|
+
// (agent, action, nonce, issuedAt). The eight signed fields cover amount/merchant/resource only; everything else a tool takes
|
|
188
|
+
// (a payee, an account number) is otherwise unbound, and this is what binds it. `payload` is whatever the executing
|
|
189
|
+
// counterparty will receive — the JSON body a gateway forwards, or the arguments an MCP tool is called with — normalised to
|
|
190
|
+
// the JSON it would be on the wire. Fails CLOSED: if a binding was asked for and cannot be produced (a payload JSON cannot
|
|
191
|
+
// carry, or a key provider that cannot sign one), this throws — it never quietly sends the request unbound.
|
|
192
|
+
async function payloadBindingFor({ action, nonce, issuedAt, payload }) {
|
|
193
|
+
if (payload === undefined) return {};
|
|
194
|
+
let payloadDigest;
|
|
195
|
+
try {
|
|
196
|
+
payloadDigest = payloadDigestOf(toWireJson(payload));
|
|
197
|
+
} catch (err) {
|
|
198
|
+
throw Object.assign(new Error(`payload cannot be bound: ${err?.message ?? err}`), { code: 'PAYLOAD_NOT_CANONICALIZABLE' });
|
|
199
|
+
}
|
|
200
|
+
if (typeof keyProvider.signPayloadBinding !== 'function') {
|
|
201
|
+
throw Object.assign(new Error('this keyProvider cannot sign a payload binding (signPayloadBinding)'), { code: 'PAYLOAD_BINDING_UNSUPPORTED' });
|
|
202
|
+
}
|
|
203
|
+
const payloadSignature = await keyProvider.signPayloadBinding({ agentDid, action, nonce, issuedAt, payloadDigest });
|
|
204
|
+
return { payloadDigest, payloadSignature };
|
|
205
|
+
}
|
|
206
|
+
|
|
185
207
|
// --- Enforcement mode (spec §9.2 + local-first plan) --------------------------------------
|
|
186
208
|
// 'local' (DEFAULT): decide the rule layer LOCALLY against a cached signed bundle — a
|
|
187
209
|
// block/escalate needs no network; an allowed VALUE action is still sealed by the remote
|
|
@@ -214,7 +236,7 @@ export function createGuard(opts = {}) {
|
|
|
214
236
|
* agent's authorization trustlessly against the agent's policy bundle (§9.3). Same shape
|
|
215
237
|
* `authorize()` posts to the gate; a fresh nonce each call.
|
|
216
238
|
*/
|
|
217
|
-
async function buildSignedRequest({ action, amount, currency, merchant, resource, context = {}, trace, materiality }) {
|
|
239
|
+
async function buildSignedRequest({ action, amount, currency, merchant, resource, context = {}, trace, materiality, payload }) {
|
|
218
240
|
const nonce = crypto.randomUUID();
|
|
219
241
|
const issuedAt = new Date().toISOString();
|
|
220
242
|
// This object is presented to a COUNTERPARTY (spec §9.3) — but its own docstring also
|
|
@@ -243,11 +265,12 @@ export function createGuard(opts = {}) {
|
|
|
243
265
|
// action fields don't include it yet either (only amount/currency/merchant) — adding it
|
|
244
266
|
// to just one side would break Tier-1 envelope-hash verification for any resource-
|
|
245
267
|
// declaring request. A coordinated backend+guard follow-up, not something to do half here.
|
|
246
|
-
const [signature, envelopeSignature] = await Promise.all([
|
|
268
|
+
const [signature, envelopeSignature, binding] = await Promise.all([
|
|
247
269
|
keyProvider.signAuthorize({ agentDid, action, amount: signedAmount, currency: signedCurrency, merchant, resource, nonce, issuedAt }),
|
|
248
270
|
envelopeSignatureFor({ action, amount: wireAmount, currency: wireCurrency, merchant, context, trace, materiality, nonce, issuedAt }),
|
|
271
|
+
payloadBindingFor({ action, nonce, issuedAt, payload }),
|
|
249
272
|
]);
|
|
250
|
-
return { agentDid, action, amount: wireAmount, currency: wireCurrency, merchant, resource, itinerary: context, trace, materiality, nonce, issuedAt, signature, envelopeSignature };
|
|
273
|
+
return { agentDid, action, amount: wireAmount, currency: wireCurrency, merchant, resource, itinerary: context, trace, materiality, nonce, issuedAt, signature, envelopeSignature, ...binding };
|
|
251
274
|
}
|
|
252
275
|
|
|
253
276
|
/**
|
|
@@ -255,7 +278,7 @@ export function createGuard(opts = {}) {
|
|
|
255
278
|
* returns { decision:'allow'|'block'|'escalate', reasonCode, authorizationId, remaining }.
|
|
256
279
|
* A network/gate failure returns a fail-CLOSED block so the agent can't proceed blind.
|
|
257
280
|
*/
|
|
258
|
-
async function authorize({ action, amount, currency, merchant, resource, context = {}, trace, materiality }) {
|
|
281
|
+
async function authorize({ action, amount, currency, merchant, resource, context = {}, trace, materiality, payload }) {
|
|
259
282
|
const nonce = crypto.randomUUID();
|
|
260
283
|
const issuedAt = new Date().toISOString();
|
|
261
284
|
try {
|
|
@@ -276,9 +299,10 @@ export function createGuard(opts = {}) {
|
|
|
276
299
|
// see key-providers.mjs for why callers pass structured fields, not a pre-built string.
|
|
277
300
|
// `resource` deliberately NOT passed to envelopeSignatureFor — see buildSignedRequest's
|
|
278
301
|
// own comment on why (backend governance-envelope.ts doesn't include it yet either).
|
|
279
|
-
const [signature, envelopeSignature] = await Promise.all([
|
|
302
|
+
const [signature, envelopeSignature, binding] = await Promise.all([
|
|
280
303
|
keyProvider.signAuthorize({ agentDid, action, amount: signedAmount, currency: signedCurrency, merchant, resource, nonce, issuedAt }),
|
|
281
304
|
envelopeSignatureFor({ action, amount, currency, merchant, context, trace, materiality, nonce, issuedAt }),
|
|
305
|
+
payloadBindingFor({ action, nonce, issuedAt, payload }),
|
|
282
306
|
]);
|
|
283
307
|
const res = await fetch(`${base}/policy/mandate/authorize`, {
|
|
284
308
|
method: 'POST',
|
|
@@ -290,11 +314,27 @@ export function createGuard(opts = {}) {
|
|
|
290
314
|
agentDid, action, amount, currency, merchant, resource, itinerary: context, trace, materiality, nonce, issuedAt,
|
|
291
315
|
signature,
|
|
292
316
|
envelopeSignature,
|
|
317
|
+
...binding, // payloadDigest + payloadSignature, or nothing for an unbound request
|
|
293
318
|
}),
|
|
294
319
|
});
|
|
295
320
|
const body = await res.json().catch(() => null);
|
|
296
|
-
|
|
321
|
+
const data = body?.data ?? { decision: 'block', reasonCode: `GATE_HTTP_${res.status}` };
|
|
322
|
+
// The gate ACKNOWLEDGES a binding by echoing the digest it stored. A permit or escalation that does not — a hop stripped
|
|
323
|
+
// the fields, or the backend predates payload binding and ignored them — was never bound, and this agent must not act as
|
|
324
|
+
// if it were: refuse, and release the hold it just got (best effort; an unclaimed hold also lapses on its own).
|
|
325
|
+
if (binding.payloadDigest && (data.decision === 'allow' || data.decision === 'observe' || data.decision === 'escalate') && data.payloadDigest !== binding.payloadDigest) {
|
|
326
|
+
if (data.authorizationId) {
|
|
327
|
+
fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(data.authorizationId)}/void`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).catch(() => {});
|
|
328
|
+
}
|
|
329
|
+
return { decision: 'block', reasonCode: 'PAYLOAD_BINDING_NOT_CONFIRMED', authorizationId: null, error: 'the gate did not confirm the payload binding (an older backend, or the digest was stripped in transit)' };
|
|
330
|
+
}
|
|
331
|
+
return data;
|
|
297
332
|
} catch (err) {
|
|
333
|
+
// A payload binding that was asked for and cannot be produced is its own answer — NOT a gate outage, and never a
|
|
334
|
+
// reason to send the request unbound. Fail closed with the real cause.
|
|
335
|
+
if (err?.code === 'PAYLOAD_NOT_CANONICALIZABLE' || err?.code === 'PAYLOAD_BINDING_UNSUPPORTED') {
|
|
336
|
+
return { decision: 'block', reasonCode: err.code, error: String(err.message) };
|
|
337
|
+
}
|
|
298
338
|
// A daemon-backed keyProvider can fail before the gate is ever reached (the signer, not
|
|
299
339
|
// the gate, was unreachable) — a distinct reasonCode so this doesn't read as a gate outage
|
|
300
340
|
// it wasn't. Still fail-CLOSED either way, which is the property that actually matters.
|
|
@@ -347,7 +387,9 @@ export function createGuard(opts = {}) {
|
|
|
347
387
|
// SIBLING (like `contained`) and biases the edge verdict identically to the gate.
|
|
348
388
|
// READ_ONLY denies a value-bearing action up-front; SUPERVISED/RESTRICTED only
|
|
349
389
|
// ESCALATE, applied to the verdict below so a rule block/escalate still outranks it.
|
|
350
|
-
|
|
390
|
+
// The EFFECTIVE risk (spec §6.4.3): the owner's tier in the mandate is a floor under the agent's own claim, so
|
|
391
|
+
// this local pre-check agrees with the gate instead of telling the agent "low" is enough.
|
|
392
|
+
const modeGate = operatingModeGate(operatingMode?.mode, { amount, riskLevel: maxRisk(riskFloorFor(mandate, action), normalizeRiskLevel(context?.riskLevel)) ?? undefined });
|
|
351
393
|
if (modeGate.decision === 'block') {
|
|
352
394
|
return { decision: 'block', reasonCode: modeGate.reasonCode, authorizationId: null, remaining: null, proofRef: null };
|
|
353
395
|
}
|
|
@@ -362,7 +404,7 @@ export function createGuard(opts = {}) {
|
|
|
362
404
|
// omitting them here means a currency-scoped amount-over/cumulative-over Standards/SOP
|
|
363
405
|
// atom always sees currency as absent and fires closed. Mirrors mandate.service.ts's
|
|
364
406
|
// ruleCtx (PR #588) and the same fix in agentsafe-mcp-guard.mjs's verdictFromBundle.
|
|
365
|
-
context:
|
|
407
|
+
context: buildRuleContext({ unsigned: context, signed: { action, agentDid, amount, currency, merchant, resource }, riskFloor: riskFloorFor(mandate, action) }),
|
|
366
408
|
mandateRequest: mandate
|
|
367
409
|
? {
|
|
368
410
|
target: action,
|
package/key-providers.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import net from 'node:net';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { buildAuthMessage, buildLocalDecisionMessage } from './policy-core.mjs';
|
|
14
14
|
import { envelopeHashFor } from './governance-envelope.mjs';
|
|
15
|
+
import { buildPayloadBindingMessage } from './payload-binding.mjs';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Today's default: the raw key lives in THIS process (see the design doc's "What this does not
|
|
@@ -37,6 +38,12 @@ export function createStaticKeyProvider(agentKeyHex) {
|
|
|
37
38
|
async signLocalDecision(fields) {
|
|
38
39
|
return rawSign(buildLocalDecisionMessage(fields));
|
|
39
40
|
},
|
|
41
|
+
// Payload binding (spec 8.3.9): sign the digest of the COMPLETE payload, bound to this authorization. OPTIONAL like
|
|
42
|
+
// signLocalDecision; the guard refuses (fail closed) to send an unbound request when a binding was asked for and the
|
|
43
|
+
// provider cannot produce one.
|
|
44
|
+
async signPayloadBinding(fields) {
|
|
45
|
+
return rawSign(buildPayloadBindingMessage(fields));
|
|
46
|
+
},
|
|
40
47
|
};
|
|
41
48
|
}
|
|
42
49
|
|
|
@@ -129,6 +136,12 @@ export function createDaemonKeyProvider({ socketPath }) {
|
|
|
129
136
|
const { signature } = await daemonRequest(socketPath, 'sign-local-decision', fields);
|
|
130
137
|
return signature;
|
|
131
138
|
},
|
|
139
|
+
// The daemon builds every message it signs from structured fields and will not sign arbitrary bytes, so payload binding
|
|
140
|
+
// needs its own `sign-payload` operation there. Until that exists this refuses, LOUDLY and closed: a guard asked to bind
|
|
141
|
+
// a payload must never fall back to sending the request unbound.
|
|
142
|
+
async signPayloadBinding() {
|
|
143
|
+
throw Object.assign(new Error('the agentsafe-signer daemon does not support payload binding yet (sign-payload); use a static key provider, or omit `payload`'), { code: 'PAYLOAD_BINDING_UNSUPPORTED' });
|
|
144
|
+
},
|
|
132
145
|
};
|
|
133
146
|
}
|
|
134
147
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Zero-dependency runtime governance for any Node AI agent \u2014 gate tool calls through MetaMynd/AgentSafe (allow / block / escalate) against the agent's mandate, enforced Standards, and SOPs. Ed25519-signed, deterministic, fail-closed.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./agentsafe-guard.mjs",
|
|
@@ -20,13 +20,14 @@
|
|
|
20
20
|
"policy-core.mjs",
|
|
21
21
|
"governance-envelope.mjs",
|
|
22
22
|
"magp-did.mjs",
|
|
23
|
+
"payload-binding.mjs",
|
|
23
24
|
"x402.mjs",
|
|
24
25
|
"example-openclaw-agent.mjs",
|
|
25
26
|
"README.md",
|
|
26
27
|
"LICENSE"
|
|
27
28
|
],
|
|
28
29
|
"scripts": {
|
|
29
|
-
"test": "node demo.mjs && node local-eval.smoke.mjs && node execution-adapter.smoke.mjs && node verify.smoke.mjs && node context-signature.smoke.mjs && node local-decision-report.smoke.mjs && node resource-constraint.smoke.mjs && node passphrase-key.smoke.mjs && node missing-config.smoke.mjs",
|
|
30
|
+
"test": "node demo.mjs && node local-eval.smoke.mjs && node execution-adapter.smoke.mjs && node verify.smoke.mjs && node context-signature.smoke.mjs && node payload-binding.smoke.mjs && node local-decision-report.smoke.mjs && node resource-constraint.smoke.mjs && node passphrase-key.smoke.mjs && node missing-config.smoke.mjs",
|
|
30
31
|
"demo": "node demo.mjs"
|
|
31
32
|
},
|
|
32
33
|
"engines": {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// GENERATED from backend/src/features/magp/payload-binding.ts - do not edit. Regenerate: npm run build:guard-core
|
|
2
|
+
|
|
3
|
+
// src/features/magp/payload-binding.ts
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
// src/policy-core/canonical.ts
|
|
7
|
+
function escapeField(v) {
|
|
8
|
+
return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/features/magp/payload-binding.ts
|
|
12
|
+
var PAYLOAD_BINDING_PREFIX = "MAGP-PAYLOAD-v1";
|
|
13
|
+
var PAYLOAD_DIGEST_PREFIX = "sha256:";
|
|
14
|
+
var PAYLOAD_DIGEST_HEADER = "x-magp-payload-digest";
|
|
15
|
+
var MAX_CANONICAL_PAYLOAD_BYTES = 256 * 1024;
|
|
16
|
+
var MAX_DEPTH = 32;
|
|
17
|
+
var PayloadNotCanonicalizable = class extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "PayloadNotCanonicalizable";
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
function hasLoneSurrogate(s) {
|
|
24
|
+
for (let i = 0; i < s.length; i++) {
|
|
25
|
+
const c = s.charCodeAt(i);
|
|
26
|
+
if (c >= 55296 && c <= 56319) {
|
|
27
|
+
const next = s.charCodeAt(i + 1);
|
|
28
|
+
if (!(next >= 56320 && next <= 57343)) return true;
|
|
29
|
+
i++;
|
|
30
|
+
} else if (c >= 56320 && c <= 57343) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
function serialize(value, depth, path) {
|
|
37
|
+
if (depth > MAX_DEPTH) throw new PayloadNotCanonicalizable(`payload is nested deeper than ${MAX_DEPTH} levels at ${path}`);
|
|
38
|
+
if (value === null) return "null";
|
|
39
|
+
switch (typeof value) {
|
|
40
|
+
case "boolean":
|
|
41
|
+
return value ? "true" : "false";
|
|
42
|
+
case "number":
|
|
43
|
+
if (!Number.isFinite(value)) throw new PayloadNotCanonicalizable(`${path} is not a finite number`);
|
|
44
|
+
return JSON.stringify(value);
|
|
45
|
+
// ECMAScript Number::toString — what RFC 8785 specifies; -0 serialises as "0"
|
|
46
|
+
case "string":
|
|
47
|
+
if (hasLoneSurrogate(value)) throw new PayloadNotCanonicalizable(`${path} contains an unpaired surrogate`);
|
|
48
|
+
return JSON.stringify(value);
|
|
49
|
+
case "object": {
|
|
50
|
+
if (Array.isArray(value)) return `[${value.map((v, i) => serialize(v, depth + 1, `${path}[${i}]`)).join(",")}]`;
|
|
51
|
+
const proto = Object.getPrototypeOf(value);
|
|
52
|
+
if (proto !== Object.prototype && proto !== null) throw new PayloadNotCanonicalizable(`${path} is not a plain JSON object`);
|
|
53
|
+
const obj = value;
|
|
54
|
+
const keys = Object.keys(obj).sort();
|
|
55
|
+
const parts = keys.map((k) => {
|
|
56
|
+
if (hasLoneSurrogate(k)) throw new PayloadNotCanonicalizable(`${path} has a key with an unpaired surrogate`);
|
|
57
|
+
return `${JSON.stringify(k)}:${serialize(obj[k], depth + 1, `${path}.${k}`)}`;
|
|
58
|
+
});
|
|
59
|
+
return `{${parts.join(",")}}`;
|
|
60
|
+
}
|
|
61
|
+
default:
|
|
62
|
+
throw new PayloadNotCanonicalizable(`${path} is a ${typeof value}, which JSON cannot represent`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function canonicalPayload(value) {
|
|
66
|
+
const text = serialize(value, 0, "$");
|
|
67
|
+
if (Buffer.byteLength(text, "utf8") > MAX_CANONICAL_PAYLOAD_BYTES) {
|
|
68
|
+
throw new PayloadNotCanonicalizable(`canonical payload exceeds ${MAX_CANONICAL_PAYLOAD_BYTES} bytes`);
|
|
69
|
+
}
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
function payloadDigestOf(value) {
|
|
73
|
+
return PAYLOAD_DIGEST_PREFIX + createHash("sha256").update(canonicalPayload(value), "utf8").digest("hex");
|
|
74
|
+
}
|
|
75
|
+
var DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
76
|
+
function isPayloadDigest(value) {
|
|
77
|
+
return typeof value === "string" && DIGEST_RE.test(value);
|
|
78
|
+
}
|
|
79
|
+
function toWireJson(value) {
|
|
80
|
+
const text = JSON.stringify(value);
|
|
81
|
+
if (text === void 0) throw new PayloadNotCanonicalizable("payload is not JSON-serialisable");
|
|
82
|
+
return JSON.parse(text);
|
|
83
|
+
}
|
|
84
|
+
function buildPayloadBindingMessage(input) {
|
|
85
|
+
return [PAYLOAD_BINDING_PREFIX, input.agentDid, input.action, input.nonce, input.issuedAt, input.payloadDigest].map((f) => escapeField(String(f))).join("|");
|
|
86
|
+
}
|
|
87
|
+
function decideClaimPayload(stored, presented) {
|
|
88
|
+
if (stored) {
|
|
89
|
+
if (!presented) return { ok: false, reasonCode: "PAYLOAD_DIGEST_REQUIRED" };
|
|
90
|
+
return presented === stored ? { ok: true } : { ok: false, reasonCode: "PAYLOAD_DIGEST_MISMATCH" };
|
|
91
|
+
}
|
|
92
|
+
return presented ? { ok: false, reasonCode: "PAYLOAD_NOT_BOUND_AT_AUTHORIZE" } : { ok: true };
|
|
93
|
+
}
|
|
94
|
+
function claimDigestField(payloadDigest) {
|
|
95
|
+
return `payload=${payloadDigest}`;
|
|
96
|
+
}
|
|
97
|
+
export {
|
|
98
|
+
MAX_CANONICAL_PAYLOAD_BYTES,
|
|
99
|
+
PAYLOAD_BINDING_PREFIX,
|
|
100
|
+
PAYLOAD_DIGEST_HEADER,
|
|
101
|
+
PAYLOAD_DIGEST_PREFIX,
|
|
102
|
+
PayloadNotCanonicalizable,
|
|
103
|
+
buildPayloadBindingMessage,
|
|
104
|
+
canonicalPayload,
|
|
105
|
+
claimDigestField,
|
|
106
|
+
decideClaimPayload,
|
|
107
|
+
isPayloadDigest,
|
|
108
|
+
payloadDigestOf,
|
|
109
|
+
toWireJson
|
|
110
|
+
};
|
package/policy-core.mjs
CHANGED
|
@@ -1,5 +1,129 @@
|
|
|
1
1
|
// GENERATED from backend/src/policy-core — do not edit. Regenerate: npm run build:guard-core
|
|
2
2
|
|
|
3
|
+
// src/policy-core/provenance.ts
|
|
4
|
+
var PROVENANCE_LEVELS = ["agent_asserted", "agent_signed", "gateway_derived", "authoritative", "attested"];
|
|
5
|
+
var PROVENANCE_RANK = {
|
|
6
|
+
agent_asserted: 0,
|
|
7
|
+
agent_signed: 1,
|
|
8
|
+
gateway_derived: 2,
|
|
9
|
+
authoritative: 3,
|
|
10
|
+
attested: 4
|
|
11
|
+
};
|
|
12
|
+
function isProvenance(v) {
|
|
13
|
+
return typeof v === "string" && Object.prototype.hasOwnProperty.call(PROVENANCE_RANK, v);
|
|
14
|
+
}
|
|
15
|
+
var PROVENANCE_KEY = Symbol.for("magp.context.provenance");
|
|
16
|
+
function provenanceOf(ctx, field) {
|
|
17
|
+
const map = ctx?.[PROVENANCE_KEY];
|
|
18
|
+
const p = map && typeof map === "object" ? map[field] : void 0;
|
|
19
|
+
return isProvenance(p) ? p : "agent_asserted";
|
|
20
|
+
}
|
|
21
|
+
function meetsProvenance(actual, minimum) {
|
|
22
|
+
return PROVENANCE_RANK[actual] >= PROVENANCE_RANK[minimum];
|
|
23
|
+
}
|
|
24
|
+
var RISK_LEVELS = ["low", "medium", "high", "critical"];
|
|
25
|
+
var RISK_ORDER = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
26
|
+
function normalizeRiskLevel(v) {
|
|
27
|
+
if (typeof v !== "string") return null;
|
|
28
|
+
const s = v.trim().toLowerCase();
|
|
29
|
+
return Object.prototype.hasOwnProperty.call(RISK_ORDER, s) ? s : null;
|
|
30
|
+
}
|
|
31
|
+
function maxRisk(...levels) {
|
|
32
|
+
let best = null;
|
|
33
|
+
for (const l of levels) if (l && (best === null || RISK_ORDER[l] > RISK_ORDER[best])) best = l;
|
|
34
|
+
return best;
|
|
35
|
+
}
|
|
36
|
+
function riskFloorFor(mandate, target) {
|
|
37
|
+
if (!mandate) return null;
|
|
38
|
+
let floor = null;
|
|
39
|
+
for (const p of mandate.permission ?? []) {
|
|
40
|
+
if (!p || typeof p !== "object") continue;
|
|
41
|
+
if ((p.target ?? mandate.target) !== target) continue;
|
|
42
|
+
floor = maxRisk(floor, normalizeRiskLevel(p.riskTier));
|
|
43
|
+
}
|
|
44
|
+
return floor;
|
|
45
|
+
}
|
|
46
|
+
var FIELD_KINDS = {
|
|
47
|
+
riskLevel: "risk",
|
|
48
|
+
consent: "boolean",
|
|
49
|
+
piiPresent: "boolean",
|
|
50
|
+
amount: "number",
|
|
51
|
+
cumulativeSpend: "number",
|
|
52
|
+
callCount: "number",
|
|
53
|
+
evidenceConfidence: "number",
|
|
54
|
+
holTrustScore: "number",
|
|
55
|
+
dataSourceId: "string",
|
|
56
|
+
jurisdiction: "string",
|
|
57
|
+
dataResidency: "string",
|
|
58
|
+
model: "string",
|
|
59
|
+
tool: "string",
|
|
60
|
+
currency: "string",
|
|
61
|
+
action: "string",
|
|
62
|
+
prompt: "string",
|
|
63
|
+
output: "string",
|
|
64
|
+
evidenceTypes: "string[]"
|
|
65
|
+
};
|
|
66
|
+
function contextFieldProblem(ctx, field) {
|
|
67
|
+
const v = ctx?.[field];
|
|
68
|
+
if (v === void 0 || v === null) return "missing";
|
|
69
|
+
if (typeof v === "string" && v.trim() === "") return "missing";
|
|
70
|
+
switch (FIELD_KINDS[field]) {
|
|
71
|
+
case "risk":
|
|
72
|
+
return normalizeRiskLevel(v) === null ? "malformed" : null;
|
|
73
|
+
case "boolean":
|
|
74
|
+
return typeof v === "boolean" ? null : "malformed";
|
|
75
|
+
case "number":
|
|
76
|
+
return typeof v === "number" && Number.isFinite(v) ? null : "malformed";
|
|
77
|
+
case "string":
|
|
78
|
+
return typeof v === "string" ? null : "malformed";
|
|
79
|
+
case "string[]":
|
|
80
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string") ? null : "malformed";
|
|
81
|
+
default:
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
var ATOM_DEFAULT_REQUIRED_CONTEXT = {
|
|
86
|
+
"risk-at-or-above": ["riskLevel"]
|
|
87
|
+
};
|
|
88
|
+
function buildRuleContext(src) {
|
|
89
|
+
const ctx = {};
|
|
90
|
+
const prov = /* @__PURE__ */ Object.create(null);
|
|
91
|
+
const put = (k, v, level) => {
|
|
92
|
+
Object.defineProperty(ctx, k, { value: v, enumerable: true, writable: true, configurable: true });
|
|
93
|
+
prov[k] = level;
|
|
94
|
+
};
|
|
95
|
+
const layers = [
|
|
96
|
+
[src.unsigned, "agent_asserted"],
|
|
97
|
+
[src.signed, "agent_signed"],
|
|
98
|
+
[src.gatewayDerived, "gateway_derived"],
|
|
99
|
+
[src.serverDerived, "authoritative"]
|
|
100
|
+
];
|
|
101
|
+
for (const [layer, level] of layers) {
|
|
102
|
+
for (const [k, v] of Object.entries(layer ?? {})) {
|
|
103
|
+
if (k === "riskLevel" && (level === "gateway_derived" || level === "authoritative") && normalizeRiskLevel(v) === null) continue;
|
|
104
|
+
put(k, v, level);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const floors = [];
|
|
108
|
+
const addFloor = (v, source) => {
|
|
109
|
+
const n = normalizeRiskLevel(v);
|
|
110
|
+
if (n) floors.push({ level: n, source });
|
|
111
|
+
};
|
|
112
|
+
addFloor(src.riskFloor, "authoritative");
|
|
113
|
+
addFloor(src.gatewayDerived?.riskLevel, "gateway_derived");
|
|
114
|
+
addFloor(src.serverDerived?.riskLevel, "authoritative");
|
|
115
|
+
const assertedUnsigned = normalizeRiskLevel(src.unsigned?.riskLevel);
|
|
116
|
+
const assertedSigned = normalizeRiskLevel(src.signed?.riskLevel);
|
|
117
|
+
const asserted = maxRisk(assertedUnsigned, assertedSigned);
|
|
118
|
+
if (floors.length > 0) {
|
|
119
|
+
put("riskLevel", maxRisk(asserted, ...floors.map((f) => f.level)), floors.reduce((best, f) => PROVENANCE_RANK[f.source] > PROVENANCE_RANK[best] ? f.source : best, "agent_asserted"));
|
|
120
|
+
} else if (asserted) {
|
|
121
|
+
put("riskLevel", asserted, assertedSigned ? "agent_signed" : "agent_asserted");
|
|
122
|
+
}
|
|
123
|
+
Object.defineProperty(ctx, PROVENANCE_KEY, { value: prov, enumerable: true, writable: false });
|
|
124
|
+
return ctx;
|
|
125
|
+
}
|
|
126
|
+
|
|
3
127
|
// src/policy-core/atom-registry.ts
|
|
4
128
|
var RISK_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
5
129
|
function currencyOutOfScope(ctx, cfgCurrency) {
|
|
@@ -14,7 +138,8 @@ var ATOM_REGISTRY = {
|
|
|
14
138
|
"data-source-not-approved": (c, cfg) => !!c.dataSourceId && !(cfg?.approved ?? []).includes(String(c.dataSourceId)),
|
|
15
139
|
"consent-missing": (c) => c.consent === false,
|
|
16
140
|
"risk-at-or-above": (c, cfg) => {
|
|
17
|
-
const
|
|
141
|
+
const haveLevel = normalizeRiskLevel(c.riskLevel);
|
|
142
|
+
const have = haveLevel === null ? void 0 : RISK_RANK[haveLevel];
|
|
18
143
|
const need = RISK_RANK[String(cfg?.level ?? "high")];
|
|
19
144
|
return have !== void 0 && need !== void 0 && have >= need;
|
|
20
145
|
},
|
|
@@ -276,6 +401,7 @@ function requiredContextFor(predicates) {
|
|
|
276
401
|
}
|
|
277
402
|
|
|
278
403
|
// src/policy-core/standards-rules.ts
|
|
404
|
+
var CONTEXT_UNVERIFIABLE = "CONTEXT_UNVERIFIABLE";
|
|
279
405
|
var PRECEDENCE = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5, decommission: 6 };
|
|
280
406
|
function atomFires(atom, ctx) {
|
|
281
407
|
const pred = ATOM_REGISTRY[atom.predicate];
|
|
@@ -304,17 +430,49 @@ function moleculeFires(m, ctx) {
|
|
|
304
430
|
return false;
|
|
305
431
|
}
|
|
306
432
|
}
|
|
433
|
+
function requiredContextOf(m) {
|
|
434
|
+
const required = /* @__PURE__ */ new Map();
|
|
435
|
+
const need = (field, level) => {
|
|
436
|
+
const have = required.get(field);
|
|
437
|
+
if (!have || PROVENANCE_RANK[level] > PROVENANCE_RANK[have]) required.set(field, level);
|
|
438
|
+
};
|
|
439
|
+
for (const a of m.atoms ?? []) {
|
|
440
|
+
if (!Object.prototype.hasOwnProperty.call(ATOM_DEFAULT_REQUIRED_CONTEXT, a.predicate)) continue;
|
|
441
|
+
for (const f of ATOM_DEFAULT_REQUIRED_CONTEXT[a.predicate]) need(f, "agent_asserted");
|
|
442
|
+
}
|
|
443
|
+
for (const [f, level] of Object.entries(m.requireProvenance ?? {})) {
|
|
444
|
+
need(f, isProvenance(level) ? level : "attested");
|
|
445
|
+
}
|
|
446
|
+
return required;
|
|
447
|
+
}
|
|
448
|
+
function moleculeUnverifiable(m, ctx) {
|
|
449
|
+
const bad = [];
|
|
450
|
+
for (const [field, minimum] of requiredContextOf(m)) {
|
|
451
|
+
if (contextFieldProblem(ctx, field) !== null || !meetsProvenance(provenanceOf(ctx, field), minimum)) bad.push(field);
|
|
452
|
+
}
|
|
453
|
+
return bad.sort();
|
|
454
|
+
}
|
|
307
455
|
function evaluateStandardRules(molecules, ctx, standardKey = null) {
|
|
308
456
|
let best = null;
|
|
309
457
|
for (const m of molecules ?? []) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
458
|
+
const fired = moleculeFires(m, ctx);
|
|
459
|
+
const unverifiable = moleculeUnverifiable(m, ctx);
|
|
460
|
+
if (!fired && unverifiable.length === 0) continue;
|
|
461
|
+
let decision = fired ? m.decision : "escalate";
|
|
462
|
+
if (unverifiable.length > 0 && PRECEDENCE[decision] < PRECEDENCE.escalate) decision = "escalate";
|
|
463
|
+
const reasonCode = fired ? m.reasonCode : CONTEXT_UNVERIFIABLE;
|
|
464
|
+
if (!best || PRECEDENCE[decision] > PRECEDENCE[best.decision]) {
|
|
465
|
+
best = { decision, reasonCode, id: m.id, unverifiable: unverifiable.length > 0 ? unverifiable : void 0 };
|
|
314
466
|
}
|
|
315
467
|
}
|
|
316
468
|
if (!best) return { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey };
|
|
317
|
-
return {
|
|
469
|
+
return {
|
|
470
|
+
decision: best.decision,
|
|
471
|
+
reasonCode: best.reasonCode,
|
|
472
|
+
firedMoleculeId: best.id,
|
|
473
|
+
standardKey,
|
|
474
|
+
...best.unverifiable ? { unverifiableContext: best.unverifiable } : {}
|
|
475
|
+
};
|
|
318
476
|
}
|
|
319
477
|
function evaluateBoundStandards(standards, ctx) {
|
|
320
478
|
let best = { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey: null };
|
|
@@ -369,6 +527,19 @@ function validateMolecules(molecules) {
|
|
|
369
527
|
if (!m.atoms || m.atoms.length === 0) {
|
|
370
528
|
issues.push({ moleculeId: m.id, message: "molecule has no atoms" });
|
|
371
529
|
}
|
|
530
|
+
if (m.requireProvenance !== void 0) {
|
|
531
|
+
const rp = m.requireProvenance;
|
|
532
|
+
if (rp === null || typeof rp !== "object" || Array.isArray(rp)) {
|
|
533
|
+
issues.push({ moleculeId: m.id, message: "requireProvenance must be an object of { field: level }" });
|
|
534
|
+
} else {
|
|
535
|
+
for (const [field, level] of Object.entries(rp)) {
|
|
536
|
+
if (field.trim() === "") issues.push({ moleculeId: m.id, message: "requireProvenance has an empty field name" });
|
|
537
|
+
if (!isProvenance(level)) {
|
|
538
|
+
issues.push({ moleculeId: m.id, message: `requireProvenance '${field}' must be one of agent_asserted|agent_signed|gateway_derived|authoritative|attested` });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
372
543
|
for (const a of m.atoms ?? []) {
|
|
373
544
|
if (!ATOM_REGISTRY[a.predicate]) {
|
|
374
545
|
issues.push({ moleculeId: m.id, message: `unknown atom predicate '${a.predicate}'` });
|
|
@@ -406,9 +577,14 @@ var REASON_BY_OPERAND = {
|
|
|
406
577
|
"mm:route": "ROUTE_NOT_ALLOWED",
|
|
407
578
|
"mm:counterparty": "COUNTERPARTY_NOT_ALLOWED"
|
|
408
579
|
};
|
|
409
|
-
|
|
580
|
+
var AMOUNT_OPERANDS = /* @__PURE__ */ new Set(["mm:payAmount", "mm:cumulativeSpend"]);
|
|
581
|
+
function reasonFor(constraint, req) {
|
|
410
582
|
if (!constraint) return "CONSTRAINT_FAILED";
|
|
411
|
-
|
|
583
|
+
const { leftOperand } = constraint;
|
|
584
|
+
if (AMOUNT_OPERANDS.has(leftOperand) && !Object.prototype.hasOwnProperty.call(req.values, leftOperand)) {
|
|
585
|
+
return "AMOUNT_NOT_DETERMINABLE";
|
|
586
|
+
}
|
|
587
|
+
return REASON_BY_OPERAND[leftOperand] ?? `CONSTRAINT_FAILED:${leftOperand}`;
|
|
412
588
|
}
|
|
413
589
|
function constraintSatisfied(c, req, strict) {
|
|
414
590
|
const op = OPERATORS[c.operator];
|
|
@@ -464,7 +640,7 @@ function evaluateMandate(mandate, req) {
|
|
|
464
640
|
const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
|
|
465
641
|
return {
|
|
466
642
|
decision: firstFail?.onFail ?? "block",
|
|
467
|
-
reasonCode: reasonFor(firstFail),
|
|
643
|
+
reasonCode: reasonFor(firstFail, req),
|
|
468
644
|
matched: { kind: "permission", target: perms[0].target, constraint: firstFail }
|
|
469
645
|
};
|
|
470
646
|
}
|
|
@@ -516,6 +692,9 @@ function escapeField(v) {
|
|
|
516
692
|
function buildAuthMessage(f) {
|
|
517
693
|
return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.resource ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
518
694
|
}
|
|
695
|
+
function buildLegacyAuthMessageV1(f) {
|
|
696
|
+
return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
697
|
+
}
|
|
519
698
|
function buildLocalDecisionMessage(f) {
|
|
520
699
|
return [f.agentDid, f.action, f.decision, f.reasonCode, f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
521
700
|
}
|
|
@@ -569,11 +748,17 @@ function operatingModeGate(mode, ctx) {
|
|
|
569
748
|
}
|
|
570
749
|
}
|
|
571
750
|
export {
|
|
751
|
+
ATOM_DEFAULT_REQUIRED_CONTEXT,
|
|
572
752
|
ATOM_REGISTRY,
|
|
573
753
|
ATOM_SPECS,
|
|
574
754
|
CATALOGUED_ATOMS,
|
|
755
|
+
CONTEXT_UNVERIFIABLE,
|
|
575
756
|
MODES_BY_RANK,
|
|
576
757
|
MODE_RANK,
|
|
758
|
+
PROVENANCE_KEY,
|
|
759
|
+
PROVENANCE_LEVELS,
|
|
760
|
+
PROVENANCE_RANK,
|
|
761
|
+
RISK_LEVELS,
|
|
577
762
|
SUPERVISED_AMOUNT_CAP,
|
|
578
763
|
applyCapture,
|
|
579
764
|
applyHold,
|
|
@@ -582,20 +767,31 @@ export {
|
|
|
582
767
|
authorityFailure,
|
|
583
768
|
buildAuthMessage,
|
|
584
769
|
buildCheckpointAnchorMessage,
|
|
770
|
+
buildLegacyAuthMessageV1,
|
|
585
771
|
buildLocalDecisionMessage,
|
|
772
|
+
buildRuleContext,
|
|
586
773
|
canAuthorize,
|
|
774
|
+
contextFieldProblem,
|
|
587
775
|
evaluate,
|
|
588
776
|
evaluateBoundStandards,
|
|
589
777
|
evaluateMandate,
|
|
590
778
|
evaluateStandardRules,
|
|
591
779
|
isAuthorityFailure,
|
|
592
780
|
isOperatingMode,
|
|
781
|
+
isProvenance,
|
|
782
|
+
maxRisk,
|
|
783
|
+
meetsProvenance,
|
|
593
784
|
moleculeFires,
|
|
785
|
+
moleculeUnverifiable,
|
|
594
786
|
moreRestrictive,
|
|
787
|
+
normalizeRiskLevel,
|
|
595
788
|
operatingModeGate,
|
|
789
|
+
provenanceOf,
|
|
596
790
|
releaseHold,
|
|
597
791
|
remainingBudget,
|
|
598
792
|
requiredContextFor,
|
|
793
|
+
requiredContextOf,
|
|
794
|
+
riskFloorFor,
|
|
599
795
|
sumEventField,
|
|
600
796
|
validateMolecules
|
|
601
797
|
};
|