@metamynd/agentsafe-guard 0.7.0 → 0.12.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 +84 -8
- package/agentsafe-guard.mjs +137 -36
- package/key-providers.mjs +174 -0
- package/package.json +3 -2
- package/policy-core.mjs +77 -7
package/README.md
CHANGED
|
@@ -112,6 +112,47 @@ OWN recorded spend history — a baseline the platform's trust-graph engine lear
|
|
|
112
112
|
threshold a human pre-sets. Opt-in (`SPEND_ANOMALY_MODE=on`), off by default. Nothing in this
|
|
113
113
|
package's own evaluation changes; documented here because the count moved again.
|
|
114
114
|
|
|
115
|
+
**0.9.0 — the `keyProvider` seam
|
|
116
|
+
([docs/design/agent-key-custody-local-signer-daemon-plan.md](../../docs/design/agent-key-custody-local-signer-daemon-plan.md)).
|
|
117
|
+
`agentKey` no longer has to be a raw hex key living in this process. Pass
|
|
118
|
+
`keyProvider: 'daemon'` + `daemonSocketPath` instead, and every signature is produced by a
|
|
119
|
+
separate `@metamynd/agentsafe-signer` daemon over a local socket — the key never enters this
|
|
120
|
+
process at all. `agentKey` (unchanged) still works exactly as before and remains the default;
|
|
121
|
+
this is additive, not a replacement.**
|
|
122
|
+
|
|
123
|
+
**Breaking, disclosed plainly rather than silently shipped**: `buildSignedRequest`,
|
|
124
|
+
`signChallenge`, and `guard.handshake().prove()` are now `async` — they return a `Promise` of
|
|
125
|
+
what they used to return directly, because a `daemon`-backed provider genuinely needs a socket
|
|
126
|
+
round trip to produce a signature, and there is no honest way to make that synchronous. Every
|
|
127
|
+
caller of these three needs an `await` added. `authorize()` and `verifyKey()` were already
|
|
128
|
+
`async` and need no changes. New internal module: `key-providers.mjs` (exports
|
|
129
|
+
`createStaticKeyProvider`, `createDaemonKeyProvider`, and the resolution logic `createGuard`
|
|
130
|
+
itself uses) — still zero external dependencies, `node:crypto` + `node:net` only.
|
|
131
|
+
|
|
132
|
+
**0.9.1 — `keyProvider: 'daemon'` retries a transient connect failure.** On Windows the signer
|
|
133
|
+
daemon's socket is a pool of independent named-pipe instances (see
|
|
134
|
+
`@metamynd/agentsafe-signer`'s `windows-secure-pipe.mjs`); each instance is consumed by one
|
|
135
|
+
connection and replaced asynchronously, so two signing requests close together could race that
|
|
136
|
+
replacement window and fail with `DAEMON_UNREACHABLE` even though the daemon was healthy.
|
|
137
|
+
`key-providers.mjs` now retries a connection that fails with `ENOENT` for up to 3 seconds before
|
|
138
|
+
giving up — a genuinely unreachable daemon still fails fast on any other error. No API change.
|
|
139
|
+
|
|
140
|
+
**0.11.0 — `keyProvider: 'daemon'` now supports `signLocalDecision`.** A daemon-custody agent
|
|
141
|
+
gets the same local-first block/escalate/non-value-allow audit reporting the static-key provider
|
|
142
|
+
has had since 0.9.4 — `createDaemonKeyProvider` gained the fifth, optional `signLocalDecision`
|
|
143
|
+
method, and `@metamynd/agentsafe-signer`'s daemon gained the matching `sign-local-decision`
|
|
144
|
+
signing-socket operation. No change for a `createGuard({ agentKey })` caller.
|
|
145
|
+
|
|
146
|
+
**0.12.0 — passphrase-encrypted managed key delivery**
|
|
147
|
+
([docs/design/passphrase-encrypted-key-delivery-plan.md](../../docs/design/passphrase-encrypted-key-delivery-plan.md)).
|
|
148
|
+
A managed key can now be delivered as ciphertext instead of plaintext: set a passphrase at
|
|
149
|
+
issuance, and `agent.metamynd.json` carries `agentKeyEncrypted` (`agentKey: null`) instead of a
|
|
150
|
+
raw key. `createGuardFromConfig(source, { passphrase })` decrypts it in memory before building
|
|
151
|
+
the guard — see "Passphrase-encrypted managed key" above. New export from `key-providers.mjs`:
|
|
152
|
+
`decryptAgentKeyWithPassword` (a byte-for-byte, `node:crypto`-only reimplementation of the
|
|
153
|
+
backend's `encryptWithPassword`/`decryptWithPassword`, cross-verified against it). Fully
|
|
154
|
+
additive: a config without `agentKeyEncrypted` is loaded exactly as before, no passphrase needed.
|
|
155
|
+
|
|
115
156
|
```yaml
|
|
116
157
|
# .github/workflows/governance.yml
|
|
117
158
|
name: Governance
|
|
@@ -201,6 +242,11 @@ nonce/replay + atomic cap, and anchored evidence **must** be server-side. If the
|
|
|
201
242
|
can't be fetched, the guard defers to the authoritative remote gate rather than
|
|
202
243
|
blind-allowing; a value action it can neither evaluate nor seal **fails closed**.
|
|
203
244
|
|
|
245
|
+
A block/escalate/non-value-allow decided locally is still reported to the gate as a
|
|
246
|
+
best-effort, signed **local decision receipt** — visible in the Activity Log and fleet
|
|
247
|
+
decision-mix, but not anchored/evidence-grade (see "Trust model" below). This never
|
|
248
|
+
blocks or delays the call above it.
|
|
249
|
+
|
|
204
250
|
```js
|
|
205
251
|
// default — local-first
|
|
206
252
|
const guard = await createGuardFromConfig('./agent.metamynd.json');
|
|
@@ -257,8 +303,29 @@ const guard = await createGuardFromConfig('./agent.metamynd.json', { agentKey: m
|
|
|
257
303
|
await guard.verifyKey({ ref: config.identityId, challenge: config.challenge, token: ownerToken }); // one-time
|
|
258
304
|
```
|
|
259
305
|
|
|
260
|
-
`guard.signChallenge(challenge)` returns just the hex signature if you'd rather submit
|
|
261
|
-
yourself. (Fastest path: `npm create metamynd-agent@latest -- --byok` does all of this
|
|
306
|
+
`await guard.signChallenge(challenge)` returns just the hex signature if you'd rather submit
|
|
307
|
+
verify-key yourself. (Fastest path: `npm create metamynd-agent@latest -- --byok` does all of this
|
|
308
|
+
for you.)
|
|
309
|
+
|
|
310
|
+
### Passphrase-encrypted managed key
|
|
311
|
+
|
|
312
|
+
If you didn't set a passphrase at issuance, a managed key is delivered **plaintext** — the
|
|
313
|
+
`agentKey` field in `agent.metamynd.json` is usable as-is. Setting a passphrase (Launchpad's
|
|
314
|
+
Identity stage, or `passphrase` on `POST /onboarding/agent`) instead delivers an
|
|
315
|
+
`agentKeyEncrypted` object and leaves `agentKey: null`; decrypt it in memory when you load the
|
|
316
|
+
config:
|
|
317
|
+
|
|
318
|
+
```js
|
|
319
|
+
const guard = await createGuardFromConfig('./agent.metamynd.json', { passphrase: myPassphrase });
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
This is strictly better than a plaintext file sitting on disk, in an email attachment, or in a
|
|
323
|
+
`git add .` — but strictly weaker than `keyProvider: 'daemon'` (no key material in this process
|
|
324
|
+
at all): the decrypted key still lives in this process's memory for as long as it runs.
|
|
325
|
+
**Passphrase loss is unrecoverable**, the same as a lost BYOK private key — MetaMynd never sees
|
|
326
|
+
or stores the passphrase itself, so there is nothing to reset. Omitting `{ passphrase }` when the
|
|
327
|
+
config actually needs one throws a clear, specific error rather than the generic "requires
|
|
328
|
+
agentKey or keyProvider" message.
|
|
262
329
|
|
|
263
330
|
## 1. Seed a bound agent (once)
|
|
264
331
|
|
|
@@ -331,7 +398,11 @@ const gatedBookFlight = guard.guardTool(
|
|
|
331
398
|
```
|
|
332
399
|
|
|
333
400
|
- If your OpenClaw build has a **pre-tool hook / middleware** instead of raw handlers, call
|
|
334
|
-
`await guard.authorize({ action, amount, merchant, context })` there and refuse on any
|
|
401
|
+
`await guard.authorize({ action, amount, merchant, resource, context })` there and refuse on any
|
|
402
|
+
non-`allow`. `resource` is a genuine, non-spoofable "what this action touches" declaration —
|
|
403
|
+
checked against a mandate's `{leftOperand:'resource'}` constraint the same way `merchant` is
|
|
404
|
+
checked against its own allow-list, and just as much a SIGNED field (tampering with it after
|
|
405
|
+
signing fails the request with `SIGNATURE_INVALID`, not a silent bypass).
|
|
335
406
|
- **`context`** is what the Standard/SOP atoms read (jurisdiction, model, tool, PII, risk, …). Each
|
|
336
407
|
atom declares what it needs — fetch the catalog at `GET /api/v1/standards/atoms` to see the exact
|
|
337
408
|
fields (`requiredContext`) for the rules your agent is bound to.
|
|
@@ -388,8 +459,8 @@ const sandboxed = (ctx) => ctx.action === 'flight-purchase' ? sandboxBook(ctx.ar
|
|
|
388
459
|
Contract: `async ({ action, args, decision, proceed }) => result`. Call `proceed()` to execute for
|
|
389
460
|
real; return without it to substitute the side-effect. Self-check: `node execution-adapter.smoke.mjs`.
|
|
390
461
|
|
|
391
|
-
Signed request fields (`amount`, `merchant`) are always applied over the unsigned
|
|
392
|
-
forged context key can never shadow them (MAGP §6.4.2). Run the self-check:
|
|
462
|
+
Signed request fields (`amount`, `merchant`, `resource`) are always applied over the unsigned
|
|
463
|
+
`context`, so a forged context key can never shadow them (MAGP §6.4.2). Run the self-check:
|
|
393
464
|
|
|
394
465
|
```powershell
|
|
395
466
|
cd integrations\agentsafe-guard
|
|
@@ -410,7 +481,7 @@ drives the initiator side:
|
|
|
410
481
|
const hs = guard.handshake();
|
|
411
482
|
const { nonceA, message } = hs.hello(); // → send HELLO to the Service
|
|
412
483
|
// Service replies with CHALLENGE { toDid, nonceB, sigB(nonceA) }
|
|
413
|
-
const { sigA, handshakeId } = hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
|
|
484
|
+
const { sigA, handshakeId } = await hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
|
|
414
485
|
// Service replies READY { channelId }
|
|
415
486
|
```
|
|
416
487
|
|
|
@@ -446,5 +517,10 @@ The gate is a **checkpoint** — enforcement is real when it's actually called.
|
|
|
446
517
|
only proceeds on `allow`. A rogue agent that skips the call can't get the counterparty to act.
|
|
447
518
|
- **Cooperative:** the agent's own tool layer (this guard) calls the gate and refuses on block/escalate.
|
|
448
519
|
|
|
449
|
-
Either way, every decision is Ed25519-authenticated
|
|
450
|
-
|
|
520
|
+
Either way, every decision is Ed25519-authenticated and deterministic. A decision that round-trips
|
|
521
|
+
the gate — a sealed value action, or `mode:'remote'` — is also **anchored as evidence** (visible in
|
|
522
|
+
the dashboard's Regulator log and on HashScan). A purely local-first decision (the default mode's
|
|
523
|
+
block/escalate/non-value-allow — see "Enforcement mode" above) is NOT anchored, but is still
|
|
524
|
+
recorded for Activity Log / decision-mix visibility via a best-effort signed receipt (no hold, no
|
|
525
|
+
spend check, not evidence-grade) — `guardToolLocal()`'s pure-offline path has no server-side trail
|
|
526
|
+
at all, by design.
|
package/agentsafe-guard.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from '
|
|
|
14
14
|
import { envelopeHashFor } from './governance-envelope.mjs';
|
|
15
15
|
import { verifyDidSignature } from './magp-did.mjs';
|
|
16
16
|
import { checkSettlementBinding } from './x402.mjs';
|
|
17
|
+
import { resolveKeyProvider, decryptAgentKeyWithPassword } from './key-providers.mjs';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Replay a Merkle sibling chain and report whether it reconstructs `root`.
|
|
@@ -87,6 +88,14 @@ export function executionAdapterFromEnv(env = (typeof process !== 'undefined' ?
|
|
|
87
88
|
* Async loader — build a guard from the portable config the one-call `POST /onboarding/agent`
|
|
88
89
|
* endpoint returns: a URL, a file path, or the config object itself. Overrides win over the config.
|
|
89
90
|
* const guard = await createGuardFromConfig('./agent.metamynd.json');
|
|
91
|
+
*
|
|
92
|
+
* Passphrase-encrypted key delivery (docs/design/passphrase-encrypted-key-delivery-plan.md): when
|
|
93
|
+
* the loaded config carries `agentKeyEncrypted` (no plaintext `agentKey` — the operator set a
|
|
94
|
+
* passphrase at issuance) and no explicit `agentKey`/`keyProvider` override was given, pass
|
|
95
|
+
* `{ passphrase }` here to decrypt it IN-MEMORY before the guard is built:
|
|
96
|
+
* const guard = await createGuardFromConfig('./agent.metamynd.json', { passphrase: '...' });
|
|
97
|
+
* The passphrase itself is never sent anywhere by this function — only used locally to derive
|
|
98
|
+
* the decryption key, matching the one hard invariant the design doc names.
|
|
90
99
|
*/
|
|
91
100
|
export async function createGuardFromConfig(source, overrides = {}) {
|
|
92
101
|
let cfg = source;
|
|
@@ -94,7 +103,15 @@ export async function createGuardFromConfig(source, overrides = {}) {
|
|
|
94
103
|
cfg = /^https?:\/\//.test(source) ? await (await fetch(source)).json() : JSON.parse(readFileSync(source, 'utf8'));
|
|
95
104
|
}
|
|
96
105
|
if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
|
|
97
|
-
|
|
106
|
+
const { passphrase, ...rest } = overrides;
|
|
107
|
+
if (cfg?.agentKeyEncrypted && !cfg.agentKey && !rest.agentKey && !rest.keyProvider) {
|
|
108
|
+
if (!passphrase) {
|
|
109
|
+
throw new Error("createGuardFromConfig: this config's key is passphrase-encrypted — pass { passphrase }");
|
|
110
|
+
}
|
|
111
|
+
const agentKey = decryptAgentKeyWithPassword(cfg.agentKeyEncrypted.ciphertext, passphrase, cfg.agentKeyEncrypted.salt);
|
|
112
|
+
return createGuard({ config: cfg, agentKey, ...rest });
|
|
113
|
+
}
|
|
114
|
+
return createGuard({ config: cfg, ...rest });
|
|
98
115
|
}
|
|
99
116
|
|
|
100
117
|
export function createGuard(opts = {}) {
|
|
@@ -108,18 +125,17 @@ export function createGuard(opts = {}) {
|
|
|
108
125
|
if (cfg && cfg.data && !cfg.agentDid) cfg = cfg.data; // unwrap a { success, data } API response
|
|
109
126
|
const api = opts.api ?? cfg?.apiBase ?? cfg?.api;
|
|
110
127
|
const agentDid = opts.agentDid ?? cfg?.agentDid;
|
|
111
|
-
|
|
112
|
-
if (!api || !agentDid || !agentKey) throw new Error('createGuard requires { api, agentDid, agentKey } — directly, or via { config } / { configPath } / createGuardFromConfig()');
|
|
128
|
+
if (!api || !agentDid) throw new Error('createGuard requires { api, agentDid } — directly, or via { config } / { configPath } / createGuardFromConfig() — plus either { agentKey } or { keyProvider }');
|
|
113
129
|
const base = api.replace(/\/$/, '');
|
|
114
130
|
// ExecutionAdapter seam (SAFR §19): an explicit opt wins, else the AGENTSAFE_EXECUTION_MODE env,
|
|
115
131
|
// else live. Applies to every guarded tool unless a tool passes its own adapter.
|
|
116
132
|
const defaultExecutionAdapter = opts.executionAdapter ?? executionAdapterFromEnv() ?? liveExecutionAdapter;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
133
|
+
// keyProvider seam (docs/design/agent-key-custody-local-signer-daemon-plan.md): defaults to
|
|
134
|
+
// 'staticKey' (the raw key in THIS process, today's only behavior before this seam existed) —
|
|
135
|
+
// pass keyProvider:'daemon' + daemonSocketPath to keep the key out of this process entirely.
|
|
136
|
+
// Every place this file used to call a local sign(message) now calls one of the provider's
|
|
137
|
+
// four methods instead — see key-providers.mjs for why there are four, not one.
|
|
138
|
+
const keyProvider = resolveKeyProvider(opts, cfg);
|
|
123
139
|
|
|
124
140
|
// Tier 1 context-claim binding (opt-in, docs/design/context-claim-binding.md): when
|
|
125
141
|
// on, sign the GovernanceEnvelope hash too, so a counterparty/gate can prove the
|
|
@@ -127,12 +143,11 @@ export function createGuard(opts = {}) {
|
|
|
127
143
|
// subset. Off by default: a bare request stays a valid degenerate envelope, exactly
|
|
128
144
|
// like today, and the wire body carries no envelopeSignature field at all.
|
|
129
145
|
const signContext = opts.signContext ?? cfg?.signContext ?? false;
|
|
130
|
-
function envelopeSignatureFor({ action, amount, currency, merchant, context, trace, materiality, nonce, issuedAt }) {
|
|
146
|
+
async function envelopeSignatureFor({ action, amount, currency, merchant, context, trace, materiality, nonce, issuedAt }) {
|
|
131
147
|
if (!signContext) return undefined;
|
|
132
148
|
// The hash is independent of `signature` (excluded from what it commits to — see
|
|
133
149
|
// governance-envelope.ts), so an empty placeholder here is exact, not approximate.
|
|
134
|
-
|
|
135
|
-
return sign(hash);
|
|
150
|
+
return keyProvider.signEnvelope({ agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt });
|
|
136
151
|
}
|
|
137
152
|
|
|
138
153
|
// --- Enforcement mode (spec §9.2 + local-first plan) --------------------------------------
|
|
@@ -167,17 +182,40 @@ export function createGuard(opts = {}) {
|
|
|
167
182
|
* agent's authorization trustlessly against the agent's policy bundle (§9.3). Same shape
|
|
168
183
|
* `authorize()` posts to the gate; a fresh nonce each call.
|
|
169
184
|
*/
|
|
170
|
-
function buildSignedRequest({ action, amount
|
|
185
|
+
async function buildSignedRequest({ action, amount, currency, merchant, resource, context = {}, trace, materiality }) {
|
|
171
186
|
const nonce = crypto.randomUUID();
|
|
172
187
|
const issuedAt = new Date().toISOString();
|
|
173
|
-
|
|
188
|
+
// This object is presented to a COUNTERPARTY (spec §9.3) — but its own docstring also
|
|
189
|
+
// promises "same shape authorize() posts to the gate", so it must ALSO independently
|
|
190
|
+
// re-verify if posted straight to /policy/mandate/authorize, not just via a naive
|
|
191
|
+
// counterparty reconstruction. Found live: signing with amount/currency genuinely
|
|
192
|
+
// undefined (bothOmitted) produced a message the backend's authMessage() — which
|
|
193
|
+
// ALWAYS defaults a missing amount/currency to 0/'USD' before reconstructing,
|
|
194
|
+
// regardless of the wire body — could never verify. Fix: SIGN with the same 0/'USD'
|
|
195
|
+
// default authorize() and the gate both use, unconditionally; the WIRE body still
|
|
196
|
+
// keeps a real omission as a real omission. A naive third-party reconstruction (e.g.
|
|
197
|
+
// today's agentsafe-mcp-guard, which doesn't yet apply this same default) would need
|
|
198
|
+
// the same fix to correctly re-verify a bothOmitted request — flagged as a follow-up,
|
|
199
|
+
// not something to leave this function broken against the gate over.
|
|
200
|
+
const bothOmitted = amount === undefined && currency === undefined;
|
|
201
|
+
const wireAmount = bothOmitted ? undefined : (amount ?? 0);
|
|
202
|
+
const wireCurrency = bothOmitted ? undefined : (currency ?? 'USD');
|
|
203
|
+
const signedAmount = amount ?? 0;
|
|
204
|
+
const signedCurrency = currency ?? 'USD';
|
|
205
|
+
// `resource` is independent of amount/currency's bothOmitted pairing — it always signs and
|
|
206
|
+
// travels exactly as given (undefined stays undefined on the wire, buildAuthMessage's own
|
|
207
|
+
// internal `?? ''` fallback handles the signed-message side, same as `merchant`).
|
|
174
208
|
// trace/materiality are GovernanceEnvelope fields (SAFR §5) — unsigned metadata; the
|
|
175
|
-
// signed message stays the action subset, so verification is unchanged.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
209
|
+
// signed message stays the action subset, so verification is unchanged. `resource` is
|
|
210
|
+
// deliberately NOT passed to envelopeSignatureFor: the backend's governance-envelope.ts
|
|
211
|
+
// action fields don't include it yet either (only amount/currency/merchant) — adding it
|
|
212
|
+
// to just one side would break Tier-1 envelope-hash verification for any resource-
|
|
213
|
+
// declaring request. A coordinated backend+guard follow-up, not something to do half here.
|
|
214
|
+
const [signature, envelopeSignature] = await Promise.all([
|
|
215
|
+
keyProvider.signAuthorize({ agentDid, action, amount: signedAmount, currency: signedCurrency, merchant, resource, nonce, issuedAt }),
|
|
216
|
+
envelopeSignatureFor({ action, amount: wireAmount, currency: wireCurrency, merchant, context, trace, materiality, nonce, issuedAt }),
|
|
217
|
+
]);
|
|
218
|
+
return { agentDid, action, amount: wireAmount, currency: wireCurrency, merchant, resource, itinerary: context, trace, materiality, nonce, issuedAt, signature, envelopeSignature };
|
|
181
219
|
}
|
|
182
220
|
|
|
183
221
|
/**
|
|
@@ -185,13 +223,31 @@ export function createGuard(opts = {}) {
|
|
|
185
223
|
* returns { decision:'allow'|'block'|'escalate', reasonCode, authorizationId, remaining }.
|
|
186
224
|
* A network/gate failure returns a fail-CLOSED block so the agent can't proceed blind.
|
|
187
225
|
*/
|
|
188
|
-
async function authorize({ action, amount
|
|
226
|
+
async function authorize({ action, amount, currency, merchant, resource, context = {}, trace, materiality }) {
|
|
189
227
|
const nonce = crypto.randomUUID();
|
|
190
228
|
const issuedAt = new Date().toISOString();
|
|
191
|
-
// Build the canonical signed message with policy-core so the guard and the
|
|
192
|
-
// backend gate produce byte-identical input to Ed25519 (spec §7.3).
|
|
193
|
-
const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
|
|
194
229
|
try {
|
|
230
|
+
// This is verified SERVER-SIDE by the gate, which independently reconstructs the signed
|
|
231
|
+
// message via its own authMessage() (mandate.service.ts) — that function ALSO defaults a
|
|
232
|
+
// missing amount/currency to 0/'USD' before rebuilding the message, regardless of what the
|
|
233
|
+
// wire body actually contains. So the client signs with that same 0/'USD' default whenever
|
|
234
|
+
// a field is genuinely omitted — matching the gate's reconstruction — while the WIRE body
|
|
235
|
+
// below keeps a real omission as a real omission (not the gate's problem: it defaults on
|
|
236
|
+
// its own) rather than fabricating amount:0/currency:'USD' for a non-financial action.
|
|
237
|
+
// `resource` needs no such dance: the gate's authMessage() spreads `...input` directly
|
|
238
|
+
// (no explicit default), and buildAuthMessage's own internal `f.resource ?? ''` fallback
|
|
239
|
+
// already matches whatever this client signs when it too is genuinely omitted.
|
|
240
|
+
const signedAmount = amount ?? 0;
|
|
241
|
+
const signedCurrency = currency ?? 'USD';
|
|
242
|
+
// The canonical message itself is built by the key provider (policy-core's
|
|
243
|
+
// buildAuthMessage, same as the backend gate verifies against — spec §7.3), not here —
|
|
244
|
+
// see key-providers.mjs for why callers pass structured fields, not a pre-built string.
|
|
245
|
+
// `resource` deliberately NOT passed to envelopeSignatureFor — see buildSignedRequest's
|
|
246
|
+
// own comment on why (backend governance-envelope.ts doesn't include it yet either).
|
|
247
|
+
const [signature, envelopeSignature] = await Promise.all([
|
|
248
|
+
keyProvider.signAuthorize({ agentDid, action, amount: signedAmount, currency: signedCurrency, merchant, resource, nonce, issuedAt }),
|
|
249
|
+
envelopeSignatureFor({ action, amount, currency, merchant, context, trace, materiality, nonce, issuedAt }),
|
|
250
|
+
]);
|
|
195
251
|
const res = await fetch(`${base}/policy/mandate/authorize`, {
|
|
196
252
|
method: 'POST',
|
|
197
253
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -199,15 +255,19 @@ export function createGuard(opts = {}) {
|
|
|
199
255
|
// as unsigned-message metadata; JSON.stringify drops them when undefined, so an
|
|
200
256
|
// agent that omits them (or leaves signContext off) sends the legacy body.
|
|
201
257
|
body: JSON.stringify({
|
|
202
|
-
agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt,
|
|
203
|
-
signature
|
|
204
|
-
envelopeSignature
|
|
258
|
+
agentDid, action, amount, currency, merchant, resource, itinerary: context, trace, materiality, nonce, issuedAt,
|
|
259
|
+
signature,
|
|
260
|
+
envelopeSignature,
|
|
205
261
|
}),
|
|
206
262
|
});
|
|
207
263
|
const body = await res.json().catch(() => null);
|
|
208
264
|
return body?.data ?? { decision: 'block', reasonCode: `GATE_HTTP_${res.status}` };
|
|
209
265
|
} catch (err) {
|
|
210
|
-
|
|
266
|
+
// A daemon-backed keyProvider can fail before the gate is ever reached (the signer, not
|
|
267
|
+
// the gate, was unreachable) — a distinct reasonCode so this doesn't read as a gate outage
|
|
268
|
+
// it wasn't. Still fail-CLOSED either way, which is the property that actually matters.
|
|
269
|
+
const reasonCode = err?.code?.startsWith?.('DAEMON_') ? 'SIGNER_UNREACHABLE' : 'GATE_UNREACHABLE';
|
|
270
|
+
return { decision: 'block', reasonCode, error: String(err?.message ?? err) };
|
|
211
271
|
}
|
|
212
272
|
}
|
|
213
273
|
|
|
@@ -355,6 +415,37 @@ export function createGuard(opts = {}) {
|
|
|
355
415
|
return _anchor;
|
|
356
416
|
}
|
|
357
417
|
|
|
418
|
+
// Verdicts the backend's /policy/decisions/local will actually accept (local-decision.service.ts's
|
|
419
|
+
// LOCAL_DECISIONS) — 'quarantine'/'suspend' are containment, a SERVER-state decision whose audit
|
|
420
|
+
// trail already lives on the server (the `contained` flag this very evaluation read came FROM
|
|
421
|
+
// the server's own bundle response), so there is nothing new to report for those.
|
|
422
|
+
const REPORTABLE_LOCAL_DECISIONS = new Set(['allow', 'observe', 'block', 'escalate']);
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Best-effort, NEVER awaited by the caller: reports a purely-local verdict to the gate
|
|
426
|
+
* for audit VISIBILITY only (Activity Log / fleet decision-mix / regulator log otherwise
|
|
427
|
+
* show nothing for the large majority of decisions under the default local-first mode —
|
|
428
|
+
* see docs/design/... and this package's own README "Enforcement mode" section for why
|
|
429
|
+
* that's a deliberate security tradeoff, not a bug, but one that used to leave zero trail
|
|
430
|
+
* anywhere). Silently does nothing if the keyProvider doesn't support it (e.g. the daemon
|
|
431
|
+
* keyProvider, which doesn't implement signLocalDecision in this version) or the verdict
|
|
432
|
+
* isn't one the endpoint accepts. Never throws, never delays the caller.
|
|
433
|
+
*/
|
|
434
|
+
function reportLocalDecision(action, decision, reasonCode) {
|
|
435
|
+
if (typeof keyProvider.signLocalDecision !== 'function') return;
|
|
436
|
+
if (!REPORTABLE_LOCAL_DECISIONS.has(decision)) return;
|
|
437
|
+
const nonce = crypto.randomUUID();
|
|
438
|
+
const issuedAt = new Date().toISOString();
|
|
439
|
+
void (async () => {
|
|
440
|
+
const signature = await keyProvider.signLocalDecision({ agentDid, action, decision, reasonCode, nonce, issuedAt });
|
|
441
|
+
await fetch(`${base}/policy/decisions/local`, {
|
|
442
|
+
method: 'POST',
|
|
443
|
+
headers: { 'Content-Type': 'application/json' },
|
|
444
|
+
body: JSON.stringify({ agentDid, action, decision, reasonCode, nonce, issuedAt, signature }),
|
|
445
|
+
});
|
|
446
|
+
})().catch(() => {});
|
|
447
|
+
}
|
|
448
|
+
|
|
358
449
|
/**
|
|
359
450
|
* LOCAL-FIRST decision (the default). Evaluates the rule layer against the cached
|
|
360
451
|
* bundle with the same policy-core the gate runs — so a block/escalate is decided
|
|
@@ -382,8 +473,12 @@ export function createGuard(opts = {}) {
|
|
|
382
473
|
const local = evaluateLocally({ ..._bundleFor(b, action), request: input });
|
|
383
474
|
// allow/observe both PERMIT; block/escalate/contain are decided locally with no network.
|
|
384
475
|
const permits = local.decision === 'allow' || local.decision === 'observe';
|
|
385
|
-
if (!permits)
|
|
476
|
+
if (!permits) {
|
|
477
|
+
reportLocalDecision(action, local.decision, local.reasonCode); // fire-and-forget — see above
|
|
478
|
+
return local; // denied/escalated locally, no network
|
|
479
|
+
}
|
|
386
480
|
if (amount > 0 && sealValueActions) return authorize(input); // seal value action remotely (allow or observe)
|
|
481
|
+
reportLocalDecision(action, local.decision, local.reasonCode); // non-value permit — fire-and-forget
|
|
387
482
|
return local; // non-value permit — local is sufficient
|
|
388
483
|
}
|
|
389
484
|
|
|
@@ -445,6 +540,12 @@ export function createGuard(opts = {}) {
|
|
|
445
540
|
* the gate — cooperative-mode, low-latency governance (spec §9.2). Fails CLOSED:
|
|
446
541
|
* any error during local evaluation throws GovernanceBlocked, never allows.
|
|
447
542
|
*
|
|
543
|
+
* Unlike `guardTool()`'s default local-first path (`authorizeLocal()`), this one makes
|
|
544
|
+
* NO network call of any kind, ever — that is its entire purpose (pure-offline,
|
|
545
|
+
* cooperative-mode use). It does NOT report to /policy/decisions/local, so a verdict
|
|
546
|
+
* decided this way has NO central audit trail at all, by design — a deliberate,
|
|
547
|
+
* pre-existing tradeoff this package leaves unchanged.
|
|
548
|
+
*
|
|
448
549
|
* @param {string} action
|
|
449
550
|
* @param {(args:any, decision:any)=>any} handler
|
|
450
551
|
* @param {(args:any)=>{amount?:number,currency?:string,merchant?:string,context?:object}} mapArgs
|
|
@@ -514,7 +615,7 @@ export function createGuard(opts = {}) {
|
|
|
514
615
|
* the DIDs, §4.1.2). Returns { hello, prove } to drive the exchange:
|
|
515
616
|
* const hs = guard.handshake();
|
|
516
617
|
* const { nonceA, message } = hs.hello(); // → send HELLO to the Service
|
|
517
|
-
* const { sigA, handshakeId } = hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
|
|
618
|
+
* const { sigA, handshakeId } = await hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
|
|
518
619
|
* `prove` throws HandshakeFailed if the Service's CHALLENGE does not verify.
|
|
519
620
|
*/
|
|
520
621
|
function handshake() {
|
|
@@ -523,7 +624,7 @@ export function createGuard(opts = {}) {
|
|
|
523
624
|
const nonceA = crypto.randomUUID();
|
|
524
625
|
return { nonceA, message: { fromDid: agentDid, nonceA, protoVersion: '1.0' } };
|
|
525
626
|
},
|
|
526
|
-
prove({ nonceA, challenge } = {}) {
|
|
627
|
+
async prove({ nonceA, challenge } = {}) {
|
|
527
628
|
const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
|
|
528
629
|
if (!toDid || !nonceB || !sigB) throw new Error('malformed CHALLENGE');
|
|
529
630
|
if (!verifyDidSignature(toDid, nonceA, sigB)) {
|
|
@@ -531,7 +632,7 @@ export function createGuard(opts = {}) {
|
|
|
531
632
|
e.name = 'HandshakeFailed';
|
|
532
633
|
throw e;
|
|
533
634
|
}
|
|
534
|
-
return { handshakeId, sigA:
|
|
635
|
+
return { handshakeId, sigA: await keyProvider.signHandshakeNonce(nonceB), remoteDid: toDid };
|
|
535
636
|
},
|
|
536
637
|
};
|
|
537
638
|
}
|
|
@@ -700,7 +801,7 @@ export function createGuard(opts = {}) {
|
|
|
700
801
|
const res = await fetch(`${base}/agent-identity/${encodeURIComponent(ref)}/verify-key`, {
|
|
701
802
|
method: 'POST',
|
|
702
803
|
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
703
|
-
body: JSON.stringify({ signature:
|
|
804
|
+
body: JSON.stringify({ signature: await keyProvider.signKeyControlChallenge(challenge) }),
|
|
704
805
|
});
|
|
705
806
|
const body = await res.json().catch(() => null);
|
|
706
807
|
if (!res.ok) {
|
|
@@ -711,10 +812,10 @@ export function createGuard(opts = {}) {
|
|
|
711
812
|
return body?.data ?? { verified: true };
|
|
712
813
|
}
|
|
713
814
|
|
|
714
|
-
/** Sign a BYOK challenge with the agent's key
|
|
715
|
-
function signChallenge(challenge) {
|
|
815
|
+
/** Sign a BYOK challenge with the agent's key — for integrators who submit verify-key themselves. */
|
|
816
|
+
async function signChallenge(challenge) {
|
|
716
817
|
if (!challenge) throw new Error('signChallenge requires the challenge nonce');
|
|
717
|
-
return
|
|
818
|
+
return keyProvider.signKeyControlChallenge(challenge);
|
|
718
819
|
}
|
|
719
820
|
|
|
720
821
|
return { authorize, authorizeLocal, check, loadBundle, policyAnchor: _currentAnchor, watchPolicy, mode, verifyOnChain, buildSignedRequest, capture, guardTool, evaluateLocally, guardToolLocal, handshake, preparePayment, escalationStatus, proof, effectDispatching, effectDispatched, effectUnknown, effectStatus, verifyKey, signChallenge, agentDid, executionAdapter: defaultExecutionAdapter };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// key-providers.mjs — the keyProvider seam (docs/design/agent-key-custody-local-signer-daemon-plan.md).
|
|
2
|
+
// A KeyProvider has four REQUIRED async methods, matching the four places this package always
|
|
3
|
+
// signs something (see agentsafe-guard.mjs: buildSignedRequest/authorize, the opt-in envelope
|
|
4
|
+
// signature, the mutual-handshake PROVE step, and BYOK key-control-proof) — every built-in
|
|
5
|
+
// provider implements all four, and a caller's own plain-object provider must too. `signLocalDecision`
|
|
6
|
+
// is a FIFTH, OPTIONAL method (both built-in providers implement it — see agentsafe-guard.mjs's
|
|
7
|
+
// authorizeLocal(), which checks for it before attempting to report a local decision) — a
|
|
8
|
+
// caller's own plain-object provider that omits it simply doesn't get that audit-visibility
|
|
9
|
+
// enhancement, with no change to its existing behavior otherwise.
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import net from 'node:net';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { buildAuthMessage, buildLocalDecisionMessage } from './policy-core.mjs';
|
|
14
|
+
import { envelopeHashFor } from './governance-envelope.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Today's default: the raw key lives in THIS process (see the design doc's "What this does not
|
|
18
|
+
* do" for the confidentiality tradeoff that implies). Builds each canonical message locally with
|
|
19
|
+
* the same policy-core/governance-envelope functions the backend verifies against, then signs.
|
|
20
|
+
*/
|
|
21
|
+
export function createStaticKeyProvider(agentKeyHex) {
|
|
22
|
+
const privateKey = crypto.createPrivateKey({ key: Buffer.from(agentKeyHex, 'hex'), format: 'der', type: 'pkcs8' });
|
|
23
|
+
const rawSign = (message) => crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
|
|
24
|
+
return {
|
|
25
|
+
async signAuthorize(fields) {
|
|
26
|
+
return rawSign(buildAuthMessage(fields));
|
|
27
|
+
},
|
|
28
|
+
async signEnvelope(fields) {
|
|
29
|
+
return rawSign(envelopeHashFor({ ...fields, signature: '' }));
|
|
30
|
+
},
|
|
31
|
+
async signHandshakeNonce(nonce) {
|
|
32
|
+
return rawSign(nonce);
|
|
33
|
+
},
|
|
34
|
+
async signKeyControlChallenge(challengeHex) {
|
|
35
|
+
return rawSign(challengeHex);
|
|
36
|
+
},
|
|
37
|
+
async signLocalDecision(fields) {
|
|
38
|
+
return rawSign(buildLocalDecisionMessage(fields));
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Same platform/path translation the signer daemon itself uses (integrations/agentsafe-signer/
|
|
45
|
+
* daemon.mjs's toPlatformSocketPath) — must stay identical or client and server compute different
|
|
46
|
+
* pipe names on Windows and never connect. Duplicated rather than imported: this package has no
|
|
47
|
+
* dependency (workspace or npm) on agentsafe-signer, by design (see that package's own README).
|
|
48
|
+
*/
|
|
49
|
+
function toPlatformSocketPath(logicalPath) {
|
|
50
|
+
if (process.platform !== 'win32') return logicalPath;
|
|
51
|
+
const name = crypto.createHash('sha256').update(path.resolve(logicalPath)).digest('hex').slice(0, 32);
|
|
52
|
+
return `\\\\.\\pipe\\agentsafe-signer-${name}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PROTOCOL_VERSION = 1;
|
|
56
|
+
|
|
57
|
+
function daemonRequest(socketPath, op, params, { connectTimeoutMs = 3000 } = {}) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const deadline = Date.now() + connectTimeoutMs;
|
|
60
|
+
function attempt() {
|
|
61
|
+
const sock = net.connect(toPlatformSocketPath(socketPath));
|
|
62
|
+
const requestId = crypto.randomUUID();
|
|
63
|
+
let buf = '';
|
|
64
|
+
const cleanup = () => sock.destroy();
|
|
65
|
+
sock.once('error', (err) => {
|
|
66
|
+
cleanup();
|
|
67
|
+
// On Windows the signing socket is a pool of independent named-pipe instances (see
|
|
68
|
+
// agentsafe-signer/windows-secure-pipe.mjs): each instance is consumed by exactly one
|
|
69
|
+
// connection, then replaced asynchronously. Two requests arriving close enough together
|
|
70
|
+
// can race that replacement window and transiently find zero live instances (ENOENT) even
|
|
71
|
+
// though the daemon itself is up and healthy. Retrying briefly is the same tolerance any
|
|
72
|
+
// client of a local, independently-started daemon needs — not a workaround for a broken
|
|
73
|
+
// invariant — and matches only ENOENT so a daemon that is genuinely down still fails fast.
|
|
74
|
+
if (err.code === 'ENOENT' && Date.now() < deadline) {
|
|
75
|
+
setTimeout(attempt, 20);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
reject(Object.assign(new Error(`agentsafe-signer daemon unreachable at ${socketPath}: ${err.message}`), { code: 'DAEMON_UNREACHABLE' }));
|
|
79
|
+
});
|
|
80
|
+
sock.once('connect', () => {
|
|
81
|
+
sock.write(JSON.stringify({ protocolVersion: PROTOCOL_VERSION, requestId, op, params }) + '\n');
|
|
82
|
+
});
|
|
83
|
+
sock.on('data', (chunk) => {
|
|
84
|
+
buf += chunk.toString('utf8');
|
|
85
|
+
const idx = buf.indexOf('\n');
|
|
86
|
+
if (idx === -1) return;
|
|
87
|
+
let res;
|
|
88
|
+
try {
|
|
89
|
+
res = JSON.parse(buf.slice(0, idx));
|
|
90
|
+
} catch (err) {
|
|
91
|
+
cleanup();
|
|
92
|
+
reject(err);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
cleanup();
|
|
96
|
+
if (res.ok) resolve(res.result);
|
|
97
|
+
else reject(Object.assign(new Error(res.error?.message || res.error?.code || 'daemon rejected request'), { code: res.error?.code }));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
attempt();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The key never enters this process at all — every signature is produced by a separate
|
|
106
|
+
* agentsafe-signer daemon over a local socket, which builds the canonical message itself from
|
|
107
|
+
* these structured fields (never a pre-built string) and returns only the signature.
|
|
108
|
+
*/
|
|
109
|
+
export function createDaemonKeyProvider({ socketPath }) {
|
|
110
|
+
if (!socketPath) throw new Error('createDaemonKeyProvider requires { socketPath }');
|
|
111
|
+
return {
|
|
112
|
+
async signAuthorize(fields) {
|
|
113
|
+
const { signature } = await daemonRequest(socketPath, 'sign-authorize', fields);
|
|
114
|
+
return signature;
|
|
115
|
+
},
|
|
116
|
+
async signEnvelope(fields) {
|
|
117
|
+
const { envelopeSignature } = await daemonRequest(socketPath, 'sign-envelope', fields);
|
|
118
|
+
return envelopeSignature;
|
|
119
|
+
},
|
|
120
|
+
async signHandshakeNonce(nonce) {
|
|
121
|
+
const { signature } = await daemonRequest(socketPath, 'sign-handshake-nonce', { nonce });
|
|
122
|
+
return signature;
|
|
123
|
+
},
|
|
124
|
+
async signKeyControlChallenge(challengeHex) {
|
|
125
|
+
const { signature } = await daemonRequest(socketPath, 'sign-key-control-challenge', { challenge: challengeHex });
|
|
126
|
+
return signature;
|
|
127
|
+
},
|
|
128
|
+
async signLocalDecision(fields) {
|
|
129
|
+
const { signature } = await daemonRequest(socketPath, 'sign-local-decision', fields);
|
|
130
|
+
return signature;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Passphrase-encrypted key delivery (docs/design/passphrase-encrypted-key-delivery-plan.md).
|
|
137
|
+
* A byte-for-byte reimplementation of the backend's `decryptWithPassword`
|
|
138
|
+
* (backend/src/util/encryption.ts) using only node:crypto — PBKDF2-SHA512/100k-iterations/
|
|
139
|
+
* 32-byte key derived from (password, salt), then AES-256-GCM decrypt of the `iv:tag:encrypted`
|
|
140
|
+
* hex-joined triple `encryptWithPassword` produces. MUST stay in lockstep with that function:
|
|
141
|
+
* two independently-written implementations of "the same algorithm" silently diverging is
|
|
142
|
+
* exactly the class of bug buildSignedRequest's bothOmitted case already caught once in this
|
|
143
|
+
* package — see daemon-keyprovider.smoke.mjs's own cross-package pattern for why this is tested
|
|
144
|
+
* against real backend-produced ciphertext, not just self-consistently.
|
|
145
|
+
* @param {string} ciphertext encryptWithPassword()'s combined "iv:tag:encrypted" hex string
|
|
146
|
+
* @param {string} password the operator's passphrase — never persisted, used only here
|
|
147
|
+
* @param {string} salt the salt returned alongside `ciphertext` at issuance time
|
|
148
|
+
* @returns {string} the decrypted plaintext (the agent's private key hex)
|
|
149
|
+
*/
|
|
150
|
+
export function decryptAgentKeyWithPassword(ciphertext, password, salt) {
|
|
151
|
+
const parts = String(ciphertext).split(':');
|
|
152
|
+
if (parts.length !== 3) throw new Error('decryptAgentKeyWithPassword: invalid encrypted data format');
|
|
153
|
+
const [ivHex, tagHex, encryptedHex] = parts;
|
|
154
|
+
// Matches deriveKeyFromPassword exactly: salt is passed as-is (its own hex STRING, UTF-8
|
|
155
|
+
// encoded by pbkdf2Sync's default), not decoded from hex to raw bytes first.
|
|
156
|
+
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha512');
|
|
157
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivHex, 'hex'));
|
|
158
|
+
decipher.setAAD(Buffer.from('hedera-data', 'utf8'));
|
|
159
|
+
decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
|
|
160
|
+
return decipher.update(encryptedHex, 'hex', 'utf8') + decipher.final('utf8');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Resolves `opts.keyProvider`/`opts.agentKey` into a concrete KeyProvider for createGuard(). */
|
|
164
|
+
export function resolveKeyProvider(opts, cfg) {
|
|
165
|
+
const kp = opts.keyProvider ?? cfg?.keyProvider;
|
|
166
|
+
if (kp && typeof kp === 'object' && typeof kp.signAuthorize === 'function') return kp;
|
|
167
|
+
if (kp === 'daemon' || (kp && typeof kp === 'object' && kp.type === 'daemon')) {
|
|
168
|
+
const socketPath = opts.daemonSocketPath ?? cfg?.daemonSocketPath ?? (kp && kp.socketPath);
|
|
169
|
+
return createDaemonKeyProvider({ socketPath });
|
|
170
|
+
}
|
|
171
|
+
const agentKey = opts.agentKey ?? cfg?.agentKey;
|
|
172
|
+
if (!agentKey) throw new Error("createGuard requires a keyProvider, or { agentKey } for the default 'staticKey' provider");
|
|
173
|
+
return createStaticKeyProvider(agentKey);
|
|
174
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Zero-dependency runtime governance for any Node AI agent \u2014 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",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"cli.mjs",
|
|
17
17
|
"verify.mjs",
|
|
18
18
|
"agentsafe-guard.mjs",
|
|
19
|
+
"key-providers.mjs",
|
|
19
20
|
"policy-core.mjs",
|
|
20
21
|
"governance-envelope.mjs",
|
|
21
22
|
"magp-did.mjs",
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
"LICENSE"
|
|
26
27
|
],
|
|
27
28
|
"scripts": {
|
|
28
|
-
"test": "node demo.mjs && node local-eval.smoke.mjs && node execution-adapter.smoke.mjs && node verify.smoke.mjs && node context-signature.smoke.mjs",
|
|
29
|
+
"test": "node demo.mjs && node local-eval.smoke.mjs && node execution-adapter.smoke.mjs && node verify.smoke.mjs && node context-signature.smoke.mjs && node local-decision-report.smoke.mjs && node resource-constraint.smoke.mjs && node passphrase-key.smoke.mjs",
|
|
29
30
|
"demo": "node demo.mjs"
|
|
30
31
|
},
|
|
31
32
|
"engines": {
|
package/policy-core.mjs
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
// src/policy-core/atom-registry.ts
|
|
4
4
|
var RISK_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
5
|
+
function currencyOutOfScope(ctx, cfgCurrency) {
|
|
6
|
+
if (cfgCurrency === void 0 || cfgCurrency === null) return false;
|
|
7
|
+
const allowed = Array.isArray(cfgCurrency) ? cfgCurrency : [cfgCurrency];
|
|
8
|
+
if (allowed.length === 0) return false;
|
|
9
|
+
const currency = ctx.currency;
|
|
10
|
+
const matches = typeof currency === "string" && allowed.some((u) => typeof u === "string" && u.toUpperCase() === currency.toUpperCase());
|
|
11
|
+
return !matches;
|
|
12
|
+
}
|
|
5
13
|
var ATOM_REGISTRY = {
|
|
6
14
|
"data-source-not-approved": (c, cfg) => !!c.dataSourceId && !(cfg?.approved ?? []).includes(String(c.dataSourceId)),
|
|
7
15
|
"consent-missing": (c) => c.consent === false,
|
|
@@ -10,7 +18,11 @@ var ATOM_REGISTRY = {
|
|
|
10
18
|
const need = RISK_RANK[String(cfg?.level ?? "high")];
|
|
11
19
|
return have !== void 0 && need !== void 0 && have >= need;
|
|
12
20
|
},
|
|
13
|
-
"amount-over": (c, cfg) =>
|
|
21
|
+
"amount-over": (c, cfg) => {
|
|
22
|
+
if (typeof c.amount !== "number") return false;
|
|
23
|
+
if (currencyOutOfScope(c, cfg?.currency)) return true;
|
|
24
|
+
return c.amount > Number(cfg?.limit ?? 0);
|
|
25
|
+
},
|
|
14
26
|
// Deny-by-default primitive for value-moving actions. Fires on ABSENCE (like the
|
|
15
27
|
// evidence atoms below, and unlike `amount-over`) OR on a NEGATIVE amount: true when
|
|
16
28
|
// the context carries no usable amount, or one that cannot be trusted for capping —
|
|
@@ -31,7 +43,12 @@ var ATOM_REGISTRY = {
|
|
|
31
43
|
"amount-unknown": (c) => !(typeof c.amount === "number" && Number.isFinite(c.amount) && c.amount >= 0),
|
|
32
44
|
// Total budget: cumulativeSpend is a SERVER-derived, signed-last context field (never
|
|
33
45
|
// shadowable by the agent's itinerary), so this compares already-spent + this amount.
|
|
34
|
-
|
|
46
|
+
// See `currencyOutOfScope` above: a configured currency scope that this request's
|
|
47
|
+
// currency doesn't match fires the cap outright, same fail-closed reasoning as `amount-over`.
|
|
48
|
+
"cumulative-over": (c, cfg) => {
|
|
49
|
+
if (currencyOutOfScope(c, cfg?.currency)) return true;
|
|
50
|
+
return Number(c.cumulativeSpend ?? 0) + Number(c.amount ?? 0) > Number(cfg?.limit ?? 0);
|
|
51
|
+
},
|
|
35
52
|
// Fires if any configured term appears in the prompt and/or output text.
|
|
36
53
|
// Used to govern agent responses on content (prohibited claims, sensitive advice).
|
|
37
54
|
"text-matches": (c, cfg) => {
|
|
@@ -87,7 +104,25 @@ var ATOM_SPECS = [
|
|
|
87
104
|
predicate: "amount-over",
|
|
88
105
|
label: "Per-transaction amount over limit",
|
|
89
106
|
description: "Fires when a single action amount exceeds a configured limit (per-transaction cap).",
|
|
90
|
-
config: [
|
|
107
|
+
config: [
|
|
108
|
+
{ key: "limit", type: "number", required: true, description: "Maximum allowed amount for one transaction" },
|
|
109
|
+
{
|
|
110
|
+
key: "currency",
|
|
111
|
+
type: "string[]",
|
|
112
|
+
required: false,
|
|
113
|
+
description: `Optional currency scope for the limit (e.g. ['USD'], or ['USD','GBP'] for several). Leave empty to keep the limit currency-blind \u2014 the historical default: the raw number is compared regardless of currency. Once set, a request in a currency outside this list \u2014 or with none supplied at all \u2014 fires this atom regardless of amount (unverifiable is treated as unsafe, not as "smaller"), so the cap can't be cleared by naming a cheaper-looking currency (e.g. 200 JPY vs 200 USD).`
|
|
114
|
+
}
|
|
115
|
+
],
|
|
116
|
+
// `currency` is NOT listed here even though the executable atom conditionally reads it:
|
|
117
|
+
// unlike `limit`, the `currency` config is OPTIONAL per atom instance, so whether an agent
|
|
118
|
+
// needs to supply it depends on how a given molecule configures this atom — something
|
|
119
|
+
// `requiredContextFor`'s per-predicate (not per-instance) model can't express. Every
|
|
120
|
+
// authorize request already carries `currency` unconditionally regardless (see
|
|
121
|
+
// AuthorizeInput), so nothing is actually left unfed by omitting it here — this only
|
|
122
|
+
// controls the Scenario Bank simulate form / docs "context contract" surfacing, and
|
|
123
|
+
// forcing it onto every amount-over molecule would spuriously mark scenarios that never
|
|
124
|
+
// configure a currency scope as unexercised (see cumulative-over-atom.test.ts's sibling
|
|
125
|
+
// comment below for the same reasoning applied there).
|
|
91
126
|
requiredContext: ["amount"]
|
|
92
127
|
},
|
|
93
128
|
{
|
|
@@ -101,8 +136,32 @@ var ATOM_SPECS = [
|
|
|
101
136
|
predicate: "cumulative-over",
|
|
102
137
|
label: "Total budget over limit",
|
|
103
138
|
description: "Fires when cumulative spend (already-spent + this transaction) exceeds a configured total budget.",
|
|
104
|
-
config: [
|
|
105
|
-
|
|
139
|
+
config: [
|
|
140
|
+
{ key: "limit", type: "number", required: true, description: "Maximum total budget across all transactions" },
|
|
141
|
+
{
|
|
142
|
+
key: "currency",
|
|
143
|
+
type: "string[]",
|
|
144
|
+
required: false,
|
|
145
|
+
description: "Optional currency scope for the budget (e.g. ['USD'], or ['USD','GBP'] for several). Leave empty to keep it currency-blind \u2014 the historical default. Once set, a request in a currency outside this list \u2014 or with none supplied at all \u2014 fires this atom regardless of amount, same fail-closed design as amount-over's currency scope."
|
|
146
|
+
}
|
|
147
|
+
],
|
|
148
|
+
// The executable atom (atom-registry.ts) reads BOTH fields: `cumulativeSpend + amount >
|
|
149
|
+
// limit`. Omitting `cumulativeSpend` here silently broke two downstream consumers this
|
|
150
|
+
// catalog is the single source of truth for (see file header): the Scenario Bank's
|
|
151
|
+
// simulate form never rendered an "already spent" field for any set using this atom —
|
|
152
|
+
// including its own seeded preset, which supplied `cumulativeSpend` for a form field
|
|
153
|
+
// that didn't exist — so the control could never actually be exercised from the UI; and
|
|
154
|
+
// the integration docs' generated "context contract" told real SDK integrators this
|
|
155
|
+
// atom only needs `amount`, so an agent that never sends `cumulativeSpend` gets it
|
|
156
|
+
// silently treated as 0 and the total-budget cap never fires in production either.
|
|
157
|
+
//
|
|
158
|
+
// `currency`, by contrast, is deliberately NOT added here even though the executable atom
|
|
159
|
+
// conditionally reads it — see the sibling comment on `amount-over`'s currency config
|
|
160
|
+
// above: it is optional PER ATOM INSTANCE (only read when a molecule configures a
|
|
161
|
+
// currency scope), so unlike `cumulativeSpend` (always read), a static per-predicate
|
|
162
|
+
// requiredContext can't represent it without forcing every set using this atom to demand
|
|
163
|
+
// a currency it may never need.
|
|
164
|
+
requiredContext: ["amount", "cumulativeSpend"]
|
|
106
165
|
},
|
|
107
166
|
{
|
|
108
167
|
predicate: "risk-at-or-above",
|
|
@@ -357,7 +416,8 @@ function constraintSatisfied(c, req, strict) {
|
|
|
357
416
|
const left = Object.prototype.hasOwnProperty.call(req.values, c.leftOperand) ? req.values[c.leftOperand] : void 0;
|
|
358
417
|
if (!c.unit) return op(left, c.rightOperand);
|
|
359
418
|
const currency = req.values["mm:currency"];
|
|
360
|
-
const
|
|
419
|
+
const allowedUnits = Array.isArray(c.unit) ? c.unit : [c.unit];
|
|
420
|
+
const unitMatches = typeof currency === "string" && allowedUnits.some((u) => u.toUpperCase() === currency.toUpperCase());
|
|
361
421
|
return unitMatches ? op(left, c.rightOperand) : !strict;
|
|
362
422
|
}
|
|
363
423
|
function targetOf(rule, mandate) {
|
|
@@ -454,7 +514,15 @@ function escapeField(v) {
|
|
|
454
514
|
return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
455
515
|
}
|
|
456
516
|
function buildAuthMessage(f) {
|
|
457
|
-
return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
517
|
+
return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.resource ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
518
|
+
}
|
|
519
|
+
function buildLocalDecisionMessage(f) {
|
|
520
|
+
return [f.agentDid, f.action, f.decision, f.reasonCode, f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/policy-core/checkpoint-anchor.ts
|
|
524
|
+
function buildCheckpointAnchorMessage(f) {
|
|
525
|
+
return [f.agentDid, f.checkpointHash, f.previousCheckpointHash, f.entryCount, f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
458
526
|
}
|
|
459
527
|
|
|
460
528
|
// src/policy-core/context.ts
|
|
@@ -513,6 +581,8 @@ export {
|
|
|
513
581
|
asOperatingMode,
|
|
514
582
|
authorityFailure,
|
|
515
583
|
buildAuthMessage,
|
|
584
|
+
buildCheckpointAnchorMessage,
|
|
585
|
+
buildLocalDecisionMessage,
|
|
516
586
|
canAuthorize,
|
|
517
587
|
evaluate,
|
|
518
588
|
evaluateBoundStandards,
|