@metamynd/agentsafe-guard 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,22 @@ compliance team edits in the dashboard, changeable live with no redeploy.
15
15
  `policy-core` the gate runs (MAGP §9.2 cooperative mode) — identical inputs give the identical
16
16
  verdict, with no network round-trip. See §4.
17
17
 
18
+ ## Try it first — no account, no network
19
+
20
+ ```bash
21
+ npx @metamynd/agentsafe-guard demo
22
+ ```
23
+
24
+ Runs the policy engine in-process against a sample bundle and prints the verdict for a
25
+ dozen tool calls — allow, observe, block, escalate, quarantine — including the ones that
26
+ matter most: an agent that lies about its own spend in unsigned context, and a quarantined
27
+ agent refused before any rule is read. It mints an ephemeral key, never opens a socket, and
28
+ needs nothing from MetaMynd. Exits non-zero if any verdict disagrees with the policy, so
29
+ it doubles as a smoke test of the installed package.
30
+
31
+ You need an account only for what can't be enforced client-side: live policy edits,
32
+ cumulative spend caps, human escalation, and anchored evidence.
33
+
18
34
  ## Install
19
35
 
20
36
  ```bash
@@ -217,6 +233,30 @@ Or wrap a tool to gate it against a local bundle (fails closed like `guardTool`)
217
233
  const gated = guard.guardToolLocal('flight-purchase', bookFlight, mapArgs, { standards, sops, mandate });
218
234
  ```
219
235
 
236
+ ### Execution adapters (dry-run / sandbox) — SAFR §19
237
+
238
+ By default a permitted (`allow`/`observe`) tool runs its real handler. An **ExecutionAdapter**
239
+ interposes between the verdict and the side-effect, so the *same* governed decision can be run
240
+ live, **simulated (dry-run)**, or routed to a sandbox — without changing the handler or the gate.
241
+ A blocked/escalated action still throws `GovernanceBlocked` before any adapter is consulted.
242
+
243
+ ```js
244
+ import { dryRunExecutionAdapter } from '@metamynd/agentsafe-guard';
245
+
246
+ // Guard-wide (or set AGENTSAFE_EXECUTION_MODE=dry-run):
247
+ const guard = createGuard({ api, agentDid, agentKey, executionAdapter: dryRunExecutionAdapter });
248
+
249
+ // …or per tool (overrides the guard default):
250
+ const preview = guard.guardTool('flight-purchase', bookFlight, mapArgs, { executionAdapter: dryRunExecutionAdapter });
251
+ await preview({ amount: 100 }); // → { dryRun: true, action, decision, authorizationId, args } — bookFlight NEVER runs
252
+
253
+ // Custom adapter: run the real handler, or substitute it. `proceed()` invokes handler(args, decision).
254
+ const sandboxed = (ctx) => ctx.action === 'flight-purchase' ? sandboxBook(ctx.args) : ctx.proceed();
255
+ ```
256
+
257
+ Contract: `async ({ action, args, decision, proceed }) => result`. Call `proceed()` to execute for
258
+ real; return without it to substitute the side-effect. Self-check: `node execution-adapter.smoke.mjs`.
259
+
220
260
  Signed request fields (`amount`, `merchant`) are always applied over the unsigned `context`, so a
221
261
  forged context key can never shadow them (MAGP §6.4.2). Run the self-check:
222
262
 
@@ -10,10 +10,52 @@
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 { evaluate, buildAuthMessage, applySignedLast } from './policy-core.mjs';
13
+ import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from './policy-core.mjs';
14
14
  import { verifyDidSignature } from './magp-did.mjs';
15
15
  import { checkSettlementBinding } from './x402.mjs';
16
16
 
