@metamynd/agentsafe-guard 0.1.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/LICENSE +21 -0
- package/README.md +223 -0
- package/agentsafe-guard.mjs +316 -0
- package/example-openclaw-agent.mjs +47 -0
- package/magp-did.mjs +126 -0
- package/package.json +59 -0
- package/policy-core.mjs +389 -0
- package/x402.mjs +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MetaMynd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# AgentSafe Guard — runtime governance for OpenClaw (and any Node agent)
|
|
2
|
+
|
|
3
|
+
Drop-in middleware that gates an agent's actions through the AgentSafe **authorize** endpoint
|
|
4
|
+
before they run. The gate deterministically returns `allow` / `block` / `escalate` after checking
|
|
5
|
+
the agent's **mandate**, its enforced **Standards**, and its assigned **SOPs** — the same rules a
|
|
6
|
+
compliance team edits in the dashboard, changeable live with no redeploy.
|
|
7
|
+
|
|
8
|
+
- **Zero dependencies.** Uses Node's built-in Ed25519 (`node:crypto`) + `fetch` (Node 18+), plus
|
|
9
|
+
`policy-core.mjs` — the deterministic evaluator, itself dependency-free, generated from
|
|
10
|
+
`backend/src/policy-core` (regenerate with `npm run build:guard-core`).
|
|
11
|
+
- **Deterministic + tamper-resistant.** Every call is Ed25519-signed over a canonical message with a
|
|
12
|
+
single-use nonce; the gate is server-authoritative and cannot be bypassed. No LLM at the gate.
|
|
13
|
+
- **Fail-closed.** A network/gate failure returns `block`, so the agent never proceeds blind.
|
|
14
|
+
- **Local evaluation.** The guard can also evaluate a signed policy bundle **locally** with the same
|
|
15
|
+
`policy-core` the gate runs (MAGP §9.2 cooperative mode) — identical inputs give the identical
|
|
16
|
+
verdict, with no network round-trip. See §4.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm i @metamynd/agentsafe-guard
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Requires Node ≥ 18 (built-in `fetch` + Ed25519). The package has **no dependencies**.
|
|
25
|
+
|
|
26
|
+
### Fastest start — scaffold a governed agent in one command
|
|
27
|
+
|
|
28
|
+
If you don't have an agent config yet, let the scaffolder log you in, provision the agent
|
|
29
|
+
(identity + mandate + starter SOP + Standards in one call), write `agent.metamynd.json`, and drop a
|
|
30
|
+
runnable example:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm create metamynd-agent@latest # or: npx create-metamynd-agent
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
import { createGuardFromConfig } from '@metamynd/agentsafe-guard';
|
|
40
|
+
const guard = await createGuardFromConfig('./agent.metamynd.json'); // no env vars
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The rest of this guide shows the manual path (seed → wire) and the advanced features
|
|
44
|
+
(local eval, handshake, escalation, payments).
|
|
45
|
+
|
|
46
|
+
### Bring your own key (BYOK)
|
|
47
|
+
|
|
48
|
+
Provision the agent with your **own** public key so MetaMynd never sees the private key. The identity
|
|
49
|
+
is issued unverified with a one-time `challenge`; the gate blocks it (`AGENT_KEY_UNVERIFIED`) until you
|
|
50
|
+
prove control. The guard signs the challenge with your key and submits it:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
const guard = await createGuardFromConfig('./agent.metamynd.json', { agentKey: myPrivateKey });
|
|
54
|
+
await guard.verifyKey({ ref: config.identityId, challenge: config.challenge, token: ownerToken }); // one-time
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`guard.signChallenge(challenge)` returns just the hex signature if you'd rather submit verify-key
|
|
58
|
+
yourself. (Fastest path: `npm create metamynd-agent@latest -- --byok` does all of this for you.)
|
|
59
|
+
|
|
60
|
+
## 1. Seed a bound agent (once)
|
|
61
|
+
|
|
62
|
+
Run the seed against a running stack — it prints the agent's `DID` and `KEY`:
|
|
63
|
+
|
|
64
|
+
```powershell
|
|
65
|
+
cd backend
|
|
66
|
+
$env:API_BASE="http://localhost:9926/api/v1" # or https://metamynd.ai/api/v1
|
|
67
|
+
node_modules\.bin\tsx scripts\demo-seed-governance.ts
|
|
68
|
+
# → AGENT_DID = did:hedera:testnet:...
|
|
69
|
+
# → AGENT_KEY = 302e0201...
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The agent is now bound to a mandate (`flight-purchase`), an **enforced Standard** (EU AI Act) and an
|
|
73
|
+
**active SOP** (spend cap + approved tools). Manage/toggle these from the dashboard:
|
|
74
|
+
Super Admin → Standards, Legal Entity → SOPs.
|
|
75
|
+
|
|
76
|
+
## 2. Try the example
|
|
77
|
+
|
|
78
|
+
```powershell
|
|
79
|
+
cd integrations\agentsafe-guard
|
|
80
|
+
$env:AGENTSAFE_API="http://localhost:9926/api/v1"
|
|
81
|
+
$env:AGENT_DID="did:hedera:testnet:..."
|
|
82
|
+
$env:AGENT_KEY="302e0201..."
|
|
83
|
+
node example-openclaw-agent.mjs
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
✅ ALLOW $150 book-flight, low risk → booked PNR-DEMO (remaining $200)
|
|
88
|
+
⛔ BLOCK $600 book-flight → SOP_SPEND_CAP
|
|
89
|
+
⛔ BLOCK $100 wire-transfer tool → SOP_TOOL_BLOCKED
|
|
90
|
+
⚠ ESCALATE $100 high-risk decision → RISK_REVIEW
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Now flip the SOP's **active → inactive** toggle in the UI (or edit a rule) and re-run — the decision
|
|
94
|
+
changes in real time.
|
|
95
|
+
|
|
96
|
+
## 3. Wire it into your OpenClaw agent
|
|
97
|
+
|
|
98
|
+
Wrap each governed tool's handler with `guardTool(...)`. The wrapped handler only runs when the gate
|
|
99
|
+
allows; otherwise it throws a `GovernanceBlocked` error your agent surfaces to the user.
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
import { createGuard, createGuardFromConfig } from '@metamynd/agentsafe-guard';
|
|
103
|
+
|
|
104
|
+
// Preferred: load the portable config the one-call onboarding endpoint returns (no env vars).
|
|
105
|
+
const guard = await createGuardFromConfig('./agent.metamynd.json');
|
|
106
|
+
|
|
107
|
+
// Or configure explicitly:
|
|
108
|
+
const guardExplicit = createGuard({
|
|
109
|
+
api: process.env.AGENTSAFE_API,
|
|
110
|
+
agentDid: process.env.AGENT_DID,
|
|
111
|
+
agentKey: process.env.AGENT_KEY, // held only by the agent
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Your existing OpenClaw tool handler:
|
|
115
|
+
async function bookFlight(args) { /* …call the airline… */ return { pnr: 'ABC123' }; }
|
|
116
|
+
|
|
117
|
+
// Register the GATED version with OpenClaw instead of the raw handler:
|
|
118
|
+
const gatedBookFlight = guard.guardTool(
|
|
119
|
+
'flight-purchase', // the governed action (matches the mandate scope)
|
|
120
|
+
bookFlight,
|
|
121
|
+
(a) => ({ // map tool args → gate inputs
|
|
122
|
+
amount: a.amount,
|
|
123
|
+
currency: 'USD',
|
|
124
|
+
merchant: a.merchant,
|
|
125
|
+
context: { tool: 'book-flight', jurisdiction: a.jurisdiction, riskLevel: a.riskLevel },
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- If your OpenClaw build has a **pre-tool hook / middleware** instead of raw handlers, call
|
|
131
|
+
`await guard.authorize({ action, amount, merchant, context })` there and refuse on any non-`allow`.
|
|
132
|
+
- **`context`** is what the Standard/SOP atoms read (jurisdiction, model, tool, PII, risk, …). Each
|
|
133
|
+
atom declares what it needs — fetch the catalog at `GET /api/v1/standards/atoms` to see the exact
|
|
134
|
+
fields (`requiredContext`) for the rules your agent is bound to.
|
|
135
|
+
- For **payment** tools (x402, §7a): after `authorize` allows, the Service returns a 402 bound to
|
|
136
|
+
your `authorizationId`. Call `guard.preparePayment(requirements, authorizationId)` — it refuses an
|
|
137
|
+
unbound or mismatched 402 — pay via x402, then reconcile the hold with
|
|
138
|
+
`await guard.capture(authorizationId, amountCharged, bookingRef, settlementTxHash)`. An
|
|
139
|
+
uncaptured hold auto-voids at its expiry (`POST /policy/mandate/authorize/:id/void` to release early).
|
|
140
|
+
|
|
141
|
+
## 4. Evaluate locally (no network)
|
|
142
|
+
|
|
143
|
+
For low-latency, cooperative-mode governance the guard can evaluate a **policy bundle** locally with
|
|
144
|
+
the same deterministic `policy-core` the gate runs — no round-trip. Given the same rule packs,
|
|
145
|
+
mandate, and request, it returns the **identical** `allow` / `block` / `escalate` verdict.
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
const verdict = guard.evaluateLocally({
|
|
149
|
+
standards: [{ standardKey: 'eu-ai-act', document: { molecules: [/* … */] } }],
|
|
150
|
+
sops: [{ standardKey: 'sop:travel', document: { molecules: [/* … */] } }],
|
|
151
|
+
mandate: { permission: [/* ODRL constraints … */] },
|
|
152
|
+
request: { action: 'flight-purchase', amount: 600, merchant: 'amadeus',
|
|
153
|
+
context: { riskLevel: 'low' } },
|
|
154
|
+
});
|
|
155
|
+
// → { decision: 'block', reasonCode: 'SOP_SPEND_CAP', authorizationId: null, remaining: null, proofRef: null }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Or wrap a tool to gate it against a local bundle (fails closed like `guardTool`):
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
const gated = guard.guardToolLocal('flight-purchase', bookFlight, mapArgs, { standards, sops, mandate });
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Signed request fields (`amount`, `merchant`) are always applied over the unsigned `context`, so a
|
|
165
|
+
forged context key can never shadow them (MAGP §6.4.2). Run the self-check:
|
|
166
|
+
|
|
167
|
+
```powershell
|
|
168
|
+
cd integrations\agentsafe-guard
|
|
169
|
+
node local-eval.smoke.mjs # PASS when every local verdict matches the gate
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Local evaluation is the cooperative-mode pre-check; the gate still owns the stateful parts
|
|
173
|
+
(single-use nonce, atomic spend-cap reservation, evidence anchoring), so value-bearing actions should
|
|
174
|
+
still settle through the gate / `capture` flow.
|
|
175
|
+
|
|
176
|
+
## 5. Mutual handshake with a Service (§8.2)
|
|
177
|
+
|
|
178
|
+
Before transacting with a Service (an MCP), the agent and Service prove control of their DIDs
|
|
179
|
+
to each other — no issuer calls, because the keys are embedded in the DIDs (§4.1.2). The agent
|
|
180
|
+
drives the initiator side:
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
const hs = guard.handshake();
|
|
184
|
+
const { nonceA, message } = hs.hello(); // → send HELLO to the Service
|
|
185
|
+
// Service replies with CHALLENGE { toDid, nonceB, sigB(nonceA) }
|
|
186
|
+
const { sigA, handshakeId } = hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
|
|
187
|
+
// Service replies READY { channelId }
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`prove()` throws `HandshakeFailed` if the Service's CHALLENGE does not verify against the key in
|
|
191
|
+
its DID. The Service side uses [`agentsafe-mcp-guard`](../agentsafe-mcp-guard), which also
|
|
192
|
+
re-evaluates the agent's signed request trustlessly (§9.6). Discover a Service's endpoint and
|
|
193
|
+
confirm its key via the public resolver `GET /did/:did` (§4.4).
|
|
194
|
+
|
|
195
|
+
## 6. Escalation — human-in-the-loop (§9a)
|
|
196
|
+
|
|
197
|
+
An `escalate` verdict is **not a denial** — the action is *held* pending the Owner's approval. The
|
|
198
|
+
verdict carries an `escalationId`; no budget is reserved and (for payments) nothing settles until it
|
|
199
|
+
is approved. The Owner resolves it in the dashboard / via `POST /policy/escalations/:id/resolve`;
|
|
200
|
+
the agent polls the outcome:
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
const d = await guard.authorize({ action: 'flight-purchase', amount: 5000, context: { riskLevel: 'high' } });
|
|
204
|
+
if (d.decision === 'escalate') {
|
|
205
|
+
// parked for review — d.escalationId, d.expiresAt
|
|
206
|
+
const outcome = await guard.escalationStatus(d.escalationId);
|
|
207
|
+
// → { status: 'approved' | 'denied' | 'expired' | 'pending', authorizationId, reasonCode }
|
|
208
|
+
// on 'approved', outcome.authorizationId carries into the §7a capture/pay flow.
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
On approval the budget/cap gate re-runs (§9a.3), so an approval still can't overspend; an
|
|
213
|
+
unresolved escalation lapses to denied after its TTL (§9a.4).
|
|
214
|
+
|
|
215
|
+
## Trust model
|
|
216
|
+
|
|
217
|
+
The gate is a **checkpoint** — enforcement is real when it's actually called.
|
|
218
|
+
- **Trustless:** the counterparty the agent transacts with calls the *public* authorize endpoint and
|
|
219
|
+
only proceeds on `allow`. A rogue agent that skips the call can't get the counterparty to act.
|
|
220
|
+
- **Cooperative:** the agent's own tool layer (this guard) calls the gate and refuses on block/escalate.
|
|
221
|
+
|
|
222
|
+
Either way, every decision is Ed25519-authenticated, deterministic, and anchored as evidence
|
|
223
|
+
(visible in the dashboard's Regulator log and on HashScan).
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// agentsafe-guard.mjs — drop-in runtime governance for any Node agent (OpenClaw, LangChain, custom).
|
|
2
|
+
//
|
|
3
|
+
// ZERO external dependencies: uses Node's built-in Ed25519 (node:crypto) + fetch (Node 18+),
|
|
4
|
+
// plus policy-core.mjs (the deterministic evaluator, itself dependency-free, generated from
|
|
5
|
+
// backend/src/policy-core). Before an agent performs a governed action the guard can either
|
|
6
|
+
// call the AgentSafe authorize gate (trustless fallback) OR evaluate a signed policy bundle
|
|
7
|
+
// LOCALLY (spec §9.2 cooperative mode) — both compute the identical allow/block/escalate
|
|
8
|
+
// verdict from the identical inputs, because they run the same policy-core.
|
|
9
|
+
//
|
|
10
|
+
// The agent's private key is a Hedera Ed25519 DER key (the AGENT_KEY the seed prints).
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { evaluate, buildAuthMessage, applySignedLast } from './policy-core.mjs';
|
|
14
|
+
import { verifyDidSignature } from './magp-did.mjs';
|
|
15
|
+
import { checkSettlementBinding } from './x402.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{ api: string, agentDid: string, agentKey: string }} cfg
|
|
19
|
+
* api e.g. "http://localhost:9926/api/v1" or "https://metamynd.ai/api/v1"
|
|
20
|
+
* agentDid the agent's did:hedera
|
|
21
|
+
* agentKey the agent's Ed25519 private key (Hedera DER hex, held only by the agent)
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Async loader — build a guard from the portable config the one-call `POST /onboarding/agent`
|
|
25
|
+
* endpoint returns: a URL, a file path, or the config object itself. Overrides win over the config.
|
|
26
|
+
* const guard = await createGuardFromConfig('./agent.metamynd.json');
|
|
27
|
+
*/
|
|
28
|
+
export async function createGuardFromConfig(source, overrides = {}) {
|
|
29
|
+
let cfg = source;
|
|
30
|
+
if (typeof source === 'string') {
|
|
31
|
+
cfg = /^https?:\/\//.test(source) ? await (await fetch(source)).json() : JSON.parse(readFileSync(source, 'utf8'));
|
|
32
|
+
}
|
|
33
|
+
if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
|
|
34
|
+
return createGuard({ config: cfg, ...overrides });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createGuard(opts = {}) {
|
|
38
|
+
// Accept a portable agent config (from /onboarding/agent) via `config` or `configPath`, in
|
|
39
|
+
// addition to explicit { api, agentDid, agentKey }. Explicit fields win over the config.
|
|
40
|
+
let cfg = opts.config ?? null;
|
|
41
|
+
if (!cfg && opts.configPath) {
|
|
42
|
+
try { cfg = JSON.parse(readFileSync(opts.configPath, 'utf8')); }
|
|
43
|
+
catch (e) { throw new Error(`createGuard: cannot read configPath "${opts.configPath}": ${e.message}`); }
|
|
44
|
+
}
|
|
45
|
+
if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
|
|
46
|
+
const api = opts.api ?? cfg?.apiBase ?? cfg?.api;
|
|
47
|
+
const agentDid = opts.agentDid ?? cfg?.agentDid;
|
|
48
|
+
const agentKey = opts.agentKey ?? cfg?.agentKey;
|
|
49
|
+
if (!api || !agentDid || !agentKey) throw new Error('createGuard requires { api, agentDid, agentKey } — directly, or via { config } / { configPath } / createGuardFromConfig()');
|
|
50
|
+
const base = api.replace(/\/$/, '');
|
|
51
|
+
const privateKey = crypto.createPrivateKey({ key: Buffer.from(agentKey, 'hex'), format: 'der', type: 'pkcs8' });
|
|
52
|
+
|
|
53
|
+
// Ed25519 over the exact canonical message the backend verifies.
|
|
54
|
+
function sign(message) {
|
|
55
|
+
return crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build a signed authorize request (spec §7.2/§7.3) WITHOUT sending it — the object an
|
|
60
|
+
* agent presents to a counterparty (e.g. an MCP) so the counterparty can re-verify the
|
|
61
|
+
* agent's authorization trustlessly against the agent's policy bundle (§9.3). Same shape
|
|
62
|
+
* `authorize()` posts to the gate; a fresh nonce each call.
|
|
63
|
+
*/
|
|
64
|
+
function buildSignedRequest({ action, amount = 0, currency = 'USD', merchant = '', context = {} }) {
|
|
65
|
+
const nonce = crypto.randomUUID();
|
|
66
|
+
const issuedAt = new Date().toISOString();
|
|
67
|
+
const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
|
|
68
|
+
return { agentDid, action, amount, currency, merchant, itinerary: context, nonce, issuedAt, signature: sign(message) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Ask the gate whether an action is authorized. Never throws on a policy decision —
|
|
73
|
+
* returns { decision:'allow'|'block'|'escalate', reasonCode, authorizationId, remaining }.
|
|
74
|
+
* A network/gate failure returns a fail-CLOSED block so the agent can't proceed blind.
|
|
75
|
+
*/
|
|
76
|
+
async function authorize({ action, amount = 0, currency = 'USD', merchant = '', context = {} }) {
|
|
77
|
+
const nonce = crypto.randomUUID();
|
|
78
|
+
const issuedAt = new Date().toISOString();
|
|
79
|
+
// Build the canonical signed message with policy-core so the guard and the
|
|
80
|
+
// backend gate produce byte-identical input to Ed25519 (spec §7.3).
|
|
81
|
+
const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(`${base}/policy/mandate/authorize`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'Content-Type': 'application/json' },
|
|
86
|
+
body: JSON.stringify({ agentDid, action, amount, currency, merchant, itinerary: context, nonce, issuedAt, signature: sign(message) }),
|
|
87
|
+
});
|
|
88
|
+
const body = await res.json().catch(() => null);
|
|
89
|
+
return body?.data ?? { decision: 'block', reasonCode: `GATE_HTTP_${res.status}` };
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return { decision: 'block', reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Settle an approved hold (two-phase). Call after the real action succeeds with the
|
|
97
|
+
* amount actually charged (≤ the authorized amount). Pass the x402 `settlementTxHash`
|
|
98
|
+
* to record the on-chain payment proof against the capture (§7a.3.2). Optional —
|
|
99
|
+
* skip for non-payment tools.
|
|
100
|
+
*/
|
|
101
|
+
async function capture(authorizationId, amountCharged, bookingRef, settlementTxHash) {
|
|
102
|
+
const res = await fetch(`${base}/policy/mandate/authorize/${authorizationId}/capture`, {
|
|
103
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify({ amountCharged, bookingRef, settlementTxHash }),
|
|
105
|
+
});
|
|
106
|
+
return res.json().catch(() => ({}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Evaluate a signed policy bundle LOCALLY — no network — using the same
|
|
111
|
+
* deterministic policy-core the gate runs (spec §9.2 cooperative mode). Given the
|
|
112
|
+
* same (rule packs, mandate, request), this returns the identical verdict the
|
|
113
|
+
* gate would. The stateful parts the gate owns (nonce/replay, atomic spend-cap
|
|
114
|
+
* reservation, evidence anchoring) are NOT done here — this is the local
|
|
115
|
+
* allow/block/escalate pre-check, so `authorizationId`/`remaining` are null.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} p
|
|
118
|
+
* @param {Array<{standardKey:string,document:object}>} [p.standards] enforced Standards bound to the agent
|
|
119
|
+
* @param {Array<{standardKey:string,document:object}>} [p.sops] active SOPs assigned to the agent
|
|
120
|
+
* @param {object} [p.mandate] the ODRL mandate document (omit to skip the mandate layer)
|
|
121
|
+
* @param {{action:string,amount?:number,merchant?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
|
|
122
|
+
* @returns {{decision:'allow'|'block'|'escalate',reasonCode:string|null,authorizationId:null,remaining:null,proofRef:null}}
|
|
123
|
+
*/
|
|
124
|
+
function evaluateLocally({ standards = [], sops = [], mandate, request }) {
|
|
125
|
+
const { action, amount = 0, merchant = '', context = {}, cumulativeSpend = amount, now } = request;
|
|
126
|
+
// Signed fields (action/agentDid/amount, mm:* operands) are applied LAST so an
|
|
127
|
+
// unsigned context key can never shadow them (spec §6.4.2) — the same invariant
|
|
128
|
+
// the gate enforces, via the same policy-core helper.
|
|
129
|
+
return evaluate({
|
|
130
|
+
standards,
|
|
131
|
+
sops,
|
|
132
|
+
mandate,
|
|
133
|
+
context: applySignedLast(context, { action, agentDid, amount }),
|
|
134
|
+
mandateRequest: mandate
|
|
135
|
+
? {
|
|
136
|
+
target: action,
|
|
137
|
+
now: now ?? new Date().toISOString(),
|
|
138
|
+
values: applySignedLast(context, {
|
|
139
|
+
'mm:payAmount': amount,
|
|
140
|
+
'mm:cumulativeSpend': cumulativeSpend,
|
|
141
|
+
'mm:merchant': merchant,
|
|
142
|
+
}),
|
|
143
|
+
}
|
|
144
|
+
: undefined,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Like guardTool, but evaluates LOCALLY against a policy bundle instead of calling
|
|
150
|
+
* the gate — cooperative-mode, low-latency governance (spec §9.2). Fails CLOSED:
|
|
151
|
+
* any error during local evaluation throws GovernanceBlocked, never allows.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} action
|
|
154
|
+
* @param {(args:any, decision:any)=>any} handler
|
|
155
|
+
* @param {(args:any)=>{amount?:number,merchant?:string,context?:object}} mapArgs
|
|
156
|
+
* @param {object|((args:any)=>object|Promise<object>)} getBundle { standards, sops, mandate } (or a resolver)
|
|
157
|
+
*/
|
|
158
|
+
function guardToolLocal(action, handler, mapArgs = (a) => a, getBundle = {}) {
|
|
159
|
+
return async (args) => {
|
|
160
|
+
let decision;
|
|
161
|
+
try {
|
|
162
|
+
const { amount, merchant, context } = mapArgs(args);
|
|
163
|
+
const bundle = typeof getBundle === 'function' ? await getBundle(args) : getBundle;
|
|
164
|
+
decision = evaluateLocally({ ...bundle, request: { action, amount, merchant, context } });
|
|
165
|
+
} catch (err) {
|
|
166
|
+
decision = { decision: 'block', reasonCode: 'LOCAL_EVAL_ERROR', error: String(err?.message ?? err) };
|
|
167
|
+
}
|
|
168
|
+
if (decision.decision !== 'allow') {
|
|
169
|
+
const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
|
|
170
|
+
err.name = 'GovernanceBlocked';
|
|
171
|
+
err.governance = decision;
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
return handler(args, decision);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Wrap a tool handler so it is gated. Returns a function you register with your agent
|
|
180
|
+
* framework in place of the raw handler. On a non-allow decision it THROWS a
|
|
181
|
+
* GovernanceBlocked error (with `.governance`) so the agent surfaces the reason and
|
|
182
|
+
* does NOT perform the action.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} action the governed action (must match a mandate scope, e.g. 'flight-purchase')
|
|
185
|
+
* @param {(args:any, decision:any)=>any} handler the real tool implementation
|
|
186
|
+
* @param {(args:any)=>{amount?:number,currency?:string,merchant?:string,context?:object}} mapArgs
|
|
187
|
+
* maps the tool's call args to the gate inputs (amount/merchant + the context the rules need)
|
|
188
|
+
*/
|
|
189
|
+
function guardTool(action, handler, mapArgs = (a) => a) {
|
|
190
|
+
return async (args) => {
|
|
191
|
+
const decision = await authorize({ action, ...mapArgs(args) });
|
|
192
|
+
if (decision.decision !== 'allow') {
|
|
193
|
+
const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
|
|
194
|
+
err.name = 'GovernanceBlocked';
|
|
195
|
+
err.governance = decision;
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
return handler(args, decision);
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Mutual-handshake INITIATOR (spec §8.2). Prove control of this agent's DID to a
|
|
204
|
+
* Service and verify the Service controls its DID — no issuer calls (keys are in
|
|
205
|
+
* the DIDs, §4.1.2). Returns { hello, prove } to drive the exchange:
|
|
206
|
+
* const hs = guard.handshake();
|
|
207
|
+
* const { nonceA, message } = hs.hello(); // → send HELLO to the Service
|
|
208
|
+
* const { sigA, handshakeId } = hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
|
|
209
|
+
* `prove` throws HandshakeFailed if the Service's CHALLENGE does not verify.
|
|
210
|
+
*/
|
|
211
|
+
function handshake() {
|
|
212
|
+
return {
|
|
213
|
+
hello() {
|
|
214
|
+
const nonceA = crypto.randomUUID();
|
|
215
|
+
return { nonceA, message: { fromDid: agentDid, nonceA, protoVersion: '0.4' } };
|
|
216
|
+
},
|
|
217
|
+
prove({ nonceA, challenge } = {}) {
|
|
218
|
+
const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
|
|
219
|
+
if (!toDid || !nonceB || !sigB) throw new Error('malformed CHALLENGE');
|
|
220
|
+
if (!verifyDidSignature(toDid, nonceA, sigB)) {
|
|
221
|
+
const e = new Error('Service failed to prove control of its DID');
|
|
222
|
+
e.name = 'HandshakeFailed';
|
|
223
|
+
throw e;
|
|
224
|
+
}
|
|
225
|
+
return { handshakeId, sigA: sign(nonceB), remoteDid: toDid };
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Read a Service's 402 PaymentRequirements and prepare to pay (spec §7a.1 step 5).
|
|
232
|
+
* Refuses a 402 that is NOT bound to a MAGP authorization (§7a.2.1) — the agent
|
|
233
|
+
* must never pay for an ungoverned request — and refuses one whose authorization
|
|
234
|
+
* does not match the `authorizationId` the agent holds from its own authorize
|
|
235
|
+
* (allow) step, so a swapped 402 can't redirect the payment.
|
|
236
|
+
*
|
|
237
|
+
* @param {object} requirements the x402 PaymentRequirements from the 402 response
|
|
238
|
+
* @param {string} [expectedAuthorizationId] the authorizationId from guard.authorize()
|
|
239
|
+
* @returns {{authorizationId:string, amountMinor:string, payTo:string, asset:string, network:string, resource:string}}
|
|
240
|
+
*/
|
|
241
|
+
function preparePayment(requirements, expectedAuthorizationId) {
|
|
242
|
+
const a = requirements?.accepts?.[0];
|
|
243
|
+
if (!a?.extra?.magpAuthorizationId) {
|
|
244
|
+
const e = new Error('402 is not bound to a MAGP authorization — refusing to pay');
|
|
245
|
+
e.name = 'UnboundPayment';
|
|
246
|
+
throw e;
|
|
247
|
+
}
|
|
248
|
+
if (expectedAuthorizationId && a.extra.magpAuthorizationId !== expectedAuthorizationId) {
|
|
249
|
+
const e = new Error('402 authorization does not match the agent authorization');
|
|
250
|
+
e.name = 'AuthorizationMismatch';
|
|
251
|
+
throw e;
|
|
252
|
+
}
|
|
253
|
+
// Pay exactly the authorized amount; the binding check guards against overpay.
|
|
254
|
+
checkSettlementBinding(requirements, { authorizationId: a.extra.magpAuthorizationId, paidAmountMinor: a.maxAmountRequired });
|
|
255
|
+
return {
|
|
256
|
+
authorizationId: a.extra.magpAuthorizationId,
|
|
257
|
+
amountMinor: a.maxAmountRequired,
|
|
258
|
+
payTo: a.payTo,
|
|
259
|
+
asset: a.asset,
|
|
260
|
+
network: a.network,
|
|
261
|
+
resource: a.resource,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Poll the outcome of an escalated action (spec §9a). When authorize() returns
|
|
267
|
+
* `escalate`, its `escalationId` parks the action for the Owner to approve/deny.
|
|
268
|
+
* The agent polls this until the status is terminal; on `approved` the returned
|
|
269
|
+
* `authorizationId` carries into the §7a capture/pay flow. Fails soft (never throws).
|
|
270
|
+
* @returns {Promise<{status:string,reasonCode:string,authorizationId:string|null,expiresAt:string|null}>}
|
|
271
|
+
*/
|
|
272
|
+
async function escalationStatus(escalationId) {
|
|
273
|
+
try {
|
|
274
|
+
const res = await fetch(`${base}/policy/escalations/${encodeURIComponent(escalationId)}/status`);
|
|
275
|
+
const body = await res.json().catch(() => null);
|
|
276
|
+
return body?.data ?? { status: 'unknown', reasonCode: `GATE_HTTP_${res.status}`, authorizationId: null, expiresAt: null };
|
|
277
|
+
} catch (err) {
|
|
278
|
+
return { status: 'unreachable', reasonCode: 'GATE_UNREACHABLE', authorizationId: null, expiresAt: null, error: String(err?.message ?? err) };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* BYOK proof-of-possession (onboarding proposal #4). For an agent that brought its OWN key,
|
|
284
|
+
* MetaMynd issued the identity with a one-time `challenge` and left the key UNVERIFIED — the gate
|
|
285
|
+
* blocks it with AGENT_KEY_UNVERIFIED until control is proven. This signs the challenge with the
|
|
286
|
+
* agent's private key (the same Ed25519 the gate checks) and submits it to verify-key, flipping
|
|
287
|
+
* the key to verified. A one-time SETUP step: verify-key is owner-authenticated, so pass the owner
|
|
288
|
+
* `token` you onboarded with. `ref` defaults to the identityId; `challenge` comes from the config.
|
|
289
|
+
*
|
|
290
|
+
* @param {{ ref: string, challenge: string, token?: string }} p
|
|
291
|
+
* @returns {Promise<{ verified: boolean, did?: string }>}
|
|
292
|
+
*/
|
|
293
|
+
async function verifyKey({ ref, challenge, token } = {}) {
|
|
294
|
+
if (!ref || !challenge) throw new Error('verifyKey requires { ref, challenge } (from the BYOK onboarding config)');
|
|
295
|
+
const res = await fetch(`${base}/agent-identity/${encodeURIComponent(ref)}/verify-key`, {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
298
|
+
body: JSON.stringify({ signature: sign(challenge) }),
|
|
299
|
+
});
|
|
300
|
+
const body = await res.json().catch(() => null);
|
|
301
|
+
if (!res.ok) {
|
|
302
|
+
const e = new Error(body?.message || `verify-key HTTP ${res.status}`);
|
|
303
|
+
e.name = 'KeyVerificationFailed';
|
|
304
|
+
throw e;
|
|
305
|
+
}
|
|
306
|
+
return body?.data ?? { verified: true };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Sign a BYOK challenge with the agent's key (hex) — for integrators who submit verify-key themselves. */
|
|
310
|
+
function signChallenge(challenge) {
|
|
311
|
+
if (!challenge) throw new Error('signChallenge requires the challenge nonce');
|
|
312
|
+
return sign(challenge);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { authorize, buildSignedRequest, capture, guardTool, evaluateLocally, guardToolLocal, handshake, preparePayment, escalationStatus, verifyKey, signChallenge, agentDid };
|
|
316
|
+
}
|
|
@@ -0,0 +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`);
|
package/magp-did.mjs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// GENERATED from backend/src/features/magp/did.ts — do not edit. Regenerate: npm run build:guard-core
|
|
2
|
+
|
|
3
|
+
// src/features/magp/did.ts
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
|
|
6
|
+
// src/features/agent-identity/did.util.ts
|
|
7
|
+
var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
8
|
+
function base58(bytes) {
|
|
9
|
+
let zeros = 0;
|
|
10
|
+
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
|
|
11
|
+
const digits = [];
|
|
12
|
+
for (let i = zeros; i < bytes.length; i++) {
|
|
13
|
+
let carry = bytes[i];
|
|
14
|
+
for (let j = 0; j < digits.length; j++) {
|
|
15
|
+
carry += digits[j] << 8;
|
|
16
|
+
digits[j] = carry % 58;
|
|
17
|
+
carry = carry / 58 | 0;
|
|
18
|
+
}
|
|
19
|
+
while (carry > 0) {
|
|
20
|
+
digits.push(carry % 58);
|
|
21
|
+
carry = carry / 58 | 0;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
let out = "";
|
|
25
|
+
for (let k = 0; k < zeros; k++) out += BASE58_ALPHABET[0];
|
|
26
|
+
for (let q = digits.length - 1; q >= 0; q--) out += BASE58_ALPHABET[digits[q]];
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
function multibaseBase58btc(bytes) {
|
|
30
|
+
return "z" + base58(bytes);
|
|
31
|
+
}
|
|
32
|
+
function buildHederaDid(network, publicKeyBytes, topicId) {
|
|
33
|
+
return `did:hedera:${network}:${multibaseBase58btc(publicKeyBytes)}_${topicId}`;
|
|
34
|
+
}
|
|
35
|
+
function base58Decode(str) {
|
|
36
|
+
const bytes = [0];
|
|
37
|
+
for (const ch of str) {
|
|
38
|
+
const value = BASE58_ALPHABET.indexOf(ch);
|
|
39
|
+
if (value === -1) throw new Error(`invalid base58 character '${ch}'`);
|
|
40
|
+
let carry = value;
|
|
41
|
+
for (let j = 0; j < bytes.length; j++) {
|
|
42
|
+
carry += bytes[j] * 58;
|
|
43
|
+
bytes[j] = carry & 255;
|
|
44
|
+
carry >>= 8;
|
|
45
|
+
}
|
|
46
|
+
while (carry > 0) {
|
|
47
|
+
bytes.push(carry & 255);
|
|
48
|
+
carry >>= 8;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
let zeros = 0;
|
|
52
|
+
for (let k = 0; k < str.length && str[k] === BASE58_ALPHABET[0]; k++) zeros++;
|
|
53
|
+
const out = new Uint8Array(zeros + bytes.length);
|
|
54
|
+
for (let i = 0; i < bytes.length; i++) out[zeros + i] = bytes[bytes.length - 1 - i];
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
function parseHederaDid(did) {
|
|
58
|
+
const m = /^did:hedera:(mainnet|testnet|previewnet|devnet):(z[1-9A-HJ-NP-Za-km-z]+)_(\d+\.\d+\.\d+)$/.exec(did ?? "");
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
const [, network, publicKeyMultibase, topicId] = m;
|
|
61
|
+
let publicKeyBytes;
|
|
62
|
+
try {
|
|
63
|
+
publicKeyBytes = base58Decode(publicKeyMultibase.slice(1));
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (publicKeyBytes.length !== 32) return null;
|
|
68
|
+
return { network, publicKeyMultibase, publicKeyBytes, topicId };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/features/magp/did.ts
|
|
72
|
+
var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
73
|
+
function ed25519KeyFromRaw(raw) {
|
|
74
|
+
const der = Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(raw)]);
|
|
75
|
+
return crypto.createPublicKey({ key: der, format: "der", type: "spki" });
|
|
76
|
+
}
|
|
77
|
+
function verifyDidSignature(did, message, signatureHex) {
|
|
78
|
+
const parsed = parseHederaDid(did);
|
|
79
|
+
if (!parsed) return false;
|
|
80
|
+
try {
|
|
81
|
+
const key = ed25519KeyFromRaw(parsed.publicKeyBytes);
|
|
82
|
+
return crypto.verify(null, Buffer.from(message, "utf8"), key, Buffer.from(signatureHex, "hex"));
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function buildDidDocument(did, service) {
|
|
88
|
+
const parsed = parseHederaDid(did);
|
|
89
|
+
if (!parsed) return null;
|
|
90
|
+
const doc = {
|
|
91
|
+
"@context": ["https://www.w3.org/ns/did/v1"],
|
|
92
|
+
id: did,
|
|
93
|
+
controller: did,
|
|
94
|
+
verificationMethod: [
|
|
95
|
+
{
|
|
96
|
+
id: `${did}#did-root-key`,
|
|
97
|
+
type: "Ed25519VerificationKey2020",
|
|
98
|
+
controller: did,
|
|
99
|
+
publicKeyMultibase: parsed.publicKeyMultibase
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
authentication: [`${did}#did-root-key`]
|
|
103
|
+
};
|
|
104
|
+
if (service) {
|
|
105
|
+
doc.service = [
|
|
106
|
+
{
|
|
107
|
+
id: `${did}#magp`,
|
|
108
|
+
type: "MAGPEndpoint",
|
|
109
|
+
serviceEndpoint: service.serviceEndpoint,
|
|
110
|
+
channels: service.channels,
|
|
111
|
+
protoVersions: service.protoVersions
|
|
112
|
+
}
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
return doc;
|
|
116
|
+
}
|
|
117
|
+
export {
|
|
118
|
+
base58,
|
|
119
|
+
base58Decode,
|
|
120
|
+
buildDidDocument,
|
|
121
|
+
buildHederaDid,
|
|
122
|
+
ed25519KeyFromRaw,
|
|
123
|
+
multibaseBase58btc,
|
|
124
|
+
parseHederaDid,
|
|
125
|
+
verifyDidSignature
|
|
126
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@metamynd/agentsafe-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "./agentsafe-guard.mjs",
|
|
7
|
+
"module": "./agentsafe-guard.mjs",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./agentsafe-guard.mjs",
|
|
10
|
+
"./policy-core": "./policy-core.mjs",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"agentsafe-guard.mjs",
|
|
15
|
+
"policy-core.mjs",
|
|
16
|
+
"magp-did.mjs",
|
|
17
|
+
"x402.mjs",
|
|
18
|
+
"example-openclaw-agent.mjs",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node local-eval.smoke.mjs"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"keywords": [
|
|
30
|
+
"ai",
|
|
31
|
+
"agent",
|
|
32
|
+
"agents",
|
|
33
|
+
"governance",
|
|
34
|
+
"ai-safety",
|
|
35
|
+
"guardrails",
|
|
36
|
+
"policy",
|
|
37
|
+
"mandate",
|
|
38
|
+
"authorization",
|
|
39
|
+
"ed25519",
|
|
40
|
+
"magp",
|
|
41
|
+
"metamynd",
|
|
42
|
+
"agentsafe",
|
|
43
|
+
"compliance"
|
|
44
|
+
],
|
|
45
|
+
"author": "MetaMynd",
|
|
46
|
+
"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
|
+
},
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/jasimp18/AgentSafe/issues"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/policy-core.mjs
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// GENERATED from backend/src/policy-core — do not edit. Regenerate: npm run build:guard-core
|
|
2
|
+
|
|
3
|
+
// src/policy-core/atom-registry.ts
|
|
4
|
+
var RISK_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
5
|
+
var ATOM_REGISTRY = {
|
|
6
|
+
"data-source-not-approved": (c, cfg) => !!c.dataSourceId && !(cfg?.approved ?? []).includes(String(c.dataSourceId)),
|
|
7
|
+
"consent-missing": (c) => c.consent === false,
|
|
8
|
+
"risk-at-or-above": (c, cfg) => {
|
|
9
|
+
const have = RISK_RANK[String(c.riskLevel)];
|
|
10
|
+
const need = RISK_RANK[String(cfg?.level ?? "high")];
|
|
11
|
+
return have !== void 0 && need !== void 0 && have >= need;
|
|
12
|
+
},
|
|
13
|
+
"amount-over": (c, cfg) => typeof c.amount === "number" && c.amount > Number(cfg?.limit ?? 0),
|
|
14
|
+
// Fires if any configured term appears in the prompt and/or output text.
|
|
15
|
+
// Used to govern agent responses on content (prohibited claims, sensitive advice).
|
|
16
|
+
"text-matches": (c, cfg) => {
|
|
17
|
+
const hay = `${c.prompt ?? ""}
|
|
18
|
+
${c.output ?? ""}`.toLowerCase();
|
|
19
|
+
const terms = (cfg?.terms ?? []).map((t) => String(t).toLowerCase());
|
|
20
|
+
return terms.some((t) => t.length > 0 && hay.includes(t));
|
|
21
|
+
},
|
|
22
|
+
// --- Compliance atoms. Allow-list atoms fire when the context field is PRESENT
|
|
23
|
+
// and NOT allowed (consistent with data-source-not-approved: a missing field
|
|
24
|
+
// does not fire — the atom's requiredContext documents what to supply). ---
|
|
25
|
+
"jurisdiction-not-allowed": (c, cfg) => notInAllowList(c.jurisdiction, cfg?.allowed),
|
|
26
|
+
"data-residency-violation": (c, cfg) => notInAllowList(c.dataResidency, cfg?.allowedRegions),
|
|
27
|
+
"model-not-allowed": (c, cfg) => notInAllowList(c.model, cfg?.allowed),
|
|
28
|
+
"tool-not-allowed": (c, cfg) => notInAllowList(c.tool, cfg?.allowed),
|
|
29
|
+
"pii-present": (c) => c.piiPresent === true,
|
|
30
|
+
"rate-limit-exceeded": (c, cfg) => typeof c.callCount === "number" && c.callCount > Number(cfg?.max ?? 0)
|
|
31
|
+
};
|
|
32
|
+
function notInAllowList(value, allowList) {
|
|
33
|
+
const v = value != null ? String(value).toLowerCase().trim() : "";
|
|
34
|
+
const allowed = (Array.isArray(allowList) ? allowList : []).map((x) => String(x).toLowerCase().trim());
|
|
35
|
+
return v !== "" && allowed.length > 0 && !allowed.includes(v);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/policy-core/atom-catalog.ts
|
|
39
|
+
var ATOM_SPECS = [
|
|
40
|
+
{
|
|
41
|
+
predicate: "amount-over",
|
|
42
|
+
label: "Amount over limit",
|
|
43
|
+
description: "Fires when the action amount exceeds a configured limit.",
|
|
44
|
+
config: [{ key: "limit", type: "number", required: true, description: "Maximum allowed amount" }],
|
|
45
|
+
requiredContext: ["amount"]
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
predicate: "risk-at-or-above",
|
|
49
|
+
label: "Risk at or above level",
|
|
50
|
+
description: "Fires when the assessed risk level is at or above the configured threshold.",
|
|
51
|
+
config: [
|
|
52
|
+
{
|
|
53
|
+
key: "level",
|
|
54
|
+
type: "enum",
|
|
55
|
+
required: true,
|
|
56
|
+
description: "Threshold risk level",
|
|
57
|
+
options: ["low", "medium", "high", "critical"]
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
requiredContext: ["riskLevel"]
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
predicate: "data-source-not-approved",
|
|
64
|
+
label: "Data source not approved",
|
|
65
|
+
description: "Fires when the action uses a data source not on the approved list.",
|
|
66
|
+
config: [
|
|
67
|
+
{ key: "approved", type: "string[]", required: true, description: "Allow-list of approved data source ids" }
|
|
68
|
+
],
|
|
69
|
+
requiredContext: ["dataSourceId"]
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
predicate: "consent-missing",
|
|
73
|
+
label: "Consent missing",
|
|
74
|
+
description: "Fires when explicit consent is absent for the action.",
|
|
75
|
+
config: [],
|
|
76
|
+
requiredContext: ["consent"]
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
predicate: "text-matches",
|
|
80
|
+
label: "Text contains prohibited terms",
|
|
81
|
+
description: "Fires when the prompt or output contains any of the configured terms.",
|
|
82
|
+
config: [{ key: "terms", type: "string[]", required: true, description: "Terms that must not appear" }],
|
|
83
|
+
requiredContext: ["prompt", "output"]
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
predicate: "jurisdiction-not-allowed",
|
|
87
|
+
label: "Jurisdiction not allowed",
|
|
88
|
+
description: "Fires when the action's jurisdiction is not on the allow-list.",
|
|
89
|
+
config: [{ key: "allowed", type: "string[]", required: true, description: "Allowed jurisdictions (e.g. US, MY, EU)" }],
|
|
90
|
+
requiredContext: ["jurisdiction"]
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
predicate: "data-residency-violation",
|
|
94
|
+
label: "Data residency violation",
|
|
95
|
+
description: "Fires when data would be processed in a region not on the allow-list.",
|
|
96
|
+
config: [{ key: "allowedRegions", type: "string[]", required: true, description: "Allowed processing regions" }],
|
|
97
|
+
requiredContext: ["dataResidency"]
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
predicate: "model-not-allowed",
|
|
101
|
+
label: "LLM model not allowed",
|
|
102
|
+
description: "Fires when the agent uses an LLM model not on the approved list.",
|
|
103
|
+
config: [{ key: "allowed", type: "string[]", required: true, description: "Approved model ids" }],
|
|
104
|
+
requiredContext: ["model"]
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
predicate: "tool-not-allowed",
|
|
108
|
+
label: "Tool not allowed",
|
|
109
|
+
description: "Fires when the agent invokes a tool/function not on the approved list.",
|
|
110
|
+
config: [{ key: "allowed", type: "string[]", required: true, description: "Approved tool names" }],
|
|
111
|
+
requiredContext: ["tool"]
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
predicate: "pii-present",
|
|
115
|
+
label: "PII present",
|
|
116
|
+
description: "Fires when the action is flagged as involving personal data (PII).",
|
|
117
|
+
config: [],
|
|
118
|
+
requiredContext: ["piiPresent"]
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
predicate: "rate-limit-exceeded",
|
|
122
|
+
label: "Rate limit exceeded",
|
|
123
|
+
description: "Fires when the rolling call count exceeds a configured maximum.",
|
|
124
|
+
config: [{ key: "max", type: "number", required: true, description: "Maximum allowed calls" }],
|
|
125
|
+
requiredContext: ["callCount"]
|
|
126
|
+
}
|
|
127
|
+
];
|
|
128
|
+
var CATALOGUED_ATOMS = ATOM_SPECS.filter((s) => !!ATOM_REGISTRY[s.predicate]);
|
|
129
|
+
function requiredContextFor(predicates) {
|
|
130
|
+
const fields = /* @__PURE__ */ new Set();
|
|
131
|
+
for (const p of predicates) {
|
|
132
|
+
const spec = ATOM_SPECS.find((s) => s.predicate === p);
|
|
133
|
+
for (const f of spec?.requiredContext ?? []) fields.add(f);
|
|
134
|
+
}
|
|
135
|
+
return [...fields].sort();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/policy-core/standards-rules.ts
|
|
139
|
+
var PRECEDENCE = { allow: 0, escalate: 1, block: 2 };
|
|
140
|
+
function atomFires(atom, ctx) {
|
|
141
|
+
const pred = ATOM_REGISTRY[atom.predicate];
|
|
142
|
+
if (!pred) return false;
|
|
143
|
+
try {
|
|
144
|
+
return !!pred(ctx, atom.config);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.warn(
|
|
147
|
+
`[standards] atom '${atom.predicate}' threw during evaluation (treated as not-firing):`,
|
|
148
|
+
err instanceof Error ? err.message : err
|
|
149
|
+
);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function moleculeFires(m, ctx) {
|
|
154
|
+
if (!m.atoms || m.atoms.length === 0) return false;
|
|
155
|
+
const results = m.atoms.map((a) => atomFires(a, ctx));
|
|
156
|
+
switch (m.combinator) {
|
|
157
|
+
case "all":
|
|
158
|
+
return results.every(Boolean);
|
|
159
|
+
case "any":
|
|
160
|
+
return results.some(Boolean);
|
|
161
|
+
case "none":
|
|
162
|
+
return !results.some(Boolean);
|
|
163
|
+
default:
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function evaluateStandardRules(molecules, ctx, standardKey = null) {
|
|
168
|
+
let best = null;
|
|
169
|
+
for (const m of molecules ?? []) {
|
|
170
|
+
if (moleculeFires(m, ctx)) {
|
|
171
|
+
if (!best || PRECEDENCE[m.decision] > PRECEDENCE[best.decision]) {
|
|
172
|
+
best = { decision: m.decision, reasonCode: m.reasonCode, id: m.id };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!best) return { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey };
|
|
177
|
+
return { decision: best.decision, reasonCode: best.reasonCode, firedMoleculeId: best.id, standardKey };
|
|
178
|
+
}
|
|
179
|
+
function evaluateBoundStandards(standards, ctx) {
|
|
180
|
+
let best = { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey: null };
|
|
181
|
+
for (const s of standards) {
|
|
182
|
+
const r = evaluateStandardRules(s.document?.molecules, ctx, s.standardKey);
|
|
183
|
+
if (PRECEDENCE[r.decision] > PRECEDENCE[best.decision]) best = r;
|
|
184
|
+
}
|
|
185
|
+
return best;
|
|
186
|
+
}
|
|
187
|
+
function configValueValid(field, value) {
|
|
188
|
+
switch (field.type) {
|
|
189
|
+
case "number":
|
|
190
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
191
|
+
case "string":
|
|
192
|
+
return typeof value === "string";
|
|
193
|
+
case "string[]":
|
|
194
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
195
|
+
case "enum":
|
|
196
|
+
return typeof value === "string" && (field.options ?? []).includes(value);
|
|
197
|
+
default:
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function validateAtomConfig(predicate, config) {
|
|
202
|
+
const spec = ATOM_SPECS.find((s) => s.predicate === predicate);
|
|
203
|
+
if (!spec) return [];
|
|
204
|
+
const errors = [];
|
|
205
|
+
const cfg = config ?? {};
|
|
206
|
+
for (const field of spec.config) {
|
|
207
|
+
const present = cfg[field.key] !== void 0 && cfg[field.key] !== null;
|
|
208
|
+
if (!present) {
|
|
209
|
+
if (field.required) errors.push(`atom '${predicate}' missing required config '${field.key}'`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (!configValueValid(field, cfg[field.key])) {
|
|
213
|
+
errors.push(`atom '${predicate}' config '${field.key}' must be a ${field.type}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return errors;
|
|
217
|
+
}
|
|
218
|
+
function validateMolecules(molecules) {
|
|
219
|
+
const issues = [];
|
|
220
|
+
for (const m of molecules ?? []) {
|
|
221
|
+
if (!m.id) issues.push({ moleculeId: "(missing id)", message: "molecule is missing an id" });
|
|
222
|
+
if (!["all", "any", "none"].includes(m.combinator)) {
|
|
223
|
+
issues.push({ moleculeId: m.id, message: `invalid combinator '${m.combinator}' (all|any|none)` });
|
|
224
|
+
}
|
|
225
|
+
if (!["block", "escalate"].includes(m.decision)) {
|
|
226
|
+
issues.push({ moleculeId: m.id, message: `invalid decision '${m.decision}' (block|escalate)` });
|
|
227
|
+
}
|
|
228
|
+
if (!m.reasonCode) issues.push({ moleculeId: m.id, message: "molecule is missing a reasonCode" });
|
|
229
|
+
if (!m.atoms || m.atoms.length === 0) {
|
|
230
|
+
issues.push({ moleculeId: m.id, message: "molecule has no atoms" });
|
|
231
|
+
}
|
|
232
|
+
for (const a of m.atoms ?? []) {
|
|
233
|
+
if (!ATOM_REGISTRY[a.predicate]) {
|
|
234
|
+
issues.push({ moleculeId: m.id, message: `unknown atom predicate '${a.predicate}'` });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
for (const err of validateAtomConfig(a.predicate, a.config)) {
|
|
238
|
+
issues.push({ moleculeId: m.id, message: err });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { ok: issues.length === 0, issues };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// src/policy-core/mandate-eval.ts
|
|
246
|
+
var toNum = (v) => typeof v === "number" ? v : Number(v);
|
|
247
|
+
var toArray = (v) => Array.isArray(v) ? v : v === void 0 || v === null ? [] : [v];
|
|
248
|
+
var toTime = (v) => Date.parse(String(v));
|
|
249
|
+
var OPERATORS = {
|
|
250
|
+
eq: (l, r) => l === r,
|
|
251
|
+
neq: (l, r) => l !== r,
|
|
252
|
+
lt: (l, r) => toNum(l) < toNum(r),
|
|
253
|
+
lteq: (l, r) => toNum(l) <= toNum(r),
|
|
254
|
+
gt: (l, r) => toNum(l) > toNum(r),
|
|
255
|
+
gteq: (l, r) => toNum(l) >= toNum(r),
|
|
256
|
+
isAnyOf: (l, r) => toArray(r).includes(l),
|
|
257
|
+
isNoneOf: (l, r) => !toArray(r).includes(l),
|
|
258
|
+
isPartOf: (l, r) => toArray(r).includes(l),
|
|
259
|
+
before: (l, r) => toTime(l) < toTime(r),
|
|
260
|
+
after: (l, r) => toTime(l) > toTime(r)
|
|
261
|
+
};
|
|
262
|
+
var REASON_BY_OPERAND = {
|
|
263
|
+
"mm:payAmount": "SPEND_LIMIT_EXCEEDED",
|
|
264
|
+
"mm:cumulativeSpend": "SPEND_LIMIT_EXCEEDED",
|
|
265
|
+
"mm:merchant": "MERCHANT_NOT_ALLOWED",
|
|
266
|
+
"mm:route": "ROUTE_NOT_ALLOWED",
|
|
267
|
+
"mm:counterparty": "COUNTERPARTY_NOT_ALLOWED"
|
|
268
|
+
};
|
|
269
|
+
function reasonFor(constraint) {
|
|
270
|
+
if (!constraint) return "CONSTRAINT_FAILED";
|
|
271
|
+
return REASON_BY_OPERAND[constraint.leftOperand] ?? `CONSTRAINT_FAILED:${constraint.leftOperand}`;
|
|
272
|
+
}
|
|
273
|
+
function constraintSatisfied(c, req) {
|
|
274
|
+
const op = OPERATORS[c.operator];
|
|
275
|
+
if (!op) return false;
|
|
276
|
+
const left = Object.prototype.hasOwnProperty.call(req.values, c.leftOperand) ? req.values[c.leftOperand] : void 0;
|
|
277
|
+
return op(left, c.rightOperand);
|
|
278
|
+
}
|
|
279
|
+
function targetOf(rule, mandate) {
|
|
280
|
+
return rule.target ?? mandate.target;
|
|
281
|
+
}
|
|
282
|
+
function evaluateMandate(mandate, req) {
|
|
283
|
+
const now = toTime(req.now);
|
|
284
|
+
if (mandate.validFrom && now < toTime(mandate.validFrom)) {
|
|
285
|
+
return { decision: "block", reasonCode: "MANDATE_NOT_YET_VALID", matched: { kind: "expiry" } };
|
|
286
|
+
}
|
|
287
|
+
if (mandate.validUntil && now > toTime(mandate.validUntil)) {
|
|
288
|
+
return { decision: "block", reasonCode: "MANDATE_EXPIRED", matched: { kind: "expiry" } };
|
|
289
|
+
}
|
|
290
|
+
for (const p of mandate.prohibition ?? []) {
|
|
291
|
+
if (targetOf(p, mandate) !== req.target) continue;
|
|
292
|
+
const fires = (p.constraint ?? []).every((c) => constraintSatisfied(c, req));
|
|
293
|
+
if (fires) {
|
|
294
|
+
return {
|
|
295
|
+
decision: p.enforcement ?? "block",
|
|
296
|
+
reasonCode: p.reasonCode ?? "PROHIBITED",
|
|
297
|
+
matched: { kind: "prohibition", target: p.target }
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const perms = (mandate.permission ?? []).filter((p) => targetOf(p, mandate) === req.target);
|
|
302
|
+
if (perms.length === 0) {
|
|
303
|
+
return {
|
|
304
|
+
decision: "block",
|
|
305
|
+
reasonCode: "NO_PERMISSION_FOR_ACTION",
|
|
306
|
+
matched: { kind: "no-permission", target: req.target }
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
for (const p of perms) {
|
|
310
|
+
const failing = (p.constraint ?? []).find((c) => !constraintSatisfied(c, req));
|
|
311
|
+
if (!failing) return { decision: "allow", reasonCode: "AUTHORIZED" };
|
|
312
|
+
}
|
|
313
|
+
const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req));
|
|
314
|
+
return {
|
|
315
|
+
decision: firstFail?.onFail ?? "block",
|
|
316
|
+
reasonCode: reasonFor(firstFail),
|
|
317
|
+
matched: { kind: "permission", target: perms[0].target, constraint: firstFail }
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function remainingBudget(b) {
|
|
321
|
+
return Math.max(0, b.cap - b.spent - b.held);
|
|
322
|
+
}
|
|
323
|
+
function canAuthorize(b, amount) {
|
|
324
|
+
return amount >= 0 && amount <= remainingBudget(b);
|
|
325
|
+
}
|
|
326
|
+
function applyHold(b, amount) {
|
|
327
|
+
return { ...b, held: b.held + amount };
|
|
328
|
+
}
|
|
329
|
+
function applyCapture(b, amount) {
|
|
330
|
+
return { cap: b.cap, spent: b.spent + amount, held: Math.max(0, b.held - amount) };
|
|
331
|
+
}
|
|
332
|
+
function releaseHold(b, amount) {
|
|
333
|
+
return { ...b, held: Math.max(0, b.held - amount) };
|
|
334
|
+
}
|
|
335
|
+
function sumEventField(events, type, field) {
|
|
336
|
+
return events.filter((e) => e.type === type).reduce((acc, e) => acc + (typeof e.payload[field] === "number" ? e.payload[field] : 0), 0);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/policy-core/evaluate.ts
|
|
340
|
+
var PRECEDENCE2 = { allow: 0, escalate: 1, block: 2 };
|
|
341
|
+
function evaluate(input) {
|
|
342
|
+
let decision = "allow";
|
|
343
|
+
let reasonCode = "AUTHORIZED";
|
|
344
|
+
const consider = (d, code) => {
|
|
345
|
+
if (PRECEDENCE2[d] > PRECEDENCE2[decision]) {
|
|
346
|
+
decision = d;
|
|
347
|
+
reasonCode = code;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
const std = evaluateBoundStandards(input.standards ?? [], input.context);
|
|
351
|
+
if (std.decision !== "allow") consider(std.decision, std.reasonCode ?? "STANDARD_RULE");
|
|
352
|
+
const sop = evaluateBoundStandards(input.sops ?? [], input.context);
|
|
353
|
+
if (sop.decision !== "allow") consider(sop.decision, sop.reasonCode ?? "SOP_RULE");
|
|
354
|
+
if (input.mandate && input.mandateRequest) {
|
|
355
|
+
const m = evaluateMandate(input.mandate, input.mandateRequest);
|
|
356
|
+
if (m.decision !== "allow") consider(m.decision, m.reasonCode);
|
|
357
|
+
}
|
|
358
|
+
return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// src/policy-core/canonical.ts
|
|
362
|
+
function buildAuthMessage(f) {
|
|
363
|
+
return `${f.agentDid}|${f.action}|${f.amount}|${f.currency}|${f.merchant ?? ""}|${f.nonce}|${f.issuedAt}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// src/policy-core/context.ts
|
|
367
|
+
function applySignedLast(unsigned, signed) {
|
|
368
|
+
return { ...unsigned ?? {}, ...signed };
|
|
369
|
+
}
|
|
370
|
+
export {
|
|
371
|
+
ATOM_REGISTRY,
|
|
372
|
+
ATOM_SPECS,
|
|
373
|
+
CATALOGUED_ATOMS,
|
|
374
|
+
applyCapture,
|
|
375
|
+
applyHold,
|
|
376
|
+
applySignedLast,
|
|
377
|
+
buildAuthMessage,
|
|
378
|
+
canAuthorize,
|
|
379
|
+
evaluate,
|
|
380
|
+
evaluateBoundStandards,
|
|
381
|
+
evaluateMandate,
|
|
382
|
+
evaluateStandardRules,
|
|
383
|
+
moleculeFires,
|
|
384
|
+
releaseHold,
|
|
385
|
+
remainingBudget,
|
|
386
|
+
requiredContextFor,
|
|
387
|
+
sumEventField,
|
|
388
|
+
validateMolecules
|
|
389
|
+
};
|
package/x402.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// GENERATED from backend/src/features/magp/x402-binding.ts — do not edit. Regenerate: npm run build:guard-core
|
|
2
|
+
|
|
3
|
+
// src/features/magp/x402-binding.ts
|
|
4
|
+
function toMinorUnits(amount, decimals) {
|
|
5
|
+
if (!Number.isFinite(amount) || amount < 0) throw new Error("amount must be a non-negative number");
|
|
6
|
+
const factor = 10 ** decimals;
|
|
7
|
+
return String(Math.round(amount * factor));
|
|
8
|
+
}
|
|
9
|
+
function buildPaymentRequirements(input) {
|
|
10
|
+
if (!input.authorizationId) throw new Error("buildPaymentRequirements requires an authorizationId");
|
|
11
|
+
const decimals = input.decimals ?? 6;
|
|
12
|
+
return {
|
|
13
|
+
x402Version: 1,
|
|
14
|
+
accepts: [
|
|
15
|
+
{
|
|
16
|
+
scheme: "exact",
|
|
17
|
+
network: input.network ?? "hedera-testnet",
|
|
18
|
+
maxAmountRequired: toMinorUnits(input.amount, decimals),
|
|
19
|
+
payTo: input.payTo,
|
|
20
|
+
asset: input.asset,
|
|
21
|
+
resource: input.resource,
|
|
22
|
+
extra: { magpAuthorizationId: input.authorizationId, magpAgentDid: input.agentDid }
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function checkSettlementBinding(requirements, claim) {
|
|
28
|
+
const accept = requirements?.accepts?.[0];
|
|
29
|
+
if (!accept?.extra?.magpAuthorizationId) return { ok: false, reasonCode: "MISSING_BINDING" };
|
|
30
|
+
if (accept.extra.magpAuthorizationId !== claim.authorizationId) {
|
|
31
|
+
return { ok: false, reasonCode: "AUTHORIZATION_MISMATCH" };
|
|
32
|
+
}
|
|
33
|
+
const authorizedMinor = Number(accept.maxAmountRequired);
|
|
34
|
+
const paidMinor = Number(claim.paidAmountMinor);
|
|
35
|
+
if (!Number.isFinite(paidMinor) || paidMinor < 0) return { ok: false, reasonCode: "AMOUNT_INVALID" };
|
|
36
|
+
if (paidMinor > authorizedMinor) return { ok: false, reasonCode: "AMOUNT_MISMATCH" };
|
|
37
|
+
return { ok: true, reasonCode: "BINDING_OK" };
|
|
38
|
+
}
|
|
39
|
+
export {
|
|
40
|
+
buildPaymentRequirements,
|
|
41
|
+
checkSettlementBinding,
|
|
42
|
+
toMinorUnits
|
|
43
|
+
};
|