@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 CHANGED
@@ -1,157 +1,302 @@
1
1
  # @observer-protocol/hermes-gate
2
2
 
3
- Fail-closed spend gate for Hermes agent operators. Enforces a signed SpendMandate and WalletBindingCredential (WBC) before any payment action, using the Observer Protocol BIND→LINK→AUTHORIZE pipeline.
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
- The gate checks three things on every call:
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
- 1. **BIND** — `wallet_id` in the request matches the wallet address bound in the WBC
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
- Any step that fails returns `allow: false` and stops.
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
- npm install @observer-protocol/hermes-gate
28
+ npx @observer-protocol/hermes-gate bootstrap generate
22
29
  ```
23
30
 
24
- ## Quickstart
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
- npx hermes-gate bootstrap generate
34
+ hermes-gate bootstrap generate
30
35
  ```
31
36
 
32
- Writes six files to `./output/`:
37
+ `generate` creates three keys, one mandate, and one wallet-binding credential:
33
38
 
34
- | File | Placement | Mode |
35
- |------|-----------|------|
36
- | `principal-key.json` | **Move offline immediately** | 600 |
37
- | `agent-identity-key.json` | `/home/<agent-user>/identity/did-key.json` | 600 |
38
- | `wallet-seed.json` | `/home/<wallet-user>/secrets/wallet-seed.json` | 600 |
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 four key files are written at mode 600 by generate.
45
+ All six files go to `./output/` by default.
44
46
 
45
- ### 2. Move the principal key offline
47
+ ---
46
48
 
47
- The principal key is only needed to re-issue credentials. It must not stay on the server:
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
- ### 3. Start the gate
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
- HERMES_MANDATE_PATH=./output/spend-mandate.json \
58
- HERMES_AGENT_DID=<agent-did-from-generate-output> \
59
- node node_modules/@observer-protocol/hermes-gate/src/mcp-server.js
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
- WBC auto-discovery: if `HERMES_WBC_PATH` is unset, the gate looks for `wbc.json` in the same directory as the mandate. Placing `wbc.json` alongside `spend-mandate.json` means no extra config is needed.
74
+ ---
63
75
 
64
- If neither `HERMES_WBC_PATH` nor an adjacent `wbc.json` is found, the gate starts in passthrough mode and logs a loud warning to stderr. This path is reserved for enterprise callers who explicitly opt out of wallet binding. Community installs should always have a WBC.
76
+ ## Wire up the gate
65
77
 
66
- ## Production: two-server G1 setup
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
- For OS-level isolation between the agent identity (who the agent is) and the wallet seed (what it can spend), use provision and verify. Run these as root on the target server:
80
+ `generate` prints the exact start command for your agent DID:
69
81
 
70
82
  ```
71
- sudo npx hermes-gate bootstrap provision \
72
- --agent-user atlas \
73
- --wallet-user atlas-wallet
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
- Then confirm the boundary is secure:
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
- sudo npx hermes-gate bootstrap verify \
80
- --agent-user atlas \
81
- --wallet-user atlas-wallet
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
- Provision places files at the paths and modes listed above and sets directory permissions (`chmod 700 /home/<wallet-user>/secrets`). Verify runs three cross-boundary deny tests and exits non-zero if any boundary is broken.
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
- ## Runtime configuration
101
+ **For Hermes** (`~/.hermes/config.yaml`), add the gate manually:
87
102
 
88
- | Variable | Default | Notes |
89
- |----------|---------|-------|
90
- | `HERMES_MANDATE_PATH` | `~/spend-mandate.json` | Path to the signed SpendMandate |
91
- | `HERMES_AGENT_DID` | read from `HERMES_IDENTITY_PATH` | Agent DID (did:key:...) |
92
- | `HERMES_IDENTITY_PATH` | `~/identity/did-key.json` | Agent identity key file; ignored when `HERMES_AGENT_DID` is set |
93
- | `HERMES_WBC_PATH` | auto-discovered | Path to wbc.json; auto-discovered from mandate directory if unset |
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
- ## MCP config
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
- Add to your Claude Desktop or Claude Code MCP settings:
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": ["/opt/hermes-gate/src/mcp-server.js"],
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
- Point `args[0]` at the mcp-server.js from your installed or deployed copy of the package.
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
- ## Bootstrap flags
170
+ ---
118
171
 
119
- `generate` accepts optional flags to customize the mandate:
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
- --output-dir ./output \
124
- --allowed-rails ethereum-mainnet,lightning \
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
- `provision` requires `--agent-user` and `--wallet-user`. The optional `--output-dir` defaults to `./output`.
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
- `verify` requires `--agent-user` and `--wallet-user`. Must be run as root or with passwordless sudo configured.
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`** Check whether a proposed spend is within the mandate. Returns `allow: true/false` with reasons. Call this before any payment action. Required params: `rail`, `amount` (positive decimal string), `currency`. Optional: `wallet_id` (required for BIND check when WBC is configured), `category`, `note`.
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
- **`gate_execute`** — Evaluate and, if allowed, signal the wallet service to submit. Returns the decision plus `tx_ref` (set by the wallet service after submission). The gate does not submit the transaction itself.
287
+ ---
138
288
 
139
- **`gate_status`** Return gate health and mandate metadata (agent DID, mandate issuer, valid-until). Does not re-verify the mandate signature.
289
+ ## Roadmap: the binding tier
140
290
 
141
- ## Threat model
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
- This gate enforces against agent-declared intent: the action the agent states (`rail`, `amount`, `currency`, `wallet_id`) is what gets checked.
293
+ ---
144
294
 
145
- What this gate blocks:
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
- What this gate does not block: an agent that correctly states intent but submits a malformed or mismatched transaction directly to the wallet service after gate approval. That boundary is the wallet service's responsibility.
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
- For stricter enforcement, use the `RuntimeAdapter` in `@observer-protocol/wdk-protocol-trust`, which decodes actual WDK transaction proposals and applies spend rules at the proposal layer before signing.
299
+ ---
155
300
 
156
301
  ## License
157
302
 
@@ -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.0",
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
+ }