@metamynd/agentsafe-guard 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,6 +43,62 @@ const guard = await createGuardFromConfig('./agent.metamynd.json'); // no env
43
43
  The rest of this guide shows the manual path (seed → wire) and the advanced features
44
44
  (local eval, handshake, escalation, payments).
45
45
 
46
+ ### Enforcement mode: local-first (default) or remote
47
+
48
+ Since v0.2.0 the guard decides **locally by default**. `guardTool` (and the mode-aware
49
+ `guard.check(...)`) evaluate the rule layer against the agent's cached signed policy
50
+ bundle using the **same `policy-core` bytes the gate runs** — so a **block or escalate
51
+ is decided with no network** (instant, works offline). An **allowed value action**
52
+ (`amount > 0`) is still sealed by the remote gate, because the cumulative-spend cap,
53
+ nonce/replay + atomic cap, and anchored evidence **must** be server-side. If the bundle
54
+ can't be fetched, the guard defers to the authoritative remote gate rather than
55
+ blind-allowing; a value action it can neither evaluate nor seal **fails closed**.
56
+
57
+ ```js
58
+ // default — local-first
59
+ const guard = await createGuardFromConfig('./agent.metamynd.json');
60
+ // opt out — every call hits the gate
61
+ const remote = await createGuardFromConfig('./agent.metamynd.json', { mode: 'remote' });
62
+ // pure offline (no remote seal; drops cumulative-cap + evidence — you accept the trade)
63
+ const offline = await createGuardFromConfig('./agent.metamynd.json', { mode: 'local', sealValueActions: false });
64
+ ```
65
+
66
+ `guard.authorize(...)` is always the explicit **remote** call (unchanged);
67
+ `guard.authorizeLocal(...)` is the explicit local-first call; `guard.check(...)` follows
68
+ the configured `mode`. All return `{ decision, reasonCode, authorizationId, … }`.
69
+
70
+ ### Trustless currency check (`verifyOnChain`)
71
+
72
+ Local eval trusts a bundle fetched over TLS. With `{ verifyOnChain: true }` the guard
73
+ additionally confirms — from a **public Hedera mirror node, with MetaMynd offline** —
74
+ that its local bundle is the **latest one anchored on the agent's own topic**. On each
75
+ recompile MetaMynd publishes `sha256(bundle signature)` to the topic; the guard reads
76
+ the latest `policy-update` op and requires `sha256(bundle.proof.signature)` to match it.
77
+ If it can't confirm (mismatch / not yet anchored / mirror down), it **defers to the
78
+ authoritative remote gate** rather than evaluate a bundle it can't prove is current.
79
+ Append-only Hedera consensus makes the latest op authoritative and a **rollback**
80
+ (serving an older signed bundle) detectable; a monotonic sequence number defeats a mirror
81
+ that hides recent updates.
82
+
83
+ ```js
84
+ const guard = await createGuardFromConfig('./agent.metamynd.json', { verifyOnChain: true });
85
+ await guard.policyAnchor(); // → { sigDigest, seq } read from Hedera (or null)
86
+ ```
87
+
88
+ ### Push invalidation (`watchPolicy`)
89
+
90
+ By default a rule change is picked up within the bundle's `maxStaleness` (or on the
91
+ next on-chain check). `guard.watchPolicy()` subscribes to a Server-Sent-Events stream
92
+ (`GET /policy/events/:did`, zero-dep — plain `fetch`) so a change **invalidates the
93
+ guard's cache in ~1s** and the next call re-fetches (and re-verifies) the new rules.
94
+ It auto-reconnects; a dropped stream still leaves staleness + the on-chain check as the
95
+ floor, so a missed push degrades latency, never safety.
96
+
97
+ ```js
98
+ const stop = guard.watchPolicy((change) => console.log('rules changed', change));
99
+ // … later: stop.close();
100
+ ```
101
+
46
102
  ### Bring your own key (BYOK)
47
103
 
48
104
  Provision the agent with your **own** public key so MetaMynd never sees the private key. The identity
@@ -55,6 +55,32 @@ export function createGuard(opts = {}) {
55
55
  return crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
56
56
  }
57
57
 
