@metamynd/agentsafe-guard 0.12.1 → 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.
@@ -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() : JSON.parse(readFileSync(source, 'utf8'));
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
- try { cfg = JSON.parse(readFileSync(opts.configPath, 'utf8')); }
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;
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.1",
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
- 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 {