@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 +158 -0
- package/bin/hermes-gate.js +58 -0
- package/index.js +8 -0
- package/package.json +38 -0
- package/src/bootstrap.js +314 -0
- package/src/gate.js +95 -0
- package/src/mcp-server.js +250 -0
- package/src/skill.js +31 -0
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
|
+
}
|
package/src/bootstrap.js
ADDED
|
@@ -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
|
+
}
|