58
+ // --- Enforcement mode (spec §9.2 + local-first plan) --------------------------------------
59
+ // 'local' (DEFAULT): decide the rule layer LOCALLY against a cached signed bundle — a
60
+ // block/escalate needs no network; an allowed VALUE action is still sealed by the remote
61
+ // gate (two-phase hold + cumulative cap + evidence). 'remote': every call hits the gate.
62
+ const mode = opts.mode ?? cfg?.mode ?? 'local';
63
+ const bundleUrl = opts.bundleUrl ?? cfg?.bundleUrl ?? `${base}/policy/bundle/${encodeURIComponent(agentDid)}`;
64
+ const sealValueActions = opts.sealValueActions !== false; // default true
65
+ // Build B — trustless currency check. When on, the guard trusts its local bundle ONLY if that
66
+ // bundle is the LATEST one anchored on the agent's Hedera topic (read from a public mirror);
67
+ // otherwise it defers to the authoritative remote gate. Opt-in for now.
68
+ const verifyOnChain = opts.verifyOnChain ?? cfg?.verifyOnChain ?? false;
69
+ const _anchorTtlMs = opts.anchorTtlMs ?? 60_000;
70
+ let _bundle = null;
71
+ let _bundleAt = 0;
72
+ let _bundleMaxAgeMs = 10 * 60 * 1000; // overwritten by the bundle's maxStaleness
73
+ let _anchor = null;
74
+ let _anchorAt = 0;
75
+ let _highestSeq = 0; // monotonic: never accept a mirror response with fewer policy ops than seen
76
+
77
+ /** Parse an ISO-8601 duration like "PT10M" / "PT30S" / "PT1H" → ms (or null). */
78
+ function _durationMs(s) {
79
+ const m = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/.exec(String(s ?? ''));
80
+ if (!m) return null;
81
+ return ((+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0)) * 1000 || null;
82
+ }
83
+
58
84
  /**
59
85
  * Build a signed authorize request (spec §7.2/§7.3) WITHOUT sending it — the object an
60
86
  * agent presents to a counterparty (e.g. an MCP) so the counterparty can re-verify the
@@ -145,6 +171,144 @@ export function createGuard(opts = {}) {
145
171
  });
146
172
  }
147
173
 
174
+ /** Fetch + cache the agent's signed policy bundle (refreshed per its maxStaleness). */
175
+ async function loadBundle(force = false) {
176
+ const now = Date.now();
177
+ if (!force && _bundle && now - _bundleAt < _bundleMaxAgeMs) return _bundle;
178
+ const res = await fetch(bundleUrl);
179
+ const body = await res.json().catch(() => null);
180
+ const b = body?.data ?? body;
181
+ if (!b || (!b.mandates && !b.sops && !b.standards)) throw new Error(`invalid policy bundle from ${bundleUrl}`);
182
+ _bundle = b;
183
+ _bundleAt = now;
184
+ _bundleMaxAgeMs = _durationMs(b.maxStaleness) ?? _bundleMaxAgeMs;
185
+ return b;
186
+ }
187
+
188
+ /** Map a fetched bundle into the shape evaluateLocally expects, for one action. */
189
+ function _bundleFor(b, action) {
190
+ return {
191
+ standards: (b.standards ?? []).map((s) => ({ standardKey: s.id ?? s.standardKey ?? 'standard', document: s.document })).filter((s) => s.document),
192
+ sops: (b.sops ?? []).map((s) => ({ standardKey: s.id ?? s.sopId ?? 'sop', document: s.document })).filter((s) => s.document),
193
+ mandate: ((b.mandates ?? []).find((m) => m.action === action) ?? (b.mandates ?? [])[0])?.document,
194
+ };
195
+ }
196
+
197
+ const _sha256 = (s) => 'sha256:' + crypto.createHash('sha256').update(String(s)).digest('hex');
198
+
199
+ /**
200
+ * Read the CURRENT anchored policy for this agent from its OWN Hedera topic via a public
201
+ * mirror node — no MetaMynd call (Build B / spec §5.3.1). Returns { sigDigest, seq } of the
202
+ * latest `policy-update` op, or null. Cached for `_anchorTtlMs`; monotonic on `seq`.
203
+ */
204
+ async function _currentAnchor() {
205
+ const now = Date.now();
206
+ if (_anchor && now - _anchorAt < _anchorTtlMs) return _anchor;
207
+ const m = /^did:hedera:([^:]+):[^_]+_(.+)$/.exec(agentDid);
208
+ if (!m) return _anchor;
209
+ const network = m[1];
210
+ const topicId = m[2];
211
+ const mbase = network === 'mainnet' ? 'https://mainnet.mirrornode.hedera.com' : 'https://testnet.mirrornode.hedera.com';
212
+ try {
213
+ // Newest-first: the latest `policy-update` op for this DID is the current policy. Its topic
214
+ // sequence_number is the monotonic marker (globally increasing under Hedera consensus), so a
215
+ // rollback / a mirror hiding recent updates shows a LOWER seq and is rejected. (A very busy
216
+ // topic could bury the op past one page; a per-agent topic won't — pagination is a refinement.)
217
+ const body = await fetch(`${mbase}/api/v1/topics/${topicId}/messages?limit=100&order=desc`).then((r) => (r.ok ? r.json() : null));
218
+ const hit = (body?.messages ?? [])
219
+ .map((x) => { try { return { seq: Number(x.sequence_number), op: JSON.parse(Buffer.from(x.message, 'base64').toString('utf8')) }; } catch { return null; } })
220
+ .filter((e) => e && e.op?.op === 'policy-update' && e.op.did === agentDid)
221
+ .sort((a, b) => b.seq - a.seq)[0];
222
+ if (!hit) return _anchor;
223
+ const a = { sigDigest: hit.op.sigDigest ?? null, seq: hit.seq };
224
+ if (a.seq >= _highestSeq) { _anchor = a; _anchorAt = now; _highestSeq = a.seq; }
225
+ } catch { /* mirror unreachable — keep the last known anchor */ }
226
+ return _anchor;
227
+ }
228
+
229
+ /**
230
+ * LOCAL-FIRST decision (the default). Evaluates the rule layer against the cached
231
+ * bundle with the same policy-core the gate runs — so a block/escalate is decided
232
+ * with NO network. An allowed VALUE action (amount > 0) is then sealed by the remote
233
+ * gate (two-phase hold + cumulative-spend cap + anchored evidence — the parts that
234
+ * MUST be server-side); set `sealValueActions:false` for pure offline. If the bundle
235
+ * can't be loaded, defers to the authoritative remote gate rather than blind-allow.
236
+ */
237
+ async function authorizeLocal(input) {
238
+ const { action, amount = 0 } = input;
239
+ let b;
240
+ try {
241
+ b = await loadBundle();
242
+ } catch {
243
+ return authorize(input); // no local rules → authoritative remote gate
244
+ }
245
+ // Trustless currency check (Build B): trust the local bundle only if it is the LATEST one
246
+ // anchored on Hedera; otherwise defer to the authoritative remote gate (never evaluate against
247
+ // a bundle we can't prove is current — this defeats a stale/rolled-back or forged bundle).
248
+ if (verifyOnChain) {
249
+ const anchor = await _currentAnchor();
250
+ const sig = b?.proof?.signature;
251
+ if (!anchor?.sigDigest || !sig || _sha256(sig) !== anchor.sigDigest) return authorize(input);
252
+ }
253
+ 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
257
+ }
258
+
259
+ /** Mode-aware decision used by guardTool: 'local' (default) or 'remote'. */
260
+ async function check(input) {
261
+ return mode === 'remote' ? authorize(input) : authorizeLocal(input);
262
+ }
263
+
264
+ /**
265
+ * Watch for policy changes over Server-Sent Events (Build C) — ZERO-dependency (plain fetch,
266
+ * no socket client). On a `policy:changed` push the guard invalidates its bundle + on-chain
267
+ * anchor cache, so the NEXT call re-fetches (and re-verifies) the new rules — reaching the edge
268
+ * in ~1s instead of within maxStaleness. Push is an optimization: a dropped stream still leaves
269
+ * staleness (A) + the on-chain check (B) as the floor. Auto-reconnects with a short backoff.
270
+ * Returns a handle with `.close()`. Optional `onChange(payload)` callback.
271
+ */
272
+ function watchPolicy(onChange) {
273
+ let stopped = false;
274
+ let controller = null;
275
+ (async () => {
276
+ while (!stopped) {
277
+ try {
278
+ controller = new AbortController();
279
+ const res = await fetch(`${base}/policy/events/${encodeURIComponent(agentDid)}`, {
280
+ headers: { Accept: 'text/event-stream' },
281
+ signal: controller.signal,
282
+ });
283
+ if (!res.ok || !res.body) throw new Error(`policy events ${res.status}`);
284
+ const reader = res.body.getReader();
285
+ const dec = new TextDecoder();
286
+ let buf = '';
287
+ while (!stopped) {
288
+ const { value, done } = await reader.read();
289
+ if (done) break;
290
+ buf += dec.decode(value, { stream: true });
291
+ let i;
292
+ while ((i = buf.indexOf('\n\n')) >= 0) {
293
+ const frame = buf.slice(0, i);
294
+ buf = buf.slice(i + 2);
295
+ if (!/^event:\s*policy:changed/m.test(frame)) continue; // ignore comments/heartbeats
296
+ _bundle = null; _bundleAt = 0; _anchor = null; _anchorAt = 0; // invalidate → next call re-fetches
297
+ if (onChange) {
298
+ const dline = frame.split('\n').find((l) => l.startsWith('data:'));
299
+ try { onChange(dline ? JSON.parse(dline.slice(5).trim()) : {}); } catch { /* ignore */ }
300
+ }
301
+ }
302
+ }
303
+ } catch {
304
+ /* stream dropped — reconnect */
305
+ }
306
+ if (!stopped) await new Promise((r) => setTimeout(r, 2000));
307
+ }
308
+ })();
309
+ return { close() { stopped = true; try { controller?.abort(); } catch { /* ignore */ } } };
310
+ }
311
+
148
312
  /**
149
313
  * Like guardTool, but evaluates LOCALLY against a policy bundle instead of calling
150
314
  * the gate — cooperative-mode, low-latency governance (spec §9.2). Fails CLOSED:
@@ -188,7 +352,7 @@ export function createGuard(opts = {}) {
188
352
  */