17
+ /**
18
+ * ExecutionAdapter (SAFR §19, Phase-4 PR-4) — the seam between a PERMITTING verdict
19
+ * (allow / observe) and the real side-effect. Before this, a guarded tool called its
20
+ * handler directly, so the only outcomes were "execute for real" or "throw". An adapter
21
+ * interposes so the SAME governed decision can be run live, SIMULATED (dry-run), or routed
22
+ * to a sandbox — without touching the tool handler or the gate.
23
+ *
24
+ * Contract: `async (execCtx) => result`, where
25
+ * execCtx = { action, args, decision, proceed }
26
+ * proceed() runs the real handler (handler(args, decision)) and returns its result.
27
+ * An adapter that calls `proceed()` executes for real; one that returns WITHOUT calling it
28
+ * substitutes the side-effect. Adapters run ONLY after the guard has permitted the action —
29
+ * a block/escalate still throws GovernanceBlocked before any adapter is consulted.
30
+ */
31
+
32
+ /** The default: execute the real handler unchanged. */
33
+ export const liveExecutionAdapter = (ctx) => ctx.proceed();
34
+
35
+ /**
36
+ * Simulate the side-effect: do NOT call the handler, return a describe-only result. Lets an
37
+ * agent exercise a fully-governed flow (identity → mandate → controls → verdict) with no real
38
+ * booking/payment/write — for staging, canaries, and OBSERVE-mode dry-runs.
39
+ */
40
+ export const dryRunExecutionAdapter = (ctx) => ({
41
+ dryRun: true,
42
+ action: ctx.action,
43
+ decision: ctx.decision?.decision ?? null,
44
+ reasonCode: ctx.decision?.reasonCode ?? null,
45
+ authorizationId: ctx.decision?.authorizationId ?? null,
46
+ args: ctx.args,
47
+ });
48
+
49
+ /**
50
+ * Process-default adapter from `AGENTSAFE_EXECUTION_MODE` ('live' | 'dry-run'). Returns null
51
+ * when unset/live so the caller's own default (live) applies — behavior-neutral by default.
52
+ */
53
+ export function executionAdapterFromEnv(env = (typeof process !== 'undefined' ? process.env : {})) {
54
+ const mode = String(env.AGENTSAFE_EXECUTION_MODE ?? '').toLowerCase().trim();
55
+ if (mode === 'dry-run' || mode === 'dryrun') return dryRunExecutionAdapter;
56
+ return null;
57
+ }
58
+
17
59
  /**
18
60
  * @param {{ api: string, agentDid: string, agentKey: string }} cfg
19
61
  * api e.g. "http://localhost:9926/api/v1" or "https://metamynd.ai/api/v1"
@@ -48,6 +90,9 @@ export function createGuard(opts = {}) {
48
90
  const agentKey = opts.agentKey ?? cfg?.agentKey;
49
91
  if (!api || !agentDid || !agentKey) throw new Error('createGuard requires { api, agentDid, agentKey } — directly, or via { config } / { configPath } / createGuardFromConfig()');
50
92
  const base = api.replace(/\/$/, '');
93
+ // ExecutionAdapter seam (SAFR §19): an explicit opt wins, else the AGENTSAFE_EXECUTION_MODE env,
94
+ // else live. Applies to every guarded tool unless a tool passes its own adapter.
95
+ const defaultExecutionAdapter = opts.executionAdapter ?? executionAdapterFromEnv() ?? liveExecutionAdapter;
51
96
  const privateKey = crypto.createPrivateKey({ key: Buffer.from(agentKey, 'hex'), format: 'der', type: 'pkcs8' });
52
97
 
53
98
  // Ed25519 over the exact canonical message the backend verifies.
@@ -87,11 +132,13 @@ export function createGuard(opts = {}) {
87
132
  * agent's authorization trustlessly against the agent's policy bundle (§9.3). Same shape
88
133
  * `authorize()` posts to the gate; a fresh nonce each call.
89
134
  */
