@metamynd/agentsafe-guard 0.12.1 → 0.14.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 +39 -0
- package/agentsafe-guard.mjs +86 -12
- package/cli.mjs +15 -0
- package/key-providers.mjs +13 -0
- package/package.json +3 -2
- package/payload-binding.mjs +110 -0
- package/policy-core.mjs +205 -9
- package/verify.mjs +22 -8
package/README.md
CHANGED
|
@@ -153,6 +153,45 @@ the guard — see "Passphrase-encrypted managed key" above. New export from `key
|
|
|
153
153
|
backend's `encryptWithPassword`/`decryptWithPassword`, cross-verified against it). Fully
|
|
154
154
|
additive: a config without `agentKeyEncrypted` is loaded exactly as before, no passphrase needed.
|
|
155
155
|
|
|
156
|
+
**0.14.0 — sign the WHOLE payload, not just eight fields (MAGP §8.3.9).** The signed authorize message covers the
|
|
157
|
+
agent, action, amount, currency, merchant and resource — not a payee, an account number or a passenger list. Pass
|
|
158
|
+
`payload` to `authorize()` / `buildSignedRequest()` and the guard also signs a digest of it (RFC 8785 canonical JSON,
|
|
159
|
+
`sha256:`), bound to that one authorization:
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
await guard.authorize({ action: 'wire', amount: 250, currency: 'USD', merchant: 'skyward-air',
|
|
163
|
+
payload: { payee: { iban: 'NL91ABNA0417164300' }, reference: 'INV-1042' } });
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The gate stores the digest with the hold, and the service that executes it (an `agentsafe-mcp-guard` with
|
|
167
|
+
`bindPayload`, or an `agentsafe-http-gateway`, both 0.11.0) must present the digest of what it is about to run: a claim
|
|
168
|
+
with a different payload is refused and the hold stays claimable. Pass the payload exactly as the service will receive
|
|
169
|
+
it as JSON. A payload JSON cannot carry (`NaN`, a function, a lone surrogate) or a `keyProvider` that cannot sign a
|
|
170
|
+
binding (the signer daemon, for now) **blocks** with `PAYLOAD_NOT_CANONICALIZABLE` / `PAYLOAD_BINDING_UNSUPPORTED` —
|
|
171
|
+
it is never sent unbound. Off unless you pass `payload`; the wire body is otherwise unchanged. Also from `guardTool`: have `mapArgs`
|
|
172
|
+
return `payload`. The gate echoes the digest it stored, and an agent that sent one **refuses a permit that does not echo
|
|
173
|
+
it** (`PAYLOAD_BINDING_NOT_CONFIRMED`, releasing the hold) — so a proxy that strips the fields, or a backend that predates
|
|
174
|
+
MAGP §8.3.9, is a loud failure, not a silently unbound request.
|
|
175
|
+
|
|
176
|
+
**0.13.0 — a risk rule can no longer be skipped by staying silent about risk (D-03).** The rule layer
|
|
177
|
+
now labels every context field with where it came from (`agent_asserted`, `agent_signed`,
|
|
178
|
+
`gateway_derived`, `authoritative`, `attested`; MAGP §6.3) and judges `riskLevel` accordingly:
|
|
179
|
+
|
|
180
|
+
- **A missing or unrecognised `riskLevel` escalates** (`CONTEXT_UNVERIFIABLE`) for any rule that uses the
|
|
181
|
+
risk atom, instead of reading as "not risky". `"HIGH"` and `" high "` are read as `high`. **If your requests
|
|
182
|
+
do not send a `riskLevel`, they will now escalate** — send one (`context: { riskLevel: 'low' }`), or have
|
|
183
|
+
your mandate's owner set a `riskTier` (below) so the risk does not depend on you.
|
|
184
|
+
- **`riskTier` on a mandate permission** is the owner's classification of the action: a floor the agent's own
|
|
185
|
+
claim can never lower (the effective risk is the *maximum* of the tier and the claim — an agent may raise
|
|
186
|
+
its risk, never lower it). It travels in the signed mandate, so `evaluateLocally` and the issuer's gate agree.
|
|
187
|
+
- **`requireProvenance`** on a rule (`{ riskLevel: 'authoritative' }`) makes it demand a trusted source: the
|
|
188
|
+
agent's own honest "low" is then not enough.
|
|
189
|
+
- `evaluateLocally` builds its context the same way, and judges the SUPERVISED-mode high-risk escalation on
|
|
190
|
+
the effective risk.
|
|
191
|
+
|
|
192
|
+
The claim itself is still the agent's word when no owner tier, gateway derivation or `requireProvenance`
|
|
193
|
+
exists: this closes hiding and garbling risk, and gives owners the means to close understating it.
|
|
194
|
+
|
|
156
195
|
```yaml
|
|
157
196
|
# .github/workflows/governance.yml
|
|
158
197
|
name: Governance
|
package/agentsafe-guard.mjs
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
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 {
|
|
13
|
+
import { resolve as resolvePath } from 'node:path';
|
|
14
|
+
import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate, buildRuleContext, riskFloorFor, maxRisk, normalizeRiskLevel } from './policy-core.mjs';
|
|
14
15
|
import { envelopeHashFor } from './governance-envelope.mjs';
|
|
16
|
+
import { payloadDigestOf, toWireJson } from './payload-binding.mjs';
|
|
15
17
|
import { verifyDidSignature } from './magp-did.mjs';
|
|
16
18
|
import { checkSettlementBinding } from './x402.mjs';
|
|
17
19
|
import { resolveKeyProvider, decryptAgentKeyWithPassword } from './key-providers.mjs';
|
|
@@ -84,6 +86,38 @@ export function executionAdapterFromEnv(env = (typeof process !== 'undefined' ?
|
|
|
84
86
|
* agentDid the agent's did:hedera
|
|
85
87
|
* agentKey the agent's Ed25519 private key (Hedera DER hex, held only by the agent)
|
|
86
88
|
*/
|
|
89
|
+
/**
|
|
90
|
+
* Read + parse an agent config file, failing with the NEXT STEP rather than a bare `ENOENT`.
|
|
91
|
+
* `agent.metamynd.json` holds the agent's identity (and, for a managed key, its secret), so it is
|
|
92
|
+
* deliberately gitignored — which means a fresh clone of any agent project can never contain it.
|
|
93
|
+
* A raw "no such file or directory" left a first-time user with no way forward (beta regression
|
|
94
|
+
* 2026-09-20, BR-004); the message below names the three ways to get the file.
|
|
95
|
+
*/
|
|
96
|
+
function readConfigFile(path, who) {
|
|
97
|
+
let text;
|
|
98
|
+
try {
|
|
99
|
+
text = readFileSync(path, 'utf8');
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (e && e.code === 'ENOENT') {
|
|
102
|
+
throw new Error([
|
|
103
|
+
`${who}: no agent config at "${resolvePath(path)}".`,
|
|
104
|
+
` agent.metamynd.json holds the agent's identity and key, so it is gitignored - a fresh clone never has it.`,
|
|
105
|
+
` To get one:`,
|
|
106
|
+
` 1. New agent: npx create-metamynd-agent (creates the agent and writes this file)`,
|
|
107
|
+
` 2. Existing agent: dashboard -> Agents -> your agent -> download its configuration, save it as ${path}`,
|
|
108
|
+
` 3. Kept elsewhere: pass its real path, e.g. createGuardFromConfig('/path/to/agent.metamynd.json')`,
|
|
109
|
+
` Then re-run. Step-by-step: https://metamynd.ai/developers/quickstart`,
|
|
110
|
+
].join('\n'));
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`${who}: cannot read agent config "${path}": ${e && e.message}`);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(text);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
throw new Error(`${who}: "${path}" is not valid JSON (${e.message}). Re-download the configuration rather than editing it by hand.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
87
121
|
/**
|
|
88
122
|
* Async loader — build a guard from the portable config the one-call `POST /onboarding/agent`
|
|
89
123
|
* endpoint returns: a URL, a file path, or the config object itself. Overrides win over the config.
|
|
@@ -100,7 +134,7 @@ export function executionAdapterFromEnv(env = (typeof process !== 'undefined' ?
|
|
|
100
134
|
export async function createGuardFromConfig(source, overrides = {}) {
|
|
101
135
|
let cfg = source;
|
|
102
136
|
if (typeof source === 'string') {
|
|
103
|
-
cfg = /^https?:\/\//.test(source) ? await (await fetch(source)).json() :
|
|
137
|
+
cfg = /^https?:\/\//.test(source) ? await (await fetch(source)).json() : readConfigFile(source, 'createGuardFromConfig');
|
|
104
138
|
}
|
|
105
139
|
if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
|
|
106
140
|
const { passphrase, ...rest } = overrides;
|
|
@@ -119,8 +153,7 @@ export function createGuard(opts = {}) {
|
|
|
119
153
|
// addition to explicit { api, agentDid, agentKey }. Explicit fields win over the config.
|
|
120
154
|
let cfg = opts.config ?? null;
|
|
121
155
|
if (!cfg && opts.configPath) {
|
|
122
|
-
|
|
123
|
-
catch (e) { throw new Error(`createGuard: cannot read configPath "${opts.configPath}": ${e.message}`); }
|
|
156
|
+
cfg = readConfigFile(opts.configPath, 'createGuard');
|
|
124
157
|
}
|
|
125
158
|
if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
|
|
126
159
|
const api = opts.api ?? cfg?.apiBase ?? cfg?.api;
|
|
@@ -150,6 +183,27 @@ export function createGuard(opts = {}) {
|
|
|
150
183
|
return keyProvider.signEnvelope({ agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt });
|
|
151
184
|
}
|
|
152
185
|
|
|
186
|
+
// Payload binding (spec §8.3.9): sign a digest of the COMPLETE payload the caller will execute, bound to THIS authorization
|
|
187
|
+
// (agent, action, nonce, issuedAt). The eight signed fields cover amount/merchant/resource only; everything else a tool takes
|
|
188
|
+
// (a payee, an account number) is otherwise unbound, and this is what binds it. `payload` is whatever the executing
|
|
189
|
+
// counterparty will receive — the JSON body a gateway forwards, or the arguments an MCP tool is called with — normalised to
|
|
190
|
+
// the JSON it would be on the wire. Fails CLOSED: if a binding was asked for and cannot be produced (a payload JSON cannot
|
|
191
|
+
// carry, or a key provider that cannot sign one), this throws — it never quietly sends the request unbound.
|
|
192
|
+
async function payloadBindingFor({ action, nonce, issuedAt, payload }) {
|
|
193
|
+
if (payload === undefined) return {};
|
|
194
|
+
let payloadDigest;
|
|
195
|
+
try {
|
|
196
|
+
payloadDigest = payloadDigestOf(toWireJson(payload));
|
|
197
|
+
} catch (err) {
|
|
198
|
+
throw Object.assign(new Error(`payload cannot be bound: ${err?.message ?? err}`), { code: 'PAYLOAD_NOT_CANONICALIZABLE' });
|
|
199
|
+
}
|
|
200
|
+
if (typeof keyProvider.signPayloadBinding !== 'function') {
|
|
201
|
+
throw Object.assign(new Error('this keyProvider cannot sign a payload binding (signPayloadBinding)'), { code: 'PAYLOAD_BINDING_UNSUPPORTED' });
|
|
202
|
+
}
|
|
203
|
+
const payloadSignature = await keyProvider.signPayloadBinding({ agentDid, action, nonce, issuedAt, payloadDigest });
|
|
204
|
+
return { payloadDigest, payloadSignature };
|
|
205
|
+
}
|
|
206
|
+
|
|
153
207
|
// --- Enforcement mode (spec §9.2 + local-first plan) --------------------------------------
|
|
154
208
|
// 'local' (DEFAULT): decide the rule layer LOCALLY against a cached signed bundle — a
|
|
155
209
|
// block/escalate needs no network; an allowed VALUE action is still sealed by the remote
|
|
@@ -182,7 +236,7 @@ export function createGuard(opts = {}) {
|
|
|
182
236
|
* agent's authorization trustlessly against the agent's policy bundle (§9.3). Same shape
|
|
183
237
|
* `authorize()` posts to the gate; a fresh nonce each call.
|
|
184
238
|
*/
|
|
185
|
-
async function buildSignedRequest({ action, amount, currency, merchant, resource, context = {}, trace, materiality }) {
|
|
239
|
+
async function buildSignedRequest({ action, amount, currency, merchant, resource, context = {}, trace, materiality, payload }) {
|
|
186
240
|
const nonce = crypto.randomUUID();
|
|
187
241
|
const issuedAt = new Date().toISOString();
|
|
188
242
|
// This object is presented to a COUNTERPARTY (spec §9.3) — but its own docstring also
|
|
@@ -211,11 +265,12 @@ export function createGuard(opts = {}) {
|
|
|
211
265
|
// action fields don't include it yet either (only amount/currency/merchant) — adding it
|
|
212
266
|
// to just one side would break Tier-1 envelope-hash verification for any resource-
|
|
213
267
|
// declaring request. A coordinated backend+guard follow-up, not something to do half here.
|
|
214
|
-
const [signature, envelopeSignature] = await Promise.all([
|
|
268
|
+
const [signature, envelopeSignature, binding] = await Promise.all([
|
|
215
269
|
keyProvider.signAuthorize({ agentDid, action, amount: signedAmount, currency: signedCurrency, merchant, resource, nonce, issuedAt }),
|
|
216
270
|
envelopeSignatureFor({ action, amount: wireAmount, currency: wireCurrency, merchant, context, trace, materiality, nonce, issuedAt }),
|
|
271
|
+
payloadBindingFor({ action, nonce, issuedAt, payload }),
|
|
217
272
|
]);
|
|
218
|
-
return { agentDid, action, amount: wireAmount, currency: wireCurrency, merchant, resource, itinerary: context, trace, materiality, nonce, issuedAt, signature, envelopeSignature };
|
|
273
|
+
return { agentDid, action, amount: wireAmount, currency: wireCurrency, merchant, resource, itinerary: context, trace, materiality, nonce, issuedAt, signature, envelopeSignature, ...binding };
|
|
219
274
|
}
|
|
220
275
|
|
|
221
276
|
/**
|
|
@@ -223,7 +278,7 @@ export function createGuard(opts = {}) {
|
|
|
223
278
|
* returns { decision:'allow'|'block'|'escalate', reasonCode, authorizationId, remaining }.
|
|
224
279
|
* A network/gate failure returns a fail-CLOSED block so the agent can't proceed blind.
|
|
225
280
|
*/
|
|
226
|
-
async function authorize({ action, amount, currency, merchant, resource, context = {}, trace, materiality }) {
|
|
281
|
+
async function authorize({ action, amount, currency, merchant, resource, context = {}, trace, materiality, payload }) {
|
|
227
282
|
const nonce = crypto.randomUUID();
|
|
228
283
|
const issuedAt = new Date().toISOString();
|
|
229
284
|
try {
|
|
@@ -244,9 +299,10 @@ export function createGuard(opts = {}) {
|
|
|
244
299
|
// see key-providers.mjs for why callers pass structured fields, not a pre-built string.
|
|
245
300
|
// `resource` deliberately NOT passed to envelopeSignatureFor — see buildSignedRequest's
|
|
246
301
|
// own comment on why (backend governance-envelope.ts doesn't include it yet either).
|
|
247
|
-
const [signature, envelopeSignature] = await Promise.all([
|
|
302
|
+
const [signature, envelopeSignature, binding] = await Promise.all([
|
|
248
303
|
keyProvider.signAuthorize({ agentDid, action, amount: signedAmount, currency: signedCurrency, merchant, resource, nonce, issuedAt }),
|
|
249
304
|
envelopeSignatureFor({ action, amount, currency, merchant, context, trace, materiality, nonce, issuedAt }),
|
|
305
|
+
payloadBindingFor({ action, nonce, issuedAt, payload }),
|
|
250
306
|
]);
|
|
251
307
|
const res = await fetch(`${base}/policy/mandate/authorize`, {
|
|
252
308
|
method: 'POST',
|
|
@@ -258,11 +314,27 @@ export function createGuard(opts = {}) {
|
|
|
258
314
|
agentDid, action, amount, currency, merchant, resource, itinerary: context, trace, materiality, nonce, issuedAt,
|
|
259
315
|
signature,
|
|
260
316
|
envelopeSignature,
|
|
317
|
+
...binding, // payloadDigest + payloadSignature, or nothing for an unbound request
|
|
261
318
|
}),
|
|
262
319
|
});
|
|
263
320
|
const body = await res.json().catch(() => null);
|
|
264
|
-
|
|
321
|
+
const data = body?.data ?? { decision: 'block', reasonCode: `GATE_HTTP_${res.status}` };
|
|
322
|
+
// The gate ACKNOWLEDGES a binding by echoing the digest it stored. A permit or escalation that does not — a hop stripped
|
|
323
|
+
// the fields, or the backend predates payload binding and ignored them — was never bound, and this agent must not act as
|
|
324
|
+
// if it were: refuse, and release the hold it just got (best effort; an unclaimed hold also lapses on its own).
|
|
325
|
+
if (binding.payloadDigest && (data.decision === 'allow' || data.decision === 'observe' || data.decision === 'escalate') && data.payloadDigest !== binding.payloadDigest) {
|
|
326
|
+
if (data.authorizationId) {
|
|
327
|
+
fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(data.authorizationId)}/void`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).catch(() => {});
|
|
328
|
+
}
|
|
329
|
+
return { decision: 'block', reasonCode: 'PAYLOAD_BINDING_NOT_CONFIRMED', authorizationId: null, error: 'the gate did not confirm the payload binding (an older backend, or the digest was stripped in transit)' };
|
|
330
|
+
}
|
|
331
|
+
return data;
|
|
265
332
|
} catch (err) {
|
|
333
|
+
// A payload binding that was asked for and cannot be produced is its own answer — NOT a gate outage, and never a
|
|
334
|
+
// reason to send the request unbound. Fail closed with the real cause.
|
|
335
|
+
if (err?.code === 'PAYLOAD_NOT_CANONICALIZABLE' || err?.code === 'PAYLOAD_BINDING_UNSUPPORTED') {
|
|
336
|
+
return { decision: 'block', reasonCode: err.code, error: String(err.message) };
|
|
337
|
+
}
|
|
266
338
|
// A daemon-backed keyProvider can fail before the gate is ever reached (the signer, not
|
|
267
339
|
// the gate, was unreachable) — a distinct reasonCode so this doesn't read as a gate outage
|
|
268
340
|
// it wasn't. Still fail-CLOSED either way, which is the property that actually matters.
|
|
@@ -315,7 +387,9 @@ export function createGuard(opts = {}) {
|
|
|
315
387
|
// SIBLING (like `contained`) and biases the edge verdict identically to the gate.
|
|
316
388
|
// READ_ONLY denies a value-bearing action up-front; SUPERVISED/RESTRICTED only
|
|
317
389
|
// ESCALATE, applied to the verdict below so a rule block/escalate still outranks it.
|
|
318
|
-
|
|
390
|
+
// The EFFECTIVE risk (spec §6.4.3): the owner's tier in the mandate is a floor under the agent's own claim, so
|
|
391
|
+
// this local pre-check agrees with the gate instead of telling the agent "low" is enough.
|
|
392
|
+
const modeGate = operatingModeGate(operatingMode?.mode, { amount, riskLevel: maxRisk(riskFloorFor(mandate, action), normalizeRiskLevel(context?.riskLevel)) ?? undefined });
|
|
319
393
|
if (modeGate.decision === 'block') {
|
|
320
394
|
return { decision: 'block', reasonCode: modeGate.reasonCode, authorizationId: null, remaining: null, proofRef: null };
|
|
321
395
|
}
|
|
@@ -330,7 +404,7 @@ export function createGuard(opts = {}) {
|
|
|
330
404
|
// omitting them here means a currency-scoped amount-over/cumulative-over Standards/SOP
|
|
331
405
|
// atom always sees currency as absent and fires closed. Mirrors mandate.service.ts's
|
|
332
406
|
// ruleCtx (PR #588) and the same fix in agentsafe-mcp-guard.mjs's verdictFromBundle.
|
|
333
|
-
context:
|
|
407
|
+
context: buildRuleContext({ unsigned: context, signed: { action, agentDid, amount, currency, merchant, resource }, riskFloor: riskFloorFor(mandate, action) }),
|
|
334
408
|
mandateRequest: mandate
|
|
335
409
|
? {
|
|
336
410
|
target: action,
|
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/key-providers.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import net from 'node:net';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { buildAuthMessage, buildLocalDecisionMessage } from './policy-core.mjs';
|
|
14
14
|
import { envelopeHashFor } from './governance-envelope.mjs';
|
|
15
|
+
import { buildPayloadBindingMessage } from './payload-binding.mjs';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Today's default: the raw key lives in THIS process (see the design doc's "What this does not
|
|
@@ -37,6 +38,12 @@ export function createStaticKeyProvider(agentKeyHex) {
|
|
|
37
38
|
async signLocalDecision(fields) {
|
|
38
39
|
return rawSign(buildLocalDecisionMessage(fields));
|
|
39
40
|
},
|
|
41
|
+
// Payload binding (spec 8.3.9): sign the digest of the COMPLETE payload, bound to this authorization. OPTIONAL like
|
|
42
|
+
// signLocalDecision; the guard refuses (fail closed) to send an unbound request when a binding was asked for and the
|
|
43
|
+
// provider cannot produce one.
|
|
44
|
+
async signPayloadBinding(fields) {
|
|
45
|
+
return rawSign(buildPayloadBindingMessage(fields));
|
|
46
|
+
},
|
|
40
47
|
};
|
|
41
48
|
}
|
|
42
49
|
|
|
@@ -129,6 +136,12 @@ export function createDaemonKeyProvider({ socketPath }) {
|
|
|
129
136
|
const { signature } = await daemonRequest(socketPath, 'sign-local-decision', fields);
|
|
130
137
|
return signature;
|
|
131
138
|
},
|
|
139
|
+
// The daemon builds every message it signs from structured fields and will not sign arbitrary bytes, so payload binding
|
|
140
|
+
// needs its own `sign-payload` operation there. Until that exists this refuses, LOUDLY and closed: a guard asked to bind
|
|
141
|
+
// a payload must never fall back to sending the request unbound.
|
|
142
|
+
async signPayloadBinding() {
|
|
143
|
+
throw Object.assign(new Error('the agentsafe-signer daemon does not support payload binding yet (sign-payload); use a static key provider, or omit `payload`'), { code: 'PAYLOAD_BINDING_UNSUPPORTED' });
|
|
144
|
+
},
|
|
132
145
|
};
|
|
133
146
|
}
|
|
134
147
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|
|
@@ -20,13 +20,14 @@
|
|
|
20
20
|
"policy-core.mjs",
|
|
21
21
|
"governance-envelope.mjs",
|
|
22
22
|
"magp-did.mjs",
|
|
23
|
+
"payload-binding.mjs",
|
|
23
24
|
"x402.mjs",
|
|
24
25
|
"example-openclaw-agent.mjs",
|
|
25
26
|
"README.md",
|
|
26
27
|
"LICENSE"
|
|
27
28
|
],
|
|
28
29
|
"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",
|
|
30
|
+
"test": "node demo.mjs && node local-eval.smoke.mjs && node execution-adapter.smoke.mjs && node verify.smoke.mjs && node context-signature.smoke.mjs && node payload-binding.smoke.mjs && node local-decision-report.smoke.mjs && node resource-constraint.smoke.mjs && node passphrase-key.smoke.mjs && node missing-config.smoke.mjs",
|
|
30
31
|
"demo": "node demo.mjs"
|
|
31
32
|
},
|
|
32
33
|
"engines": {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// GENERATED from backend/src/features/magp/payload-binding.ts - do not edit. Regenerate: npm run build:guard-core
|
|
2
|
+
|
|
3
|
+
// src/features/magp/payload-binding.ts
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
// src/policy-core/canonical.ts
|
|
7
|
+
function escapeField(v) {
|
|
8
|
+
return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/features/magp/payload-binding.ts
|
|
12
|
+
var PAYLOAD_BINDING_PREFIX = "MAGP-PAYLOAD-v1";
|
|
13
|
+
var PAYLOAD_DIGEST_PREFIX = "sha256:";
|
|
14
|
+
var PAYLOAD_DIGEST_HEADER = "x-magp-payload-digest";
|
|
15
|
+
var MAX_CANONICAL_PAYLOAD_BYTES = 256 * 1024;
|
|
16
|
+
var MAX_DEPTH = 32;
|
|
17
|
+
var PayloadNotCanonicalizable = class extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "PayloadNotCanonicalizable";
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
function hasLoneSurrogate(s) {
|
|
24
|
+
for (let i = 0; i < s.length; i++) {
|
|
25
|
+
const c = s.charCodeAt(i);
|
|
26
|
+
if (c >= 55296 && c <= 56319) {
|
|
27
|
+
const next = s.charCodeAt(i + 1);
|
|
28
|
+
if (!(next >= 56320 && next <= 57343)) return true;
|
|
29
|
+
i++;
|
|
30
|
+
} else if (c >= 56320 && c <= 57343) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
function serialize(value, depth, path) {
|
|
37
|
+
if (depth > MAX_DEPTH) throw new PayloadNotCanonicalizable(`payload is nested deeper than ${MAX_DEPTH} levels at ${path}`);
|
|
38
|
+
if (value === null) return "null";
|
|
39
|
+
switch (typeof value) {
|
|
40
|
+
case "boolean":
|
|
41
|
+
return value ? "true" : "false";
|
|
42
|
+
case "number":
|
|
43
|
+
if (!Number.isFinite(value)) throw new PayloadNotCanonicalizable(`${path} is not a finite number`);
|
|
44
|
+
return JSON.stringify(value);
|
|
45
|
+
// ECMAScript Number::toString — what RFC 8785 specifies; -0 serialises as "0"
|
|
46
|
+
case "string":
|
|
47
|
+
if (hasLoneSurrogate(value)) throw new PayloadNotCanonicalizable(`${path} contains an unpaired surrogate`);
|
|
48
|
+
return JSON.stringify(value);
|
|
49
|
+
case "object": {
|
|
50
|
+
if (Array.isArray(value)) return `[${value.map((v, i) => serialize(v, depth + 1, `${path}[${i}]`)).join(",")}]`;
|
|
51
|
+
const proto = Object.getPrototypeOf(value);
|
|
52
|
+
if (proto !== Object.prototype && proto !== null) throw new PayloadNotCanonicalizable(`${path} is not a plain JSON object`);
|
|
53
|
+
const obj = value;
|
|
54
|
+
const keys = Object.keys(obj).sort();
|
|
55
|
+
const parts = keys.map((k) => {
|
|
56
|
+
if (hasLoneSurrogate(k)) throw new PayloadNotCanonicalizable(`${path} has a key with an unpaired surrogate`);
|
|
57
|
+
return `${JSON.stringify(k)}:${serialize(obj[k], depth + 1, `${path}.${k}`)}`;
|
|
58
|
+
});
|
|
59
|
+
return `{${parts.join(",")}}`;
|
|
60
|
+
}
|
|
61
|
+
default:
|
|
62
|
+
throw new PayloadNotCanonicalizable(`${path} is a ${typeof value}, which JSON cannot represent`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function canonicalPayload(value) {
|
|
66
|
+
const text = serialize(value, 0, "$");
|
|
67
|
+
if (Buffer.byteLength(text, "utf8") > MAX_CANONICAL_PAYLOAD_BYTES) {
|
|
68
|
+
throw new PayloadNotCanonicalizable(`canonical payload exceeds ${MAX_CANONICAL_PAYLOAD_BYTES} bytes`);
|
|
69
|
+
}
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
function payloadDigestOf(value) {
|
|
73
|
+
return PAYLOAD_DIGEST_PREFIX + createHash("sha256").update(canonicalPayload(value), "utf8").digest("hex");
|
|
74
|
+
}
|
|
75
|
+
var DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
76
|
+
function isPayloadDigest(value) {
|
|
77
|
+
return typeof value === "string" && DIGEST_RE.test(value);
|
|
78
|
+
}
|
|
79
|
+
function toWireJson(value) {
|
|
80
|
+
const text = JSON.stringify(value);
|
|
81
|
+
if (text === void 0) throw new PayloadNotCanonicalizable("payload is not JSON-serialisable");
|
|
82
|
+
return JSON.parse(text);
|
|
83
|
+
}
|
|
84
|
+
function buildPayloadBindingMessage(input) {
|
|
85
|
+
return [PAYLOAD_BINDING_PREFIX, input.agentDid, input.action, input.nonce, input.issuedAt, input.payloadDigest].map((f) => escapeField(String(f))).join("|");
|
|
86
|
+
}
|
|
87
|
+
function decideClaimPayload(stored, presented) {
|
|
88
|
+
if (stored) {
|
|
89
|
+
if (!presented) return { ok: false, reasonCode: "PAYLOAD_DIGEST_REQUIRED" };
|
|
90
|
+
return presented === stored ? { ok: true } : { ok: false, reasonCode: "PAYLOAD_DIGEST_MISMATCH" };
|
|
91
|
+
}
|
|
92
|
+
return presented ? { ok: false, reasonCode: "PAYLOAD_NOT_BOUND_AT_AUTHORIZE" } : { ok: true };
|
|
93
|
+
}
|
|
94
|
+
function claimDigestField(payloadDigest) {
|
|
95
|
+
return `payload=${payloadDigest}`;
|
|
96
|
+
}
|
|
97
|
+
export {
|
|
98
|
+
MAX_CANONICAL_PAYLOAD_BYTES,
|
|
99
|
+
PAYLOAD_BINDING_PREFIX,
|
|
100
|
+
PAYLOAD_DIGEST_HEADER,
|
|
101
|
+
PAYLOAD_DIGEST_PREFIX,
|
|
102
|
+
PayloadNotCanonicalizable,
|
|
103
|
+
buildPayloadBindingMessage,
|
|
104
|
+
canonicalPayload,
|
|
105
|
+
claimDigestField,
|
|
106
|
+
decideClaimPayload,
|
|
107
|
+
isPayloadDigest,
|
|
108
|
+
payloadDigestOf,
|
|
109
|
+
toWireJson
|
|
110
|
+
};
|
package/policy-core.mjs
CHANGED
|
@@ -1,5 +1,129 @@
|
|
|
1
1
|
// GENERATED from backend/src/policy-core — do not edit. Regenerate: npm run build:guard-core
|
|
2
2
|
|
|
3
|
+
// src/policy-core/provenance.ts
|
|
4
|
+
var PROVENANCE_LEVELS = ["agent_asserted", "agent_signed", "gateway_derived", "authoritative", "attested"];
|
|
5
|
+
var PROVENANCE_RANK = {
|
|
6
|
+
agent_asserted: 0,
|
|
7
|
+
agent_signed: 1,
|
|
8
|
+
gateway_derived: 2,
|
|
9
|
+
authoritative: 3,
|
|
10
|
+
attested: 4
|
|
11
|
+
};
|
|
12
|
+
function isProvenance(v) {
|
|
13
|
+
return typeof v === "string" && Object.prototype.hasOwnProperty.call(PROVENANCE_RANK, v);
|
|
14
|
+
}
|
|
15
|
+
var PROVENANCE_KEY = Symbol.for("magp.context.provenance");
|
|
16
|
+
function provenanceOf(ctx, field) {
|
|
17
|
+
const map = ctx?.[PROVENANCE_KEY];
|
|
18
|
+
const p = map && typeof map === "object" ? map[field] : void 0;
|
|
19
|
+
return isProvenance(p) ? p : "agent_asserted";
|
|
20
|
+
}
|
|
21
|
+
function meetsProvenance(actual, minimum) {
|
|
22
|
+
return PROVENANCE_RANK[actual] >= PROVENANCE_RANK[minimum];
|
|
23
|
+
}
|
|
24
|
+
var RISK_LEVELS = ["low", "medium", "high", "critical"];
|
|
25
|
+
var RISK_ORDER = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
26
|
+
function normalizeRiskLevel(v) {
|
|
27
|
+
if (typeof v !== "string") return null;
|
|
28
|
+
const s = v.trim().toLowerCase();
|
|
29
|
+
return Object.prototype.hasOwnProperty.call(RISK_ORDER, s) ? s : null;
|
|
30
|
+
}
|
|
31
|
+
function maxRisk(...levels) {
|
|
32
|
+
let best = null;
|
|
33
|
+
for (const l of levels) if (l && (best === null || RISK_ORDER[l] > RISK_ORDER[best])) best = l;
|
|
34
|
+
return best;
|
|
35
|
+
}
|
|
36
|
+
function riskFloorFor(mandate, target) {
|
|
37
|
+
if (!mandate) return null;
|
|
38
|
+
let floor = null;
|
|
39
|
+
for (const p of mandate.permission ?? []) {
|
|
40
|
+
if (!p || typeof p !== "object") continue;
|
|
41
|
+
if ((p.target ?? mandate.target) !== target) continue;
|
|
42
|
+
floor = maxRisk(floor, normalizeRiskLevel(p.riskTier));
|
|
43
|
+
}
|
|
44
|
+
return floor;
|
|
45
|
+
}
|
|
46
|
+
var FIELD_KINDS = {
|
|
47
|
+
riskLevel: "risk",
|
|
48
|
+
consent: "boolean",
|
|
49
|
+
piiPresent: "boolean",
|
|
50
|
+
amount: "number",
|
|
51
|
+
cumulativeSpend: "number",
|
|
52
|
+
callCount: "number",
|
|
53
|
+
evidenceConfidence: "number",
|
|
54
|
+
holTrustScore: "number",
|
|
55
|
+
dataSourceId: "string",
|
|
56
|
+
jurisdiction: "string",
|
|
57
|
+
dataResidency: "string",
|
|
58
|
+
model: "string",
|
|
59
|
+
tool: "string",
|
|
60
|
+
currency: "string",
|
|
61
|
+
action: "string",
|
|
62
|
+
prompt: "string",
|
|
63
|
+
output: "string",
|
|
64
|
+
evidenceTypes: "string[]"
|
|
65
|
+
};
|
|
66
|
+
function contextFieldProblem(ctx, field) {
|
|
67
|
+
const v = ctx?.[field];
|
|
68
|
+
if (v === void 0 || v === null) return "missing";
|
|
69
|
+
if (typeof v === "string" && v.trim() === "") return "missing";
|
|
70
|
+
switch (FIELD_KINDS[field]) {
|
|
71
|
+
case "risk":
|
|
72
|
+
return normalizeRiskLevel(v) === null ? "malformed" : null;
|
|
73
|
+
case "boolean":
|
|
74
|
+
return typeof v === "boolean" ? null : "malformed";
|
|
75
|
+
case "number":
|
|
76
|
+
return typeof v === "number" && Number.isFinite(v) ? null : "malformed";
|
|
77
|
+
case "string":
|
|
78
|
+
return typeof v === "string" ? null : "malformed";
|
|
79
|
+
case "string[]":
|
|
80
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string") ? null : "malformed";
|
|
81
|
+
default:
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
var ATOM_DEFAULT_REQUIRED_CONTEXT = {
|
|
86
|
+
"risk-at-or-above": ["riskLevel"]
|
|
87
|
+
};
|
|
88
|
+
function buildRuleContext(src) {
|
|
89
|
+
const ctx = {};
|
|
90
|
+
const prov = /* @__PURE__ */ Object.create(null);
|
|
91
|
+
const put = (k, v, level) => {
|
|
92
|
+
Object.defineProperty(ctx, k, { value: v, enumerable: true, writable: true, configurable: true });
|
|
93
|
+
prov[k] = level;
|
|
94
|
+
};
|
|
95
|
+
const layers = [
|
|
96
|
+
[src.unsigned, "agent_asserted"],
|
|
97
|
+
[src.signed, "agent_signed"],
|
|
98
|
+
[src.gatewayDerived, "gateway_derived"],
|
|
99
|
+
[src.serverDerived, "authoritative"]
|
|
100
|
+
];
|
|
101
|
+
for (const [layer, level] of layers) {
|
|
102
|
+
for (const [k, v] of Object.entries(layer ?? {})) {
|
|
103
|
+
if (k === "riskLevel" && (level === "gateway_derived" || level === "authoritative") && normalizeRiskLevel(v) === null) continue;
|
|
104
|
+
put(k, v, level);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const floors = [];
|
|
108
|
+
const addFloor = (v, source) => {
|
|
109
|
+
const n = normalizeRiskLevel(v);
|
|
110
|
+
if (n) floors.push({ level: n, source });
|
|
111
|
+
};
|
|
112
|
+
addFloor(src.riskFloor, "authoritative");
|
|
113
|
+
addFloor(src.gatewayDerived?.riskLevel, "gateway_derived");
|
|
114
|
+
addFloor(src.serverDerived?.riskLevel, "authoritative");
|
|
115
|
+
const assertedUnsigned = normalizeRiskLevel(src.unsigned?.riskLevel);
|
|
116
|
+
const assertedSigned = normalizeRiskLevel(src.signed?.riskLevel);
|
|
117
|
+
const asserted = maxRisk(assertedUnsigned, assertedSigned);
|
|
118
|
+
if (floors.length > 0) {
|
|
119
|
+
put("riskLevel", maxRisk(asserted, ...floors.map((f) => f.level)), floors.reduce((best, f) => PROVENANCE_RANK[f.source] > PROVENANCE_RANK[best] ? f.source : best, "agent_asserted"));
|
|
120
|
+
} else if (asserted) {
|
|
121
|
+
put("riskLevel", asserted, assertedSigned ? "agent_signed" : "agent_asserted");
|
|
122
|
+
}
|
|
123
|
+
Object.defineProperty(ctx, PROVENANCE_KEY, { value: prov, enumerable: true, writable: false });
|
|
124
|
+
return ctx;
|
|
125
|
+
}
|
|
126
|
+
|
|
3
127
|
// src/policy-core/atom-registry.ts
|
|
4
128
|
var RISK_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
5
129
|
function currencyOutOfScope(ctx, cfgCurrency) {
|
|
@@ -14,7 +138,8 @@ var ATOM_REGISTRY = {
|
|
|
14
138
|
"data-source-not-approved": (c, cfg) => !!c.dataSourceId && !(cfg?.approved ?? []).includes(String(c.dataSourceId)),
|
|
15
139
|
"consent-missing": (c) => c.consent === false,
|
|
16
140
|
"risk-at-or-above": (c, cfg) => {
|
|
17
|
-
const
|
|
141
|
+
const haveLevel = normalizeRiskLevel(c.riskLevel);
|
|
142
|
+
const have = haveLevel === null ? void 0 : RISK_RANK[haveLevel];
|
|
18
143
|
const need = RISK_RANK[String(cfg?.level ?? "high")];
|
|
19
144
|
return have !== void 0 && need !== void 0 && have >= need;
|
|
20
145
|
},
|
|
@@ -276,6 +401,7 @@ function requiredContextFor(predicates) {
|
|
|
276
401
|
}
|
|
277
402
|
|
|
278
403
|
// src/policy-core/standards-rules.ts
|
|
404
|
+
var CONTEXT_UNVERIFIABLE = "CONTEXT_UNVERIFIABLE";
|
|
279
405
|
var PRECEDENCE = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5, decommission: 6 };
|
|
280
406
|
function atomFires(atom, ctx) {
|
|
281
407
|
const pred = ATOM_REGISTRY[atom.predicate];
|
|
@@ -304,17 +430,49 @@ function moleculeFires(m, ctx) {
|
|
|
304
430
|
return false;
|
|
305
431
|
}
|
|
306
432
|
}
|
|
433
|
+
function requiredContextOf(m) {
|
|
434
|
+
const required = /* @__PURE__ */ new Map();
|
|
435
|
+
const need = (field, level) => {
|
|
436
|
+
const have = required.get(field);
|
|
437
|
+
if (!have || PROVENANCE_RANK[level] > PROVENANCE_RANK[have]) required.set(field, level);
|
|
438
|
+
};
|
|
439
|
+
for (const a of m.atoms ?? []) {
|
|
440
|
+
if (!Object.prototype.hasOwnProperty.call(ATOM_DEFAULT_REQUIRED_CONTEXT, a.predicate)) continue;
|
|
441
|
+
for (const f of ATOM_DEFAULT_REQUIRED_CONTEXT[a.predicate]) need(f, "agent_asserted");
|
|
442
|
+
}
|
|
443
|
+
for (const [f, level] of Object.entries(m.requireProvenance ?? {})) {
|
|
444
|
+
need(f, isProvenance(level) ? level : "attested");
|
|
445
|
+
}
|
|
446
|
+
return required;
|
|
447
|
+
}
|
|
448
|
+
function moleculeUnverifiable(m, ctx) {
|
|
449
|
+
const bad = [];
|
|
450
|
+
for (const [field, minimum] of requiredContextOf(m)) {
|
|
451
|
+
if (contextFieldProblem(ctx, field) !== null || !meetsProvenance(provenanceOf(ctx, field), minimum)) bad.push(field);
|
|
452
|
+
}
|
|
453
|
+
return bad.sort();
|
|
454
|
+
}
|
|
307
455
|
function evaluateStandardRules(molecules, ctx, standardKey = null) {
|
|
308
456
|
let best = null;
|
|
309
457
|
for (const m of molecules ?? []) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
458
|
+
const fired = moleculeFires(m, ctx);
|
|
459
|
+
const unverifiable = moleculeUnverifiable(m, ctx);
|
|
460
|
+
if (!fired && unverifiable.length === 0) continue;
|
|
461
|
+
let decision = fired ? m.decision : "escalate";
|
|
462
|
+
if (unverifiable.length > 0 && PRECEDENCE[decision] < PRECEDENCE.escalate) decision = "escalate";
|
|
463
|
+
const reasonCode = fired ? m.reasonCode : CONTEXT_UNVERIFIABLE;
|
|
464
|
+
if (!best || PRECEDENCE[decision] > PRECEDENCE[best.decision]) {
|
|
465
|
+
best = { decision, reasonCode, id: m.id, unverifiable: unverifiable.length > 0 ? unverifiable : void 0 };
|
|
314
466
|
}
|
|
315
467
|
}
|
|
316
468
|
if (!best) return { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey };
|
|
317
|
-
return {
|
|
469
|
+
return {
|
|
470
|
+
decision: best.decision,
|
|
471
|
+
reasonCode: best.reasonCode,
|
|
472
|
+
firedMoleculeId: best.id,
|
|
473
|
+
standardKey,
|
|
474
|
+
...best.unverifiable ? { unverifiableContext: best.unverifiable } : {}
|
|
475
|
+
};
|
|
318
476
|
}
|
|
319
477
|
function evaluateBoundStandards(standards, ctx) {
|
|
320
478
|
let best = { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey: null };
|
|
@@ -369,6 +527,19 @@ function validateMolecules(molecules) {
|
|
|
369
527
|
if (!m.atoms || m.atoms.length === 0) {
|
|
370
528
|
issues.push({ moleculeId: m.id, message: "molecule has no atoms" });
|
|
371
529
|
}
|
|
530
|
+
if (m.requireProvenance !== void 0) {
|
|
531
|
+
const rp = m.requireProvenance;
|
|
532
|
+
if (rp === null || typeof rp !== "object" || Array.isArray(rp)) {
|
|
533
|
+
issues.push({ moleculeId: m.id, message: "requireProvenance must be an object of { field: level }" });
|
|
534
|
+
} else {
|
|
535
|
+
for (const [field, level] of Object.entries(rp)) {
|
|
536
|
+
if (field.trim() === "") issues.push({ moleculeId: m.id, message: "requireProvenance has an empty field name" });
|
|
537
|
+
if (!isProvenance(level)) {
|
|
538
|
+
issues.push({ moleculeId: m.id, message: `requireProvenance '${field}' must be one of agent_asserted|agent_signed|gateway_derived|authoritative|attested` });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
372
543
|
for (const a of m.atoms ?? []) {
|
|
373
544
|
if (!ATOM_REGISTRY[a.predicate]) {
|
|
374
545
|
issues.push({ moleculeId: m.id, message: `unknown atom predicate '${a.predicate}'` });
|
|
@@ -406,9 +577,14 @@ var REASON_BY_OPERAND = {
|
|
|
406
577
|
"mm:route": "ROUTE_NOT_ALLOWED",
|
|
407
578
|
"mm:counterparty": "COUNTERPARTY_NOT_ALLOWED"
|
|
408
579
|
};
|
|
409
|
-
|
|
580
|
+
var AMOUNT_OPERANDS = /* @__PURE__ */ new Set(["mm:payAmount", "mm:cumulativeSpend"]);
|
|
581
|
+
function reasonFor(constraint, req) {
|
|
410
582
|
if (!constraint) return "CONSTRAINT_FAILED";
|
|
411
|
-
|
|
583
|
+
const { leftOperand } = constraint;
|
|
584
|
+
if (AMOUNT_OPERANDS.has(leftOperand) && !Object.prototype.hasOwnProperty.call(req.values, leftOperand)) {
|
|
585
|
+
return "AMOUNT_NOT_DETERMINABLE";
|
|
586
|
+
}
|
|
587
|
+
return REASON_BY_OPERAND[leftOperand] ?? `CONSTRAINT_FAILED:${leftOperand}`;
|
|
412
588
|
}
|
|
413
589
|
function constraintSatisfied(c, req, strict) {
|
|
414
590
|
const op = OPERATORS[c.operator];
|
|
@@ -464,7 +640,7 @@ function evaluateMandate(mandate, req) {
|
|
|
464
640
|
const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
|
|
465
641
|
return {
|
|
466
642
|
decision: firstFail?.onFail ?? "block",
|
|
467
|
-
reasonCode: reasonFor(firstFail),
|
|
643
|
+
reasonCode: reasonFor(firstFail, req),
|
|
468
644
|
matched: { kind: "permission", target: perms[0].target, constraint: firstFail }
|
|
469
645
|
};
|
|
470
646
|
}
|
|
@@ -516,6 +692,9 @@ function escapeField(v) {
|
|
|
516
692
|
function buildAuthMessage(f) {
|
|
517
693
|
return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.resource ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
518
694
|
}
|
|
695
|
+
function buildLegacyAuthMessageV1(f) {
|
|
696
|
+
return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
697
|
+
}
|
|
519
698
|
function buildLocalDecisionMessage(f) {
|
|
520
699
|
return [f.agentDid, f.action, f.decision, f.reasonCode, f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
521
700
|
}
|
|
@@ -569,11 +748,17 @@ function operatingModeGate(mode, ctx) {
|
|
|
569
748
|
}
|
|
570
749
|
}
|
|
571
750
|
export {
|
|
751
|
+
ATOM_DEFAULT_REQUIRED_CONTEXT,
|
|
572
752
|
ATOM_REGISTRY,
|
|
573
753
|
ATOM_SPECS,
|
|
574
754
|
CATALOGUED_ATOMS,
|
|
755
|
+
CONTEXT_UNVERIFIABLE,
|
|
575
756
|
MODES_BY_RANK,
|
|
576
757
|
MODE_RANK,
|
|
758
|
+
PROVENANCE_KEY,
|
|
759
|
+
PROVENANCE_LEVELS,
|
|
760
|
+
PROVENANCE_RANK,
|
|
761
|
+
RISK_LEVELS,
|
|
577
762
|
SUPERVISED_AMOUNT_CAP,
|
|
578
763
|
applyCapture,
|
|
579
764
|
applyHold,
|
|
@@ -582,20 +767,31 @@ export {
|
|
|
582
767
|
authorityFailure,
|
|
583
768
|
buildAuthMessage,
|
|
584
769
|
buildCheckpointAnchorMessage,
|
|
770
|
+
buildLegacyAuthMessageV1,
|
|
585
771
|
buildLocalDecisionMessage,
|
|
772
|
+
buildRuleContext,
|
|
586
773
|
canAuthorize,
|
|
774
|
+
contextFieldProblem,
|
|
587
775
|
evaluate,
|
|
588
776
|
evaluateBoundStandards,
|
|
589
777
|
evaluateMandate,
|
|
590
778
|
evaluateStandardRules,
|
|
591
779
|
isAuthorityFailure,
|
|
592
780
|
isOperatingMode,
|
|
781
|
+
isProvenance,
|
|
782
|
+
maxRisk,
|
|
783
|
+
meetsProvenance,
|
|
593
784
|
moleculeFires,
|
|
785
|
+
moleculeUnverifiable,
|
|
594
786
|
moreRestrictive,
|
|
787
|
+
normalizeRiskLevel,
|
|
595
788
|
operatingModeGate,
|
|
789
|
+
provenanceOf,
|
|
596
790
|
releaseHold,
|
|
597
791
|
remainingBudget,
|
|
598
792
|
requiredContextFor,
|
|
793
|
+
requiredContextOf,
|
|
794
|
+
riskFloorFor,
|
|
599
795
|
sumEventField,
|
|
600
796
|
validateMolecules
|
|
601
797
|
};
|
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 {
|