189
353
  function guardTool(action, handler, mapArgs = (a) => a) {
190
354
  return async (args) => {
191
- const decision = await authorize({ action, ...mapArgs(args) });
355
+ const decision = await check({ action, ...mapArgs(args) });
192
356
  if (decision.decision !== 'allow') {
193
357
  const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
194
358
  err.name = 'GovernanceBlocked';
@@ -312,5 +476,5 @@ export function createGuard(opts = {}) {
312
476
  return sign(challenge);
313
477
  }
314
478
 
315
- return { authorize, buildSignedRequest, capture, guardTool, evaluateLocally, guardToolLocal, handshake, preparePayment, escalationStatus, verifyKey, signChallenge, agentDid };
479
+ return { authorize, authorizeLocal, check, loadBundle, policyAnchor: _currentAnchor, watchPolicy, mode, verifyOnChain, buildSignedRequest, capture, guardTool, evaluateLocally, guardToolLocal, handshake, preparePayment, escalationStatus, verifyKey, signChallenge, agentDid };
316
480
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-guard",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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",
package/policy-core.mjs CHANGED
@@ -30,7 +30,12 @@ ${c.output ?? ""}`.toLowerCase();
30
30
  "model-not-allowed": (c, cfg) => notInAllowList(c.model, cfg?.allowed),
31
31
  "tool-not-allowed": (c, cfg) => notInAllowList(c.tool, cfg?.allowed),
32
32
  "pii-present": (c) => c.piiPresent === true,
33
- "rate-limit-exceeded": (c, cfg) => typeof c.callCount === "number" && c.callCount > Number(cfg?.max ?? 0)
33
+ "rate-limit-exceeded": (c, cfg) => typeof c.callCount === "number" && c.callCount > Number(cfg?.max ?? 0),
34
+ // Trust guidance (MetaMynd Trust Index / HCS-28). Fires when the counterparty's trust score is
35
+ // below a soft REVIEW line — intended to author an ESCALATE (route to a human), NOT a hard block.
36
+ // The score is server-derived (signed-last) so the agent's itinerary can't fake it; when no score
37
+ // is present (e.g. no counterparty resolved) the atom simply does not fire — no guidance.
38
+ "hol-trust-below-review": (c, cfg) => typeof c.holTrustScore === "number" && c.holTrustScore < Number(cfg?.reviewBelow ?? 60)
34
39
  };
35
40
  function notInAllowList(value, allowList) {
36
41
  const v = value != null ? String(value).toLowerCase().trim() : "";
@@ -133,6 +138,13 @@ var ATOM_SPECS = [
133
138
  description: "Fires when the rolling call count exceeds a configured maximum.",
134
139
  config: [{ key: "max", type: "number", required: true, description: "Maximum allowed calls" }],
135
140
  requiredContext: ["callCount"]
141
+ },
142
+ {
143
+ predicate: "hol-trust-below-review",
144
+ label: "Counterparty trust below review line",
145
+ 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
+ config: [{ key: "reviewBelow", type: "number", required: true, description: "Trust score (0\u2013100) below which a human is asked to decide" }],
147
+ requiredContext: ["holTrustScore"]
136
148
  }
137
149
  ];
138
150
  var CATALOGUED_ATOMS = ATOM_SPECS.filter((s) => !!ATOM_REGISTRY[s.predicate]);