90
- function buildSignedRequest({ action, amount = 0, currency = 'USD', merchant = '', context = {} }) {
135
+ function buildSignedRequest({ action, amount = 0, currency = 'USD', merchant = '', context = {}, trace, materiality }) {
91
136
  const nonce = crypto.randomUUID();
92
137
  const issuedAt = new Date().toISOString();
93
138
  const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
94
- return { agentDid, action, amount, currency, merchant, itinerary: context, nonce, issuedAt, signature: sign(message) };
139
+ // trace/materiality are GovernanceEnvelope fields (SAFR §5) unsigned metadata; the
140
+ // signed message stays the action subset, so verification is unchanged.
141
+ return { agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt, signature: sign(message) };
95
142
  }
96
143
 
97
144
  /**
@@ -99,7 +146,7 @@ export function createGuard(opts = {}) {
99
146
  * returns { decision:'allow'|'block'|'escalate', reasonCode, authorizationId, remaining }.
100
147
  * A network/gate failure returns a fail-CLOSED block so the agent can't proceed blind.
101
148
  */
102
- async function authorize({ action, amount = 0, currency = 'USD', merchant = '', context = {} }) {
149
+ async function authorize({ action, amount = 0, currency = 'USD', merchant = '', context = {}, trace, materiality }) {
103
150
  const nonce = crypto.randomUUID();
104
151
  const issuedAt = new Date().toISOString();
105
152
  // Build the canonical signed message with policy-core so the guard and the
@@ -109,7 +156,9 @@ export function createGuard(opts = {}) {
109
156
  const res = await fetch(`${base}/policy/mandate/authorize`, {
110
157
  method: 'POST',
111
158
  headers: { 'Content-Type': 'application/json' },
112
- body: JSON.stringify({ agentDid, action, amount, currency, merchant, itinerary: context, nonce, issuedAt, signature: sign(message) }),
159
+ // trace/materiality (SAFR §5 envelope) ride as unsigned metadata; JSON.stringify
160
+ // drops them when undefined, so an agent that omits them sends the legacy body.
161
+ body: JSON.stringify({ agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt, signature: sign(message) }),
113
162
  });
114
163
  const body = await res.json().catch(() => null);
115
164
  return body?.data ?? { decision: 'block', reasonCode: `GATE_HTTP_${res.status}` };
@@ -147,12 +196,29 @@ export function createGuard(opts = {}) {
147
196
  * @param {{action:string,amount?:number,merchant?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
148
197
  * @returns {{decision:'allow'|'block'|'escalate',reasonCode:string|null,authorizationId:null,remaining:null,proofRef:null}}
149
198
  */
150
- function evaluateLocally({ standards = [], sops = [], mandate, request }) {
199
+ function evaluateLocally({ contained = null, operatingMode = null, standards = [], sops = [], mandate, request }) {
200
+ // Push containment (Phase 2.3): a server-CONTAINED agent is denied at the EDGE,
201
+ // before any rule eval. `contained` rides alongside the signed bundle as a SIBLING
202
+ // response field (never inside the signed payload, so the bundle signature stays
203
+ // valid) and is refreshed on the `policy:changed` push, reaching the guard in ~1s.
204
+ if (contained && contained.status) {
205
+ const decision = contained.status === 'quarantined' ? 'quarantine' : 'suspend';
206
+ const reasonCode = contained.status === 'quarantined' ? 'AGENT_QUARANTINED' : 'AGENT_SUSPENDED';
207
+ return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
208
+ }
151
209
  const { action, amount = 0, merchant = '', context = {}, cumulativeSpend = amount, now } = request;
210
+ // Operating-mode autonomy ladder (Phase 2.5b): the trust-driven posture rides as a
211
+ // SIBLING (like `contained`) and biases the edge verdict identically to the gate.
212
+ // READ_ONLY denies a value-bearing action up-front; SUPERVISED/RESTRICTED only
213
+ // ESCALATE, applied to the verdict below so a rule block/escalate still outranks it.
214
+ const modeGate = operatingModeGate(operatingMode?.mode, { amount, riskLevel: context?.riskLevel });
215
+ if (modeGate.decision === 'block') {
216
+ return { decision: 'block', reasonCode: modeGate.reasonCode, authorizationId: null, remaining: null, proofRef: null };
217
+ }
152
218
  // Signed fields (action/agentDid/amount, mm:* operands) are applied LAST so an
153
219
  // unsigned context key can never shadow them (spec §6.4.2) — the same invariant
154
220
  // the gate enforces, via the same policy-core helper.
155
- return evaluate({
221
+ const verdict = evaluate({
156
222
  standards,
157
223
  sops,
158
224
  mandate,
@@ -169,6 +235,13 @@ export function createGuard(opts = {}) {
169
235
  }
170
236
  : undefined,
171
237
  });
238
+ // Mode ESCALATE floor: only lifts an otherwise-PERMIT (allow or observe) to human
239
+ // review (never softens a stricter verdict) — most-restrictive-wins, mirroring the
240
+ // backend gate exactly (escalate outranks observe, so a flag never masks it).
241
+ if ((verdict.decision === 'allow' || verdict.decision === 'observe') && modeGate.decision === 'escalate') {
242
+ return { ...verdict, decision: 'escalate', reasonCode: modeGate.reasonCode };
243
+ }
244
+ return verdict;
172
245
  }
173
246
 
174
247
  /** Fetch + cache the agent's signed policy bundle (refreshed per its maxStaleness). */
@@ -179,6 +252,10 @@ export function createGuard(opts = {}) {
179
252
  const body = await res.json().catch(() => null);
180
253
  const b = body?.data ?? body;
181
254
  if (!b || (!b.mandates && !b.sops && !b.standards)) throw new Error(`invalid policy bundle from ${bundleUrl}`);
255
+ // Live containment + operating mode ride as SIBLINGS of the signed bundle (never
256
+ // inside it, so the signature stays valid); stash them on the in-memory copy.
257
+ b.contained = body?.contained ?? null;
258
+ b.operatingMode = body?.operatingMode ?? null;
182
259
  _bundle = b;
183
260
  _bundleAt = now;
184
261
  _bundleMaxAgeMs = _durationMs(b.maxStaleness) ?? _bundleMaxAgeMs;
@@ -188,6 +265,8 @@ export function createGuard(opts = {}) {
188
265
  /** Map a fetched bundle into the shape evaluateLocally expects, for one action. */
189
266
  function _bundleFor(b, action) {
190
267
  return {
268
+ contained: b.contained ?? null,
269
+ operatingMode: b.operatingMode ?? null,
191
270
  standards: (b.standards ?? []).map((s) => ({ standardKey: s.id ?? s.standardKey ?? 'standard', document: s.document })).filter((s) => s.document),
192
271
  sops: (b.sops ?? []).map((s) => ({ standardKey: s.id ?? s.sopId ?? 'sop', document: s.document })).filter((s) => s.document),
193
272
  mandate: ((b.mandates ?? []).find((m) => m.action === action) ?? (b.mandates ?? [])[0])?.document,
@@ -251,9 +330,11 @@ export function createGuard(opts = {}) {
251
330
  if (!anchor?.sigDigest || !sig || _sha256(sig) !== anchor.sigDigest) return authorize(input);
252
331
  }
253
332
  const local = evaluateLocally({ ..._bundleFor(b, action), request: input });
254
- if (local.decision !== 'allow') return local; // decided locally, no network
255
- if (amount > 0 && sealValueActions) return authorize(input); // seal value action remotely
256
- return local; // non-value allow local is sufficient
333
+ // allow/observe both PERMIT; block/escalate/contain are decided locally with no network.
334
+ const permits = local.decision === 'allow' || local.decision === 'observe';
335
+ if (!permits) return local; // denied/escalated locally, no network
336
+ if (amount > 0 && sealValueActions) return authorize(input); // seal value action remotely (allow or observe)
337
+ return local; // non-value permit — local is sufficient
257
338
  }
258
339
 
259
340
  /** Mode-aware decision used by guardTool: 'local' (default) or 'remote'. */
@@ -319,7 +400,8 @@ export function createGuard(opts = {}) {
319
400
  * @param {(args:any)=>{amount?:number,merchant?:string,context?:object}} mapArgs
320
401
  * @param {object|((args:any)=>object|Promise<object>)} getBundle { standards, sops, mandate } (or a resolver)
321
402
  */
322
- function guardToolLocal(action, handler, mapArgs = (a) => a, getBundle = {}) {
403
+ function guardToolLocal(action, handler, mapArgs = (a) => a, getBundle = {}, toolOpts = {}) {
404
+ const adapter = toolOpts.executionAdapter ?? defaultExecutionAdapter;
323
405
  return async (args) => {
324
406
  let decision;
325
407
  try {
@@ -329,13 +411,19 @@ export function createGuard(opts = {}) {
329
411
  } catch (err) {
330
412
  decision = { decision: 'block', reasonCode: 'LOCAL_EVAL_ERROR', error: String(err?.message ?? err) };
331
413
  }
332
- if (decision.decision !== 'allow') {
414
+ // allow/observe both PERMIT execution; observe is permit-but-flag (SAFR §11) — the
415
+ // handler receives the `decision` so a caller can surface/log the observation.
416
+ if (decision.decision !== 'allow' && decision.decision !== 'observe') {
333
417
  const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
334
418
  err.name = 'GovernanceBlocked';
335
419
  err.governance = decision;
336
420
  throw err;
337
421
  }
338
- return handler(args, decision);
422
+ if (decision.decision === 'observe') {
423
+ console.warn(`[agentsafe] OBSERVE "${action}": ${decision.reasonCode} — permitted under monitoring`);
424
+ }
425
+ // ExecutionAdapter seam (§19): the adapter runs the real handler (proceed) or substitutes it.
426
+ return adapter({ action, args, decision, proceed: () => handler(args, decision) });
339
427
  };
340
428
  }
341
429
 
@@ -350,16 +438,23 @@ export function createGuard(opts = {}) {
350
438
  * @param {(args:any)=>{amount?:number,currency?:string,merchant?:string,context?:object}} mapArgs
351
439
  * maps the tool's call args to the gate inputs (amount/merchant + the context the rules need)
352
440
  */
353
- function guardTool(action, handler, mapArgs = (a) => a) {
441
+ function guardTool(action, handler, mapArgs = (a) => a, toolOpts = {}) {
442
+ const adapter = toolOpts.executionAdapter ?? defaultExecutionAdapter;
354
443
  return async (args) => {
355
444
  const decision = await check({ action, ...mapArgs(args) });
356
- if (decision.decision !== 'allow') {
445
+ // allow/observe both PERMIT execution; observe is permit-but-flag (SAFR §11) — the
446
+ // handler receives the `decision` so a caller can surface/log the observation.
447
+ if (decision.decision !== 'allow' && decision.decision !== 'observe') {
357
448
  const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
358
449
  err.name = 'GovernanceBlocked';
359
450
  err.governance = decision;
360
451
  throw err;
361
452
  }
362
- return handler(args, decision);
453
+ if (decision.decision === 'observe') {
454
+ console.warn(`[agentsafe] OBSERVE "${action}": ${decision.reasonCode} — permitted under monitoring`);
455
+ }
456
+ // ExecutionAdapter seam (§19): the adapter runs the real handler (proceed) or substitutes it.
457
+ return adapter({ action, args, decision, proceed: () => handler(args, decision) });
363
458
  };
364
459
  }
365
460
 
@@ -443,6 +538,37 @@ export function createGuard(opts = {}) {
443
538
  }
444
539
  }
445
540
 
541
+ /**
542
+ * Effect-safety runtime (E2): report the external-effect lifecycle so an AMBIGUOUS
543
+ * connector outcome never becomes a blind capture/void. Call effectDispatching() just
544
+ * before the side-effecting call, effectDispatched() when the connector accepts, and —
545
+ * critically — effectUnknown() when the response is lost/timed out (instead of guessing).
546
+ * Once UNKNOWN, capture/void are refused by the gate until the effect is reconciled.
547
+ */
548
+ async function _effectPost(authorizationId, kind, payload = {}) {
549
+ try {
550
+ const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/${kind}`, {
551
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
552
+ });
553
+ const body = await res.json().catch(() => null);
554
+ return body?.data ?? { ok: false, reasonCode: `GATE_HTTP_${res.status}` };
555
+ } catch (err) {
556
+ return { ok: false, reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
557
+ }
558
+ }
559
+ const effectDispatching = (authorizationId) => _effectPost(authorizationId, 'dispatching');
560
+ const effectDispatched = (authorizationId, remoteRef) => _effectPost(authorizationId, 'dispatched', { remoteRef });
561
+ const effectUnknown = (authorizationId, reason) => _effectPost(authorizationId, 'unknown', { reason });
562
+ async function effectStatus(authorizationId) {
563
+ try {
564
+ const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect`);
565
+ const body = await res.json().catch(() => null);
566
+ return body?.data ?? { effectState: null, reasonCode: `GATE_HTTP_${res.status}` };
567
+ } catch (err) {
568
+ return { effectState: 'unreachable', reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
569
+ }
570
+ }
571
+
446
572
  /**
447
573
  * BYOK proof-of-possession (onboarding proposal #4). For an agent that brought its OWN key,
448
574
  * MetaMynd issued the identity with a one-time `challenge` and left the key UNVERIFIED — the gate
@@ -476,5 +602,5 @@ export function createGuard(opts = {}) {
476
602
  return sign(challenge);
477
603
  }
478
604
 
479
- return { authorize, authorizeLocal, check, loadBundle, policyAnchor: _currentAnchor, watchPolicy, mode, verifyOnChain, buildSignedRequest, capture, guardTool, evaluateLocally, guardToolLocal, handshake, preparePayment, escalationStatus, verifyKey, signChallenge, agentDid };
605
+ return { authorize, authorizeLocal, check, loadBundle, policyAnchor: _currentAnchor, watchPolicy, mode, verifyOnChain, buildSignedRequest, capture, guardTool, evaluateLocally, guardToolLocal, handshake, preparePayment, escalationStatus, effectDispatching, effectDispatched, effectUnknown, effectStatus, verifyKey, signChallenge, agentDid, executionAdapter: defaultExecutionAdapter };
480
606
  }
package/cli.mjs ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // cli.mjs — the package's only executable. Its whole job is to make the offline
3
+ // demo reachable without cloning anything:
4
+ //
5
+ // npx @metamynd/agentsafe-guard demo
6
+ //
7
+ // Kept deliberately thin: no argument parser, no dependencies, no network.
8
+ const [, , cmd] = process.argv;
9
+
10
+ if (cmd === 'demo') {
11
+ await import('./demo.mjs');
12
+ } else if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
13
+ console.log(`
14
+ @metamynd/agentsafe-guard — runtime governance for Node AI agents
15
+
16
+ Usage
17
+ npx @metamynd/agentsafe-guard demo Run the offline policy demo
18
+ (no account, no API key, no network)
19
+
20
+ In your agent
21
+ import { createGuard } from '@metamynd/agentsafe-guard';
22
+
23
+ Docs https://www.npmjs.com/package/@metamynd/agentsafe-guard
24
+ Hosted https://metamynd.ai
25
+ `);
26
+ } else {
27
+ console.error(`Unknown command: ${cmd}\nTry: npx @metamynd/agentsafe-guard demo`);
28
+ process.exit(1);
29
+ }
package/demo.mjs ADDED
@@ -0,0 +1,161 @@
1
+ // demo.mjs — the no-account, no-network tour of the guard.
2
+ //
3
+ // npx @metamynd/agentsafe-guard demo
4
+ //
5
+ // Everything here runs locally: an ephemeral Ed25519 key, an inline policy bundle,
6
+ // and `policy-core` — the same evaluator bytes the hosted gate runs. No MetaMynd
7
+ // account, no API key, no Hedera, no outbound request (the `api` URL below is
8
+ // deliberately unroutable, and nothing ever calls it).
9
+ //
10
+ // Exits 0 when every verdict matches what the policy says it should be, 1 otherwise —
11
+ // so this doubles as a smoke test of the shipped package.
12
+ import crypto from 'node:crypto';
13
+ import { createGuard } from './agentsafe-guard.mjs';
14
+
15
+ const c = (code, s) => (process.stdout.isTTY ? `\u001b[${code}m${s}\u001b[0m` : s);
16
+ const bold = (s) => c('1', s);
17
+ const dim = (s) => c('2', s);
18
+ const head = (s) => console.log(`\n${bold(s)}\n${dim('─'.repeat(s.length))}`);
19
+
20
+ // Colour per decision — but never colour ALONE: the verdict is always spelled out.
21
+ const paint = { allow: '32', observe: '36', escalate: '33', block: '31', quarantine: '35' };
22
+ const verdict = (d) => c(paint[d] ?? '0', d.toUpperCase().padEnd(10));
23
+
24
+ const { privateKey } = crypto.generateKeyPairSync('ed25519');
25
+ const guard = createGuard({
26
+ api: 'http://unused.local/api/v1', // never contacted — this demo is entirely local
27
+ agentDid: 'did:hedera:testnet:z6MkDemo_0.0.1',
28
+ agentKey: privateKey.export({ format: 'der', type: 'pkcs8' }).toString('hex'),
29
+ });
30
+
31
+ // The policy an owner would author in the dashboard, as the agent receives it:
32
+ // one enforced Standard, one SOP, and an ODRL mandate.
33
+ const bundle = {
34
+ standards: [
35
+ {
36
+ standardKey: 'eu-ai-act',
37
+ document: {
38
+ molecules: [
39
+ {
40
+ id: 'risk',
41
+ combinator: 'any',
42
+ atoms: [{ id: 'r', predicate: 'risk-at-or-above', config: { level: 'high' } }],
43
+ decision: 'escalate',
44
+ reasonCode: 'RISK_REVIEW',
45
+ },
46
+ ],
47
+ },
48
+ },
49
+ ],
50
+ sops: [
51
+ {
52
+ standardKey: 'sop:travel',
53
+ document: {
54
+ molecules: [
55
+ {
56
+ id: 'watch',
57
+ combinator: 'any',
58
+ atoms: [{ id: 'w', predicate: 'amount-over', config: { limit: 200 } }],
59
+ decision: 'observe',
60
+ reasonCode: 'WATCH_LARGE',
61
+ },
62
+ {
63
+ id: 'cap',
64
+ combinator: 'any',
65
+ atoms: [{ id: 'a', predicate: 'amount-over', config: { limit: 500 } }],
66
+ decision: 'block',
67
+ reasonCode: 'SOP_SPEND_CAP',
68
+ },
69
+ {
70
+ id: 'contain',
71
+ combinator: 'any',
72
+ atoms: [{ id: 'q', predicate: 'amount-over', config: { limit: 5000 } }],
73
+ decision: 'quarantine',
74
+ reasonCode: 'GROSS_OVERSPEND',
75
+ },
76
+ ],
77
+ },
78
+ },
79
+ ],
80
+ mandate: {
81
+ permission: [
82
+ {
83
+ target: 'flight-purchase',
84
+ constraint: [
85
+ { leftOperand: 'mm:payAmount', operator: 'lteq', rightOperand: 1000 },
86
+ { leftOperand: 'mm:cumulativeSpend', operator: 'lteq', rightOperand: 1000 },
87
+ { leftOperand: 'mm:merchant', operator: 'isAnyOf', rightOperand: ['amadeus'] },
88
+ ],
89
+ },
90
+ ],
91
+ },
92
+ };
93
+
94
+ let failed = 0;
95
+ const check = (ok) => { if (!ok) failed++; return ok ? dim('ok') : c('31', 'FAIL'); };
96
+
97
+ const run = (label, request, expect, extra = {}) => {
98
+ const v = guard.evaluateLocally({ ...bundle, ...extra, request });
99
+ const ok = v.decision === expect[0] && v.reasonCode === expect[1];
100
+ console.log(` ${verdict(v.decision)} ${String(v.reasonCode).padEnd(22)} ${label} ${check(ok)}`);
101
+ };
102
+
103
+ console.log(bold('\nAgentSafe Guard — local policy evaluation'));
104
+ console.log(dim('No account, no API key, no network. Same policy-core the hosted gate runs.'));
105
+
106
+ head('The policy this agent is under');
107
+ console.log(` Standard ${dim('eu-ai-act')} risk ≥ high → escalate`);
108
+ console.log(` SOP ${dim('sop:travel')} amount > 200 → observe ${dim('(permit, but flag)')}`);
109
+ console.log(` amount > 500 → block`);
110
+ console.log(` amount > 5000 → quarantine ${dim('(contain the agent)')}`);
111
+ console.log(` Mandate ${dim('ODRL')} merchant ∈ [amadeus], ≤ 1000 per action and in total`);
112
+
113
+ head('A tool call, evaluated against it');
114
+ run('$100 to amadeus, low risk', { action: 'flight-purchase', amount: 100, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['allow', 'AUTHORIZED']);
115
+ run('$300 — over the watch line', { action: 'flight-purchase', amount: 300, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['observe', 'WATCH_LARGE']);
116
+ run('$600 — over the SOP cap', { action: 'flight-purchase', amount: 600, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['block', 'SOP_SPEND_CAP']);
117
+ run('$100 but flagged high risk', { action: 'flight-purchase', amount: 100, merchant: 'amadeus', context: { riskLevel: 'high' } }, ['escalate', 'RISK_REVIEW']);
118
+ run('$100 to a merchant off the mandate', { action: 'flight-purchase', amount: 100, merchant: 'sabre', context: { riskLevel: 'low' } }, ['block', 'MERCHANT_NOT_ALLOWED']);
119
+ run('$6000 — gross overspend', { action: 'flight-purchase', amount: 6000, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['quarantine', 'GROSS_OVERSPEND']);
120
+
121
+ head('The agent cannot argue its way out');
122
+ console.log(dim(' Unsigned context is untrusted input — it never shadows the signed amount.'));
123
+ run('$600 claiming "payAmount: 1"', { action: 'flight-purchase', amount: 600, merchant: 'amadeus', context: { 'mm:payAmount': 1, riskLevel: 'low' } }, ['block', 'SOP_SPEND_CAP']);
124
+ console.log(dim(' A quarantined agent is refused at the edge, before any rule is read.'));
125
+ run('$1 while quarantined', { action: 'flight-purchase', amount: 1, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['quarantine', 'AGENT_QUARANTINED'], { contained: { status: 'quarantined', reason: 'GROSS_OVERSPEND' } });
126
+
127
+ head('Operating mode narrows the ladder further');
128
+ const mode = (m) => ({ operatingMode: { mode: m } });
129
+ run('read_only — value action refused', { action: 'flight-purchase', amount: 100, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['block', 'MODE_READ_ONLY'], mode('read_only'));
130
+ run('read_only — a $0 read still passes', { action: 'flight-purchase', amount: 0, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['allow', 'AUTHORIZED'], mode('read_only'));
131
+ run('restricted — spend needs a human', { action: 'flight-purchase', amount: 100, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['escalate', 'MODE_RESTRICTED_REVIEW'], mode('restricted'));
132
+ run('restricted — but a block stays a block', { action: 'flight-purchase', amount: 600, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['block', 'SOP_SPEND_CAP'], mode('restricted'));
133
+
134
+ head('What that means for your tool');
135
+ {
136
+ let ran = false;
137
+ const book = guard.guardToolLocal('flight-purchase', async () => { ran = true; return 'booked'; }, (a) => a, bundle);
138
+ const out = await book({ amount: 300, merchant: 'amadeus', context: { riskLevel: 'low' } });
139
+ console.log(` observe → handler RAN, returned ${JSON.stringify(out)} ${check(ran === true && out === 'booked')}`);
140
+ }
141
+ {
142
+ let ran = false;
143
+ const book = guard.guardToolLocal('flight-purchase', async () => { ran = true; return 'booked'; }, (a) => a, bundle);
144
+ let name = null;
145
+ try { await book({ amount: 600, merchant: 'amadeus', context: { riskLevel: 'low' } }); } catch (e) { name = e?.name; }
146
+ console.log(` block → threw ${name}, handler never ran ${check(name === 'GovernanceBlocked' && ran === false)}`);
147
+ }
148
+
149
+ if (failed) {
150
+ console.error(c('31', `\n${failed} case(s) FAILED — that is a bug, please report it.`));
151
+ console.error(dim('Please report it — https://metamynd.ai\n'));
152
+ process.exit(1);
153
+ }
154
+
155
+ console.log(bold('\nPASS') + dim(' — every verdict matched the policy, decided locally in-process.\n'));
156
+ console.log('Wire it into your own agent:');
157
+ console.log(dim(" import { createGuard } from '@metamynd/agentsafe-guard';"));
158
+ console.log(dim(' const safeBooking = guard.guardToolLocal(\'flight-purchase\', bookFlight, mapArgs, bundle);'));
159
+ console.log(`\n${dim('Local evaluation needs nothing from us. The hosted platform adds live policy')}`);
160
+ console.log(`${dim('editing, cumulative spend caps, human escalation, and anchored evidence:')}`);
161
+ console.log(`${dim('https://metamynd.ai')}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-guard",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Zero-dependency runtime governance for any Node AI agent — 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",
@@ -11,6 +11,8 @@
11
11
  "./package.json": "./package.json"
12
12
  },
13
13
  "files": [
14
+ "demo.mjs",
15
+ "cli.mjs",
14
16
  "agentsafe-guard.mjs",
15
17
  "policy-core.mjs",
16
18
  "magp-did.mjs",
@@ -20,7 +22,8 @@
20
22
  "LICENSE"
21
23
  ],
22
24
  "scripts": {
23
- "test": "node local-eval.smoke.mjs"
25
+ "test": "node demo.mjs && node local-eval.smoke.mjs && node execution-adapter.smoke.mjs",
26
+ "demo": "node demo.mjs"
24
27
  },
25
28
  "engines": {
26
29
  "node": ">=18"
@@ -44,16 +47,14 @@
44
47
  ],
45
48
  "author": "MetaMynd",
46
49
  "license": "MIT",
47
- "homepage": "https://github.com/jasimp18/AgentSafe/tree/main/integrations/agentsafe-guard#readme",
48
- "repository": {
49
- "type": "git",
50
- "url": "git+https://github.com/jasimp18/AgentSafe.git",
51
- "directory": "integrations/agentsafe-guard"
52
- },
50
+ "homepage": "https://metamynd.ai",
53
51
  "bugs": {
54
- "url": "https://github.com/jasimp18/AgentSafe/issues"
52
+ "url": "https://metamynd.ai/en/support/contact"
55
53
  },
56
54
  "publishConfig": {
57
55
  "access": "public"
56
+ },
57
+ "bin": {
58
+ "agentsafe-guard": "./cli.mjs"
58
59
  }
59
60
  }
package/policy-core.mjs CHANGED
@@ -31,6 +31,25 @@ ${c.output ?? ""}`.toLowerCase();
31
31
  "tool-not-allowed": (c, cfg) => notInAllowList(c.tool, cfg?.allowed),
32
32
  "pii-present": (c) => c.piiPresent === true,
33
33
  "rate-limit-exceeded": (c, cfg) => typeof c.callCount === "number" && c.callCount > Number(cfg?.max ?? 0),
34
+ // --- Evidence-quality atoms (SAFR §24). Unlike the allow-list atoms, these fire on ABSENCE:
35
+ // a REQUIRE semantic — "the action must be backed by this evidence; if it isn't, fire"
36
+ // (author with escalate/block). Opt-in: they only run when a rule keys them. ---
37
+ // Fires when any REQUIRED evidence type is not among the attested `evidenceTypes` (missing
38
+ // evidence — including none supplied at all → all required missing → fires).
39
+ "evidence-requirement": (c, cfg) => {
40
+ const required = (cfg?.required ?? []).map((t) => String(t).toLowerCase().trim()).filter(Boolean);
41
+ if (required.length === 0) return false;
42
+ const have = new Set((Array.isArray(c.evidenceTypes) ? c.evidenceTypes : []).map((t) => String(t).toLowerCase().trim()));
43
+ return required.some((r) => !have.has(r));
44
+ },
45
+ // Fires when a required minimum confidence (min > 0) is not met — the attested confidence is
46
+ // below it, or absent (a required confidence that was never supplied fails the bar). A min of
47
+ // 0 / unset is no requirement and never fires.
48
+ "evidence-confidence-below": (c, cfg) => {
49
+ const min = Number(cfg?.min ?? 0);
50
+ if (!(min > 0)) return false;
51
+ return typeof c.evidenceConfidence !== "number" || c.evidenceConfidence < min;
52
+ },
34
53
  // Trust guidance (MetaMynd Trust Index / HCS-28). Fires when the counterparty's trust score is
35
54
  // below a soft REVIEW line — intended to author an ESCALATE (route to a human), NOT a hard block.
36
55
  // The score is server-derived (signed-last) so the agent's itinerary can't fake it; when no score
@@ -145,6 +164,20 @@ var ATOM_SPECS = [
145
164
  description: "Routes to human review when the counterparty's MetaMynd Trust Index (HCS-28) score is below a soft review line. Guidance, not a hard block \u2014 author it with an ESCALATE decision. The score is resolved server-side; no counterparty score \u2192 the atom does not fire.",
146
165
  config: [{ key: "reviewBelow", type: "number", required: true, description: "Trust score (0\u2013100) below which a human is asked to decide" }],
147
166
  requiredContext: ["holTrustScore"]
167
+ },
168
+ {
169
+ predicate: "evidence-requirement",
170
+ label: "Required evidence missing",
171
+ description: "Fires when the action is not backed by every REQUIRED evidence type the agent attests to in `evidenceTypes` (missing evidence \u2014 including none supplied). A REQUIRE control (SAFR \xA724): author it with ESCALATE or BLOCK so an under-evidenced action is stopped or reviewed.",
172
+ config: [{ key: "required", type: "string[]", required: true, description: "Evidence types that must all be present (e.g. kyc, source-doc, signature)" }],
173
+ requiredContext: ["evidenceTypes"]
174
+ },
175
+ {
176
+ predicate: "evidence-confidence-below",
177
+ label: "Evidence confidence below minimum",
178
+ description: "Fires when the attested evidence confidence is below a required minimum \u2014 or absent (SAFR \xA724). A min of 0 / unset is no requirement. Author with ESCALATE to route low-confidence actions to review.",
179
+ config: [{ key: "min", type: "number", required: true, description: "Minimum evidence confidence (0\u20131) required" }],
180
+ requiredContext: ["evidenceConfidence"]
148
181
  }
149
182
  ];
150
183
  var CATALOGUED_ATOMS = ATOM_SPECS.filter((s) => !!ATOM_REGISTRY[s.predicate]);
@@ -158,7 +191,7 @@ function requiredContextFor(predicates) {
158
191
  }
159
192
 
160
193
  // src/policy-core/standards-rules.ts
161
- var PRECEDENCE = { allow: 0, escalate: 1, block: 2 };
194
+ var PRECEDENCE = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5 };
162
195
  function atomFires(atom, ctx) {
163
196
  const pred = ATOM_REGISTRY[atom.predicate];
164
197
  if (!pred) return false;
@@ -244,8 +277,8 @@ function validateMolecules(molecules) {
244
277
  if (!["all", "any", "none"].includes(m.combinator)) {
245
278
  issues.push({ moleculeId: m.id, message: `invalid combinator '${m.combinator}' (all|any|none)` });
246
279
  }
247
- if (!["block", "escalate"].includes(m.decision)) {
248
- issues.push({ moleculeId: m.id, message: `invalid decision '${m.decision}' (block|escalate)` });
280
+ if (!["observe", "block", "escalate", "suspend", "quarantine"].includes(m.decision)) {
281
+ issues.push({ moleculeId: m.id, message: `invalid decision '${m.decision}' (observe|block|escalate|suspend|quarantine)` });
249
282
  }
250
283
  if (!m.reasonCode) issues.push({ moleculeId: m.id, message: "molecule is missing a reasonCode" });
251
284
  if (!m.atoms || m.atoms.length === 0) {
@@ -359,7 +392,7 @@ function sumEventField(events, type, field) {
359
392
  }
360
393
 
361
394
  // src/policy-core/evaluate.ts
362
- var PRECEDENCE2 = { allow: 0, escalate: 1, block: 2 };
395
+ var PRECEDENCE2 = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5 };
363
396
  function evaluate(input) {
364
397
  let decision = "allow";
365
398
  let reasonCode = "AUTHORIZED";
@@ -389,20 +422,66 @@ function buildAuthMessage(f) {
389
422
  function applySignedLast(unsigned, signed) {
390
423
  return { ...unsigned ?? {}, ...signed };
391
424
  }
425
+
426
+ // src/policy-core/operating-mode.ts
427
+ var MODE_RANK = {
428
+ read_only: 0,
429
+ restricted: 1,
430
+ supervised: 2,
431
+ autonomous: 3
432
+ };
433
+ var MODES_BY_RANK = ["read_only", "restricted", "supervised", "autonomous"];
434
+ function isOperatingMode(v) {
435
+ return typeof v === "string" && Object.prototype.hasOwnProperty.call(MODE_RANK, v);
436
+ }
437
+ function asOperatingMode(v) {
438
+ return isOperatingMode(v) ? v : "autonomous";
439
+ }
440
+ function moreRestrictive(a, b) {
441
+ return MODE_RANK[a] <= MODE_RANK[b] ? a : b;
442
+ }
443
+ var SUPERVISED_AMOUNT_CAP = 100;
444
+ var RISK_RANK2 = { low: 0, medium: 1, high: 2, critical: 3 };
445
+ function riskAtOrAboveHigh(riskLevel) {
446
+ const r = typeof riskLevel === "string" ? RISK_RANK2[riskLevel.toLowerCase()] : void 0;
447
+ return r !== void 0 && r >= RISK_RANK2.high;
448
+ }
449
+ function operatingModeGate(mode, ctx) {
450
+ const m = asOperatingMode(mode);
451
+ const valueBearing = (ctx.amount ?? 0) > 0;
452
+ if (!valueBearing || m === "autonomous") return { decision: "allow", reasonCode: null };
453
+ switch (m) {
454
+ case "read_only":
455
+ return { decision: "block", reasonCode: "MODE_READ_ONLY" };
456
+ case "restricted":
457
+ return { decision: "escalate", reasonCode: "MODE_RESTRICTED_REVIEW" };
458
+ case "supervised":
459
+ return riskAtOrAboveHigh(ctx.riskLevel) || (ctx.amount ?? 0) >= SUPERVISED_AMOUNT_CAP ? { decision: "escalate", reasonCode: "MODE_SUPERVISED_REVIEW" } : { decision: "allow", reasonCode: null };
460
+ default:
461
+ return { decision: "allow", reasonCode: null };
462
+ }
463
+ }
392
464
  export {
393
465
  ATOM_REGISTRY,
394
466
  ATOM_SPECS,
395
467
  CATALOGUED_ATOMS,
468
+ MODES_BY_RANK,
469
+ MODE_RANK,
470
+ SUPERVISED_AMOUNT_CAP,
396
471
  applyCapture,
397
472
  applyHold,
398
473
  applySignedLast,
474
+ asOperatingMode,
399
475
  buildAuthMessage,
400
476
  canAuthorize,
401
477
  evaluate,
402
478
  evaluateBoundStandards,
403
479
  evaluateMandate,
404
480
  evaluateStandardRules,
481
+ isOperatingMode,
405
482
  moleculeFires,
483
+ moreRestrictive,
484
+ operatingModeGate,
406
485
  releaseHold,
407
486
  remainingBudget,
408
487
  requiredContextFor,