@metamynd/agentsafe-guard 0.4.5 → 0.5.1

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.
Files changed (4) hide show
  1. package/README.md +52 -0
  2. package/cli.mjs +39 -0
  3. package/package.json +5 -3
  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/cli.mjs CHANGED
@@ -7,8 +7,35 @@
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.exitCode`, never `process.exit()`. Calling exit() here aborts while the
28
+ // fetch keep-alive handle is still closing, which trips a libuv assertion on Windows
29
+ // (`!(handle->flags & UV_HANDLE_CLOSING)`) and returns 127 — AFTER printing that every
30
+ // control held. A CI command whose whole job is to fail a build honestly must not fail
31
+ // it dishonestly, and failing a PASSING agent is the one direction that teaches teams
32
+ // to delete the check. Setting the code and letting node drain exits cleanly.
33
+ process.exitCode = result.ok ? 0 : 1;
34
+ } catch (e) {
35
+ // Failing to verify is not the same as verifying a pass, and CI must not read it as one.
36
+ console.error(`verify could not run: ${e.message}`);
37
+ process.exitCode = 2;
38
+ }
12
39
  } else if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
13
40
  console.log(`
14
41
  @metamynd/agentsafe-guard — runtime governance for Node AI agents
@@ -17,6 +44,18 @@ if (cmd === 'demo') {
17
44
  npx @metamynd/agentsafe-guard demo Run the offline policy demo
18
45
  (no account, no API key, no network)
19
46
 
47
+ npx @metamynd/agentsafe-guard verify Assert this agent cannot exceed its
48
+ mandate. Exit 1 if it can. For CI.
49
+
50
+ --config <path> agent config (default ./agent.metamynd.json)
51
+ --require <a,b> fail when a control is NOT configured, e.g.
52
+ --require merchants,perTxn
53
+ --json machine-readable output
54
+
55
+ A control the mandate does not set is reported, never passed: an empty
56
+ merchant allow-list permits every merchant, and this says so rather than
57
+ calling it a pass.
58
+
20
59
  In your agent
21
60
  import { createGuard } from '@metamynd/agentsafe-guard';
22
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-guard",
3
- "version": "0.4.5",
3
+ "version": "0.5.1",
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": {
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
+ }