@observer-protocol/hermes-gate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # @observer-protocol/hermes-gate
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.
4
+
5
+ The gate checks three things on every call:
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
10
+
11
+ Any step that fails returns `allow: false` and stops.
12
+
13
+ ## Prerequisites
14
+
15
+ - Node 20 or later
16
+ - npm
17
+
18
+ ## Install
19
+
20
+ ```
21
+ npm install @observer-protocol/hermes-gate
22
+ ```
23
+
24
+ ## Quickstart
25
+
26
+ ### 1. Generate key material
27
+
28
+ ```
29
+ npx hermes-gate bootstrap generate
30
+ ```
31
+
32
+ Writes six files to `./output/`:
33
+
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 |
42
+
43
+ All four key files are written at mode 600 by generate.
44
+
45
+ ### 2. Move the principal key offline
46
+
47
+ The principal key is only needed to re-issue credentials. It must not stay on the server:
48
+
49
+ ```
50
+ cp output/principal-key.json /path/to/offline/storage
51
+ rm output/principal-key.json
52
+ ```
53
+
54
+ ### 3. Start the gate
55
+
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
+ ```
61
+
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.
63
+
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.
65
+
66
+ ## Production: two-server G1 setup
67
+
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:
69
+
70
+ ```
71
+ sudo npx hermes-gate bootstrap provision \
72
+ --agent-user atlas \
73
+ --wallet-user atlas-wallet
74
+ ```
75
+
76
+ Then confirm the boundary is secure:
77
+
78
+ ```
79
+ sudo npx hermes-gate bootstrap verify \
80
+ --agent-user atlas \
81
+ --wallet-user atlas-wallet
82
+ ```
83
+
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.
85
+
86
+ ## Runtime configuration
87
+
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 |
94
+
95
+ ## MCP config
96
+
97
+ Add to your Claude Desktop or Claude Code MCP settings:
98
+
99
+ ```json
100
+ {
101
+ "mcpServers": {
102
+ "hermes-gate": {
103
+ "command": "node",
104
+ "args": ["/opt/hermes-gate/src/mcp-server.js"],
105
+ "env": {
106
+ "HERMES_MANDATE_PATH": "/home/atlas/spend-mandate.json",
107
+ "HERMES_AGENT_DID": "did:key:z6Mk...",
108
+ "HERMES_WBC_PATH": "/home/atlas/wbc.json"
109
+ }
110
+ }
111
+ }
112
+ }
113
+ ```
114
+
115
+ Point `args[0]` at the mcp-server.js from your installed or deployed copy of the package.
116
+
117
+ ## Bootstrap flags
118
+
119
+ `generate` accepts optional flags to customize the mandate:
120
+
121
+ ```
122
+ npx hermes-gate bootstrap generate \
123
+ --output-dir ./output \
124
+ --allowed-rails ethereum-mainnet,lightning \
125
+ --ceiling-amount 500 \
126
+ --ceil-currency USDT
127
+ ```
128
+
129
+ `provision` requires `--agent-user` and `--wallet-user`. The optional `--output-dir` defaults to `./output`.
130
+
131
+ `verify` requires `--agent-user` and `--wallet-user`. Must be run as root or with passwordless sudo configured.
132
+
133
+ ## Gate tools
134
+
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`.
136
+
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.
138
+
139
+ **`gate_status`** — Return gate health and mandate metadata (agent DID, mandate issuer, valid-until). Does not re-verify the mandate signature.
140
+
141
+ ## Threat model
142
+
143
+ This gate enforces against agent-declared intent: the action the agent states (`rail`, `amount`, `currency`, `wallet_id`) is what gets checked.
144
+
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)
151
+
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.
153
+
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.
155
+
156
+ ## License
157
+
158
+ Apache-2.0
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ 'use strict'
5
+
6
+ import { generate, provision, verify } from '../src/bootstrap.js'
7
+
8
+ const [,, cmd, sub, ...rest] = process.argv
9
+
10
+ function parseFlags (args) {
11
+ const flags = {}
12
+ for (let i = 0; i < args.length; i++) {
13
+ const a = args[i]
14
+ if (a.startsWith('--')) {
15
+ const key = a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())
16
+ flags[key] = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true
17
+ }
18
+ }
19
+ return flags
20
+ }
21
+
22
+ if (cmd !== 'bootstrap') {
23
+ console.error('Usage: hermes-gate bootstrap <generate|provision|verify> [flags]')
24
+ process.exit(1)
25
+ }
26
+
27
+ const flags = parseFlags(rest)
28
+
29
+ switch (sub) {
30
+ case 'generate':
31
+ generate({
32
+ outputDir: flags.outputDir || './output',
33
+ agentLabel: flags.agentLabel,
34
+ allowedRails: flags.allowedRails ? flags.allowedRails.split(',') : undefined,
35
+ ceilingAmount: flags.ceilingAmount,
36
+ ceilCurrency: flags.ceilCurrency
37
+ })
38
+ break
39
+
40
+ case 'provision':
41
+ provision({
42
+ agentUser: flags.agentUser,
43
+ walletUser: flags.walletUser,
44
+ outputDir: flags.outputDir || './output'
45
+ })
46
+ break
47
+
48
+ case 'verify':
49
+ verify({
50
+ agentUser: flags.agentUser,
51
+ walletUser: flags.walletUser
52
+ })
53
+ break
54
+
55
+ default:
56
+ console.error('Usage: hermes-gate bootstrap <generate|provision|verify>')
57
+ process.exit(1)
58
+ }
package/index.js ADDED
@@ -0,0 +1,8 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // @observer-protocol/hermes-gate
3
+
4
+ 'use strict'
5
+
6
+ export { SpendGate, GateError } from './src/gate.js'
7
+ export { generate, provision, verify } from './src/bootstrap.js'
8
+ export { evaluateSpend, skillManifest } from './src/skill.js'
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@observer-protocol/hermes-gate",
3
+ "version": "0.1.0",
4
+ "description": "Fail-closed spend gate for Hermes agent operators — OP trust enforcement over a WDK wallet",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "hermes-gate": "./bin/hermes-gate.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "bin/",
13
+ "src/"
14
+ ],
15
+ "keywords": [
16
+ "observer-protocol",
17
+ "hermes",
18
+ "wdk",
19
+ "spend-gate",
20
+ "did",
21
+ "agent-identity"
22
+ ],
23
+ "license": "Apache-2.0",
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.29.0",
29
+ "@observer-protocol/wdk-protocol-trust": "^0.2.0-beta.2"
30
+ },
31
+ "scripts": {
32
+ "test": "node --test tests/gate.test.js tests/bootstrap.test.js",
33
+ "gate": "node src/mcp-server.js"
34
+ },
35
+ "exports": {
36
+ ".": "./index.js"
37
+ }
38
+ }
@@ -0,0 +1,314 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Part of @observer-protocol/hermes-gate
3
+
4
+ 'use strict'
5
+
6
+ import { randomBytes } from 'node:crypto'
7
+ import { writeFileSync, mkdirSync, cpSync, chmodSync } from 'node:fs'
8
+ import { execSync } from 'node:child_process'
9
+ import { join } from 'node:path'
10
+ import {
11
+ createDidKeyAgent,
12
+ signDocument,
13
+ DELEGATION_SCHEMA_V2_1,
14
+ BOOTSTRAP_PATH_PRINCIPAL,
15
+ BOOTSTRAP_PATH_AGENT,
16
+ BOOTSTRAP_PATH_WALLET
17
+ } from '@observer-protocol/wdk-protocol-trust'
18
+
19
+ function hex (bytes) {
20
+ return Buffer.from(bytes).toString('hex')
21
+ }
22
+
23
+ function toISO (d) {
24
+ return d.toISOString().replace(/\.\d+Z$/, 'Z')
25
+ }
26
+
27
+ /**
28
+ * Generate all key material, a signed SpendMandate, and a WalletBindingCredential
29
+ * locally. No network required. Writes to `./output/`.
30
+ *
31
+ * The WBC is ALWAYS produced so every default community install is protected by
32
+ * the BIND→LINK→AUTHORIZE gate. The passthrough (no-WBC) path in runRuntimeAdapter
33
+ * is reserved for deliberate enterprise callers who explicitly opt out — it is
34
+ * never the community default.
35
+ *
36
+ * Both credentials are signed with DataIntegrityProof / eddsa-jcs-2022 via
37
+ * signDocument(), which is the only proof suite accepted by the policy engine.
38
+ *
39
+ * @param {object} [opts]
40
+ * @param {string} [opts.outputDir] - Output directory (default: ./output)
41
+ * @param {string} [opts.agentLabel] - Label for the agent (default: agent)
42
+ * @param {string[]} [opts.allowedRails] - Rails to permit (default: ethereum-mainnet + lightning)
43
+ * @param {string} [opts.ceilingAmount] - Per-tx ceiling amount (default: 100)
44
+ * @param {string} [opts.ceilCurrency] - Ceiling currency (default: USDT)
45
+ * @returns {{ principalDid: string, agentDid: string, walletDid: string, mandateId: string }}
46
+ */
47
+ export function generate (opts = {}) {
48
+ const {
49
+ outputDir = './output',
50
+ agentLabel = 'agent',
51
+ allowedRails = ['ethereum-mainnet', 'lightning'],
52
+ ceilingAmount = '100',
53
+ ceilCurrency = 'USDT'
54
+ } = opts
55
+
56
+ mkdirSync(outputDir, { recursive: true })
57
+
58
+ // 1. Principal key (operator — store offline)
59
+ const principalSeed = randomBytes(32)
60
+ const principal = createDidKeyAgent(principalSeed, BOOTSTRAP_PATH_PRINCIPAL)
61
+
62
+ // 2. Agent identity key (agent user — carries no spend authority)
63
+ const agentSeed = randomBytes(32)
64
+ const agent = createDidKeyAgent(agentSeed, BOOTSTRAP_PATH_AGENT)
65
+
66
+ // 3. Wallet identity key (wallet-service user — the wallet the WBC binds to)
67
+ // BOOTSTRAP_PATH_WALLET is the canonical path. The WBC binds the DID derived
68
+ // from this path; if wallet-service derives its DID with a different path, the
69
+ // BIND step DENYs every call. Both sides must import this constant.
70
+ const walletSeed = randomBytes(32)
71
+ const wallet = createDidKeyAgent(walletSeed, BOOTSTRAP_PATH_WALLET)
72
+
73
+ const now = new Date()
74
+ const validFrom = toISO(now)
75
+ const validUntil = toISO(new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000))
76
+
77
+ // 4. Issue SpendMandate: principal → agent
78
+ //
79
+ // allowed_transaction_categories is NOT included. Its enforcement depends on
80
+ // config.transactionCategory being set at runtime; in the default community
81
+ // install that field is not provided, so including it would silently DENY
82
+ // every transaction (category check fires fail-closed). The ceiling + rail
83
+ // constraints are the enforceable guards. Category-gating requires runtime
84
+ // config to back it.
85
+ const mandate = {
86
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
87
+ type: ['VerifiableCredential', 'ObserverDelegationCredential'],
88
+ id: `urn:uuid:hermes-spend-mandate-${hex(randomBytes(8))}`,
89
+ issuer: principal.did,
90
+ validFrom,
91
+ validUntil,
92
+ credentialSchema: {
93
+ id: DELEGATION_SCHEMA_V2_1,
94
+ type: 'JsonSchema'
95
+ },
96
+ credentialSubject: {
97
+ id: agent.did,
98
+ authorizationLevel: 'recurring',
99
+ authorizationConfig: {
100
+ recurring: {
101
+ ceiling_amount: ceilingAmount,
102
+ ceiling_currency: ceilCurrency
103
+ }
104
+ },
105
+ actionScope: {
106
+ allowed_rails: allowedRails,
107
+ per_transaction_ceiling: {
108
+ amount: ceilingAmount,
109
+ currency: ceilCurrency
110
+ }
111
+ },
112
+ delegationScope: { may_delegate_further: false },
113
+ enforcementMode: 'pre_transaction_check'
114
+ }
115
+ }
116
+
117
+ const signedMandate = signDocument(mandate, principal)
118
+
119
+ // 5. Issue WalletBindingCredential: principal binds wallet.did to itself.
120
+ //
121
+ // The LINK step (dev mode) checks: wbc.issuer === mandate.issuer.
122
+ // Both are principal.did, so the LINK check passes for every default install.
123
+ //
124
+ // At runtime, ctx.wallet_id must equal wbc.credentialSubject.walletAddress
125
+ // (the BIND address check). The wallet service uses wallet.did as its
126
+ // identifier and passes it as ctx.wallet_id to the gate.
127
+ const wbc = signDocument({
128
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
129
+ id: `urn:uuid:hermes-wbc-${hex(randomBytes(8))}`,
130
+ type: ['VerifiableCredential', 'WalletBindingCredential'],
131
+ issuer: principal.did,
132
+ validFrom,
133
+ validUntil,
134
+ credentialSubject: {
135
+ id: principal.did,
136
+ walletAddress: wallet.did,
137
+ rail: allowedRails[0],
138
+ issuanceMode: 'dev'
139
+ }
140
+ }, principal)
141
+
142
+ // 6. Write output files
143
+ const principalKeyPath = join(outputDir, 'principal-key.json')
144
+ writeFileSync(principalKeyPath, JSON.stringify({
145
+ _note: 'PRINCIPAL KEY — store offline, never on server',
146
+ seed_hex: hex(principalSeed),
147
+ did: principal.did,
148
+ public_key_hex: hex(principal.publicKey)
149
+ }, null, 2))
150
+ chmodSync(principalKeyPath, 0o600)
151
+
152
+ const agentKeyPath = join(outputDir, 'agent-identity-key.json')
153
+ writeFileSync(agentKeyPath, JSON.stringify({
154
+ _note: 'AGENT IDENTITY KEY — agent user only, mode 600',
155
+ seed_hex: hex(agentSeed),
156
+ did: agent.did,
157
+ public_key_hex: hex(agent.publicKey),
158
+ label: agentLabel
159
+ }, null, 2))
160
+ chmodSync(agentKeyPath, 0o600)
161
+
162
+ const walletSeedPath = join(outputDir, 'wallet-seed.json')
163
+ writeFileSync(walletSeedPath, JSON.stringify({
164
+ _note: 'WALLET SEED — wallet-service user only, mode 600',
165
+ seed_hex: hex(walletSeed)
166
+ }, null, 2))
167
+ chmodSync(walletSeedPath, 0o600)
168
+
169
+ const walletKeyPath = join(outputDir, 'wallet-identity-key.json')
170
+ writeFileSync(walletKeyPath, JSON.stringify({
171
+ _note: 'WALLET IDENTITY KEY — wallet-service user only, mode 600',
172
+ seed_hex: hex(walletSeed),
173
+ did: wallet.did,
174
+ public_key_hex: hex(wallet.publicKey)
175
+ }, null, 2))
176
+ chmodSync(walletKeyPath, 0o600)
177
+
178
+ writeFileSync(join(outputDir, 'spend-mandate.json'), JSON.stringify(signedMandate, null, 2))
179
+ writeFileSync(join(outputDir, 'wbc.json'), JSON.stringify(wbc, null, 2))
180
+
181
+ console.log('Generated key material in', outputDir)
182
+ console.log()
183
+ console.log(' Principal DID:', principal.did)
184
+ console.log(' Agent DID: ', agent.did)
185
+ console.log(' Wallet DID: ', wallet.did)
186
+ console.log(' Mandate ID: ', signedMandate.id)
187
+ console.log(' Valid: ', validFrom, '→', validUntil)
188
+ console.log()
189
+ console.log('PLACEMENT INSTRUCTIONS:')
190
+ console.log()
191
+ console.log(' principal-key.json → store OFFLINE (e.g. encrypted drive). Never on server.')
192
+ console.log(' agent-identity-key.json → /home/<agent-user>/identity/did-key.json (mode 600, chown <agent-user>)')
193
+ console.log(' wallet-seed.json → /home/<wallet-user>/secrets/wallet-seed.json (mode 600, chown <wallet-user>)')
194
+ console.log(' wallet-identity-key.json → /home/<wallet-user>/secrets/wallet-key.json (mode 600, chown <wallet-user>)')
195
+ console.log(' spend-mandate.json → /home/<agent-user>/spend-mandate.json (mode 644)')
196
+ console.log(' wbc.json → /home/<agent-user>/wbc.json (mode 644)')
197
+ console.log()
198
+ console.log('START THE GATE:')
199
+ console.log()
200
+ console.log(' HERMES_MANDATE_PATH=/home/<agent-user>/spend-mandate.json \\')
201
+ console.log(' HERMES_AGENT_DID=' + agent.did + ' \\')
202
+ console.log(' node /path/to/hermes-gate/src/mcp-server.js')
203
+ console.log()
204
+ console.log(' WBC auto-discovery: the gate looks for wbc.json in the same directory as the mandate.')
205
+ console.log(' No HERMES_WBC_PATH needed when wbc.json is placed alongside spend-mandate.json.')
206
+ console.log(' Set HERMES_WBC_PATH explicitly to load wbc.json from a different path.')
207
+ console.log()
208
+ console.log('Run `hermes-gate bootstrap provision` to copy files to the correct locations.')
209
+
210
+ return { principalDid: principal.did, agentDid: agent.did, walletDid: wallet.did, mandateId: signedMandate.id }
211
+ }
212
+
213
+ /**
214
+ * Provision key material from ./output/ to the correct system paths.
215
+ *
216
+ * @param {{ agentUser: string, walletUser: string, outputDir?: string }} opts
217
+ */
218
+ export function provision ({ agentUser, walletUser, outputDir = './output' }) {
219
+ if (!agentUser || !walletUser) throw new Error('--agent-user and --wallet-user required')
220
+
221
+ const agentHome = `/home/${agentUser}`
222
+ const walletHome = `/home/${walletUser}`
223
+
224
+ execSync(`mkdir -p ${agentHome}/identity ${walletHome}/secrets`)
225
+
226
+ cpSync(join(outputDir, 'agent-identity-key.json'), `${agentHome}/identity/did-key.json`)
227
+ cpSync(join(outputDir, 'wallet-seed.json'), `${walletHome}/secrets/wallet-seed.json`)
228
+ cpSync(join(outputDir, 'wallet-identity-key.json'), `${walletHome}/secrets/wallet-key.json`)
229
+ cpSync(join(outputDir, 'spend-mandate.json'), `${agentHome}/spend-mandate.json`)
230
+ cpSync(join(outputDir, 'wbc.json'), `${agentHome}/wbc.json`)
231
+
232
+ execSync(`chown -R ${agentUser}:${agentUser} ${agentHome}/identity ${agentHome}/spend-mandate.json ${agentHome}/wbc.json`)
233
+ execSync(`chmod 600 ${agentHome}/identity/did-key.json`)
234
+ execSync(`chmod 644 ${agentHome}/spend-mandate.json`)
235
+ execSync(`chmod 644 ${agentHome}/wbc.json`)
236
+
237
+ execSync(`chown -R ${walletUser}:${walletUser} ${walletHome}/secrets`)
238
+ execSync(`chmod 700 ${walletHome}`)
239
+ execSync(`chmod 700 ${walletHome}/secrets`)
240
+ execSync(`chmod 600 ${walletHome}/secrets/wallet-seed.json`)
241
+ execSync(`chmod 600 ${walletHome}/secrets/wallet-key.json`)
242
+
243
+ console.log('Provisioned:')
244
+ console.log(' ', `${agentHome}/identity/did-key.json (600)`)
245
+ console.log(' ', `${agentHome}/spend-mandate.json (644)`)
246
+ console.log(' ', `${agentHome}/wbc.json (644)`)
247
+ console.log(' ', `${walletHome}/secrets/wallet-seed.json (600)`)
248
+ console.log(' ', `${walletHome}/secrets/wallet-key.json (600)`)
249
+ console.log()
250
+ console.log('Run `hermes-gate bootstrap verify` to confirm G1 boundary.')
251
+ }
252
+
253
+ /**
254
+ * G1 boundary verification. Runs the three cross-boundary deny tests.
255
+ * Exits non-zero if any boundary check passes (meaning it should have been denied).
256
+ *
257
+ * @param {{ agentUser: string, walletUser: string }} opts
258
+ */
259
+ export function verify ({ agentUser, walletUser }) {
260
+ if (!agentUser || !walletUser) throw new Error('--agent-user and --wallet-user required')
261
+
262
+ const agentHome = `/home/${agentUser}`
263
+ const walletHome = `/home/${walletUser}`
264
+
265
+ const checks = [
266
+ {
267
+ label: `${agentUser} cannot read ${walletUser}'s wallet seed`,
268
+ cmd: `sudo -u ${agentUser} cat ${walletHome}/secrets/wallet-seed.json`
269
+ },
270
+ {
271
+ label: `${agentUser} cannot list ${walletUser}'s secrets dir`,
272
+ cmd: `sudo -u ${agentUser} ls ${walletHome}/secrets/`
273
+ },
274
+ {
275
+ label: `${walletUser} cannot read ${agentUser}'s identity key`,
276
+ cmd: `sudo -u ${walletUser} cat ${agentHome}/identity/did-key.json`
277
+ }
278
+ ]
279
+
280
+ let passed = 0
281
+ let failed = 0
282
+
283
+ for (const { label, cmd } of checks) {
284
+ try {
285
+ execSync(cmd, { stdio: 'pipe' })
286
+ console.error(`FAIL [boundary broken]: ${label}`)
287
+ failed++
288
+ } catch (e) {
289
+ const out = ((e.stderr || '') + (e.stdout || '')).toString()
290
+ if (/[Pp]ermission denied|cannot open|[Nn]o such file/.test(out)) {
291
+ console.log(`PASS [access denied]: ${label}`)
292
+ passed++
293
+ } else if (/sudo:/.test(out)) {
294
+ console.error(`INCONCLUSIVE [sudo not configured]: ${label}`)
295
+ console.error(' Run as root or configure passwordless sudo for this user')
296
+ failed++
297
+ } else {
298
+ console.error(`INCONCLUSIVE [unexpected error]: ${label}`)
299
+ console.error(` ${out.trim().split('\n')[0] || '(no stderr)'}`)
300
+ failed++
301
+ }
302
+ }
303
+ }
304
+
305
+ console.log()
306
+ console.log(`G1 boundary: ${passed}/${checks.length} checks passed`)
307
+
308
+ if (failed > 0) {
309
+ console.error(`${failed} boundary check(s) failed — G1 boundary is NOT secure.`)
310
+ process.exit(1)
311
+ } else {
312
+ console.log('G1 boundary verified.')
313
+ }
314
+ }
package/src/gate.js ADDED
@@ -0,0 +1,95 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Part of @observer-protocol/hermes-gate
3
+
4
+ 'use strict'
5
+
6
+ import { runRuntimeAdapter } from '@observer-protocol/wdk-protocol-trust'
7
+
8
+ export class GateError extends Error {
9
+ constructor (code, detail) {
10
+ super(detail || code)
11
+ this.code = code
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Fail-closed spend gate backed by a signed SpendMandate.
17
+ *
18
+ * Instantiated by the wallet-service user (atlas-wallet). Holds no wallet seed.
19
+ *
20
+ * Every evaluate() call re-reads and re-verifies the mandate from disk.
21
+ * Mandate integrity comes from the DataIntegrityProof / eddsa-jcs-2022 Ed25519
22
+ * proof, not filesystem secrecy (the file is 644). Re-verification catches key
23
+ * rotation on the issuer side without a gate restart.
24
+ *
25
+ * COMMUNITY GATE SECURITY CAVEAT — action-input is agent-stated intent, not
26
+ * decoded on-chain bytes. A skill that declares amount: 5 but builds a
27
+ * transaction for 500 is caught by the enterprise RuntimeAdapter (which decodes
28
+ * actual on-chain bytes via ResolvedTransfer) and NOT by this gate. This is the
29
+ * headline v1 limitation: SpendGate enforces against what the agent declares,
30
+ * not what the transaction executes. The enterprise path (wdk-op-policy +
31
+ * runRuntimeAdapter) closes this gap via ProposalBinding.
32
+ */
33
+ export class SpendGate {
34
+ /**
35
+ * @param {object} opts
36
+ * @param {string} opts.mandatePath - Path to the signed spend-mandate.json (644)
37
+ * @param {string} opts.agentDid - The agent's did:key (public)
38
+ * @param {string[]} [opts.trustedIssuers] - Issuer DIDs the gate trusts
39
+ * @param {string} [opts.walletBindingCredentialPath] - Path to wbc.json; when set, enforces BIND+LINK
40
+ * @param {'dev'|'full'} [opts.issuanceMode] - Governs LINK check; defaults to 'dev'
41
+ */
42
+ constructor ({ mandatePath, agentDid, trustedIssuers, walletBindingCredentialPath, issuanceMode }) {
43
+ if (!mandatePath || typeof mandatePath !== 'string') throw new GateError('CONFIG', 'mandatePath required')
44
+ if (!agentDid || typeof agentDid !== 'string') throw new GateError('CONFIG', 'agentDid required')
45
+ this._config = {
46
+ mandatePath,
47
+ agentDid,
48
+ trustedIssuers: trustedIssuers || [],
49
+ walletBindingCredentialPath,
50
+ issuanceMode
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Evaluate a proposed spend action. Re-reads and re-verifies the mandate on
56
+ * every call. Optionally enforces BIND+LINK when walletBindingCredentialPath
57
+ * is configured.
58
+ *
59
+ * @param {{
60
+ * rail: string,
61
+ * amount: string,
62
+ * currency: string,
63
+ * category?: string,
64
+ * wallet_id?: string
65
+ * }} action - Flat MCP surface. wallet_id is the signing wallet DID/address;
66
+ * required only when BIND address verification is desired.
67
+ * @returns {Promise<{ allow: boolean, reasons: object[], advisories: object[], mandateValidUntil: string }>}
68
+ */
69
+ async evaluate (action) {
70
+ const result = await runRuntimeAdapter(action, this._config)
71
+ // Surface GateError on gate-internal failures so callers can distinguish
72
+ // mandate/config issues from policy denials.
73
+ if (!result.allow && result.reasons.some(r => r.ruleField === 'mandate_read')) {
74
+ throw new GateError('MANDATE_READ', result.reasons[0].message)
75
+ }
76
+ if (!result.allow && result.reasons.some(r => r.ruleField === 'mandate_invalid')) {
77
+ throw new GateError('MANDATE_INVALID', result.reasons[0].message)
78
+ }
79
+ if (!result.allow && result.reasons.some(r => r.ruleField === 'self_signed_mandate')) {
80
+ throw new GateError('SELF_SIGNED_MANDATE', result.reasons[0].message)
81
+ }
82
+ if (!result.allow && result.reasons.some(r => r.ruleField === 'subject_mismatch')) {
83
+ throw new GateError('SUBJECT_MISMATCH', result.reasons[0].message)
84
+ }
85
+ if (!result.allow && result.reasons.some(r => r.ruleField === 'untrusted_issuer')) {
86
+ throw new GateError('UNTRUSTED_ISSUER', result.reasons[0].message)
87
+ }
88
+ return {
89
+ allow: result.allow,
90
+ reasons: result.reasons,
91
+ advisories: result.advisories,
92
+ mandateValidUntil: result.mandateValidUntil
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,250 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Part of @observer-protocol/hermes-gate
3
+
4
+ 'use strict'
5
+
6
+ import { readFileSync, existsSync } from 'node:fs'
7
+ import { dirname, join, resolve } from 'node:path'
8
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
10
+ import {
11
+ CallToolRequestSchema,
12
+ ListToolsRequestSchema
13
+ } from '@modelcontextprotocol/sdk/types.js'
14
+ import { SpendGate } from './gate.js'
15
+
16
+ // ── Config from environment ───────────────────────────────────────────────
17
+
18
+ const MANDATE_PATH = process.env.HERMES_MANDATE_PATH || `${process.env.HOME}/spend-mandate.json`
19
+ const IDENTITY_PATH = process.env.HERMES_IDENTITY_PATH || `${process.env.HOME}/identity/did-key.json`
20
+ const WBC_PATH = (() => {
21
+ if (process.env.HERMES_WBC_PATH) return process.env.HERMES_WBC_PATH
22
+ const candidate = join(dirname(resolve(MANDATE_PATH)), 'wbc.json')
23
+ return existsSync(candidate) ? candidate : null
24
+ })()
25
+
26
+ if (!WBC_PATH) {
27
+ console.error('WARNING: No WalletBindingCredential configured.')
28
+ console.error(' HERMES_WBC_PATH is unset and wbc.json was not found alongside the mandate.')
29
+ console.error(' Gate is in pe-042 PASSTHROUGH mode — wallet identity is NOT verified.')
30
+ console.error(' This is only valid for enterprise callers explicitly opting out.')
31
+ console.error(' Community installs: run `hermes-gate bootstrap generate` and either set')
32
+ console.error(' HERMES_WBC_PATH=<path/to/wbc.json> or place wbc.json alongside the mandate.')
33
+ }
34
+
35
+ // Load agent DID from identity file (agent-user's key, not wallet seed)
36
+ function loadAgentDid () {
37
+ if (process.env.HERMES_AGENT_DID) return process.env.HERMES_AGENT_DID
38
+ try {
39
+ const id = JSON.parse(readFileSync(IDENTITY_PATH, 'utf8'))
40
+ return id.did || id.agent?.did
41
+ } catch (err) {
42
+ throw new Error(`Cannot load agent identity from ${IDENTITY_PATH}: ${err.message}`)
43
+ }
44
+ }
45
+
46
+ function loadMandateIssuer () {
47
+ try {
48
+ const m = JSON.parse(readFileSync(MANDATE_PATH, 'utf8'))
49
+ return typeof m.issuer === 'object' ? m.issuer?.id : m.issuer
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+
55
+ // ── Input parsing (fail-closed) ───────────────────────────────────────────
56
+
57
+ /**
58
+ * Parse and validate gate_evaluate / gate_execute action input.
59
+ * Throws on any malformed, missing, or out-of-range field.
60
+ * Caller catches and returns { allow: false }.
61
+ *
62
+ * @param {unknown} params
63
+ * @returns {{ rail: string, amount: string, currency: string, category?: string, note?: string }}
64
+ */
65
+ function parseAction (params) {
66
+ if (params === null || typeof params !== 'object' || Array.isArray(params)) {
67
+ throw new Error('params must be a non-null object')
68
+ }
69
+ const { rail, amount, currency, category, note, wallet_id } = params
70
+
71
+ if (typeof rail !== 'string' || rail.trim().length === 0) {
72
+ throw new Error('rail must be a non-empty string')
73
+ }
74
+ if (typeof amount !== 'string' || amount.trim().length === 0) {
75
+ throw new Error('amount must be a non-empty string')
76
+ }
77
+ // Reject negative, zero, or non-numeric amounts
78
+ if (!/^[0-9]+(\.[0-9]+)?$/.test(amount.trim()) || parseFloat(amount) <= 0) {
79
+ throw new Error('amount must be a positive decimal string')
80
+ }
81
+ if (typeof currency !== 'string' || currency.trim().length === 0) {
82
+ throw new Error('currency must be a non-empty string')
83
+ }
84
+
85
+ return {
86
+ rail: rail.trim(),
87
+ amount: amount.trim(),
88
+ currency: currency.trim(),
89
+ ...(typeof category === 'string' && category ? { category: category.trim() } : {}),
90
+ ...(typeof note === 'string' && note ? { note: note.trim() } : {}),
91
+ ...(typeof wallet_id === 'string' && wallet_id ? { wallet_id: wallet_id.trim() } : {})
92
+ }
93
+ }
94
+
95
+ // ── Server setup ──────────────────────────────────────────────────────────
96
+
97
+ const agentDid = loadAgentDid()
98
+ const mandateIssuer = loadMandateIssuer()
99
+ const gate = new SpendGate({
100
+ mandatePath: MANDATE_PATH,
101
+ agentDid,
102
+ trustedIssuers: mandateIssuer ? [mandateIssuer] : [],
103
+ walletBindingCredentialPath: WBC_PATH || undefined
104
+ })
105
+
106
+ const server = new Server(
107
+ { name: 'hermes-gate', version: '0.1.0' },
108
+ { capabilities: { tools: {} } }
109
+ )
110
+
111
+ // ── Tool: gate_evaluate (fail-closed boundary) ────────────────────────────
112
+
113
+ async function handleGateEvaluate (params) {
114
+ try {
115
+ const action = parseAction(params)
116
+ return await gate.evaluate(action)
117
+ } catch (err) {
118
+ return {
119
+ allow: false,
120
+ reasons: [{ ruleType: 'gate_error', ruleField: 'input', message: err.message }],
121
+ advisories: [],
122
+ mandateValidUntil: ''
123
+ }
124
+ }
125
+ }
126
+
127
+ // ── Tool: gate_execute ────────────────────────────────────────────────────
128
+
129
+ async function handleGateExecute (params) {
130
+ const decision = await handleGateEvaluate(params)
131
+ if (!decision.allow) {
132
+ return {
133
+ allowed: false,
134
+ reasons: decision.reasons,
135
+ advisories: decision.advisories,
136
+ pec: null,
137
+ tx_ref: null
138
+ }
139
+ }
140
+ // Actual wallet signing is the wallet-service's responsibility.
141
+ // This stub returns the decision and signals to the caller to proceed.
142
+ // The wallet-service integration replaces this stub with actual WDK submission.
143
+ return {
144
+ allowed: true,
145
+ reasons: [],
146
+ advisories: decision.advisories,
147
+ pec: null, // PolicyEvaluationCredential — emitted by buildSettlementAttestation
148
+ tx_ref: null, // set by wallet-service after submission
149
+ _note: 'Submission stub — integrate with WDK wallet client for live execution'
150
+ }
151
+ }
152
+
153
+ // ── Tool: gate_status (informational, no re-verify) ──────────────────────
154
+
155
+ function handleGateStatus () {
156
+ let mandateValidUntil = null
157
+ let mandateIssuerOut = null
158
+ try {
159
+ const m = JSON.parse(readFileSync(MANDATE_PATH, 'utf8'))
160
+ mandateValidUntil = m.validUntil || null
161
+ mandateIssuerOut = typeof m.issuer === 'object' ? m.issuer?.id : m.issuer || null
162
+ } catch {
163
+ // status is informational
164
+ }
165
+ return {
166
+ gate_up: true,
167
+ agent_did: agentDid,
168
+ mandate_valid_until: mandateValidUntil,
169
+ mandate_issuer: mandateIssuerOut,
170
+ mandate_path: MANDATE_PATH
171
+ }
172
+ }
173
+
174
+ // ── MCP tool definitions ──────────────────────────────────────────────────
175
+
176
+ const TOOLS = [
177
+ {
178
+ name: 'gate_evaluate',
179
+ description: 'Evaluate whether a proposed spend is within the agent\'s SpendMandate. Returns allow/deny with reasons.',
180
+ inputSchema: {
181
+ type: 'object',
182
+ properties: {
183
+ rail: { type: 'string', description: 'Payment rail (e.g. ethereum-mainnet, lightning)' },
184
+ amount: { type: 'string', description: 'Proposed amount as a positive decimal string' },
185
+ currency: { type: 'string', description: 'Currency or token symbol (e.g. USDT, sats)' },
186
+ category: { type: 'string', description: 'Transaction category (e.g. payment)' },
187
+ wallet_id: { type: 'string', description: 'Wallet DID or address — required for BIND address verification when WBC is configured' },
188
+ note: { type: 'string', description: 'Optional human-readable note' }
189
+ },
190
+ required: ['rail', 'amount', 'currency']
191
+ }
192
+ },
193
+ {
194
+ name: 'gate_execute',
195
+ description: 'Evaluate and, if allowed, signal wallet-service to submit spend. Returns decision + tx_ref when allowed.',
196
+ inputSchema: {
197
+ type: 'object',
198
+ properties: {
199
+ rail: { type: 'string' },
200
+ amount: { type: 'string' },
201
+ currency: { type: 'string' },
202
+ category: { type: 'string' },
203
+ wallet_id: { type: 'string', description: 'Wallet DID or address — triggers BIND address verification' },
204
+ tx_details: { type: 'object', description: 'Rail-specific transaction parameters' }
205
+ },
206
+ required: ['rail', 'amount', 'currency']
207
+ }
208
+ },
209
+ {
210
+ name: 'gate_status',
211
+ description: 'Return gate health and mandate metadata. Does not re-verify the mandate.',
212
+ inputSchema: { type: 'object', properties: {} }
213
+ }
214
+ ]
215
+
216
+ // ── Request handlers ──────────────────────────────────────────────────────
217
+
218
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
219
+
220
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
221
+ const { name, arguments: args } = request.params
222
+
223
+ let result
224
+ switch (name) {
225
+ case 'gate_evaluate':
226
+ result = await handleGateEvaluate(args)
227
+ break
228
+ case 'gate_execute':
229
+ result = await handleGateExecute(args)
230
+ break
231
+ case 'gate_status':
232
+ result = handleGateStatus()
233
+ break
234
+ default:
235
+ return {
236
+ content: [{ type: 'text', text: JSON.stringify({ error: `Unknown tool: ${name}` }) }],
237
+ isError: true
238
+ }
239
+ }
240
+
241
+ return {
242
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
243
+ }
244
+ })
245
+
246
+ // ── Start ─────────────────────────────────────────────────────────────────
247
+
248
+ const transport = new StdioServerTransport()
249
+ await server.connect(transport)
250
+ console.error(`hermes-gate MCP server running (agent: ${agentDid})`)
package/src/skill.js ADDED
@@ -0,0 +1,31 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Part of @observer-protocol/hermes-gate
3
+ //
4
+ // agentskills.io skill — thin wrapper over the gate_evaluate MCP tool.
5
+ // Zero enforcement logic: the gate enforces, this skill surfaces the decision.
6
+
7
+ 'use strict'
8
+
9
+ /**
10
+ * Evaluate a spend action via the gate MCP server.
11
+ *
12
+ * @param {{ rail: string, amount: string, currency: string, category?: string, note?: string }} action
13
+ * @param {{ callTool: (name: string, args: object) => Promise<object> }} mcpClient
14
+ * @returns {Promise<{ allow: boolean, reasons: object[], advisories: object[], mandateValidUntil: string }>}
15
+ */
16
+ export async function evaluateSpend (action, mcpClient) {
17
+ return mcpClient.callTool('gate_evaluate', action)
18
+ }
19
+
20
+ /**
21
+ * agentskills.io skill manifest — describes this skill to the Hermes skill registry.
22
+ */
23
+ export const skillManifest = {
24
+ name: 'hermes-spend-gate',
25
+ version: '0.1.0',
26
+ description: 'Fail-closed spend gate — evaluates proposed spends against the agent\'s OP-issued SpendMandate',
27
+ tools: ['gate_evaluate', 'gate_execute', 'gate_status'],
28
+ permissions: ['mcp:gate_evaluate', 'mcp:gate_execute'],
29
+ author: 'observer-protocol',
30
+ homepage: 'https://github.com/observer-protocol/hermes-gate'
31
+ }