@metamynd/agentsafe-guard 0.12.0 → 0.12.4
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/agentsafe-guard.mjs +82 -9
- package/cli.mjs +15 -0
- package/package.json +2 -2
- package/verify.mjs +22 -8
package/agentsafe-guard.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
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 { resolve as resolvePath } from 'node:path';
|
|
13
14
|
import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from './policy-core.mjs';
|
|
14
15
|
import { envelopeHashFor } from './governance-envelope.mjs';
|
|
15
16
|
import { verifyDidSignature } from './magp-did.mjs';
|
|
@@ -84,6 +85,38 @@ export function executionAdapterFromEnv(env = (typeof process !== 'undefined' ?
|
|
|
84
85
|
* agentDid the agent's did:hedera
|
|
85
86
|
* agentKey the agent's Ed25519 private key (Hedera DER hex, held only by the agent)
|
|
86
87
|
*/
|
|
88
|
+
/**
|
|
89
|
+
* Read + parse an agent config file, failing with the NEXT STEP rather than a bare `ENOENT`.
|
|
90
|
+
* `agent.metamynd.json` holds the agent's identity (and, for a managed key, its secret), so it is
|
|
91
|
+
* deliberately gitignored — which means a fresh clone of any agent project can never contain it.
|
|
92
|
+
* A raw "no such file or directory" left a first-time user with no way forward (beta regression
|
|
93
|
+
* 2026-09-20, BR-004); the message below names the three ways to get the file.
|
|
94
|
+
*/
|
|
95
|
+
function readConfigFile(path, who) {
|
|
96
|
+
let text;
|
|
97
|
+
try {
|
|
98
|
+
text = readFileSync(path, 'utf8');
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (e && e.code === 'ENOENT') {
|
|
101
|
+
throw new Error([
|
|
102
|
+
`${who}: no agent config at "${resolvePath(path)}".`,
|
|
103
|
+
` agent.metamynd.json holds the agent's identity and key, so it is gitignored - a fresh clone never has it.`,
|
|
104
|
+
` To get one:`,
|
|
105
|
+
` 1. New agent: npx create-metamynd-agent (creates the agent and writes this file)`,
|
|
106
|
+
` 2. Existing agent: dashboard -> Agents -> your agent -> download its configuration, save it as ${path}`,
|
|
107
|
+
` 3. Kept elsewhere: pass its real path, e.g. createGuardFromConfig('/path/to/agent.metamynd.json')`,
|
|
108
|
+
` Then re-run. Step-by-step: https://metamynd.ai/developers/quickstart`,
|
|
109
|
+
].join('\n'));
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`${who}: cannot read agent config "${path}": ${e && e.message}`);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(text);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
throw new Error(`${who}: "${path}" is not valid JSON (${e.message}). Re-download the configuration rather than editing it by hand.`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
87
120
|
/**
|
|
88
121
|
* Async loader — build a guard from the portable config the one-call `POST /onboarding/agent`
|
|
89
122
|
* endpoint returns: a URL, a file path, or the config object itself. Overrides win over the config.
|
|
@@ -100,7 +133,7 @@ export function executionAdapterFromEnv(env = (typeof process !== 'undefined' ?
|
|
|
100
133
|
export async function createGuardFromConfig(source, overrides = {}) {
|
|
101
134
|
let cfg = source;
|
|
102
135
|
if (typeof source === 'string') {
|
|
103
|
-
cfg = /^https?:\/\//.test(source) ? await (await fetch(source)).json() :
|
|
136
|
+
cfg = /^https?:\/\//.test(source) ? await (await fetch(source)).json() : readConfigFile(source, 'createGuardFromConfig');
|
|
104
137
|
}
|
|
105
138
|
if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
|
|
106
139
|
const { passphrase, ...rest } = overrides;
|
|
@@ -119,8 +152,7 @@ export function createGuard(opts = {}) {
|
|
|
119
152
|
// addition to explicit { api, agentDid, agentKey }. Explicit fields win over the config.
|
|
120
153
|
let cfg = opts.config ?? null;
|
|
121
154
|
if (!cfg && opts.configPath) {
|
|
122
|
-
|
|
123
|
-
catch (e) { throw new Error(`createGuard: cannot read configPath "${opts.configPath}": ${e.message}`); }
|
|
155
|
+
cfg = readConfigFile(opts.configPath, 'createGuard');
|
|
124
156
|
}
|
|
125
157
|
if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
|
|
126
158
|
const api = opts.api ?? cfg?.apiBase ?? cfg?.api;
|
|
@@ -297,7 +329,7 @@ export function createGuard(opts = {}) {
|
|
|
297
329
|
* @param {Array<{standardKey:string,document:object}>} [p.standards] enforced Standards bound to the agent
|
|
298
330
|
* @param {Array<{standardKey:string,document:object}>} [p.sops] active SOPs assigned to the agent
|
|
299
331
|
* @param {object} [p.mandate] the ODRL mandate document (omit to skip the mandate layer)
|
|
300
|
-
* @param {{action:string,amount?:number,merchant?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
|
|
332
|
+
* @param {{action:string,amount?:number,currency?:string,merchant?:string,resource?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
|
|
301
333
|
* @returns {{decision:'allow'|'block'|'escalate',reasonCode:string|null,authorizationId:null,remaining:null,proofRef:null}}
|
|
302
334
|
*/
|
|
303
335
|
function evaluateLocally({ contained = null, operatingMode = null, standards = [], sops = [], mandate, request }) {
|
|
@@ -310,7 +342,7 @@ export function createGuard(opts = {}) {
|
|
|
310
342
|
const reasonCode = contained.status === 'quarantined' ? 'AGENT_QUARANTINED' : 'AGENT_SUSPENDED';
|
|
311
343
|
return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
|
|
312
344
|
}
|
|
313
|
-
const { action, amount = 0, currency = 'USD', merchant = '', context = {}, cumulativeSpend = amount, now } = request;
|
|
345
|
+
const { action, amount = 0, currency = 'USD', merchant = '', resource = null, context = {}, cumulativeSpend = amount, now } = request;
|
|
314
346
|
// Operating-mode autonomy ladder (Phase 2.5b): the trust-driven posture rides as a
|
|
315
347
|
// SIBLING (like `contained`) and biases the edge verdict identically to the gate.
|
|
316
348
|
// READ_ONLY denies a value-bearing action up-front; SUPERVISED/RESTRICTED only
|
|
@@ -326,7 +358,11 @@ export function createGuard(opts = {}) {
|
|
|
326
358
|
standards,
|
|
327
359
|
sops,
|
|
328
360
|
mandate,
|
|
329
|
-
|
|
361
|
+
// currency/merchant/resource are signed fields too, same as action/agentDid/amount —
|
|
362
|
+
// omitting them here means a currency-scoped amount-over/cumulative-over Standards/SOP
|
|
363
|
+
// atom always sees currency as absent and fires closed. Mirrors mandate.service.ts's
|
|
364
|
+
// ruleCtx (PR #588) and the same fix in agentsafe-mcp-guard.mjs's verdictFromBundle.
|
|
365
|
+
context: applySignedLast(context, { action, agentDid, amount, currency, merchant, resource }),
|
|
330
366
|
mandateRequest: mandate
|
|
331
367
|
? {
|
|
332
368
|
target: action,
|
|
@@ -341,6 +377,9 @@ export function createGuard(opts = {}) {
|
|
|
341
377
|
// amount, since undefined never equals a real unit. Defaults to 'USD' to match
|
|
342
378
|
// the same default this file already uses for authorize()/buildSignedRequest().
|
|
343
379
|
'mm:currency': currency,
|
|
380
|
+
// Unprefixed `resource` (not `mm:resource`) to match the constraint's own
|
|
381
|
+
// leftOperand (ResourceService.scopeConstraint()) — mirrors mandate.service.ts.
|
|
382
|
+
resource,
|
|
344
383
|
}),
|
|
345
384
|
}
|
|
346
385
|
: undefined,
|
|
@@ -372,14 +411,29 @@ export function createGuard(opts = {}) {
|
|
|
372
411
|
return b;
|
|
373
412
|
}
|
|
374
413
|
|
|
375
|
-
/**
|
|
414
|
+
/**
|
|
415
|
+
* Map a fetched bundle into the shape evaluateLocally expects, for one action.
|
|
416
|
+
*
|
|
417
|
+
* `mandateFound` distinguishes "no mandate matches THIS action" from "omit mandate to
|
|
418
|
+
* skip the layer" (evaluateLocally's own documented, intentional behavior for a caller
|
|
419
|
+
* that never resolves one at all, e.g. guardToolLocal's caller-supplied bundle) — found
|
|
420
|
+
* live: this used to fall back to `mandates[0]` (an ARBITRARY, possibly unrelated
|
|
421
|
+
* mandate for a completely different action) rather than correctly reporting no
|
|
422
|
+
* authority for this one, and `authorizeLocal` never distinguished either case from a
|
|
423
|
+
* genuinely-mandate-less bundle, so an ungranted action with no Standard/SOP rule
|
|
424
|
+
* happening to also catch it was silently ALLOWED instead of NO_PERMISSION_FOR_ACTION.
|
|
425
|
+
*/
|
|
376
426
|
function _bundleFor(b, action) {
|
|
427
|
+
const mandates = b.mandates ?? [];
|
|
428
|
+
const match = mandates.find((m) => m.action === action);
|
|
377
429
|
return {
|
|
378
430
|
contained: b.contained ?? null,
|
|
379
431
|
operatingMode: b.operatingMode ?? null,
|
|
380
432
|
standards: (b.standards ?? []).map((s) => ({ standardKey: s.id ?? s.standardKey ?? 'standard', document: s.document })).filter((s) => s.document),
|
|
381
433
|
sops: (b.sops ?? []).map((s) => ({ standardKey: s.id ?? s.sopId ?? 'sop', document: s.document })).filter((s) => s.document),
|
|
382
|
-
mandate:
|
|
434
|
+
mandate: match?.document,
|
|
435
|
+
mandateFound: !!match,
|
|
436
|
+
anyMandates: mandates.length > 0,
|
|
383
437
|
};
|
|
384
438
|
}
|
|
385
439
|
|
|
@@ -470,7 +524,26 @@ export function createGuard(opts = {}) {
|
|
|
470
524
|
const sig = b?.proof?.signature;
|
|
471
525
|
if (!anchor?.sigDigest || !sig || _sha256(sig) !== anchor.sigDigest) return authorize(input);
|
|
472
526
|
}
|
|
473
|
-
const
|
|
527
|
+
const { mandateFound, anyMandates, ...bundleForAction } = _bundleFor(b, action);
|
|
528
|
+
// No mandate covers this action at all — refuse outright rather than let evaluateLocally
|
|
529
|
+
// silently allow (its documented "omit mandate to skip the layer" behavior is for a
|
|
530
|
+
// caller that never intended a mandate check, not for one that looked and found none).
|
|
531
|
+
// Standards/SOP containment still applies first — a suspended/quarantined agent is
|
|
532
|
+
// refused for THAT reason, not misreported as merely lacking this one action.
|
|
533
|
+
if (!mandateFound) {
|
|
534
|
+
const contained = bundleForAction.contained;
|
|
535
|
+
if (contained && contained.status) {
|
|
536
|
+
const decision = contained.status === 'quarantined' ? 'quarantine' : 'suspend';
|
|
537
|
+
const reasonCode = contained.status === 'quarantined' ? 'AGENT_QUARANTINED' : 'AGENT_SUSPENDED';
|
|
538
|
+
const local = { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
|
|
539
|
+
reportLocalDecision(action, local.decision, local.reasonCode);
|
|
540
|
+
return local;
|
|
541
|
+
}
|
|
542
|
+
const local = { decision: 'block', reasonCode: anyMandates ? 'NO_PERMISSION_FOR_ACTION' : 'NO_MANDATE', authorizationId: null, remaining: null, proofRef: null };
|
|
543
|
+
reportLocalDecision(action, local.decision, local.reasonCode);
|
|
544
|
+
return local;
|
|
545
|
+
}
|
|
546
|
+
const local = evaluateLocally({ ...bundleForAction, request: input });
|
|
474
547
|
// allow/observe both PERMIT; block/escalate/contain are decided locally with no network.
|
|
475
548
|
const permits = local.decision === 'allow' || local.decision === 'observe';
|
|
476
549
|
if (!permits) {
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-guard",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.4",
|
|
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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"LICENSE"
|
|
27
27
|
],
|
|
28
28
|
"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",
|
|
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
30
|
"demo": "node demo.mjs"
|
|
31
31
|
},
|
|
32
32
|
"engines": {
|
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
|
-
|
|
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 {
|