@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.
- package/agentsafe-guard.mjs +35 -3
- 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;
|
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 {
|