@metamynd/agentsafe-guard 0.1.2 → 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 +56 -0
- package/agentsafe-guard.mjs +166 -2
- package/package.json +1 -1
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
|
package/agentsafe-guard.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|