@metamynd/agentsafe-guard 0.8.0 → 0.12.1
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 +184 -42
- package/key-providers.mjs +174 -0
- package/package.json +3 -2
- package/policy-core.mjs +11 -1
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
|
|
|
@@ -237,7 +297,7 @@ export function createGuard(opts = {}) {
|
|
|
237
297
|
* @param {Array<{standardKey:string,document:object}>} [p.standards] enforced Standards bound to the agent
|
|
238
298
|
* @param {Array<{standardKey:string,document:object}>} [p.sops] active SOPs assigned to the agent
|
|
239
299
|
* @param {object} [p.mandate] the ODRL mandate document (omit to skip the mandate layer)
|
|
240
|
-
* @param {{action:string,amount?:number,merchant?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
|
|
300
|
+
* @param {{action:string,amount?:number,currency?:string,merchant?:string,resource?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
|
|
241
301
|
* @returns {{decision:'allow'|'block'|'escalate',reasonCode:string|null,authorizationId:null,remaining:null,proofRef:null}}
|
|
242
302
|
*/
|
|
243
303
|
function evaluateLocally({ contained = null, operatingMode = null, standards = [], sops = [], mandate, request }) {
|
|
@@ -250,7 +310,7 @@ export function createGuard(opts = {}) {
|
|
|
250
310
|
const reasonCode = contained.status === 'quarantined' ? 'AGENT_QUARANTINED' : 'AGENT_SUSPENDED';
|
|
251
311
|
return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
|
|
252
312
|
}
|
|
253
|
-
const { action, amount = 0, currency = 'USD', merchant = '', context = {}, cumulativeSpend = amount, now } = request;
|
|
313
|
+
const { action, amount = 0, currency = 'USD', merchant = '', resource = null, context = {}, cumulativeSpend = amount, now } = request;
|
|
254
314
|
// Operating-mode autonomy ladder (Phase 2.5b): the trust-driven posture rides as a
|
|
255
315
|
// SIBLING (like `contained`) and biases the edge verdict identically to the gate.
|
|
256
316
|
// READ_ONLY denies a value-bearing action up-front; SUPERVISED/RESTRICTED only
|
|
@@ -266,7 +326,11 @@ export function createGuard(opts = {}) {
|
|
|
266
326
|
standards,
|
|
267
327
|
sops,
|
|
268
328
|
mandate,
|
|
269
|
-
|
|
329
|
+
// currency/merchant/resource are signed fields too, same as action/agentDid/amount —
|
|
330
|
+
// omitting them here means a currency-scoped amount-over/cumulative-over Standards/SOP
|
|
331
|
+
// atom always sees currency as absent and fires closed. Mirrors mandate.service.ts's
|
|
332
|
+
// ruleCtx (PR #588) and the same fix in agentsafe-mcp-guard.mjs's verdictFromBundle.
|
|
333
|
+
context: applySignedLast(context, { action, agentDid, amount, currency, merchant, resource }),
|
|
270
334
|
mandateRequest: mandate
|
|
271
335
|
? {
|
|
272
336
|
target: action,
|
|
@@ -281,6 +345,9 @@ export function createGuard(opts = {}) {
|
|
|
281
345
|
// amount, since undefined never equals a real unit. Defaults to 'USD' to match
|
|
282
346
|
// the same default this file already uses for authorize()/buildSignedRequest().
|
|
283
347
|
'mm:currency': currency,
|
|
348
|
+
// Unprefixed `resource` (not `mm:resource`) to match the constraint's own
|
|
349
|
+
// leftOperand (ResourceService.scopeConstraint()) — mirrors mandate.service.ts.
|
|
350
|
+
resource,
|
|
284
351
|
}),
|
|
285
352
|
}
|
|
286
353
|
: undefined,
|
|
@@ -312,14 +379,29 @@ export function createGuard(opts = {}) {
|
|
|
312
379
|
return b;
|
|
313
380
|
}
|
|
314
381
|
|
|
315
|
-
/**
|
|
382
|
+
/**
|
|
383
|
+
* Map a fetched bundle into the shape evaluateLocally expects, for one action.
|
|
384
|
+
*
|
|
385
|
+
* `mandateFound` distinguishes "no mandate matches THIS action" from "omit mandate to
|
|
386
|
+
* skip the layer" (evaluateLocally's own documented, intentional behavior for a caller
|
|
387
|
+
* that never resolves one at all, e.g. guardToolLocal's caller-supplied bundle) — found
|
|
388
|
+
* live: this used to fall back to `mandates[0]` (an ARBITRARY, possibly unrelated
|
|
389
|
+
* mandate for a completely different action) rather than correctly reporting no
|
|
390
|
+
* authority for this one, and `authorizeLocal` never distinguished either case from a
|
|
391
|
+
* genuinely-mandate-less bundle, so an ungranted action with no Standard/SOP rule
|
|
392
|
+
* happening to also catch it was silently ALLOWED instead of NO_PERMISSION_FOR_ACTION.
|
|
393
|
+
*/
|
|
316
394
|
function _bundleFor(b, action) {
|
|
395
|
+
const mandates = b.mandates ?? [];
|
|
396
|
+
const match = mandates.find((m) => m.action === action);
|
|
317
397
|
return {
|
|
318
398
|
contained: b.contained ?? null,
|
|
319
399
|
operatingMode: b.operatingMode ?? null,
|
|
320
400
|
standards: (b.standards ?? []).map((s) => ({ standardKey: s.id ?? s.standardKey ?? 'standard', document: s.document })).filter((s) => s.document),
|
|
321
401
|
sops: (b.sops ?? []).map((s) => ({ standardKey: s.id ?? s.sopId ?? 'sop', document: s.document })).filter((s) => s.document),
|
|
322
|
-
mandate:
|
|
402
|
+
mandate: match?.document,
|
|
403
|
+
mandateFound: !!match,
|
|
404
|
+
anyMandates: mandates.length > 0,
|
|
323
405
|
};
|
|
324
406
|
}
|
|
325
407
|
|
|
@@ -355,6 +437,37 @@ export function createGuard(opts = {}) {
|
|
|
355
437
|
return _anchor;
|
|
356
438
|
}
|
|
357
439
|
|
|
440
|
+
// Verdicts the backend's /policy/decisions/local will actually accept (local-decision.service.ts's
|
|
441
|
+
// LOCAL_DECISIONS) — 'quarantine'/'suspend' are containment, a SERVER-state decision whose audit
|
|
442
|
+
// trail already lives on the server (the `contained` flag this very evaluation read came FROM
|
|
443
|
+
// the server's own bundle response), so there is nothing new to report for those.
|
|
444
|
+
const REPORTABLE_LOCAL_DECISIONS = new Set(['allow', 'observe', 'block', 'escalate']);
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Best-effort, NEVER awaited by the caller: reports a purely-local verdict to the gate
|
|
448
|
+
* for audit VISIBILITY only (Activity Log / fleet decision-mix / regulator log otherwise
|
|
449
|
+
* show nothing for the large majority of decisions under the default local-first mode —
|
|
450
|
+
* see docs/design/... and this package's own README "Enforcement mode" section for why
|
|
451
|
+
* that's a deliberate security tradeoff, not a bug, but one that used to leave zero trail
|
|
452
|
+
* anywhere). Silently does nothing if the keyProvider doesn't support it (e.g. the daemon
|
|
453
|
+
* keyProvider, which doesn't implement signLocalDecision in this version) or the verdict
|
|
454
|
+
* isn't one the endpoint accepts. Never throws, never delays the caller.
|
|
455
|
+
*/
|
|
456
|
+
function reportLocalDecision(action, decision, reasonCode) {
|
|
457
|
+
if (typeof keyProvider.signLocalDecision !== 'function') return;
|
|
458
|
+
if (!REPORTABLE_LOCAL_DECISIONS.has(decision)) return;
|
|
459
|
+
const nonce = crypto.randomUUID();
|
|
460
|
+
const issuedAt = new Date().toISOString();
|
|
461
|
+
void (async () => {
|
|
462
|
+
const signature = await keyProvider.signLocalDecision({ agentDid, action, decision, reasonCode, nonce, issuedAt });
|
|
463
|
+
await fetch(`${base}/policy/decisions/local`, {
|
|
464
|
+
method: 'POST',
|
|
465
|
+
headers: { 'Content-Type': 'application/json' },
|
|
466
|
+
body: JSON.stringify({ agentDid, action, decision, reasonCode, nonce, issuedAt, signature }),
|
|
467
|
+
});
|
|
468
|
+
})().catch(() => {});
|
|
469
|
+
}
|
|
470
|
+
|
|
358
471
|
/**
|
|
359
472
|
* LOCAL-FIRST decision (the default). Evaluates the rule layer against the cached
|
|
360
473
|
* bundle with the same policy-core the gate runs — so a block/escalate is decided
|
|
@@ -379,11 +492,34 @@ export function createGuard(opts = {}) {
|
|
|
379
492
|
const sig = b?.proof?.signature;
|
|
380
493
|
if (!anchor?.sigDigest || !sig || _sha256(sig) !== anchor.sigDigest) return authorize(input);
|
|
381
494
|
}
|
|
382
|
-
const
|
|
495
|
+
const { mandateFound, anyMandates, ...bundleForAction } = _bundleFor(b, action);
|
|
496
|
+
// No mandate covers this action at all — refuse outright rather than let evaluateLocally
|
|
497
|
+
// silently allow (its documented "omit mandate to skip the layer" behavior is for a
|
|
498
|
+
// caller that never intended a mandate check, not for one that looked and found none).
|
|
499
|
+
// Standards/SOP containment still applies first — a suspended/quarantined agent is
|
|
500
|
+
// refused for THAT reason, not misreported as merely lacking this one action.
|
|
501
|
+
if (!mandateFound) {
|
|
502
|
+
const contained = bundleForAction.contained;
|
|
503
|
+
if (contained && contained.status) {
|
|
504
|
+
const decision = contained.status === 'quarantined' ? 'quarantine' : 'suspend';
|
|
505
|
+
const reasonCode = contained.status === 'quarantined' ? 'AGENT_QUARANTINED' : 'AGENT_SUSPENDED';
|
|
506
|
+
const local = { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
|
|
507
|
+
reportLocalDecision(action, local.decision, local.reasonCode);
|
|
508
|
+
return local;
|
|
509
|
+
}
|
|
510
|
+
const local = { decision: 'block', reasonCode: anyMandates ? 'NO_PERMISSION_FOR_ACTION' : 'NO_MANDATE', authorizationId: null, remaining: null, proofRef: null };
|
|
511
|
+
reportLocalDecision(action, local.decision, local.reasonCode);
|
|
512
|
+
return local;
|
|
513
|
+
}
|
|
514
|
+
const local = evaluateLocally({ ...bundleForAction, request: input });
|
|
383
515
|
// allow/observe both PERMIT; block/escalate/contain are decided locally with no network.
|
|
384
516
|
const permits = local.decision === 'allow' || local.decision === 'observe';
|
|
385
|
-
if (!permits)
|
|
517
|
+
if (!permits) {
|
|
518
|
+
reportLocalDecision(action, local.decision, local.reasonCode); // fire-and-forget — see above
|
|
519
|
+
return local; // denied/escalated locally, no network
|
|
520
|
+
}
|
|
386
521
|
if (amount > 0 && sealValueActions) return authorize(input); // seal value action remotely (allow or observe)
|
|
522
|
+
reportLocalDecision(action, local.decision, local.reasonCode); // non-value permit — fire-and-forget
|
|
387
523
|
return local; // non-value permit — local is sufficient
|
|
388
524
|
}
|
|
389
525
|
|
|
@@ -445,6 +581,12 @@ export function createGuard(opts = {}) {
|
|
|
445
581
|
* the gate — cooperative-mode, low-latency governance (spec §9.2). Fails CLOSED:
|
|
446
582
|
* any error during local evaluation throws GovernanceBlocked, never allows.
|
|
447
583
|
*
|
|
584
|
+
* Unlike `guardTool()`'s default local-first path (`authorizeLocal()`), this one makes
|
|
585
|
+
* NO network call of any kind, ever — that is its entire purpose (pure-offline,
|
|
586
|
+
* cooperative-mode use). It does NOT report to /policy/decisions/local, so a verdict
|
|
587
|
+
* decided this way has NO central audit trail at all, by design — a deliberate,
|
|
588
|
+
* pre-existing tradeoff this package leaves unchanged.
|
|
589
|
+
*
|
|
448
590
|
* @param {string} action
|
|
449
591
|
* @param {(args:any, decision:any)=>any} handler
|
|
450
592
|
* @param {(args:any)=>{amount?:number,currency?:string,merchant?:string,context?:object}} mapArgs
|
|
@@ -514,7 +656,7 @@ export function createGuard(opts = {}) {
|
|
|
514
656
|
* the DIDs, §4.1.2). Returns { hello, prove } to drive the exchange:
|
|
515
657
|
* const hs = guard.handshake();
|
|
516
658
|
* const { nonceA, message } = hs.hello(); // → send HELLO to the Service
|
|
517
|
-
* const { sigA, handshakeId } = hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
|
|
659
|
+
* const { sigA, handshakeId } = await hs.prove({ nonceA, challenge }); // verifies the Service, → send PROVE
|
|
518
660
|
* `prove` throws HandshakeFailed if the Service's CHALLENGE does not verify.
|
|
519
661
|
*/
|
|
520
662
|
function handshake() {
|
|
@@ -523,7 +665,7 @@ export function createGuard(opts = {}) {
|
|
|
523
665
|
const nonceA = crypto.randomUUID();
|
|
524
666
|
return { nonceA, message: { fromDid: agentDid, nonceA, protoVersion: '1.0' } };
|
|
525
667
|
},
|
|
526
|
-
prove({ nonceA, challenge } = {}) {
|
|
668
|
+
async prove({ nonceA, challenge } = {}) {
|
|
527
669
|
const { toDid, nonceB, sigB, handshakeId } = challenge ?? {};
|
|
528
670
|
if (!toDid || !nonceB || !sigB) throw new Error('malformed CHALLENGE');
|
|
529
671
|
if (!verifyDidSignature(toDid, nonceA, sigB)) {
|
|
@@ -531,7 +673,7 @@ export function createGuard(opts = {}) {
|
|
|
531
673
|
e.name = 'HandshakeFailed';
|
|
532
674
|
throw e;
|
|
533
675
|
}
|
|
534
|
-
return { handshakeId, sigA:
|
|
676
|
+
return { handshakeId, sigA: await keyProvider.signHandshakeNonce(nonceB), remoteDid: toDid };
|
|
535
677
|
},
|
|
536
678
|
};
|
|
537
679
|
}
|
|
@@ -700,7 +842,7 @@ export function createGuard(opts = {}) {
|
|
|
700
842
|
const res = await fetch(`${base}/agent-identity/${encodeURIComponent(ref)}/verify-key`, {
|
|
701
843
|
method: 'POST',
|
|
702
844
|
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
703
|
-
body: JSON.stringify({ signature:
|
|
845
|
+
body: JSON.stringify({ signature: await keyProvider.signKeyControlChallenge(challenge) }),
|
|
704
846
|
});
|
|
705
847
|
const body = await res.json().catch(() => null);
|
|
706
848
|
if (!res.ok) {
|
|
@@ -711,10 +853,10 @@ export function createGuard(opts = {}) {
|
|
|
711
853
|
return body?.data ?? { verified: true };
|
|
712
854
|
}
|
|
713
855
|
|
|
714
|
-
/** Sign a BYOK challenge with the agent's key
|
|
715
|
-
function signChallenge(challenge) {
|
|
856
|
+
/** Sign a BYOK challenge with the agent's key — for integrators who submit verify-key themselves. */
|
|
857
|
+
async function signChallenge(challenge) {
|
|
716
858
|
if (!challenge) throw new Error('signChallenge requires the challenge nonce');
|
|
717
|
-
return
|
|
859
|
+
return keyProvider.signKeyControlChallenge(challenge);
|
|
718
860
|
}
|
|
719
861
|
|
|
720
862
|
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.1",
|
|
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
|
@@ -514,7 +514,15 @@ function escapeField(v) {
|
|
|
514
514
|
return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
515
515
|
}
|
|
516
516
|
function buildAuthMessage(f) {
|
|
517
|
-
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("|");
|
|
518
526
|
}
|
|
519
527
|
|
|
520
528
|
// src/policy-core/context.ts
|
|
@@ -573,6 +581,8 @@ export {
|
|
|
573
581
|
asOperatingMode,
|
|
574
582
|
authorityFailure,
|
|
575
583
|
buildAuthMessage,
|
|
584
|
+
buildCheckpointAnchorMessage,
|
|
585
|
+
buildLocalDecisionMessage,
|
|
576
586
|
canAuthorize,
|
|
577
587
|
evaluate,
|
|
578
588
|
evaluateBoundStandards,
|