@metamynd/agentsafe-guard 0.4.2 → 0.5.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 +52 -0
- package/agentsafe-guard.mjs +1 -1
- package/cli.mjs +33 -0
- package/package.json +11 -4
- package/policy-core.mjs +13 -4
- package/verify.mjs +212 -0
package/README.md
CHANGED
|
@@ -46,6 +46,58 @@ it doubles as a smoke test of the installed package.
|
|
|
46
46
|
You need an account only for what can't be enforced client-side: live policy edits,
|
|
47
47
|
cumulative spend caps, human escalation, and anchored evidence.
|
|
48
48
|
|
|
49
|
+
## Governance as a build step
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx @metamynd/agentsafe-guard verify
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Asserts your agent **cannot** exceed its mandate, and exits non-zero if it can. Put it in
|
|
56
|
+
CI and a pull request that widens an agent's authority fails the build, which is a control;
|
|
57
|
+
a dashboard that would have shown you is not.
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
ok permits ordinary in-scope work
|
|
61
|
+
ok refuses an action the mandate never granted → block/NO_PERMISSION_FOR_ACTION
|
|
62
|
+
ok refuses 501 against a cap of 500 → block/SOP_SPEND_CAP
|
|
63
|
+
n/a no merchant allow-list in this mandate
|
|
64
|
+
EVERY merchant is permitted
|
|
65
|
+
|
|
66
|
+
1 control(s) are not configured — reported, not passed.
|
|
67
|
+
Fail the build on these with: verify --require merchants
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**A control the mandate does not set is reported, never passed.** That distinction is the
|
|
71
|
+
entire point, and it exists because the alternative shipped: our own public sandbox was
|
|
72
|
+
issued with an empty merchant list, an unapproved supplier was paid $250, and
|
|
73
|
+
`MERCHANT_NOT_ALLOWED` sat documented in the example's own glossary the whole time. A green
|
|
74
|
+
check on a control that does not exist is worse than no check.
|
|
75
|
+
|
|
76
|
+
`--require merchants,perTxn,cumulative` promotes "not configured" to a build failure — how
|
|
77
|
+
you state *our agents must carry a merchant allow-list* and find out when one does not.
|
|
78
|
+
`--json` for machine-readable output. Evaluation is local and pure: no holds are minted, no
|
|
79
|
+
nonces spent, no budget consumed, and a run costs one GET.
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
# .github/workflows/governance.yml
|
|
83
|
+
name: Governance
|
|
84
|
+
on: [push, pull_request]
|
|
85
|
+
|
|
86
|
+
jobs:
|
|
87
|
+
mandate:
|
|
88
|
+
runs-on: ubuntu-latest
|
|
89
|
+
steps:
|
|
90
|
+
- uses: actions/checkout@v5
|
|
91
|
+
- uses: actions/setup-node@v5
|
|
92
|
+
with:
|
|
93
|
+
node-version: '20'
|
|
94
|
+
- run: npx @metamynd/agentsafe-guard verify --require merchants,perTxn
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`agent.metamynd.json` holds no secret beyond the agent key, so commit a key-less config and
|
|
98
|
+
set `AGENT_KEY` in the environment — `AGENT_KEY`, `AGENT_DID` and `METAMYND_API` all
|
|
99
|
+
override the file when present.
|
|
100
|
+
|
|
49
101
|
## Install
|
|
50
102
|
|
|
51
103
|
```bash
|
package/agentsafe-guard.mjs
CHANGED
|
@@ -491,7 +491,7 @@ export function createGuard(opts = {}) {
|
|
|
491
491
|
return {
|
|
492
492
|
hello() {
|
|
493
493
|
const nonceA = crypto.randomUUID();
|
|
494
|
-
return { nonceA, message: { fromDid: agentDid, nonceA, protoVersion: '0
|
|
494
|
+
return { nonceA, message: { fromDid: agentDid, nonceA, protoVersion: '1.0' } };
|
|
495
495
|
},
|
|
496
496
|
prove({ nonceA, challenge } = {}) {
|
|
497
497
|
const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
|
package/cli.mjs
CHANGED
|
@@ -7,8 +7,29 @@
|
|
|
7
7
|
// Kept deliberately thin: no argument parser, no dependencies, no network.
|
|
8
8
|
const [, , cmd] = process.argv;
|
|
9
9
|
|
|
10
|
+
const flag = (name) => {
|
|
11
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
12
|
+
return i === -1 ? null : (process.argv[i + 1] ?? '');
|
|
13
|
+
};
|
|
14
|
+
|
|
10
15
|
if (cmd === 'demo') {
|
|
11
16
|
await import('./demo.mjs');
|
|
17
|
+
} else if (cmd === 'verify') {
|
|
18
|
+
// Governance as a build step. Exits non-zero when a configured control does not hold,
|
|
19
|
+
// or when a control named by --require is not configured at all.
|
|
20
|
+
const { verify } = await import('./verify.mjs');
|
|
21
|
+
try {
|
|
22
|
+
const result = await verify({
|
|
23
|
+
configPath: flag('config') ?? './agent.metamynd.json',
|
|
24
|
+
require: (flag('require') ?? '').split(',').map((s) => s.trim()).filter(Boolean),
|
|
25
|
+
json: process.argv.includes('--json'),
|
|
26
|
+
});
|
|
27
|
+
process.exit(result.ok ? 0 : 1);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
// Failing to verify is not the same as verifying a pass, and CI must not read it as one.
|
|
30
|
+
console.error(`verify could not run: ${e.message}`);
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
12
33
|
} else if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
13
34
|
console.log(`
|
|
14
35
|
@metamynd/agentsafe-guard — runtime governance for Node AI agents
|
|
@@ -17,6 +38,18 @@ if (cmd === 'demo') {
|
|
|
17
38
|
npx @metamynd/agentsafe-guard demo Run the offline policy demo
|
|
18
39
|
(no account, no API key, no network)
|
|
19
40
|
|
|
41
|
+
npx @metamynd/agentsafe-guard verify Assert this agent cannot exceed its
|
|
42
|
+
mandate. Exit 1 if it can. For CI.
|
|
43
|
+
|
|
44
|
+
--config <path> agent config (default ./agent.metamynd.json)
|
|
45
|
+
--require <a,b> fail when a control is NOT configured, e.g.
|
|
46
|
+
--require merchants,perTxn
|
|
47
|
+
--json machine-readable output
|
|
48
|
+
|
|
49
|
+
A control the mandate does not set is reported, never passed: an empty
|
|
50
|
+
merchant allow-list permits every merchant, and this says so rather than
|
|
51
|
+
calling it a pass.
|
|
52
|
+
|
|
20
53
|
In your agent
|
|
21
54
|
import { createGuard } from '@metamynd/agentsafe-guard';
|
|
22
55
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./agentsafe-guard.mjs",
|
|
10
10
|
"./policy-core": "./policy-core.mjs",
|
|
11
|
-
"./package.json": "./package.json"
|
|
11
|
+
"./package.json": "./package.json",
|
|
12
|
+
"./verify": "./verify.mjs"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"demo.mjs",
|
|
15
16
|
"cli.mjs",
|
|
17
|
+
"verify.mjs",
|
|
16
18
|
"agentsafe-guard.mjs",
|
|
17
19
|
"policy-core.mjs",
|
|
18
20
|
"magp-did.mjs",
|
|
@@ -22,7 +24,7 @@
|
|
|
22
24
|
"LICENSE"
|
|
23
25
|
],
|
|
24
26
|
"scripts": {
|
|
25
|
-
"test": "node demo.mjs && node local-eval.smoke.mjs && node execution-adapter.smoke.mjs",
|
|
27
|
+
"test": "node demo.mjs && node local-eval.smoke.mjs && node execution-adapter.smoke.mjs && node verify.smoke.mjs",
|
|
26
28
|
"demo": "node demo.mjs"
|
|
27
29
|
},
|
|
28
30
|
"engines": {
|
|
@@ -48,8 +50,13 @@
|
|
|
48
50
|
"author": "MetaMynd",
|
|
49
51
|
"license": "MIT",
|
|
50
52
|
"homepage": "https://metamynd.ai",
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/Metamynd/agentsafe-guard.git",
|
|
56
|
+
"directory": "packages/agentsafe-guard"
|
|
57
|
+
},
|
|
51
58
|
"bugs": {
|
|
52
|
-
"url": "https://
|
|
59
|
+
"url": "https://github.com/Metamynd/agentsafe-guard/issues"
|
|
53
60
|
},
|
|
54
61
|
"publishConfig": {
|
|
55
62
|
"access": "public"
|
package/policy-core.mjs
CHANGED
|
@@ -334,6 +334,13 @@ function constraintSatisfied(c, req) {
|
|
|
334
334
|
function targetOf(rule, mandate) {
|
|
335
335
|
return rule.target ?? mandate.target;
|
|
336
336
|
}
|
|
337
|
+
function isAuthorityFailure(result) {
|
|
338
|
+
return result.matched?.kind === "expiry" || result.matched?.kind === "no-permission";
|
|
339
|
+
}
|
|
340
|
+
function authorityFailure(mandate, target, now) {
|
|
341
|
+
const result = evaluateMandate(mandate, { target, now, values: {} });
|
|
342
|
+
return isAuthorityFailure(result) ? { ...result, decision: "block" } : null;
|
|
343
|
+
}
|
|
337
344
|
function evaluateMandate(mandate, req) {
|
|
338
345
|
const now = toTime(req.now);
|
|
339
346
|
if (mandate.validFrom && now < toTime(mandate.validFrom)) {
|
|
@@ -402,14 +409,14 @@ function evaluate(input) {
|
|
|
402
409
|
reasonCode = code;
|
|
403
410
|
}
|
|
404
411
|
};
|
|
412
|
+
const m = input.mandate && input.mandateRequest ? evaluateMandate(input.mandate, input.mandateRequest) : null;
|
|
413
|
+
const authority = m !== null && isAuthorityFailure(m);
|
|
414
|
+
if (m && authority) consider(m.decision, m.reasonCode);
|
|
405
415
|
const std = evaluateBoundStandards(input.standards ?? [], input.context);
|
|
406
416
|
if (std.decision !== "allow") consider(std.decision, std.reasonCode ?? "STANDARD_RULE");
|
|
407
417
|
const sop = evaluateBoundStandards(input.sops ?? [], input.context);
|
|
408
418
|
if (sop.decision !== "allow") consider(sop.decision, sop.reasonCode ?? "SOP_RULE");
|
|
409
|
-
if (
|
|
410
|
-
const m = evaluateMandate(input.mandate, input.mandateRequest);
|
|
411
|
-
if (m.decision !== "allow") consider(m.decision, m.reasonCode);
|
|
412
|
-
}
|
|
419
|
+
if (m && !authority && m.decision !== "allow") consider(m.decision, m.reasonCode);
|
|
413
420
|
return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
|
|
414
421
|
}
|
|
415
422
|
|
|
@@ -472,12 +479,14 @@ export {
|
|
|
472
479
|
applyHold,
|
|
473
480
|
applySignedLast,
|
|
474
481
|
asOperatingMode,
|
|
482
|
+
authorityFailure,
|
|
475
483
|
buildAuthMessage,
|
|
476
484
|
canAuthorize,
|
|
477
485
|
evaluate,
|
|
478
486
|
evaluateBoundStandards,
|
|
479
487
|
evaluateMandate,
|
|
480
488
|
evaluateStandardRules,
|
|
489
|
+
isAuthorityFailure,
|
|
481
490
|
isOperatingMode,
|
|
482
491
|
moleculeFires,
|
|
483
492
|
moreRestrictive,
|
package/verify.mjs
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// agentsafe-guard verify — assert an agent cannot exceed its mandate, in CI.
|
|
2
|
+
//
|
|
3
|
+
// npx @metamynd/agentsafe-guard verify
|
|
4
|
+
//
|
|
5
|
+
// The point is to turn governance into a repo artifact rather than a dashboard someone
|
|
6
|
+
// visits. A build that fails when an agent CAN do something it should not is a control;
|
|
7
|
+
// a screen that would have shown you is not.
|
|
8
|
+
//
|
|
9
|
+
// The design decision that matters is the one this package learned the hard way. A
|
|
10
|
+
// conversion test paid an unapproved supplier $250 through the public sandbox and it went
|
|
11
|
+
// through, because the sandbox mandate was issued with `merchants: []` — and an empty
|
|
12
|
+
// allow-list is not an allow-list with nothing on it, it is NO allow-list. Meanwhile
|
|
13
|
+
// MERCHANT_NOT_ALLOWED sat documented in the example's own glossary of reason codes.
|
|
14
|
+
// Nothing caught it because nothing looked.
|
|
15
|
+
//
|
|
16
|
+
// So this never reports a control as holding when the control does not exist. Every check
|
|
17
|
+
// resolves to one of three states, and only the first is a pass:
|
|
18
|
+
//
|
|
19
|
+
// HELD the control is configured, and it refused the violation
|
|
20
|
+
// NOT CONFIGURED the mandate says nothing about it — reported, never passed
|
|
21
|
+
// FAILED the control is configured and it did NOT refuse. Exit 1.
|
|
22
|
+
//
|
|
23
|
+
// `--require <control,...>` promotes NOT CONFIGURED to a build failure, which is how a
|
|
24
|
+
// team states "our agents must carry a merchant allow-list" and finds out when one does not.
|
|
25
|
+
//
|
|
26
|
+
// Evaluation is LOCAL and pure: the same policy-core the gate runs, over the agent's signed
|
|
27
|
+
// bundle. No holds are minted, no nonces are spent, no budget is consumed, and a CI run
|
|
28
|
+
// costs one GET. Verifying an agent must not be an action the agent takes.
|
|
29
|
+
|
|
30
|
+
import { createGuardFromConfig } from './agentsafe-guard.mjs';
|
|
31
|
+
|
|
32
|
+
const CONTROLS = {
|
|
33
|
+
scope: {
|
|
34
|
+
label: 'action scope',
|
|
35
|
+
detail: 'the set of actions this mandate grants at all',
|
|
36
|
+
/** Always present: every mandate grants SOMETHING, so there is always a beyond. */
|
|
37
|
+
configured: () => ({ configured: true, summary: 'default-deny' }),
|
|
38
|
+
},
|
|
39
|
+
perTxn: {
|
|
40
|
+
label: 'per-transaction cap',
|
|
41
|
+
detail: 'the ceiling on any single action',
|
|
42
|
+
configured: (c) => {
|
|
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 };
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
cumulative: {
|
|
48
|
+
label: 'cumulative cap',
|
|
49
|
+
detail: 'the ceiling on total spend across actions',
|
|
50
|
+
configured: (c) => {
|
|
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 };
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
merchants: {
|
|
56
|
+
label: 'merchant allow-list',
|
|
57
|
+
detail: 'which counterparties this agent may pay',
|
|
58
|
+
configured: (c) => {
|
|
59
|
+
const k = c.find((x) => x.leftOperand === 'mm:merchant' && x.operator === 'isAnyOf');
|
|
60
|
+
if (!k) {
|
|
61
|
+
// The shape that bit us. `issueMandate` OMITS the constraint when the list is
|
|
62
|
+
// empty, so the mandate says nothing about merchants and every merchant is
|
|
63
|
+
// permitted. Absent, and it must never read as a pass.
|
|
64
|
+
return { configured: false };
|
|
65
|
+
}
|
|
66
|
+
const list = Array.isArray(k.rightOperand) ? k.rightOperand : [];
|
|
67
|
+
// A PRESENT constraint over an empty list is the opposite of vacuous, which is worth
|
|
68
|
+
// stating because the intuition runs the other way: `[].includes(x)` is always false,
|
|
69
|
+
// so the permission never grants and the agent may pay NOBODY. That is configured —
|
|
70
|
+
// pathologically — and the baseline check below is what surfaces it, by failing.
|
|
71
|
+
return list.length
|
|
72
|
+
? { configured: true, summary: list.join(', '), list }
|
|
73
|
+
: { configured: true, summary: 'EMPTY — this permits no merchant at all', list: [], empty: true };
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const PASS = 'held';
|
|
79
|
+
const ABSENT = 'not-configured';
|
|
80
|
+
const FAIL = 'failed';
|
|
81
|
+
|
|
82
|
+
/** Map a raw bundle into the shape evaluateLocally expects, for one action. */
|
|
83
|
+
function packsFor(bundle, action) {
|
|
84
|
+
return {
|
|
85
|
+
contained: bundle.contained ?? null,
|
|
86
|
+
operatingMode: bundle.operatingMode ?? null,
|
|
87
|
+
standards: (bundle.standards ?? []).map((s) => ({ standardKey: s.key ?? s.id ?? 'standard', document: s.document })).filter((s) => s.document),
|
|
88
|
+
sops: (bundle.sops ?? []).map((s) => ({ standardKey: s.id ?? 'sop', document: s.document })).filter((s) => s.document),
|
|
89
|
+
mandate: ((bundle.mandates ?? []).find((m) => m.action === action) ?? (bundle.mandates ?? [])[0])?.document,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const permits = (v) => v.decision === 'allow' || v.decision === 'observe';
|
|
94
|
+
|
|
95
|
+
export async function verify({ configPath = './agent.metamynd.json', require: required = [], json = false, log = console.log, env = process.env } = {}) {
|
|
96
|
+
// AGENT_KEY / AGENT_DID / METAMYND_API from the environment win over the config file.
|
|
97
|
+
// CI is the whole point of this command, and a CI story that requires committing the
|
|
98
|
+
// agent's signing key to the repository is not one — so a key-less config plus a secret
|
|
99
|
+
// has to work.
|
|
100
|
+
const overrides = {
|
|
101
|
+
...(env.AGENT_KEY ? { agentKey: env.AGENT_KEY } : {}),
|
|
102
|
+
...(env.AGENT_DID ? { agentDid: env.AGENT_DID } : {}),
|
|
103
|
+
...(env.METAMYND_API ? { api: env.METAMYND_API } : {}),
|
|
104
|
+
};
|
|
105
|
+
const guard = await createGuardFromConfig(configPath, overrides);
|
|
106
|
+
const bundle = await guard.loadBundle();
|
|
107
|
+
|
|
108
|
+
const mandates = bundle.mandates ?? [];
|
|
109
|
+
if (!mandates.length) throw new Error('This agent has no mandate in its policy bundle — there is nothing to verify.');
|
|
110
|
+
|
|
111
|
+
const action = mandates[0].action;
|
|
112
|
+
const packs = packsFor(bundle, action);
|
|
113
|
+
const constraints = (packs.mandate?.permission ?? []).flatMap((p) => p.constraint ?? []);
|
|
114
|
+
const evaluate = (request) => guard.evaluateLocally({ ...packs, request });
|
|
115
|
+
|
|
116
|
+
const found = {};
|
|
117
|
+
for (const [key, spec] of Object.entries(CONTROLS)) found[key] = spec.configured(constraints);
|
|
118
|
+
|
|
119
|
+
const checks = [];
|
|
120
|
+
const add = (control, status, assertion, verdict, note) =>
|
|
121
|
+
checks.push({ control, status, assertion, decision: verdict?.decision ?? null, reasonCode: verdict?.reasonCode ?? null, note });
|
|
122
|
+
|
|
123
|
+
// 1. Ordinary work still runs. A governed agent that cannot do its job is not governed,
|
|
124
|
+
// it is broken — and every refusal below means nothing if this one does not pass.
|
|
125
|
+
{
|
|
126
|
+
const amount = found.perTxn.configured ? Math.max(1, Math.floor(found.perTxn.limit / 2)) : 1;
|
|
127
|
+
const merchant = found.merchants.list?.length ? found.merchants.list[0] : 'any-merchant';
|
|
128
|
+
const v = evaluate({ action, amount, merchant, context: { riskLevel: 'low' } });
|
|
129
|
+
add('baseline', permits(v) ? PASS : FAIL, 'permits ordinary in-scope work', v,
|
|
130
|
+
permits(v) ? null : 'the agent cannot perform the action it was issued for');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 2. Scope. Needs no configuration and no special anti-self-escalation rule: an agent
|
|
134
|
+
// cannot name an action nobody delegated to it.
|
|
135
|
+
{
|
|
136
|
+
const v = evaluate({ action: 'permissions.update', amount: 100000, merchant: 'any-merchant', context: {} });
|
|
137
|
+
add('scope', permits(v) ? FAIL : PASS, 'refuses an action the mandate never granted', v,
|
|
138
|
+
permits(v) ? 'THIS AGENT CAN ACT OUTSIDE ITS MANDATE' : null);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 3–5. Only assert a limit the mandate actually sets. Asserting an absent control is how
|
|
142
|
+
// you end up believing in one.
|
|
143
|
+
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: {} });
|
|
145
|
+
add('perTxn', permits(v) ? FAIL : PASS, `refuses ${found.perTxn.limit + 1} against a cap of ${found.perTxn.limit}`, v,
|
|
146
|
+
permits(v) ? 'the per-transaction cap did not hold' : null);
|
|
147
|
+
} else {
|
|
148
|
+
add('perTxn', ABSENT, 'no per-transaction cap in this mandate', null, 'any single amount is permitted');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (found.cumulative.configured) {
|
|
152
|
+
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: {},
|
|
155
|
+
});
|
|
156
|
+
add('cumulative', permits(v) ? FAIL : PASS, `refuses spending past a total of ${found.cumulative.limit}`, v,
|
|
157
|
+
permits(v) ? 'the cumulative cap did not hold' : null);
|
|
158
|
+
} else {
|
|
159
|
+
add('cumulative', ABSENT, 'no cumulative cap in this mandate', null, 'total spend is unbounded');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (found.merchants.empty) {
|
|
163
|
+
add('merchants', FAIL, 'merchant allow-list is present but EMPTY', null,
|
|
164
|
+
'this permits no merchant at all — the agent cannot transact with anyone');
|
|
165
|
+
} else if (found.merchants.configured) {
|
|
166
|
+
// Deliberately UNDER any cap: a refusal at an amount that also trips a spend limit
|
|
167
|
+
// proves nothing about merchants, which is exactly how this went unnoticed before.
|
|
168
|
+
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: {} });
|
|
170
|
+
add('merchants', permits(v) ? FAIL : PASS, 'refuses an unlisted merchant, under the cap', v,
|
|
171
|
+
permits(v) ? 'the merchant allow-list did not hold' : null);
|
|
172
|
+
} else {
|
|
173
|
+
add('merchants', ABSENT, 'no merchant allow-list in this mandate', null, 'EVERY merchant is permitted');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const failed = checks.filter((c) => c.status === FAIL);
|
|
177
|
+
const absent = checks.filter((c) => c.status === ABSENT);
|
|
178
|
+
const requiredMissing = absent.filter((c) => required.includes(c.control));
|
|
179
|
+
const ok = failed.length === 0 && requiredMissing.length === 0;
|
|
180
|
+
|
|
181
|
+
if (json) {
|
|
182
|
+
log(JSON.stringify({ ok, agent: guard.agentDid, action, checks, required }, null, 2));
|
|
183
|
+
return { ok, checks, failed, absent, requiredMissing };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const mark = { [PASS]: ' ok ', [FAIL]: ' FAIL ', [ABSENT]: ' n/a ' };
|
|
187
|
+
log('');
|
|
188
|
+
log(` agentsafe verify — ${guard.agentDid}`);
|
|
189
|
+
log(` scope: ${action}`);
|
|
190
|
+
log('');
|
|
191
|
+
for (const c of checks) {
|
|
192
|
+
log(`${mark[c.status]} ${c.assertion}${c.reasonCode ? ` → ${c.decision}/${c.reasonCode}` : ''}`);
|
|
193
|
+
if (c.note) log(` ${c.note}`);
|
|
194
|
+
}
|
|
195
|
+
log('');
|
|
196
|
+
if (failed.length) {
|
|
197
|
+
log(` ${failed.length} control(s) did NOT hold. This agent can exceed its mandate.`);
|
|
198
|
+
}
|
|
199
|
+
if (absent.length) {
|
|
200
|
+
log(` ${absent.length} control(s) are not configured — reported, not passed.`);
|
|
201
|
+
if (!requiredMissing.length) {
|
|
202
|
+
log(` Fail the build on these with: verify --require ${absent.map((c) => c.control).join(',')}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (requiredMissing.length) {
|
|
206
|
+
log(` ${requiredMissing.length} REQUIRED control(s) are missing: ${requiredMissing.map((c) => c.control).join(', ')}`);
|
|
207
|
+
}
|
|
208
|
+
if (ok) log(' Every configured control held, and nothing required is missing.');
|
|
209
|
+
log('');
|
|
210
|
+
|
|
211
|
+
return { ok, checks, failed, absent, requiredMissing };
|
|
212
|
+
}
|