@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 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
@@ -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
- return body?.data ?? { decision: 'block', reasonCode: `GATE_HTTP_${res.status}` };
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
- const modeGate = operatingModeGate(operatingMode?.mode, { amount, riskLevel: context?.riskLevel });
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: applySignedLast(context, { action, agentDid, amount, currency, merchant, resource }),
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.12.4",
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 have = RISK_RANK[String(c.riskLevel)];
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
- if (moleculeFires(m, ctx)) {
311
- if (!best || PRECEDENCE[m.decision] > PRECEDENCE[best.decision]) {
312
- best = { decision: m.decision, reasonCode: m.reasonCode, id: m.id };
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 { decision: best.decision, reasonCode: best.reasonCode, firedMoleculeId: best.id, standardKey };
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
- function reasonFor(constraint) {
580
+ var AMOUNT_OPERANDS = /* @__PURE__ */ new Set(["mm:payAmount", "mm:cumulativeSpend"]);
581
+ function reasonFor(constraint, req) {
410
582
  if (!constraint) return "CONSTRAINT_FAILED";
411
- return REASON_BY_OPERAND[constraint.leftOperand] ?? `CONSTRAINT_FAILED:${constraint.leftOperand}`;
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
  };