@haven_ai/signer 0.3.0-alpha.0 → 0.5.0-alpha.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 +204 -38
- package/dist/cli.cjs +1477 -1130
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1385 -1038
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +778 -448
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +337 -35
- package/dist/index.d.ts +337 -35
- package/dist/index.js +744 -423
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ It exposes four stdio MCP tools, all sign-only:
|
|
|
61
61
|
|
|
62
62
|
| Tool | Does | Emits |
|
|
63
63
|
|---|---|---|
|
|
64
|
-
| `haven_sign` | Sign one payment. Preferred form is `{ payment_id }` alone — the signer fetches the exact payload itself. Signs
|
|
64
|
+
| `haven_sign` | Sign one payment. Preferred form is `{ payment_id }` alone — the signer fetches the exact payload itself. Signs only Haven-prepared payloads (#3272, #3281): a direct-payment `PackedUserOperation` from this signer's own delegate account whose only call redeems a delegation made to that account, or — against a Haven-signed context, which it records and binds — an EIP-3009 funding leg of that same shape paying this signer's own delegate EOA, or an erc7710 settlement child from this signer's own account; other typed data is refused (`TYPED_DATA_NOT_ALLOWED`) | `{ signature }` or `{ signature, x402_binding }` |
|
|
65
65
|
| `haven_sign_x402` | One-shot x402: funding signature **and** the merchant header in a single local call (`haven_sign` + `haven_x402_sign_header`). `{ payment_id }` alone is the preferred call | `{ signature, x402_binding, payment_header, accepted }` |
|
|
66
66
|
| `haven_x402_sign_header` | Build + sign the EIP-3009 merchant payment header, only when the fresh merchant `payment_required` matches the recorded `x402_binding` | `{ payment_header, accepted }` |
|
|
67
67
|
| `haven_sign_sweep_delegate` | Sign a Haven-prepared gasless EIP-3009 sweep that recovers stranded funds from the delegate wallet back to your own account. Never broadcasts | `{ signature }` |
|
|
@@ -70,7 +70,8 @@ The `initialize` handshake advertises which binding versions this signer
|
|
|
70
70
|
understands, under `capabilities.experimental['haven/signer-compatibility']`
|
|
71
71
|
and in the MCP `instructions` string. Both are **derived** from
|
|
72
72
|
`SUPPORTED_X402_EXPECTED_VERSIONS` / `SUPPORTED_SWEEP_BINDING_VERSIONS` in
|
|
73
|
-
`src/core.ts`
|
|
73
|
+
`src/core.ts` and `SUPPORTED_DIRECT_SIGN_CONTEXT_VERSIONS` in
|
|
74
|
+
`src/sign-context.ts` (#3271) — the same constants the signing path enforces — so this README
|
|
74
75
|
deliberately does not restate the numbers. Read them from the handshake, or
|
|
75
76
|
from those constants.
|
|
76
77
|
|
|
@@ -93,11 +94,106 @@ const { paymentHeader } = await signer.buildX402PaymentHeader(
|
|
|
93
94
|
)
|
|
94
95
|
```
|
|
95
96
|
|
|
96
|
-
The signer also exposes `
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
The signer also exposes `signSweepAuthorization(input)` for the gasless sweep.
|
|
98
|
+
All four are methods on the object `createEdgeSigner` returns, not standalone
|
|
99
|
+
exports. There is NO raw-hash primitive: `signPaymentHash(hash)` (raw ECDSA over
|
|
100
|
+
the retired AllowanceModule rail's hash) was removed in #3169, and
|
|
101
|
+
`signX402FundingHash` (the expected-context v1 bare-hash path) in #3272 — the
|
|
102
|
+
signer supports expected-context versions 2 and 3 only. **Library boundary
|
|
103
|
+
(#3272):** `signDelegationTypedData` is a verbatim primitive for embedders —
|
|
104
|
+
it signs whatever typed data it is handed. The allowlist lives in the MCP tool
|
|
105
|
+
layer: `haven_sign` signs typed data only when it is a bound direct-payment
|
|
106
|
+
`PackedUserOperation` (below) or an x402 payload against a Haven-signed context,
|
|
107
|
+
refuses anything else with `TYPED_DATA_NOT_ALLOWED`, and answers a bare
|
|
108
|
+
`payload_hash` with `BARE_HASH_REFUSED`. An embedder that calls
|
|
109
|
+
`signDelegationTypedData` directly owns that check itself. The core's
|
|
110
|
+
`signX402FundingTypedData` is not a verbatim primitive: since #3281 it runs
|
|
111
|
+
the x402 shape checks itself (a guarded funding leg or a verified settlement
|
|
112
|
+
child only), whoever calls it.
|
|
113
|
+
|
|
114
|
+
**The unbound-branch allowlist (#3272).** Without an x402 context, `haven_sign`
|
|
115
|
+
signs typed data only when ALL of these hold; otherwise it refuses, with no
|
|
116
|
+
signature and no audit entry (`TYPED_DATA_NOT_ALLOWED`, or
|
|
117
|
+
`USEROP_BINDING_MISMATCH` when the #3271 binding check fails, or the #1476
|
|
118
|
+
refusal for an unbound `Delegation`): the primary type is
|
|
119
|
+
`PackedUserOperation`; it passes the #3271 binding check below; its chain has pinned delegation contracts (Base, Base Sepolia); its
|
|
120
|
+
sender is THIS signer's own delegate account (the counterfactual
|
|
121
|
+
HybridDeleGator for the delegate key, derived offline — `delegate-account.ts` in `@haven_ai/sdk`);
|
|
122
|
+
and its `callData` is a single `execute` to the DelegationManager calling
|
|
123
|
+
`redeemDelegations`, whose arguments are decoded too (`redemption-guard.ts` in `@haven_ai/sdk`):
|
|
124
|
+
exactly one delegation (a single grant, never an empty or multi-link chain)
|
|
125
|
+
made to this signer's own account by a different account, `SingleDefault` mode, and canonical
|
|
126
|
+
encoding at every level. The argument check matters: an EMPTY permission
|
|
127
|
+
context makes the DelegationManager run the execution as the account itself,
|
|
128
|
+
which would reach `transferOwnership`. A delegate-wallet `TransferWithAuthorization` or `Permit`,
|
|
129
|
+
a UserOp that calls the account itself (`transferOwnership`, `updateSigners`,
|
|
130
|
+
`upgradeToAndCall`), a UserOp for another account, and arbitrary typed data are
|
|
131
|
+
all refused. The audit log records the EIP-712 digest actually signed, not the
|
|
132
|
+
caller's `payload_hash`. Since #3283 the check itself is `@haven_ai/sdk`'s
|
|
133
|
+
`assertBoundDirectPaymentUserOp` (with the redemption guard, the account
|
|
134
|
+
derivation and the settlement-child verifier), which this signer imports and
|
|
135
|
+
which `HavenClient.signForData` runs as well — one implementation.
|
|
136
|
+
|
|
137
|
+
**Direct payments (#3271).** A direct payment (`POST /payments`, surfaced as
|
|
138
|
+
`haven_send` / `haven_pay`) is signed as the account's EIP-712
|
|
139
|
+
`PackedUserOperation`, and Haven returns both that typed data and
|
|
140
|
+
`payload_hash` — the ERC-4337 v0.7 UserOperation hash of the same operation.
|
|
141
|
+
Before `haven_sign` signs one, it recomputes that hash from the typed data's
|
|
142
|
+
own domain, types and message and refuses (`USEROP_BINDING_MISMATCH`) unless
|
|
143
|
+
it equals `payload_hash` exactly, in the HybridDeleGator domain of the typed
|
|
144
|
+
data's own sender, against the v0.7 EntryPoint. This is a **corruption**
|
|
145
|
+
check: the caller supplies both values, so it proves they describe the same
|
|
146
|
+
operation, never that Haven prepared it — provenance is `payment_id` (below).
|
|
147
|
+
It runs whether the typed data arrived as a tool argument or by the
|
|
148
|
+
`payment_id` fetch; a fetched direct context that is not a
|
|
149
|
+
`PackedUserOperation` at all is refused by the same check. The x402 EIP-3009
|
|
150
|
+
bridge's funding leg is checked against the Haven-signed expected context AND,
|
|
151
|
+
since #3281, by this same binding check against the declared UserOp hash, the
|
|
152
|
+
direct-payment allowlist and a recipient pin (a transfer of the quoted amount
|
|
153
|
+
to this signer's own delegate EOA). A settlement child must be re-delegated
|
|
154
|
+
from this signer's own account, never a ROOT grant. Anything else on the x402
|
|
155
|
+
arm is `TYPED_DATA_NOT_ALLOWED`, however validly Haven's binding key declared
|
|
156
|
+
it. The check exists because this typed data is
|
|
157
|
+
multi-KB and can reach the signer through a language model relaying it by
|
|
158
|
+
hand: one corrupted character used to produce a valid-looking signature over
|
|
159
|
+
the wrong digest, surfacing only as an opaque `AA24 signature error` from the
|
|
160
|
+
bundler, after the signature was already produced.
|
|
161
|
+
|
|
162
|
+
## Startup, CLI options and the consent screen (#3173)
|
|
163
|
+
|
|
164
|
+
**Startup cost.** The signer loads `@haven_ai/sdk/edge` — the ethers-free
|
|
165
|
+
subset of the SDK it actually calls (error classes, typed-next-step builder,
|
|
166
|
+
x402 message builders, viem-based key helpers) — never the SDK barrel, and it
|
|
167
|
+
loads the `x402` package only on the merchant-header leg, on first use. Measured
|
|
168
|
+
on the same machine (median of 5 cold runs, macOS, Node 22): `--help` 1.47 s →
|
|
169
|
+
0.71 s; the consent refusal 1.55 s → 0.77 s; `import('@haven_ai/sdk')` 1135 ms
|
|
170
|
+
versus `import('@haven_ai/sdk/edge')` 385 ms, of which viem is ~330 ms and stays
|
|
171
|
+
(the signer signs typed data with it). A loader hook resolving every module at
|
|
172
|
+
startup finds zero packages named `ethers` or `x402` (the SDK barrel, as a
|
|
173
|
+
positive control, resolves two). Two tests keep it so: `sdk-edge-import.test.ts`
|
|
174
|
+
fails if any runtime file imports the barrel or `x402/schemes` statically, and
|
|
175
|
+
the SDK's `edge-imports.test.ts` fails if the subpath's import graph ever
|
|
176
|
+
reaches ethers, `x402` or the HTTP client.
|
|
177
|
+
|
|
178
|
+
**CLI.** `--credentials <path>` (alias `--credentials-path`), `--ack`,
|
|
179
|
+
`--help`/`-h`. Any other option is
|
|
180
|
+
refused with one stderr line naming `--help` and exit code 2 — before #3173 an
|
|
181
|
+
unknown flag was silently ignored, so `--ack-local-tools` (the connector's
|
|
182
|
+
flag, which the connector's doctor tells you to pass to the *connector*)
|
|
183
|
+
produced only the consent wall. `--help` lists every registered tool (pinned
|
|
184
|
+
against `toolSchemas`, so a fifth tool cannot drift out of the text) and names
|
|
185
|
+
`npx @haven_ai/connect --doctor`.
|
|
186
|
+
|
|
187
|
+
**Consent screen.** The first-launch block summarises each tool in one
|
|
188
|
+
human-sized line (the full agent-facing descriptions are what the runtime sees,
|
|
189
|
+
not what a person approves) and ends by naming the connector's doctor for the
|
|
190
|
+
connector-wired case, where the doctor shows this as a failed *Signer stdio
|
|
191
|
+
handshake* check (the connector's setup outcome reports it as
|
|
192
|
+
`local_signer_ack_required`) and the repair is the connector's
|
|
193
|
+
`--ack-local-tools`. The refusal an MCP host
|
|
194
|
+
relays ("Connection closed" plus this process's exit message) names the same
|
|
195
|
+
command. The consent hash covers identity, tool names and the surface version —
|
|
196
|
+
not the block's prose — so neither change re-prompts an acknowledged install.
|
|
101
197
|
|
|
102
198
|
## Orchestration
|
|
103
199
|
|
|
@@ -156,6 +252,10 @@ funding confirms — so relay it promptly.
|
|
|
156
252
|
|
|
157
253
|
## What the signer refuses to sign
|
|
158
254
|
|
|
255
|
+
(`USEROP_BINDING_MISMATCH`, the #3271 direct-payment refusal, is described
|
|
256
|
+
under [Two ways to use it](#two-ways-to-use-it) and in the
|
|
257
|
+
[refusal table](#sign-context-refusal-codes).)
|
|
258
|
+
|
|
159
259
|
These are local, independent checks. They do not trust Haven's assertion about
|
|
160
260
|
what a payload means; they re-derive it.
|
|
161
261
|
|
|
@@ -164,9 +264,9 @@ what a payload means; they re-derive it.
|
|
|
164
264
|
`x402_binding_signer` in the credential file) so the signer can reject
|
|
165
265
|
locally invented or tampered contexts before signing anything.
|
|
166
266
|
- **Wrong signing mode.** The *context* selects the path, never the caller's
|
|
167
|
-
arguments: a context
|
|
168
|
-
data
|
|
169
|
-
|
|
267
|
+
arguments: a context commits to a typed-data digest and requires exactly that
|
|
268
|
+
typed data. A context without one (the retired version 1) is refused as an
|
|
269
|
+
unsupported version since #3272 — there is no bare-hash signing path.
|
|
170
270
|
- **Another agent's quote.** A context naming a `payer_delegate` that is not
|
|
171
271
|
this signer's own delegate is refused.
|
|
172
272
|
- **An unbound delegation payload.** Typed data with `primaryType: "Delegation"`
|
|
@@ -180,8 +280,8 @@ what a payload means; they re-derive it.
|
|
|
180
280
|
allowed — top-level caveats are AND-ed during redemption, so an unrecognised
|
|
181
281
|
one can only add a constraint.
|
|
182
282
|
- **A binding version it does not understand.** The refusal is machine-readable
|
|
183
|
-
— `code`, `supported_versions`, `received_version`, `fallback
|
|
184
|
-
updating the signer as the fix.
|
|
283
|
+
— `code`, `supported_versions`, `received_version`, `fallback`, and (#3103)
|
|
284
|
+
`next_tool_omitted_reason` — and names updating the signer as the fix.
|
|
185
285
|
- **A sweep that does not move funds out of this delegate's own key** — the
|
|
186
286
|
`from` check is unconditional. The **destination** check is not, and this is
|
|
187
287
|
the one asymmetry in this list: the signer compares the sweep's `to` against
|
|
@@ -200,32 +300,60 @@ what a payload means; they re-derive it.
|
|
|
200
300
|
## Sign-context refusal codes
|
|
201
301
|
|
|
202
302
|
`{ payment_id }` calls (`haven_sign` / `haven_sign_x402`) fetch the exact
|
|
203
|
-
signing payload from Haven (`GET /x402/:id/sign-context`,
|
|
303
|
+
signing payload from Haven (`GET /x402/:id/sign-context`, and for a direct
|
|
304
|
+
payment `GET /payments/:id/sign-context` — see the #3271 paragraph below and
|
|
204
305
|
[Custody](#custody)). Every refusal on that fetch is a `HavenSignContextError`
|
|
205
306
|
(#3001) — a `HavenSigningError` subclass, so `instanceof HavenSigningError`
|
|
206
307
|
still holds everywhere it did before, but structured like the version-mismatch
|
|
207
308
|
refusal below rather than prose alone: `code`, `next_action`, and — per
|
|
208
309
|
refusal class — `fallback`, `retry_with_new_quote`, `http_status`,
|
|
209
|
-
`backend_error_code`.
|
|
310
|
+
`backend_error_code`. Since #3103 each also carries a typed next step: the
|
|
311
|
+
`SIGN_CONTEXT_REFUSED` (other) row names the hosted status read
|
|
312
|
+
(`next_tool_server_role: hosted`, `next_tool_name: haven_get_payment_status`,
|
|
313
|
+
`next_arguments: { payment_id }` — resolve the role against your own server
|
|
314
|
+
names); every other row carries `next_tool_omitted_reason` with the exact
|
|
315
|
+
remedy. `message` is unchanged.
|
|
316
|
+
|
|
317
|
+
**#3271: `haven_sign` (never `haven_sign_x402`) has one escape from this
|
|
318
|
+
table.** When the x402 fetch answers `SIGN_CONTEXT_REFUSED` with
|
|
319
|
+
`http_status: 409` and `backend_error_code: 'sign_context_unavailable'` — this
|
|
320
|
+
`payment_id` names a direct payment, not an x402 intent — `haven_sign` fetches
|
|
321
|
+
`GET /payments/:id/sign-context` instead, same auth header, timeout and
|
|
322
|
+
refusal structuring. That second fetch's own refusals reuse the codes in the
|
|
323
|
+
table below with direct-payment remedies: no quote to re-run, and the
|
|
324
|
+
`typed_data_b64` relay from the `haven_send` / `haven_pay` result is the
|
|
325
|
+
fallback. A 409 `sign_context_unavailable` from the direct route too (an
|
|
326
|
+
x402 row the x402 route could not serve, or a direct row with no stored
|
|
327
|
+
signing payload) surfaces the x402 route's own refusal instead. `haven_sign_x402` never takes this branch: a direct payment
|
|
328
|
+
carries no x402 context to fund a merchant retry with, so it surfaces the
|
|
329
|
+
409 unchanged.
|
|
210
330
|
|
|
211
331
|
| `code` | When | `next_action` | `fallback` | extra |
|
|
212
332
|
|---|---|---|---|---|
|
|
213
333
|
| `SIGN_CONTEXT_TIMEOUT` | The fetch (or its body read) did not finish within `SIGN_CONTEXT_TIMEOUT_MS` | `stop_and_tell_user` | `typed_data_b64` | — |
|
|
214
334
|
| `SIGN_CONTEXT_UNREACHABLE` | The fetch failed before any response (DNS, connection refused, TLS, …) | `stop_and_tell_user` | `typed_data_b64` | — |
|
|
215
|
-
| `SIGN_CONTEXT_MALFORMED` | The response body was missing `sign_data.typed_data`
|
|
216
|
-
| `SIGN_CONTEXT_REFUSED` (410
|
|
217
|
-
| `SIGN_CONTEXT_REFUSED` (
|
|
335
|
+
| `SIGN_CONTEXT_MALFORMED` | The response body was missing `sign_data.typed_data` / `x402_expected` (a pre-#1263 backend), or — on the direct-payment fetch — an unsupported `direct_sign_context_version` or a `signature_scheme` other than `eip712_userop` | `stop_and_tell_user` | `typed_data_b64` | — |
|
|
336
|
+
| `SIGN_CONTEXT_REFUSED` (x402: 410 or `expired`; direct: `expired` only) | x402: the quote's window closed. Direct: the payment's window closed — call `haven_send` / `haven_pay` again with the same `idempotency_key` | `payment_window_expired` | — | x402 only: `retry_with_new_quote: true`; both: `http_status`, and `backend_error_code: 'expired'` when the backend sent one |
|
|
337
|
+
| `SIGN_CONTEXT_REFUSED` (404, direct fetch) | The `payment_id` is not this agent's, or the backend predates #3271 and has no direct route | `stop_and_tell_user` | `typed_data_b64` | `http_status`, `backend_error_code` |
|
|
338
|
+
| `SIGN_CONTEXT_REFUSED` (426 `client_outdated`, #3303) | This signer is below the minimum version the Haven deployment has set (only when one is set). Nothing is signed; the prepared payment is left unsigned. Update with `client_update.upgrade_command`, restart the agent runtime, then retry the same call | `stop_and_tell_user` | — | `http_status: 426`, `backend_error_code: 'client_outdated'`, `client_update` (the update command as data); carries `next_tool_omitted_reason`, not the status read |
|
|
339
|
+
| `SIGN_CONTEXT_REFUSED` (other) | Unknown `payment_id` (404, x402 fetch), `already_executed` / `not_signable` (409), a bare 410 retired-rail tombstone (direct fetch) — or `sign_context_unavailable` (409): always on `haven_sign_x402`; on `haven_sign` only when neither route can serve the row | `stop_and_tell_user` | — | `http_status`, `backend_error_code` |
|
|
340
|
+
| `USEROP_BINDING_MISMATCH` | A direct payment's `PackedUserOperation` typed data (from the direct fetch, or a tool argument) does not recompute to its own `payload_hash`, or a fetched direct context is not a `PackedUserOperation` — see [Two ways to use it](#two-ways-to-use-it) above | `stop_and_tell_user` | — | no `http_status` — this is a local recomputation, not a backend refusal |
|
|
341
|
+
| `TYPED_DATA_NOT_ALLOWED` | Typed data without an x402 context that is not a bound direct-payment `PackedUserOperation` (#3272): wrong chain, not this signer's own account, a call other than `execute` → DelegationManager → `redeemDelegations`, or a redemption whose delegations are empty, not made to this account, or in a non-default mode — see the allowlist paragraph above. Reachable by `payment_id` when Haven serves such a payload | `stop_and_tell_user` | — | no `http_status` — a local shape check, not a backend refusal |
|
|
218
342
|
|
|
219
343
|
`fallback: 'typed_data_b64'` appears only where signing OTHER bytes is a
|
|
220
|
-
remedy — a transport failure or a body this signer could not read
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
`
|
|
344
|
+
remedy — a transport failure or a body this signer could not read, plus one
|
|
345
|
+
backend refusal: a 404 from the direct-payment fetch (an older backend with
|
|
346
|
+
no direct route, #3271). For x402 it is **not** in the default quote result
|
|
347
|
+
since #1272: obtain it by re-running the SAME quote tool with the SAME
|
|
348
|
+
`idempotency_key` plus `include_signing_payload: true`, then pass
|
|
349
|
+
`typed_data_b64` (plus `payload_hash` / `x402_expected`) instead of
|
|
350
|
+
`payment_id`. A direct payment's `haven_send` / `haven_pay` result always
|
|
351
|
+
carries `payload_hash` + `typed_data_b64`. Any other backend REFUSAL
|
|
225
352
|
carries no fallback: an expired, executed or unsignable intent cannot be
|
|
226
|
-
rescued by re-signing its bytes — an expired one is re-quoted (the same
|
|
353
|
+
rescued by re-signing its bytes — an expired x402 one is re-quoted (the same
|
|
227
354
|
`payment_window_expired` + `retry_with_new_quote` the signer emits for
|
|
228
|
-
`PAYMENT_WINDOW_EXPIRED`),
|
|
355
|
+
`PAYMENT_WINDOW_EXPIRED`), an expired direct one is re-sent with the same
|
|
356
|
+
`idempotency_key` (no `retry_with_new_quote`), the rest stop. These codes are signer-local,
|
|
229
357
|
not part of `@haven_ai/sdk`'s `AgentPaymentFailureCode` taxonomy, since they
|
|
230
358
|
describe a local fetch failure, not a payment-domain outcome, and never reach
|
|
231
359
|
the backend's REST/OpenAPI surface — only this package's MCP tool responses.
|
|
@@ -236,19 +364,23 @@ The delegate key is read from `HAVEN_DELEGATE_KEY` or a `--credentials` file's
|
|
|
236
364
|
`delegate_key` (with a permissive-file warning). It stays in this process, and
|
|
237
365
|
is never transmitted.
|
|
238
366
|
|
|
239
|
-
**The signer makes at most
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
`
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
367
|
+
**The signer makes at most two kinds of network call, both reads.** Since
|
|
368
|
+
[#1263](https://github.com/d-hinders/Haven-AI/issues/1263) the `{ payment_id }`
|
|
369
|
+
form of `haven_sign` and `haven_sign_x402` performs an authenticated,
|
|
370
|
+
read-only `GET /x402/:payment_id/sign-context` against Haven, so that agents
|
|
371
|
+
never have to relay multi-KB EIP-712 payloads through a model's context
|
|
372
|
+
window. Since #3271, `haven_sign` (never `haven_sign_x402`) falls back to a
|
|
373
|
+
second read — `GET /payments/:payment_id/sign-context` — only when that first
|
|
374
|
+
fetch answers the backend's 409 `sign_context_unavailable`, i.e. this
|
|
375
|
+
`payment_id` names a direct payment rather than an x402 intent. **Only the
|
|
376
|
+
Bearer API key goes out on either read; the delegate key is never part of
|
|
377
|
+
either request or response.** Since #2985 both reads are bounded: each aborts
|
|
378
|
+
after `SIGN_CONTEXT_TIMEOUT_MS` (15 s) and reports a `HavenSignContextError`
|
|
379
|
+
naming the timeout and the `typed_data_b64` fallback, so a hung backend cannot
|
|
380
|
+
hang the signer — and the agent — past the funding window. Every refusal on
|
|
381
|
+
either fetch (timeout, unreachable host, a non-ok backend response, a
|
|
382
|
+
malformed body) is structured the same way, not just prose — see
|
|
383
|
+
[Sign-context refusal codes](#sign-context-refusal-codes) below.
|
|
252
384
|
Nothing else in the package reaches the
|
|
253
385
|
network: `haven_x402_sign_header` and `haven_sign_sweep_delegate` never fetch,
|
|
254
386
|
the library surface above (`createEdgeSigner` and its six signing methods, over
|
|
@@ -278,13 +410,47 @@ protected storage/runtime config.
|
|
|
278
410
|
|
|
279
411
|
## Local audit
|
|
280
412
|
|
|
281
|
-
Every MCP signing operation appends a JSONL row locally
|
|
413
|
+
Every MCP signing operation appends a JSONL row locally, best-effort (see the
|
|
414
|
+
end of this section). File-backed runs write
|
|
282
415
|
next to the credential as `<credential>.signer-audit.jsonl`; env-only runs use
|
|
283
416
|
`~/.haven/signer-audit.jsonl`. Rows include timestamp, tool, payload hash and
|
|
284
417
|
delegate address, plus the account address and chain id when the credential
|
|
285
418
|
carries them. They never include the delegate key, the signature, or the x402
|
|
286
419
|
payment header.
|
|
287
420
|
|
|
421
|
+
Since #3172 the sidecar is owner-only and bounded. It is created `0600` — the
|
|
422
|
+
mode the credential beside it is expected to have — and a sidecar found
|
|
423
|
+
readable beyond its owner (every release before #3172 created it with the
|
|
424
|
+
default mode, `0644` under the usual `umask 022`, or an operator loosened it
|
|
425
|
+
later) is tightened to `0600` in place on the next
|
|
426
|
+
append, with one stderr line per occurrence. If `chmod` is refused, the line
|
|
427
|
+
names the same `chmod 600` remedy the credential warning gives. If the path
|
|
428
|
+
is a symlink (or anything but a regular file) nothing is chmod-ed — that
|
|
429
|
+
would hit the target — but note that audit rows are still written through
|
|
430
|
+
the link to its target, so the line says to remove the link (`rm <path>`),
|
|
431
|
+
not to chmod it. The check runs before rotation, so a legacy file that
|
|
432
|
+
rotates carries `0600` into `.1`. When the live file reaches
|
|
433
|
+
`AUDIT_ROTATE_BYTES` (8 MiB, roughly 30 000 rows) it is renamed to
|
|
434
|
+
`<path>.1`, replacing the previous `.1`, and a fresh file starts — two
|
|
435
|
+
generations at most. The rotation decision is not atomic across processes:
|
|
436
|
+
two signer processes on one credential that both hit the bound can leave the
|
|
437
|
+
predecessor generation discarded (the current entry is never lost). A failed
|
|
438
|
+
audit write (disk full, read-only, two appends racing at the bound) is
|
|
439
|
+
reported on stderr and never fails the signing call that already produced
|
|
440
|
+
its signature — so the consent block's
|
|
441
|
+
"appended for every signing operation" is a best-effort promise since #3172,
|
|
442
|
+
kept unchanged in text (editing consent copy does NOT move the consent hash —
|
|
443
|
+
that covers identity, tool names and the surface version, as the consent
|
|
444
|
+
screen section above already says; #3279 rewords the block without
|
|
445
|
+
re-prompting anyone). The
|
|
446
|
+
credential check itself is unchanged in wording and still judges a symlinked
|
|
447
|
+
credential by its target. The
|
|
448
|
+
`payload_hash` argument itself is bounded on the tool schema to a 32-byte hash
|
|
449
|
+
(`0x` + 64 hex), so the audit field is never caller-controlled free text; the
|
|
450
|
+
two object arguments that do reach the file (`payment_required`,
|
|
451
|
+
`authorization`) were already hashed before being written, and
|
|
452
|
+
`x402_expected` is never written to the sidecar.
|
|
453
|
+
|
|
288
454
|
## Hot-wallet minimization
|
|
289
455
|
|
|
290
456
|
This applies to the **EIP-3009 bridge only** — the erc7710 path above has no
|