@observer-protocol/hermes-gate 0.1.0 → 0.1.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 +221 -76
- package/bin/hermes-gate.js +3 -1
- package/package.json +2 -2
- package/src/bootstrap.js +16 -2
- package/src/gate.js +43 -1
- package/src/mcp-server.js +29 -1
- package/src/spend-ledger.js +105 -0
package/README.md
CHANGED
|
@@ -1,157 +1,302 @@
|
|
|
1
1
|
# @observer-protocol/hermes-gate
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A fail-closed spend gate for Hermes agents. Set what your agent can pay and how much; enforced below the skill layer so a hostile skill cannot move money outside the rules you set.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> **Status: community tier.** Binding-on-chain enforcement is on the roadmap. See *What this protects against* for exactly where the line is. We would rather you know the boundary than discover it.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
2. **LINK** — WBC issuer matches the mandate issuer (same principal)
|
|
9
|
-
3. **AUTHORIZE** — amount, rail, and currency are within the mandate's ceilings and allowed rails
|
|
7
|
+
---
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
## What it is
|
|
10
|
+
|
|
11
|
+
`hermes-gate` sits between your agent and its wallet. Your agent asks to spend; the gate checks the request against a spend mandate you signed, and allows or denies. Fail-closed: anything it cannot verify is denied, not waved through. The gate runs as an MCP server your Hermes agent calls.
|
|
12
|
+
|
|
13
|
+
You are the principal. You anoint your own agent with your own key and issue your own spend mandate. No external service is in the loop, no account, no domain. The gate verifies offline.
|
|
14
|
+
|
|
15
|
+
---
|
|
12
16
|
|
|
13
17
|
## Prerequisites
|
|
14
18
|
|
|
15
19
|
- Node 20 or later
|
|
16
20
|
- npm
|
|
21
|
+
- For `provision` and `verify`: a Linux server, root access, and two dedicated system users (agent user and wallet-service user)
|
|
22
|
+
|
|
23
|
+
---
|
|
17
24
|
|
|
18
|
-
## Install
|
|
25
|
+
## Install and generate
|
|
19
26
|
|
|
20
27
|
```
|
|
21
|
-
|
|
28
|
+
npx @observer-protocol/hermes-gate bootstrap generate
|
|
22
29
|
```
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
### 1. Generate key material
|
|
31
|
+
After a global install (`npm install -g @observer-protocol/hermes-gate`), the same command is:
|
|
27
32
|
|
|
28
33
|
```
|
|
29
|
-
|
|
34
|
+
hermes-gate bootstrap generate
|
|
30
35
|
```
|
|
31
36
|
|
|
32
|
-
|
|
37
|
+
`generate` creates three keys, one mandate, and one wallet-binding credential:
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
| `wallet-identity-key.json` | `/home/<wallet-user>/secrets/wallet-key.json` | 600 |
|
|
40
|
-
| `spend-mandate.json` | `/home/<agent-user>/spend-mandate.json` | 644 |
|
|
41
|
-
| `wbc.json` | `/home/<agent-user>/wbc.json` | 644 |
|
|
39
|
+
1. **Principal key** (`principal-key.json`): a `did:key`, self-contained, no domain required. You sign your mandate with this key. It is written at mode 600 and must be moved offline immediately after generate runs.
|
|
40
|
+
2. **Agent identity key** (`agent-identity-key.json`): the key the agent user holds on the server. It carries no spend authority; mode 600.
|
|
41
|
+
3. **Wallet identity key** (`wallet-identity-key.json`) and **wallet seed** (`wallet-seed.json`): held by the wallet-service user. The wallet-binding credential ties this key to your mandate. Both written at mode 600.
|
|
42
|
+
4. **SpendMandate** (`spend-mandate.json`): your spending rules, signed by your principal key; mode 644.
|
|
43
|
+
5. **WalletBindingCredential** (`wbc.json`): your principal signs that this wallet address is bound to the mandate; mode 644.
|
|
42
44
|
|
|
43
|
-
All
|
|
45
|
+
All six files go to `./output/` by default.
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
---
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
## Move the principal key offline
|
|
50
|
+
|
|
51
|
+
This step is not optional.
|
|
52
|
+
|
|
53
|
+
`./output/principal-key.json` contains the key material that signed your mandate. The gate does not need it at runtime. Move it off the server before the gate starts:
|
|
48
54
|
|
|
49
55
|
```
|
|
50
56
|
cp output/principal-key.json /path/to/offline/storage
|
|
51
57
|
rm output/principal-key.json
|
|
52
58
|
```
|
|
53
59
|
|
|
54
|
-
|
|
60
|
+
An attacker with the principal key can re-issue credentials. An attacker with only the agent key or wallet key cannot rewrite your mandate.
|
|
55
61
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## The two-user boundary
|
|
65
|
+
|
|
66
|
+
The bootstrap provisions two system users, and this is the security model, not optional ceremony.
|
|
67
|
+
|
|
68
|
+
The **agent user** holds the agent identity key. It carries no spend authority and cannot read the wallet seed.
|
|
69
|
+
|
|
70
|
+
The **wallet-service user** holds the wallet seed. It is the only path to signing.
|
|
71
|
+
|
|
72
|
+
Your agent reaches spend authorization only through the gate's narrow MCP interface. A hostile skill running as the agent has no path to the wallet seed: it has to go through the gate, and the gate fails closed.
|
|
61
73
|
|
|
62
|
-
|
|
74
|
+
---
|
|
63
75
|
|
|
64
|
-
|
|
76
|
+
## Wire up the gate
|
|
65
77
|
|
|
66
|
-
|
|
78
|
+
After `generate`, `./output/` contains `spend-mandate.json` and `wbc.json` in the same directory. The gate auto-discovers `wbc.json` when it sits alongside the mandate; no `HERMES_WBC_PATH` is needed.
|
|
67
79
|
|
|
68
|
-
|
|
80
|
+
`generate` prints the exact start command for your agent DID:
|
|
69
81
|
|
|
70
82
|
```
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
83
|
+
HERMES_MANDATE_PATH=/home/<agent-user>/spend-mandate.json \
|
|
84
|
+
HERMES_AGENT_DID=did:key:z6Mk... \
|
|
85
|
+
node /path/to/hermes-gate/src/mcp-server.js
|
|
74
86
|
```
|
|
75
87
|
|
|
76
|
-
|
|
88
|
+
With `wbc.json` alongside the mandate, the gate comes up in bound mode with no additional configuration.
|
|
89
|
+
|
|
90
|
+
If neither `HERMES_WBC_PATH` nor an adjacent `wbc.json` is found at startup, the gate logs this to stderr and enters passthrough mode:
|
|
77
91
|
|
|
78
92
|
```
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
93
|
+
WARNING: No WalletBindingCredential configured.
|
|
94
|
+
HERMES_WBC_PATH is unset and wbc.json was not found alongside the mandate.
|
|
95
|
+
Gate is in pe-042 PASSTHROUGH mode — wallet identity is NOT verified.
|
|
96
|
+
...
|
|
82
97
|
```
|
|
83
98
|
|
|
84
|
-
|
|
99
|
+
Passthrough means the wallet-binding check is skipped; a wrong wallet ID returns `allow:true`. This is not a valid community install. Never ignore the warning.
|
|
85
100
|
|
|
86
|
-
|
|
101
|
+
**For Hermes** (`~/.hermes/config.yaml`), add the gate manually:
|
|
87
102
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
103
|
+
```yaml
|
|
104
|
+
mcp_servers:
|
|
105
|
+
hermes-gate:
|
|
106
|
+
command: node
|
|
107
|
+
args:
|
|
108
|
+
- /path/to/hermes-gate/src/mcp-server.js
|
|
109
|
+
env:
|
|
110
|
+
HERMES_MANDATE_PATH: /home/atlas/spend-mandate.json
|
|
111
|
+
HERMES_AGENT_DID: "did:key:z6Mk..."
|
|
112
|
+
timeout: 30
|
|
113
|
+
```
|
|
94
114
|
|
|
95
|
-
|
|
115
|
+
`wbc.json` alongside the mandate is auto-discovered. Set `HERMES_WBC_PATH` explicitly only if it lives at a different path.
|
|
96
116
|
|
|
97
|
-
|
|
117
|
+
**For Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
98
118
|
|
|
99
119
|
```json
|
|
100
120
|
{
|
|
101
121
|
"mcpServers": {
|
|
102
122
|
"hermes-gate": {
|
|
103
123
|
"command": "node",
|
|
104
|
-
"args": ["/
|
|
124
|
+
"args": ["/path/to/hermes-gate/src/mcp-server.js"],
|
|
105
125
|
"env": {
|
|
106
126
|
"HERMES_MANDATE_PATH": "/home/atlas/spend-mandate.json",
|
|
107
|
-
"HERMES_AGENT_DID": "did:key:z6Mk..."
|
|
108
|
-
"HERMES_WBC_PATH": "/home/atlas/wbc.json"
|
|
127
|
+
"HERMES_AGENT_DID": "did:key:z6Mk..."
|
|
109
128
|
}
|
|
110
129
|
}
|
|
111
130
|
}
|
|
112
131
|
}
|
|
113
132
|
```
|
|
114
133
|
|
|
115
|
-
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Runtime env vars
|
|
137
|
+
|
|
138
|
+
| Variable | Default | Notes |
|
|
139
|
+
|----------|---------|-------|
|
|
140
|
+
| `HERMES_MANDATE_PATH` | `~/spend-mandate.json` | Path to the signed SpendMandate |
|
|
141
|
+
| `HERMES_AGENT_DID` | read from `HERMES_IDENTITY_PATH` | Agent DID; set explicitly to skip the identity file |
|
|
142
|
+
| `HERMES_IDENTITY_PATH` | `~/identity/did-key.json` | Agent identity key file; ignored when `HERMES_AGENT_DID` is set |
|
|
143
|
+
| `HERMES_WBC_PATH` | auto-discovered | Path to wbc.json; auto-discovered from mandate directory if unset |
|
|
144
|
+
| `HERMES_LEDGER_PATH` | alongside mandate | Path to the JSONL spend ledger; set when mandate declares `cumulative_budget` |
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Customize your mandate
|
|
149
|
+
|
|
150
|
+
Set your limits at generate time with flags:
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
npx @observer-protocol/hermes-gate bootstrap generate \
|
|
154
|
+
--ceiling-amount 250 \
|
|
155
|
+
--ceil-currency USDT \
|
|
156
|
+
--allowed-rails ethereum-mainnet,lightning
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
| Flag | Default | Notes |
|
|
160
|
+
|------|---------|-------|
|
|
161
|
+
| `--output-dir` | `./output` | Where to write generated files |
|
|
162
|
+
| `--ceiling-amount` | `100` | Per-transaction ceiling |
|
|
163
|
+
| `--ceil-currency` | `USDT` | Currency for the ceiling |
|
|
164
|
+
| `--allowed-rails` | `ethereum-mainnet,lightning` | Comma-separated list of allowed rails |
|
|
165
|
+
| `--daily-cap-amount` | none | Rolling 24h cumulative cap; enables ledger enforcement |
|
|
166
|
+
| `--daily-cap-currency` | same as `--ceil-currency` | Currency for the daily cap |
|
|
167
|
+
|
|
168
|
+
The ceiling applies to each individual transaction. A spend on a rail not in `--allowed-rails` is denied. If `--daily-cap-amount` is set, the gate also enforces a rolling 24h cumulative cap via a spend ledger — see below.
|
|
116
169
|
|
|
117
|
-
|
|
170
|
+
---
|
|
118
171
|
|
|
119
|
-
|
|
172
|
+
## Rolling daily cap
|
|
173
|
+
|
|
174
|
+
When `--daily-cap-amount` is set, `generate` adds a `cumulative_budget` field to the mandate. At runtime the gate enforces a rolling 24h cumulative cap in addition to the per-transaction ceiling.
|
|
120
175
|
|
|
121
176
|
```
|
|
122
|
-
npx hermes-gate bootstrap generate \
|
|
123
|
-
--
|
|
124
|
-
--
|
|
125
|
-
--ceiling-amount 500 \
|
|
177
|
+
npx @observer-protocol/hermes-gate bootstrap generate \
|
|
178
|
+
--ceiling-amount 10 \
|
|
179
|
+
--daily-cap-amount 30 \
|
|
126
180
|
--ceil-currency USDT
|
|
127
181
|
```
|
|
128
182
|
|
|
129
|
-
|
|
183
|
+
This generates a mandate where:
|
|
184
|
+
- Each individual transaction is capped at 10 USDT.
|
|
185
|
+
- The total authorized in any rolling 24h window is capped at 30 USDT.
|
|
186
|
+
- Call #4 of four 10-USDT calls returns `allow: false` with `ruleType: 'cumulativeCap'`.
|
|
187
|
+
|
|
188
|
+
**How it works.** The gate maintains a JSONL spend ledger at `HERMES_LEDGER_PATH` (default: alongside the mandate). On each `gate_evaluate`, after the full BIND-LINK-AUTHORIZE gate passes, the gate sums ledger entries from the trailing 24h for the same rail and currency. If the sum plus the proposed amount would exceed the cap, the call is denied. On allow, the entry is recorded.
|
|
189
|
+
|
|
190
|
+
**Rolling window, not calendar day.** The window is `(now - 86400000ms)` to `now` — not a midnight reset. No timezone dependency, no gameable boundary.
|
|
130
191
|
|
|
131
|
-
`
|
|
192
|
+
**Counts authorizations, not settlements.** The entry is written when the gate approves, not when the wallet service settles. Since `gate_execute` is a stub at the lite tier, counting settled transactions would never decrement the cap.
|
|
193
|
+
|
|
194
|
+
**Durable across restart.** The ledger is a file on disk. Restarting the gate process does not reset the cap. The rolling window query picks up prior entries naturally.
|
|
195
|
+
|
|
196
|
+
**Security boundary.** The ledger is on the agent-user side of the G1 boundary, mode 600. A malicious skill cannot reach it — it can only call `gate_evaluate`, and the gate controls the response. A fully-compromised gate process (which could delete the ledger file) is the binding-tier threat, out of scope for lite. The per-transaction ceiling and rail restriction remain enforced by the mandate signature regardless of ledger state.
|
|
197
|
+
|
|
198
|
+
**To override the ledger path:**
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
HERMES_LEDGER_PATH=/home/atlas/spend-ledger.jsonl \
|
|
202
|
+
hermes-gate ...
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The gate creates the ledger file on startup if it does not exist.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Production: provision and verify
|
|
210
|
+
|
|
211
|
+
For OS-level key isolation, run `provision` as root after `generate`:
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
sudo npx @observer-protocol/hermes-gate bootstrap provision \
|
|
215
|
+
--agent-user atlas \
|
|
216
|
+
--wallet-user atlas-wallet
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Flags:
|
|
220
|
+
|
|
221
|
+
| Flag | Required | Notes |
|
|
222
|
+
|------|----------|-------|
|
|
223
|
+
| `--agent-user` | yes | System user that runs the agent |
|
|
224
|
+
| `--wallet-user` | yes | System user that runs the wallet service |
|
|
225
|
+
| `--output-dir` | no | Source directory; defaults to `./output` |
|
|
226
|
+
|
|
227
|
+
`provision` copies files and sets permissions:
|
|
228
|
+
|
|
229
|
+
| File | Destination | Mode |
|
|
230
|
+
|------|-------------|------|
|
|
231
|
+
| `agent-identity-key.json` | `/home/<agent-user>/identity/did-key.json` | 600 |
|
|
232
|
+
| `spend-mandate.json` | `/home/<agent-user>/spend-mandate.json` | 644 |
|
|
233
|
+
| `wbc.json` | `/home/<agent-user>/wbc.json` | 644 |
|
|
234
|
+
| `wallet-seed.json` | `/home/<wallet-user>/secrets/wallet-seed.json` | 600 |
|
|
235
|
+
| `wallet-identity-key.json` | `/home/<wallet-user>/secrets/wallet-key.json` | 600 |
|
|
236
|
+
|
|
237
|
+
The wallet-service user's home directory and secrets directory are set to mode 700.
|
|
238
|
+
|
|
239
|
+
Then confirm the boundary is secure:
|
|
240
|
+
|
|
241
|
+
```
|
|
242
|
+
sudo npx @observer-protocol/hermes-gate bootstrap verify \
|
|
243
|
+
--agent-user atlas \
|
|
244
|
+
--wallet-user atlas-wallet
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Flags: `--agent-user` and `--wallet-user` (required). No `--output-dir`.
|
|
248
|
+
|
|
249
|
+
`verify` runs three cross-boundary deny tests. PASS means access was correctly denied. INCONCLUSIVE means sudo is not configured for this user; run as root or configure passwordless sudo. The command exits non-zero if any boundary is broken.
|
|
250
|
+
|
|
251
|
+
---
|
|
132
252
|
|
|
133
253
|
## Gate tools
|
|
134
254
|
|
|
135
|
-
**`gate_evaluate
|
|
255
|
+
**`gate_evaluate`**: Check whether a proposed spend is within the mandate. Call this before any payment. Returns `allow: true/false` with reasons.
|
|
256
|
+
|
|
257
|
+
Required params: `rail` (string), `amount` (positive decimal string), `currency` (string).
|
|
258
|
+
Optional: `wallet_id` (string, required for the BIND wallet-address check when a WBC is configured), `category`, `note`.
|
|
259
|
+
|
|
260
|
+
**`gate_execute`**: Evaluate and, if allowed, signal the wallet service to submit. Returns the decision and a `tx_ref` placeholder (set by the wallet service after submission). The gate does not submit transactions itself.
|
|
261
|
+
|
|
262
|
+
**`gate_status`**: Return gate health and mandate metadata (agent DID, mandate issuer, valid-until). Does not re-verify the mandate signature.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Secrets and output/
|
|
267
|
+
|
|
268
|
+
`generate` writes all four key files at mode 600. `output/` is in `.gitignore`.
|
|
269
|
+
|
|
270
|
+
After generate:
|
|
271
|
+
- Move `principal-key.json` offline immediately. Do not leave it on the server.
|
|
272
|
+
- Do not commit `output/` or any key file.
|
|
273
|
+
- `spend-mandate.json` and `wbc.json` are signed credentials readable by the gate (mode 644). They contain no key material.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## What this protects against, and what it does not
|
|
278
|
+
|
|
279
|
+
**It protects against the malicious-skill threat.** A hostile skill, a prompt injection, a poisoned MCP server: anything trying to make your agent act outside your mandate is stopped at the gate, fail-closed. This is the threat the agent community has been burned by, and this tier closes it.
|
|
280
|
+
|
|
281
|
+
**The current limit: the gate enforces against your agent's stated intent.** Your agent tells the gate what it is about to do, and the gate checks that against your mandate. This trusts your agent to report its own actions honestly. It is complete protection against external manipulation of an honest agent.
|
|
282
|
+
|
|
283
|
+
**What it does not yet catch: a compromised or erratic agent that misreports.** If your own agent is itself compromised, hallucinating, or manipulated into building a transaction whose actual bytes differ from what it declares, this tier checks the declaration, not the wire. Closing that gap is the binding tier, on the roadmap.
|
|
284
|
+
|
|
285
|
+
**Rails.** Binding enforcement is live for EVM stablecoins (USDT, USDC). Lightning support is advisory in this tier: the gate surfaces a decision but does not hard-enforce the Lightning payment. Binding Lightning enforcement lands with the binding tier. If you need a hard deny on Lightning today, that enforcement is not there yet.
|
|
136
286
|
|
|
137
|
-
|
|
287
|
+
---
|
|
138
288
|
|
|
139
|
-
|
|
289
|
+
## Roadmap: the binding tier
|
|
140
290
|
|
|
141
|
-
|
|
291
|
+
The same gate, wired one level deeper: into your wallet's signing path instead of beside it, so it enforces against the actual transaction rather than your agent's description of it. This protects against the agent-itself threat (a rogue, hallucinating, or out-of-scope agent), and brings binding Lightning enforcement. It is the same product at a deeper integration depth. You will not switch; you will deepen the gate you already run.
|
|
142
292
|
|
|
143
|
-
|
|
293
|
+
---
|
|
144
294
|
|
|
145
|
-
|
|
146
|
-
- Spends above the mandate ceiling
|
|
147
|
-
- Spends on disallowed rails
|
|
148
|
-
- Calls where `wallet_id` does not match the bound wallet address
|
|
149
|
-
- Calls with expired mandates
|
|
150
|
-
- Any input that fails to parse (fail-closed)
|
|
295
|
+
## Honest summary
|
|
151
296
|
|
|
152
|
-
|
|
297
|
+
`hermes-gate` (community tier): a fail-closed spend gate that a malicious skill cannot bypass, enforcing per-transaction ceilings, rolling 24h cumulative caps, and rail restrictions against your agent's intended spends, binding on EVM stablecoins, advisory on Lightning, installed in one command with the wallet you already have. The binding tier, which enforces against decoded on-chain transactions and protects against your own agent going off-script, is the roadmap.
|
|
153
298
|
|
|
154
|
-
|
|
299
|
+
---
|
|
155
300
|
|
|
156
301
|
## License
|
|
157
302
|
|
package/bin/hermes-gate.js
CHANGED
|
@@ -33,7 +33,9 @@ switch (sub) {
|
|
|
33
33
|
agentLabel: flags.agentLabel,
|
|
34
34
|
allowedRails: flags.allowedRails ? flags.allowedRails.split(',') : undefined,
|
|
35
35
|
ceilingAmount: flags.ceilingAmount,
|
|
36
|
-
ceilCurrency: flags.ceilCurrency
|
|
36
|
+
ceilCurrency: flags.ceilCurrency,
|
|
37
|
+
dailyCapAmount: flags.dailyCapAmount,
|
|
38
|
+
dailyCapCurrency: flags.dailyCapCurrency
|
|
37
39
|
})
|
|
38
40
|
break
|
|
39
41
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@observer-protocol/hermes-gate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Fail-closed spend gate for Hermes agent operators — OP trust enforcement over a WDK wallet",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"@observer-protocol/wdk-protocol-trust": "^0.2.0-beta.2"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
|
-
"test": "node --test tests/gate.test.js tests/bootstrap.test.js",
|
|
32
|
+
"test": "node --test tests/gate.test.js tests/bootstrap.test.js tests/ledger.test.js",
|
|
33
33
|
"gate": "node src/mcp-server.js"
|
|
34
34
|
},
|
|
35
35
|
"exports": {
|
package/src/bootstrap.js
CHANGED
|
@@ -42,6 +42,8 @@ function toISO (d) {
|
|
|
42
42
|
* @param {string[]} [opts.allowedRails] - Rails to permit (default: ethereum-mainnet + lightning)
|
|
43
43
|
* @param {string} [opts.ceilingAmount] - Per-tx ceiling amount (default: 100)
|
|
44
44
|
* @param {string} [opts.ceilCurrency] - Ceiling currency (default: USDT)
|
|
45
|
+
* @param {string} [opts.dailyCapAmount] - Rolling 24h cumulative cap; if set, adds cumulative_budget to mandate
|
|
46
|
+
* @param {string} [opts.dailyCapCurrency] - Currency for daily cap (defaults to ceilCurrency)
|
|
45
47
|
* @returns {{ principalDid: string, agentDid: string, walletDid: string, mandateId: string }}
|
|
46
48
|
*/
|
|
47
49
|
export function generate (opts = {}) {
|
|
@@ -50,7 +52,9 @@ export function generate (opts = {}) {
|
|
|
50
52
|
agentLabel = 'agent',
|
|
51
53
|
allowedRails = ['ethereum-mainnet', 'lightning'],
|
|
52
54
|
ceilingAmount = '100',
|
|
53
|
-
ceilCurrency = 'USDT'
|
|
55
|
+
ceilCurrency = 'USDT',
|
|
56
|
+
dailyCapAmount = null,
|
|
57
|
+
dailyCapCurrency = null
|
|
54
58
|
} = opts
|
|
55
59
|
|
|
56
60
|
mkdirSync(outputDir, { recursive: true })
|
|
@@ -107,7 +111,14 @@ export function generate (opts = {}) {
|
|
|
107
111
|
per_transaction_ceiling: {
|
|
108
112
|
amount: ceilingAmount,
|
|
109
113
|
currency: ceilCurrency
|
|
110
|
-
}
|
|
114
|
+
},
|
|
115
|
+
...(dailyCapAmount ? {
|
|
116
|
+
cumulative_budget: {
|
|
117
|
+
amount: dailyCapAmount,
|
|
118
|
+
currency: dailyCapCurrency || ceilCurrency,
|
|
119
|
+
period: '24h'
|
|
120
|
+
}
|
|
121
|
+
} : {})
|
|
111
122
|
},
|
|
112
123
|
delegationScope: { may_delegate_further: false },
|
|
113
124
|
enforcementMode: 'pre_transaction_check'
|
|
@@ -185,6 +196,9 @@ export function generate (opts = {}) {
|
|
|
185
196
|
console.log(' Wallet DID: ', wallet.did)
|
|
186
197
|
console.log(' Mandate ID: ', signedMandate.id)
|
|
187
198
|
console.log(' Valid: ', validFrom, '→', validUntil)
|
|
199
|
+
if (dailyCapAmount) {
|
|
200
|
+
console.log(' Daily cap: ', dailyCapAmount, (dailyCapCurrency || ceilCurrency), '/ 24h rolling window (enforced via ledger)')
|
|
201
|
+
}
|
|
188
202
|
console.log()
|
|
189
203
|
console.log('PLACEMENT INSTRUCTIONS:')
|
|
190
204
|
console.log()
|
package/src/gate.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
'use strict'
|
|
5
5
|
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
6
7
|
import { runRuntimeAdapter } from '@observer-protocol/wdk-protocol-trust'
|
|
7
8
|
|
|
8
9
|
export class GateError extends Error {
|
|
@@ -38,8 +39,9 @@ export class SpendGate {
|
|
|
38
39
|
* @param {string[]} [opts.trustedIssuers] - Issuer DIDs the gate trusts
|
|
39
40
|
* @param {string} [opts.walletBindingCredentialPath] - Path to wbc.json; when set, enforces BIND+LINK
|
|
40
41
|
* @param {'dev'|'full'} [opts.issuanceMode] - Governs LINK check; defaults to 'dev'
|
|
42
|
+
* @param {import('./spend-ledger.js').SpendLedger} [opts.spendLedger] - Ledger for rolling 24h cap; required when mandate declares cumulative_budget
|
|
41
43
|
*/
|
|
42
|
-
constructor ({ mandatePath, agentDid, trustedIssuers, walletBindingCredentialPath, issuanceMode }) {
|
|
44
|
+
constructor ({ mandatePath, agentDid, trustedIssuers, walletBindingCredentialPath, issuanceMode, spendLedger }) {
|
|
43
45
|
if (!mandatePath || typeof mandatePath !== 'string') throw new GateError('CONFIG', 'mandatePath required')
|
|
44
46
|
if (!agentDid || typeof agentDid !== 'string') throw new GateError('CONFIG', 'agentDid required')
|
|
45
47
|
this._config = {
|
|
@@ -49,6 +51,7 @@ export class SpendGate {
|
|
|
49
51
|
walletBindingCredentialPath,
|
|
50
52
|
issuanceMode
|
|
51
53
|
}
|
|
54
|
+
this._spendLedger = spendLedger || null
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
/**
|
|
@@ -67,7 +70,24 @@ export class SpendGate {
|
|
|
67
70
|
* @returns {Promise<{ allow: boolean, reasons: object[], advisories: object[], mandateValidUntil: string }>}
|
|
68
71
|
*/
|
|
69
72
|
async evaluate (action) {
|
|
73
|
+
// Read cumulative_budget from mandate before calling runRuntimeAdapter so
|
|
74
|
+
// we have cap metadata ready if allow:true. Re-read matches runRuntimeAdapter's
|
|
75
|
+
// own re-read on every call, picking up key rotation without a gate restart.
|
|
76
|
+
let dailyCap = null
|
|
77
|
+
if (this._spendLedger) {
|
|
78
|
+
try {
|
|
79
|
+
const raw = JSON.parse(readFileSync(this._config.mandatePath, 'utf8'))
|
|
80
|
+
const budget = raw.credentialSubject?.actionScope?.cumulative_budget
|
|
81
|
+
if (budget?.amount && budget?.currency) {
|
|
82
|
+
dailyCap = { amount: parseFloat(budget.amount), currency: budget.currency }
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// mandate read failure is surfaced below by runRuntimeAdapter
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
70
89
|
const result = await runRuntimeAdapter(action, this._config)
|
|
90
|
+
|
|
71
91
|
// Surface GateError on gate-internal failures so callers can distinguish
|
|
72
92
|
// mandate/config issues from policy denials.
|
|
73
93
|
if (!result.allow && result.reasons.some(r => r.ruleField === 'mandate_read')) {
|
|
@@ -85,6 +105,28 @@ export class SpendGate {
|
|
|
85
105
|
if (!result.allow && result.reasons.some(r => r.ruleField === 'untrusted_issuer')) {
|
|
86
106
|
throw new GateError('UNTRUSTED_ISSUER', result.reasons[0].message)
|
|
87
107
|
}
|
|
108
|
+
|
|
109
|
+
// Cumulative cap enforcement — runs after the full BIND→LINK→AUTHORIZE gate
|
|
110
|
+
// passes. The check lives here (stateful orchestration layer), not inside
|
|
111
|
+
// withinScope, preserving withinScope's I/O-free evaluator invariant.
|
|
112
|
+
if (result.allow && this._spendLedger && dailyCap && dailyCap.currency === action.currency) {
|
|
113
|
+
const windowSum = this._spendLedger.sumWindow(action.rail, action.currency)
|
|
114
|
+
const proposed = parseFloat(action.amount)
|
|
115
|
+
if (windowSum + proposed > dailyCap.amount) {
|
|
116
|
+
return {
|
|
117
|
+
allow: false,
|
|
118
|
+
reasons: [{
|
|
119
|
+
ruleType: 'cumulativeCap',
|
|
120
|
+
ruleField: 'cumulative_budget',
|
|
121
|
+
message: `Rolling 24h cap exceeded: ${windowSum.toFixed(2)} + ${proposed} > ${dailyCap.amount} ${dailyCap.currency} on rail ${action.rail}`
|
|
122
|
+
}],
|
|
123
|
+
advisories: result.advisories,
|
|
124
|
+
mandateValidUntil: result.mandateValidUntil
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
this._spendLedger.record({ rail: action.rail, amount: action.amount, currency: action.currency })
|
|
128
|
+
}
|
|
129
|
+
|
|
88
130
|
return {
|
|
89
131
|
allow: result.allow,
|
|
90
132
|
reasons: result.reasons,
|
package/src/mcp-server.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
ListToolsRequestSchema
|
|
13
13
|
} from '@modelcontextprotocol/sdk/types.js'
|
|
14
14
|
import { SpendGate } from './gate.js'
|
|
15
|
+
import { SpendLedger } from './spend-ledger.js'
|
|
15
16
|
|
|
16
17
|
// ── Config from environment ───────────────────────────────────────────────
|
|
17
18
|
|
|
@@ -32,6 +33,32 @@ if (!WBC_PATH) {
|
|
|
32
33
|
console.error(' HERMES_WBC_PATH=<path/to/wbc.json> or place wbc.json alongside the mandate.')
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
// Spend ledger for rolling 24h cumulative cap. Created only when the mandate
|
|
37
|
+
// declares cumulative_budget (or HERMES_LEDGER_PATH is set explicitly).
|
|
38
|
+
// Default path: alongside the mandate, on the agent-user side of the G1 boundary.
|
|
39
|
+
const LEDGER_PATH = process.env.HERMES_LEDGER_PATH || join(dirname(resolve(MANDATE_PATH)), 'spend-ledger.jsonl')
|
|
40
|
+
|
|
41
|
+
function loadSpendLedger () {
|
|
42
|
+
if (process.env.HERMES_LEDGER_PATH) {
|
|
43
|
+
return new SpendLedger(process.env.HERMES_LEDGER_PATH)
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const m = JSON.parse(readFileSync(MANDATE_PATH, 'utf8'))
|
|
47
|
+
if (m.credentialSubject?.actionScope?.cumulative_budget) {
|
|
48
|
+
return new SpendLedger(LEDGER_PATH)
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// mandate unreadable at startup — gate will surface this on first evaluate
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const spendLedger = loadSpendLedger()
|
|
57
|
+
if (spendLedger) {
|
|
58
|
+
spendLedger.prune()
|
|
59
|
+
console.error(`hermes-gate: rolling 24h cap active — ledger at ${LEDGER_PATH}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
35
62
|
// Load agent DID from identity file (agent-user's key, not wallet seed)
|
|
36
63
|
function loadAgentDid () {
|
|
37
64
|
if (process.env.HERMES_AGENT_DID) return process.env.HERMES_AGENT_DID
|
|
@@ -100,7 +127,8 @@ const gate = new SpendGate({
|
|
|
100
127
|
mandatePath: MANDATE_PATH,
|
|
101
128
|
agentDid,
|
|
102
129
|
trustedIssuers: mandateIssuer ? [mandateIssuer] : [],
|
|
103
|
-
walletBindingCredentialPath: WBC_PATH || undefined
|
|
130
|
+
walletBindingCredentialPath: WBC_PATH || undefined,
|
|
131
|
+
spendLedger: spendLedger || undefined
|
|
104
132
|
})
|
|
105
133
|
|
|
106
134
|
const server = new Server(
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Part of @observer-protocol/hermes-gate
|
|
3
|
+
|
|
4
|
+
'use strict'
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, renameSync } from 'node:fs'
|
|
7
|
+
import { dirname } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const WINDOW_MS = 24 * 60 * 60 * 1000 // 24h rolling window
|
|
10
|
+
const PRUNE_AFTER_MS = 25 * 60 * 60 * 1000 // prune entries older than this
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Append-only JSONL spend ledger for rolling 24h cumulative cap enforcement.
|
|
14
|
+
*
|
|
15
|
+
* Written on each authorized spend — when the full BIND→LINK→AUTHORIZE gate
|
|
16
|
+
* returns allow:true. Single writer (one gate process); no concurrent access.
|
|
17
|
+
*
|
|
18
|
+
* Security boundary: the ledger secures against the malicious-skill threat
|
|
19
|
+
* (lite tier). A malicious skill can only call gate_evaluate; it cannot reach
|
|
20
|
+
* the filesystem. A fully-compromised gate process that deletes the ledger
|
|
21
|
+
* resets the cap — that is the binding-tier threat, out of scope for lite.
|
|
22
|
+
*
|
|
23
|
+
* The ledger file must be:
|
|
24
|
+
* - Mode 600, owned by the gate process user (agent-user side of G1 boundary)
|
|
25
|
+
* - Not readable by the wallet-service user
|
|
26
|
+
*/
|
|
27
|
+
export class SpendLedger {
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} path - Absolute path to the .jsonl ledger file
|
|
30
|
+
*/
|
|
31
|
+
constructor (path) {
|
|
32
|
+
if (!path || typeof path !== 'string') throw new Error('SpendLedger: path required')
|
|
33
|
+
this._path = path
|
|
34
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
35
|
+
if (!existsSync(path)) {
|
|
36
|
+
writeFileSync(path, '', { encoding: 'utf8', mode: 0o600 })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Record an authorized spend. Call when the full gate returns allow:true.
|
|
42
|
+
* @param {{ rail: string, amount: string, currency: string }} entry
|
|
43
|
+
*/
|
|
44
|
+
record ({ rail, amount, currency }) {
|
|
45
|
+
const line = JSON.stringify({ ts: Date.now(), rail, amount, currency })
|
|
46
|
+
appendFileSync(this._path, line + '\n', { encoding: 'utf8' })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sum authorized spends in the last 24h for a given rail+currency pair.
|
|
51
|
+
* Rolling window: entries where ts >= (now - 24h). Timezone-agnostic.
|
|
52
|
+
* @param {string} rail
|
|
53
|
+
* @param {string} currency
|
|
54
|
+
* @returns {number}
|
|
55
|
+
*/
|
|
56
|
+
sumWindow (rail, currency) {
|
|
57
|
+
const cutoff = Date.now() - WINDOW_MS
|
|
58
|
+
let total = 0
|
|
59
|
+
try {
|
|
60
|
+
const lines = readFileSync(this._path, 'utf8').split('\n')
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
if (!line.trim()) continue
|
|
63
|
+
try {
|
|
64
|
+
const e = JSON.parse(line)
|
|
65
|
+
if (e.ts >= cutoff && e.rail === rail && e.currency === currency) {
|
|
66
|
+
total += parseFloat(e.amount) || 0
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// malformed line — skip
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err.code !== 'ENOENT') throw err
|
|
74
|
+
// file not found = no prior spends; return 0
|
|
75
|
+
}
|
|
76
|
+
return total
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Prune entries older than 25h. Atomic rewrite via temp file + rename.
|
|
81
|
+
* Safe to call at startup to bound file growth across restarts.
|
|
82
|
+
*/
|
|
83
|
+
prune () {
|
|
84
|
+
const cutoff = Date.now() - PRUNE_AFTER_MS
|
|
85
|
+
try {
|
|
86
|
+
const lines = readFileSync(this._path, 'utf8').split('\n')
|
|
87
|
+
const kept = []
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
if (!line.trim()) continue
|
|
90
|
+
try {
|
|
91
|
+
const e = JSON.parse(line)
|
|
92
|
+
if (e.ts >= cutoff) kept.push(line)
|
|
93
|
+
} catch {
|
|
94
|
+
// drop malformed lines on prune
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const tmp = this._path + '.tmp'
|
|
98
|
+
writeFileSync(tmp, kept.join('\n') + (kept.length ? '\n' : ''), { encoding: 'utf8', mode: 0o600 })
|
|
99
|
+
renameSync(tmp, this._path)
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err.code !== 'ENOENT') throw err
|
|
102
|
+
// file doesn't exist yet — nothing to prune
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|