@metamynd/agentsafe-guard 0.1.0 → 0.1.2
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 +223 -223
- package/agentsafe-guard.mjs +316 -316
- package/example-openclaw-agent.mjs +47 -47
- package/magp-did.mjs +39 -8
- package/package.json +1 -1
- package/policy-core.mjs +26 -4
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
// example-openclaw-agent.mjs — a runnable demo of the guard against a seeded bound agent.
|
|
2
|
-
//
|
|
3
|
-
// Simulates how an OpenClaw agent's TOOL is wrapped: the raw tool only runs if the
|
|
4
|
-
// AgentSafe gate returns `allow`. Run it after seeding an agent with
|
|
5
|
-
// backend/scripts/demo-seed-governance.ts:
|
|
6
|
-
//
|
|
7
|
-
// $env:AGENTSAFE_API="http://localhost:9926/api/v1"
|
|
8
|
-
// $env:AGENT_DID="did:hedera:testnet:..."
|
|
9
|
-
// $env:AGENT_KEY="302e0201..."
|
|
10
|
-
// node example-openclaw-agent.mjs
|
|
11
|
-
import { createGuard } from './agentsafe-guard.mjs';
|
|
12
|
-
|
|
13
|
-
const guard = createGuard({
|
|
14
|
-
api: process.env.AGENTSAFE_API ?? 'http://localhost:9926/api/v1',
|
|
15
|
-
agentDid: process.env.AGENT_DID,
|
|
16
|
-
agentKey: process.env.AGENT_KEY,
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
// --- The agent's tool. In OpenClaw you register this handler for the tool; here we
|
|
20
|
-
// wrap it with guard.guardTool so every call is gated first. ---
|
|
21
|
-
const bookFlight = guard.guardTool(
|
|
22
|
-
'flight-purchase', // the governed action (matches the mandate scope)
|
|
23
|
-
async (args, decision) => {
|
|
24
|
-
// Only reached when the gate ALLOWED. Real booking would go here.
|
|
25
|
-
return { booked: true, pnr: 'PNR-DEMO', remaining: decision.remaining, ...args };
|
|
26
|
-
},
|
|
27
|
-
// Map the tool args → gate inputs. `context` carries what the Standard/SOP rules need.
|
|
28
|
-
(a) => ({ amount: a.amount, currency: 'USD', merchant: a.merchant, context: { tool: a.tool ?? 'book-flight', riskLevel: a.riskLevel ?? 'low' } }),
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
async function ask(label, args) {
|
|
32
|
-
try {
|
|
33
|
-
const r = await bookFlight(args);
|
|
34
|
-
console.log(` \x1b[32m✅ ALLOW\x1b[0m ${label.padEnd(30)} → booked ${r.pnr} (remaining $${r.remaining})`);
|
|
35
|
-
} catch (e) {
|
|
36
|
-
const g = e.governance ?? {};
|
|
37
|
-
const tag = g.decision === 'escalate' ? '\x1b[33m⚠ ESCALATE\x1b[0m' : '\x1b[31m⛔ BLOCK\x1b[0m';
|
|
38
|
-
console.log(` ${tag} ${label.padEnd(30)} → ${g.reasonCode ?? e.message}`);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
console.log(`\n OpenClaw agent — every booking passes through the AgentSafe gate\n ${'─'.repeat(60)}`);
|
|
43
|
-
await ask('$150 book-flight, low risk', { amount: 150, merchant: 'skyward-air', tool: 'book-flight', riskLevel: 'low' });
|
|
44
|
-
await ask('$600 book-flight', { amount: 600, merchant: 'skyward-air', tool: 'book-flight', riskLevel: 'low' });
|
|
45
|
-
await ask('$100 wire-transfer tool', { amount: 100, merchant: 'skyward-air', tool: 'wire-transfer', riskLevel: 'low' });
|
|
46
|
-
await ask('$100 high-risk decision', { amount: 100, merchant: 'skyward-air', tool: 'book-flight', riskLevel: 'high' });
|
|
47
|
-
console.log(`\n The agent refuses blocked/escalated actions itself — governance decided, not the LLM.\n`);
|
|
1
|
+
// example-openclaw-agent.mjs — a runnable demo of the guard against a seeded bound agent.
|
|
2
|
+
//
|
|
3
|
+
// Simulates how an OpenClaw agent's TOOL is wrapped: the raw tool only runs if the
|
|
4
|
+
// AgentSafe gate returns `allow`. Run it after seeding an agent with
|
|
5
|
+
// backend/scripts/demo-seed-governance.ts:
|
|
6
|
+
//
|
|
7
|
+
// $env:AGENTSAFE_API="http://localhost:9926/api/v1"
|
|
8
|
+
// $env:AGENT_DID="did:hedera:testnet:..."
|
|
9
|
+
// $env:AGENT_KEY="302e0201..."
|
|
10
|
+
// node example-openclaw-agent.mjs
|
|
11
|
+
import { createGuard } from './agentsafe-guard.mjs';
|
|
12
|
+
|
|
13
|
+
const guard = createGuard({
|
|
14
|
+
api: process.env.AGENTSAFE_API ?? 'http://localhost:9926/api/v1',
|
|
15
|
+
agentDid: process.env.AGENT_DID,
|
|
16
|
+
agentKey: process.env.AGENT_KEY,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// --- The agent's tool. In OpenClaw you register this handler for the tool; here we
|
|
20
|
+
// wrap it with guard.guardTool so every call is gated first. ---
|
|
21
|
+
const bookFlight = guard.guardTool(
|
|
22
|
+
'flight-purchase', // the governed action (matches the mandate scope)
|
|
23
|
+
async (args, decision) => {
|
|
24
|
+
// Only reached when the gate ALLOWED. Real booking would go here.
|
|
25
|
+
return { booked: true, pnr: 'PNR-DEMO', remaining: decision.remaining, ...args };
|
|
26
|
+
},
|
|
27
|
+
// Map the tool args → gate inputs. `context` carries what the Standard/SOP rules need.
|
|
28
|
+
(a) => ({ amount: a.amount, currency: 'USD', merchant: a.merchant, context: { tool: a.tool ?? 'book-flight', riskLevel: a.riskLevel ?? 'low' } }),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
async function ask(label, args) {
|
|
32
|
+
try {
|
|
33
|
+
const r = await bookFlight(args);
|
|
34
|
+
console.log(` \x1b[32m✅ ALLOW\x1b[0m ${label.padEnd(30)} → booked ${r.pnr} (remaining $${r.remaining})`);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
const g = e.governance ?? {};
|
|
37
|
+
const tag = g.decision === 'escalate' ? '\x1b[33m⚠ ESCALATE\x1b[0m' : '\x1b[31m⛔ BLOCK\x1b[0m';
|
|
38
|
+
console.log(` ${tag} ${label.padEnd(30)} → ${g.reasonCode ?? e.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(`\n OpenClaw agent — every booking passes through the AgentSafe gate\n ${'─'.repeat(60)}`);
|
|
43
|
+
await ask('$150 book-flight, low risk', { amount: 150, merchant: 'skyward-air', tool: 'book-flight', riskLevel: 'low' });
|
|
44
|
+
await ask('$600 book-flight', { amount: 600, merchant: 'skyward-air', tool: 'book-flight', riskLevel: 'low' });
|
|
45
|
+
await ask('$100 wire-transfer tool', { amount: 100, merchant: 'skyward-air', tool: 'wire-transfer', riskLevel: 'low' });
|
|
46
|
+
await ask('$100 high-risk decision', { amount: 100, merchant: 'skyward-air', tool: 'book-flight', riskLevel: 'high' });
|
|
47
|
+
console.log(`\n The agent refuses blocked/escalated actions itself — governance decided, not the LLM.\n`);
|
package/magp-did.mjs
CHANGED
|
@@ -67,6 +67,30 @@ function parseHederaDid(did) {
|
|
|
67
67
|
if (publicKeyBytes.length !== 32) return null;
|
|
68
68
|
return { network, publicKeyMultibase, publicKeyBytes, topicId };
|
|
69
69
|
}
|
|
70
|
+
var ED25519_MULTICODEC = Uint8Array.of(237, 1);
|
|
71
|
+
function buildDidKey(publicKeyBytes) {
|
|
72
|
+
const prefixed = new Uint8Array(ED25519_MULTICODEC.length + publicKeyBytes.length);
|
|
73
|
+
prefixed.set(ED25519_MULTICODEC, 0);
|
|
74
|
+
prefixed.set(publicKeyBytes, ED25519_MULTICODEC.length);
|
|
75
|
+
return `did:key:${multibaseBase58btc(prefixed)}`;
|
|
76
|
+
}
|
|
77
|
+
function parseDidKey(did) {
|
|
78
|
+
const m = /^did:key:(z[1-9A-HJ-NP-Za-km-z]+)$/.exec(did ?? "");
|
|
79
|
+
if (!m) return null;
|
|
80
|
+
const multibase = m[1];
|
|
81
|
+
let decoded;
|
|
82
|
+
try {
|
|
83
|
+
decoded = base58Decode(multibase.slice(1));
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (decoded.length !== ED25519_MULTICODEC.length + 32) return null;
|
|
88
|
+
if (decoded[0] !== ED25519_MULTICODEC[0] || decoded[1] !== ED25519_MULTICODEC[1]) return null;
|
|
89
|
+
return { method: "key", publicKeyMultibase: multibase, publicKeyBytes: decoded.slice(ED25519_MULTICODEC.length) };
|
|
90
|
+
}
|
|
91
|
+
function didPublicKeyBytes(did) {
|
|
92
|
+
return parseHederaDid(did)?.publicKeyBytes ?? parseDidKey(did)?.publicKeyBytes ?? null;
|
|
93
|
+
}
|
|
70
94
|
|
|
71
95
|
// src/features/magp/did.ts
|
|
72
96
|
var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
@@ -75,31 +99,35 @@ function ed25519KeyFromRaw(raw) {
|
|
|
75
99
|
return crypto.createPublicKey({ key: der, format: "der", type: "spki" });
|
|
76
100
|
}
|
|
77
101
|
function verifyDidSignature(did, message, signatureHex) {
|
|
78
|
-
const
|
|
79
|
-
if (!
|
|
102
|
+
const publicKeyBytes = didPublicKeyBytes(did);
|
|
103
|
+
if (!publicKeyBytes) return false;
|
|
80
104
|
try {
|
|
81
|
-
const key = ed25519KeyFromRaw(
|
|
105
|
+
const key = ed25519KeyFromRaw(publicKeyBytes);
|
|
82
106
|
return crypto.verify(null, Buffer.from(message, "utf8"), key, Buffer.from(signatureHex, "hex"));
|
|
83
107
|
} catch {
|
|
84
108
|
return false;
|
|
85
109
|
}
|
|
86
110
|
}
|
|
87
111
|
function buildDidDocument(did, service) {
|
|
88
|
-
const
|
|
89
|
-
|
|
112
|
+
const hedera = parseHederaDid(did);
|
|
113
|
+
const key = hedera ? null : parseDidKey(did);
|
|
114
|
+
if (!hedera && !key) return null;
|
|
115
|
+
const publicKeyMultibase = hedera ? hedera.publicKeyMultibase : key.publicKeyMultibase;
|
|
116
|
+
const fragment = hedera ? "#did-root-key" : `#${key.publicKeyMultibase}`;
|
|
117
|
+
const vmId = `${did}${fragment}`;
|
|
90
118
|
const doc = {
|
|
91
119
|
"@context": ["https://www.w3.org/ns/did/v1"],
|
|
92
120
|
id: did,
|
|
93
121
|
controller: did,
|
|
94
122
|
verificationMethod: [
|
|
95
123
|
{
|
|
96
|
-
id:
|
|
124
|
+
id: vmId,
|
|
97
125
|
type: "Ed25519VerificationKey2020",
|
|
98
126
|
controller: did,
|
|
99
|
-
publicKeyMultibase
|
|
127
|
+
publicKeyMultibase
|
|
100
128
|
}
|
|
101
129
|
],
|
|
102
|
-
authentication: [
|
|
130
|
+
authentication: [vmId]
|
|
103
131
|
};
|
|
104
132
|
if (service) {
|
|
105
133
|
doc.service = [
|
|
@@ -118,9 +146,12 @@ export {
|
|
|
118
146
|
base58,
|
|
119
147
|
base58Decode,
|
|
120
148
|
buildDidDocument,
|
|
149
|
+
buildDidKey,
|
|
121
150
|
buildHederaDid,
|
|
151
|
+
didPublicKeyBytes,
|
|
122
152
|
ed25519KeyFromRaw,
|
|
123
153
|
multibaseBase58btc,
|
|
154
|
+
parseDidKey,
|
|
124
155
|
parseHederaDid,
|
|
125
156
|
verifyDidSignature
|
|
126
157
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-guard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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
|
@@ -11,6 +11,9 @@ var ATOM_REGISTRY = {
|
|
|
11
11
|
return have !== void 0 && need !== void 0 && have >= need;
|
|
12
12
|
},
|
|
13
13
|
"amount-over": (c, cfg) => typeof c.amount === "number" && c.amount > Number(cfg?.limit ?? 0),
|
|
14
|
+
// Total budget: cumulativeSpend is a SERVER-derived, signed-last context field (never
|
|
15
|
+
// shadowable by the agent's itinerary), so this compares already-spent + this amount.
|
|
16
|
+
"cumulative-over": (c, cfg) => Number(c.cumulativeSpend ?? 0) + Number(c.amount ?? 0) > Number(cfg?.limit ?? 0),
|
|
14
17
|
// Fires if any configured term appears in the prompt and/or output text.
|
|
15
18
|
// Used to govern agent responses on content (prohibited claims, sensitive advice).
|
|
16
19
|
"text-matches": (c, cfg) => {
|
|
@@ -27,7 +30,12 @@ ${c.output ?? ""}`.toLowerCase();
|
|
|
27
30
|
"model-not-allowed": (c, cfg) => notInAllowList(c.model, cfg?.allowed),
|
|
28
31
|
"tool-not-allowed": (c, cfg) => notInAllowList(c.tool, cfg?.allowed),
|
|
29
32
|
"pii-present": (c) => c.piiPresent === true,
|
|
30
|
-
"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)
|
|
31
39
|
};
|
|
32
40
|
function notInAllowList(value, allowList) {
|
|
33
41
|
const v = value != null ? String(value).toLowerCase().trim() : "";
|
|
@@ -39,9 +47,16 @@ function notInAllowList(value, allowList) {
|
|
|
39
47
|
var ATOM_SPECS = [
|
|
40
48
|
{
|
|
41
49
|
predicate: "amount-over",
|
|
42
|
-
label: "
|
|
43
|
-
description: "Fires when
|
|
44
|
-
config: [{ key: "limit", type: "number", required: true, description: "Maximum allowed amount" }],
|
|
50
|
+
label: "Per-transaction amount over limit",
|
|
51
|
+
description: "Fires when a single action amount exceeds a configured limit (per-transaction cap).",
|
|
52
|
+
config: [{ key: "limit", type: "number", required: true, description: "Maximum allowed amount for one transaction" }],
|
|
53
|
+
requiredContext: ["amount"]
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
predicate: "cumulative-over",
|
|
57
|
+
label: "Total budget over limit",
|
|
58
|
+
description: "Fires when cumulative spend (already-spent + this transaction) exceeds a configured total budget.",
|
|
59
|
+
config: [{ key: "limit", type: "number", required: true, description: "Maximum total budget across all transactions" }],
|
|
45
60
|
requiredContext: ["amount"]
|
|
46
61
|
},
|
|
47
62
|
{
|
|
@@ -123,6 +138,13 @@ var ATOM_SPECS = [
|
|
|
123
138
|
description: "Fires when the rolling call count exceeds a configured maximum.",
|
|
124
139
|
config: [{ key: "max", type: "number", required: true, description: "Maximum allowed calls" }],
|
|
125
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"]
|
|
126
148
|
}
|
|
127
149
|
];
|
|
128
150
|
var CATALOGUED_ATOMS = ATOM_SPECS.filter((s) => !!ATOM_REGISTRY[s.predicate]);
|