@metamynd/agentsafe-guard 0.12.1 → 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
@@ -10,8 +10,10 @@
10
10
  // The agent's private key is a Hedera Ed25519 DER key (the AGENT_KEY the seed prints).
11
11
  import crypto from 'node:crypto';
12
12
  import { readFileSync } from 'node:fs';
13
- import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from './policy-core.mjs';
13
+ import { resolve as resolvePath } from 'node:path';
14
+ import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate, buildRuleContext, riskFloorFor, maxRisk, normalizeRiskLevel } from './policy-core.mjs';
14
15
  import { envelopeHashFor } from './governance-envelope.mjs';
16
+ import { payloadDigestOf, toWireJson } from './payload-binding.mjs';
15
17
  import { verifyDidSignature } from './magp-did.mjs';
16
18
  import { checkSettlementBinding } from './x402.mjs';
17
19
  import { resolveKeyProvider, decryptAgentKeyWithPassword } from './key-providers.mjs';
@@ -84,6 +86,38 @@ export function executionAdapterFromEnv(env = (typeof process !== 'undefined' ?
84
86
  * agentDid the agent's did:hedera
85
87
  * agentKey the agent's Ed25519 private key (Hedera DER hex, held only by the agent)
86
88
  */
89
+ /**
90
+ * Read + parse an agent config file, failing with the NEXT STEP rather than a bare `ENOENT`.
91
+ * `agent.metamynd.json` holds the agent's identity (and, for a managed key, its secret), so it is
92
+ * deliberately gitignored — which means a fresh clone of any agent project can never contain it.
93
+ * A raw "no such file or directory" left a first-time user with no way forward (beta regression
94
+ * 2026-09-20, BR-004); the message below names the three ways to get the file.
95
+ */
96
+ function readConfigFile(path, who) {
97
+ let text;
98
+ try {
99
+ text = readFileSync(path, 'utf8');
100
+ } catch (e) {
101
+ if (e && e.code === 'ENOENT') {
102
+ throw new Error([
103
+ `${who}: no agent config at "${resolvePath(path)}".`,
104
+ ` agent.metamynd.json holds the agent's identity and key, so it is gitignored - a fresh clone never has it.`,
105
+ ` To get one:`,
106
+ ` 1. New agent: npx create-metamynd-agent (creates the agent and writes this file)`,
107
+ ` 2. Existing agent: dashboard -> Agents -> your agent -> download its configuration, save it as ${path}`,
108
+ ` 3. Kept elsewhere: pass its real path, e.g. createGuardFromConfig('/path/to/agent.metamynd.json')`,
109
+ ` Then re-run. Step-by-step: https://metamynd.ai/developers/quickstart`,
110
+ ].join('\n'));
111
+ }
112
+ throw new Error(`${who}: cannot read agent config "${path}": ${e && e.message}`);
113
+ }
114
+ try {
115
+ return JSON.parse(text);
116
+ } catch (e) {
117
+ throw new Error(`${who}: "${path}" is not valid JSON (${e.message}). Re-download the configuration rather than editing it by hand.`);
118
+ }
119
+ }
120
+
87
121
  /**
88
122
  * Async loader — build a guard from the portable config the one-call `POST /onboarding/agent`
89
123
  * endpoint returns: a URL, a file path, or the config object itself. Overrides win over the config.
@@ -100,7 +134,7 @@ export function executionAdapterFromEnv(env = (typeof process !== 'undefined' ?
100
134
  export async function createGuardFromConfig(source, overrides = {}) {
101
135
  let cfg = source;
102
136
  if (typeof source === 'string') {
103
- cfg = /^https?:\/\//.test(source) ? await (await fetch(source)).json() : JSON.parse(readFileSync(source, 'utf8'));
137
+ cfg = /^https?:\/\//.test(source) ? await (await fetch(source)).json() : readConfigFile(source, 'createGuardFromConfig');
104
138
  }
105
139
  if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
106
140
  const { passphrase, ...rest } = overrides;
@@ -119,8 +153,7 @@ export function createGuard(opts = {}) {
119
153
  // addition to explicit { api, agentDid, agentKey }. Explicit fields win over the config.
120
154
  let cfg = opts.config ?? null;
121
155
  if (!cfg && opts.configPath) {
122
- try { cfg = JSON.parse(readFileSync(opts.configPath, 'utf8')); }
123
- catch (e) { throw new Error(`createGuard: cannot read configPath "${opts.configPath}": ${e.message}`); }
156
+ cfg = readConfigFile(opts.configPath, 'createGuard');
124
157
  }
125
158
  if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
126
159
  const api = opts.api ?? cfg?.apiBase ?? cfg?.api;
@@ -150,6 +183,27 @@ export function createGuard(opts = {}) {
150
183
  return keyProvider.signEnvelope({ agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt });
151
184
  }
152
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
+
153
207
  // --- Enforcement mode (spec §9.2 + local-first plan) --------------------------------------
154
208
  // 'local' (DEFAULT): decide the rule layer LOCALLY against a cached signed bundle — a
155
209
  // block/escalate needs no network; an allowed VALUE action is still sealed by the remote
@@ -182,7 +236,7 @@ export function createGuard(opts = {}) {
182
236
  * agent's authorization trustlessly against the agent's policy bundle (§9.3). Same shape
183
237
  * `authorize()` posts to the gate; a fresh nonce each call.
184
238
  */
185
- async function buildSignedRequest({ action, amount, currency, merchant, resource, context = {}, trace, materiality }) {
239
+ async function buildSignedRequest({ action, amount, currency, merchant, resource, context = {}, trace, materiality, payload }) {
186
240
  const nonce = crypto.randomUUID();
187
241
  const issuedAt = new Date().toISOString();
188
242
  // This object is presented to a COUNTERPARTY (spec §9.3) — but its own docstring also
@@ -211,11 +265,12 @@ export function createGuard(opts = {}) {
211
265
  // action fields don't include it yet either (only amount/currency/merchant) — adding it
212
266
  // to just one side would break Tier-1 envelope-hash verification for any resource-
213
267
  // declaring request. A coordinated backend+guard follow-up, not something to do half here.
214
- const [signature, envelopeSignature] = await Promise.all([
268
+ const [signature, envelopeSignature, binding] = await Promise.all([
215
269
  keyProvider.signAuthorize({ agentDid, action, amount: signedAmount, currency: signedCurrency, merchant, resource, nonce, issuedAt }),
216
270
  envelopeSignatureFor({ action, amount: wireAmount, currency: wireCurrency, merchant, context, trace, materiality, nonce, issuedAt }),
271
+ payloadBindingFor({ action, nonce, issuedAt, payload }),
217
272
  ]);
218
- 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 };
219
274
  }
220
275
 
221
276
  /**
@@ -223,7 +278,7 @@ export function createGuard(opts = {}) {
223
278
  * returns { decision:'allow'|'block'|'escalate', reasonCode, authorizationId, remaining }.
224
279
  * A network/gate failure returns a fail-CLOSED block so the agent can't proceed blind.
225
280
  */
226
- async function authorize({ action, amount, currency, merchant, resource, context = {}, trace, materiality }) {
281
+ async function authorize({ action, amount, currency, merchant, resource, context = {}, trace, materiality, payload }) {
227
282
  const nonce = crypto.randomUUID();
228
283
  const issuedAt = new Date().toISOString();
229
284
  try {
@@ -244,9 +299,10 @@ export function createGuard(opts = {}) {
244
299
  // see key-providers.mjs for why callers pass structured fields, not a pre-built string.
245
300
  // `resource` deliberately NOT passed to envelopeSignatureFor — see buildSignedRequest's
246
301
  // own comment on why (backend governance-envelope.ts doesn't include it yet either).
247
- const [signature, envelopeSignature] = await Promise.all([
302
+ const [signature, envelopeSignature, binding] = await Promise.all([
248
303
  keyProvider.signAuthorize({ agentDid, action, amount: signedAmount, currency: signedCurrency, merchant, resource, nonce, issuedAt }),
249
304
  envelopeSignatureFor({ action, amount, currency, merchant, context, trace, materiality, nonce, issuedAt }),
305
+ payloadBindingFor({ action, nonce, issuedAt, payload }),
250
306
  ]);
251
307
  const res = await fetch(`${base}/policy/mandate/authorize`, {
252
308
  method: 'POST',
@@ -258,11 +314,27 @@ export function createGuard(opts = {}) {
258
314
  agentDid, action, amount, currency, merchant, resource, itinerary: context, trace, materiality, nonce, issuedAt,
259
315
  signature,
260
316
  envelopeSignature,
317
+ ...binding, // payloadDigest + payloadSignature, or nothing for an unbound request
261
318
  }),
262
319
  });
263
320
  const body = await res.json().catch(() => null);
264
- 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;
265
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
+ }
266
338
  // A daemon-backed keyProvider can fail before the gate is ever reached (the signer, not
267
339
  // the gate, was unreachable) — a distinct reasonCode so this doesn't read as a gate outage
268
340
  // it wasn't. Still fail-CLOSED either way, which is the property that actually matters.
@@ -315,7 +387,9 @@ export function createGuard(opts = {}) {
315
387
  // SIBLING (like `contained`) and biases the edge verdict identically to the gate.
316
388
  // READ_ONLY denies a value-bearing action up-front; SUPERVISED/RESTRICTED only
317
389
  // ESCALATE, applied to the verdict below so a rule block/escalate still outranks it.
318
- 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 });
319
393
  if (modeGate.decision === 'block') {
320
394
  return { decision: 'block', reasonCode: modeGate.reasonCode, authorizationId: null, remaining: null, proofRef: null };
321
395
  }
@@ -330,7 +404,7 @@ export function createGuard(opts = {}) {
330
404
  // omitting them here means a currency-scoped amount-over/cumulative-over Standards/SOP
331
405
  // atom always sees currency as absent and fires closed. Mirrors mandate.service.ts's
332
406
  // ruleCtx (PR #588) and the same fix in agentsafe-mcp-guard.mjs's verdictFromBundle.
333
- 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) }),
334
408
  mandateRequest: mandate
335
409
  ? {
336
410
  target: action,
package/cli.mjs CHANGED
@@ -19,7 +19,19 @@ if (cmd === 'demo') {
19
19
  // or when a control named by --require is not configured at all.
20
20
  const { verify } = await import('./verify.mjs');
21
21
  try {
22
+ // --context <file>: the request context of a request that satisfies every rule (see verify.mjs).
23
+ let context;
24
+ // flag() is null when absent and '' when given with no value (`--context` last, or an unset shell
25
+ // variable) — the latter must be an error, not a silent bare run.
26
+ if (flag('context') !== null) {
27
+ const path = flag('context');
28
+ if (!path) throw new Error('--context needs a file path');
29
+ try { context = JSON.parse((await import('node:fs')).readFileSync(path, 'utf8')); }
30
+ catch (e) { throw new Error(`cannot read --context ${path}: ${e.message}`); }
31
+ if (context === null || typeof context !== 'object' || Array.isArray(context)) throw new Error(`--context ${path} must contain a JSON object`);
32
+ }
22
33
  const result = await verify({
34
+ context,
23
35
  configPath: flag('config') ?? './agent.metamynd.json',
24
36
  require: (flag('require') ?? '').split(',').map((s) => s.trim()).filter(Boolean),
25
37
  json: process.argv.includes('--json'),
@@ -50,6 +62,9 @@ if (cmd === 'demo') {
50
62
  --config <path> agent config (default ./agent.metamynd.json)
51
63
  --require <a,b> fail when a control is NOT configured, e.g.
52
64
  --require merchants,perTxn
65
+ --context <path> JSON file: the request context of a request that
66
+ satisfies every rule (rule inputs like consent or
67
+ evidenceTypes), for policies that require inputs
53
68
  --json machine-readable output
54
69
 
55
70
  A control the mandate does not set is reported, never passed: an empty
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.1",
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",
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
  };
package/verify.mjs CHANGED
@@ -41,7 +41,7 @@ const CONTROLS = {
41
41
  detail: 'the ceiling on any single action',
42
42
  configured: (c) => {
43
43
  const k = c.find((x) => x.leftOperand === 'mm:payAmount' && x.operator === 'lteq');
44
- return k ? { configured: true, summary: `${k.rightOperand}${k.unit ? ' ' + k.unit : ''}`, limit: Number(k.rightOperand) } : { configured: false };
44
+ return k ? { configured: true, summary: `${k.rightOperand}${k.unit ? ' ' + k.unit : ''}`, limit: Number(k.rightOperand), unit: k.unit } : { configured: false };
45
45
  },
46
46
  },
47
47
  cumulative: {
@@ -49,7 +49,7 @@ const CONTROLS = {
49
49
  detail: 'the ceiling on total spend across actions',
50
50
  configured: (c) => {
51
51
  const k = c.find((x) => x.leftOperand === 'mm:cumulativeSpend' && x.operator === 'lteq');
52
- return k ? { configured: true, summary: `${k.rightOperand}${k.unit ? ' ' + k.unit : ''}`, limit: Number(k.rightOperand) } : { configured: false };
52
+ return k ? { configured: true, summary: `${k.rightOperand}${k.unit ? ' ' + k.unit : ''}`, limit: Number(k.rightOperand), unit: k.unit } : { configured: false };
53
53
  },
54
54
  },
55
55
  merchants: {
@@ -92,7 +92,15 @@ function packsFor(bundle, action) {
92
92
 
93
93
  const permits = (v) => v.decision === 'allow' || v.decision === 'observe';
94
94
 
95
- export async function verify({ configPath = './agent.metamynd.json', require: required = [], json = false, log = console.log, env = process.env } = {}) {
95
+ /**
96
+ * `context` is the request context of a request that satisfies every rule in the agent's policy
97
+ * (rule inputs such as `consent`, `evidenceTypes`, `jurisdiction`). The baseline and cap checks send
98
+ * it, because a policy that REQUIRES an input blocks a request without it — which would make the
99
+ * baseline report a healthy agent as broken, and let a cap "hold" for the wrong reason. Omit it for
100
+ * a policy with no input-dependent rules (the old behaviour, unchanged). The scope check never
101
+ * uses it: an ungranted action must be refused whatever the context says.
102
+ */
103
+ export async function verify({ configPath = './agent.metamynd.json', require: required = [], json = false, log = console.log, env = process.env, context: baseContext = {} } = {}) {
96
104
  // AGENT_KEY / AGENT_DID / METAMYND_API from the environment win over the config file.
97
105
  // CI is the whole point of this command, and a CI story that requires committing the
98
106
  // agent's signing key to the repository is not one — so a key-less config plus a secret
@@ -116,6 +124,12 @@ export async function verify({ configPath = './agent.metamynd.json', require: re
116
124
  const found = {};
117
125
  for (const [key, spec] of Object.entries(CONTROLS)) found[key] = spec.configured(constraints);
118
126
 
127
+ // The probes must be denominated in the currency the mandate's caps are. `evaluateLocally` assumes USD when a
128
+ // request names none, and a cap in any other currency refuses a currency-less or mismatched request (fail-closed),
129
+ // so a GBP agent would fail its own baseline check and read as broken. Only a cap that names a unit sets one.
130
+ const unitOf = (control) => (control.unit ? { currency: control.unit } : {});
131
+ const baselineCurrency = unitOf(found.perTxn.configured ? found.perTxn : found.cumulative);
132
+
119
133
  const checks = [];
120
134
  const add = (control, status, assertion, verdict, note) =>
121
135
  checks.push({ control, status, assertion, decision: verdict?.decision ?? null, reasonCode: verdict?.reasonCode ?? null, note });
@@ -125,7 +139,7 @@ export async function verify({ configPath = './agent.metamynd.json', require: re
125
139
  {
126
140
  const amount = found.perTxn.configured ? Math.max(1, Math.floor(found.perTxn.limit / 2)) : 1;
127
141
  const merchant = found.merchants.list?.length ? found.merchants.list[0] : 'any-merchant';
128
- const v = evaluate({ action, amount, merchant, context: { riskLevel: 'low' } });
142
+ const v = evaluate({ action, amount, ...baselineCurrency, merchant, context: { riskLevel: 'low', ...baseContext } });
129
143
  add('baseline', permits(v) ? PASS : FAIL, 'permits ordinary in-scope work', v,
130
144
  permits(v) ? null : 'the agent cannot perform the action it was issued for');
131
145
  }
@@ -141,7 +155,7 @@ export async function verify({ configPath = './agent.metamynd.json', require: re
141
155
  // 3–5. Only assert a limit the mandate actually sets. Asserting an absent control is how
142
156
  // you end up believing in one.
143
157
  if (found.perTxn.configured) {
144
- const v = evaluate({ action, amount: found.perTxn.limit + 1, merchant: found.merchants.list?.length ? found.merchants.list[0] : 'any-merchant', context: {} });
158
+ const v = evaluate({ action, amount: found.perTxn.limit + 1, ...unitOf(found.perTxn), merchant: found.merchants.list?.length ? found.merchants.list[0] : 'any-merchant', context: { ...baseContext } });
145
159
  add('perTxn', permits(v) ? FAIL : PASS, `refuses ${found.perTxn.limit + 1} against a cap of ${found.perTxn.limit}`, v,
146
160
  permits(v) ? 'the per-transaction cap did not hold' : null);
147
161
  } else {
@@ -150,8 +164,8 @@ export async function verify({ configPath = './agent.metamynd.json', require: re
150
164
 
151
165
  if (found.cumulative.configured) {
152
166
  const v = evaluate({
153
- action, amount: 1, merchant: found.merchants.list?.length ? found.merchants.list[0] : 'any-merchant',
154
- cumulativeSpend: found.cumulative.limit + 1, context: {},
167
+ action, amount: 1, ...unitOf(found.cumulative), merchant: found.merchants.list?.length ? found.merchants.list[0] : 'any-merchant',
168
+ cumulativeSpend: found.cumulative.limit + 1, context: { ...baseContext },
155
169
  });
156
170
  add('cumulative', permits(v) ? FAIL : PASS, `refuses spending past a total of ${found.cumulative.limit}`, v,
157
171
  permits(v) ? 'the cumulative cap did not hold' : null);
@@ -166,7 +180,7 @@ export async function verify({ configPath = './agent.metamynd.json', require: re
166
180
  // Deliberately UNDER any cap: a refusal at an amount that also trips a spend limit
167
181
  // proves nothing about merchants, which is exactly how this went unnoticed before.
168
182
  const amt = found.perTxn.configured ? Math.max(1, Math.floor(found.perTxn.limit / 2)) : 1;
169
- const v = evaluate({ action, amount: amt, merchant: '__unapproved_supplier__', context: {} });
183
+ const v = evaluate({ action, amount: amt, ...baselineCurrency, merchant: '__unapproved_supplier__', context: { ...baseContext } });
170
184
  add('merchants', permits(v) ? FAIL : PASS, 'refuses an unlisted merchant, under the cap', v,
171
185
  permits(v) ? 'the merchant allow-list did not hold' : null);
172
186
  } else {