@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 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
@@ -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.4' } };
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.4.2",
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://metamynd.ai/en/support/contact"
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 (input.mandate && input.mandateRequest) {
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
+ }