@strixgov/mcp-proxy 0.1.0 → 0.1.6

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/CHANGELOG.md CHANGED
@@ -5,6 +5,258 @@ All notable changes to `@strixgov/mcp-proxy` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.6]
9
+
10
+ Documentation + license-fidelity patch. No functional change to the proxy,
11
+ the receipt format, the signing flow, or any runtime behavior.
12
+
13
+ ### Fixed
14
+ - **`LICENSE` corrected to canonical Elastic License 2.0.** The Limitations
15
+ clause read "provide the software to **others** as a hosted or managed
16
+ service"; the canonical ELv2 wording is "to **third parties**." Corrected
17
+ byte-for-byte against Elastic's published license. The `0.1.0` release note
18
+ flagged this exact verification ("verify the bundled LICENSE against
19
+ canonical ELv2 before tagging") — the deviation shipped anyway in 0.1.0 and
20
+ 0.1.5. Legal-text fidelity only; no change to intent or the MIT/ELv2 split.
21
+ **Counsel should confirm before publish.**
22
+ - **README version labels.** Notes describing *current* behavior were tagged
23
+ `v0.1.0` (the floor where they were first true); a reader on npm sees
24
+ `0.1.5+`. Relabeled the current-behavior notes (transport scope, env-var
25
+ substitution, feature-table column, Mode-3 seam, single-tenant boundary) to
26
+ `v0.1.x`. Genuinely historical references (the `v0.1.0–v0.1.3` ephemeral-key
27
+ known issue, `v0.1.4+` default `storagePath`) are unchanged.
28
+
29
+ ### Note
30
+ - Closes findings F-3 and F-4 of
31
+ `docs/reviews/mcp-proxy-publish-readiness-2026-06-07.md`. Repeatable
32
+ publish gate: `scripts/mcp-proxy-preflight.mjs`.
33
+
34
+ ## [0.1.5]
35
+
36
+ Documentation-hygiene patch. No functional change to the proxy, the
37
+ receipt format, the signing flow, or any runtime behavior.
38
+
39
+ ### Fixed
40
+ - **`SECURITY.md`, `README.md`, and `CHANGELOG.md` cross-references**
41
+ rewritten to remove links into the maintainer's internal source tree.
42
+ The threat-model cross-reference in `SECURITY.md` now points reviewers
43
+ at `security@strixgov.com`; runbook cross-references and an internal
44
+ development-branch name embedded in a 0.1.0 release note have been
45
+ removed. Static text only — no API, no behavior, no tests changed.
46
+
47
+ ### Note
48
+ - Operators on 0.1.0 are encouraged to upgrade. v0.1.0 has been
49
+ deprecated on npm to surface this guidance on install; `npm install
50
+ @strixgov/mcp-proxy@0.1.0` continues to work but emits a deprecation
51
+ warning pointing here.
52
+
53
+ ## [0.1.4]
54
+
55
+ Fixes the silent-ephemeral-key footgun surfaced by the same dogfood that
56
+ produced 0.1.1–0.1.3. With `storagePath` unset, pre-0.1.4 left
57
+ `governMCPServer` to default both halves of persistence wrong:
58
+ `MemoryStorage` (so the proxy's receipts vanished on exit) AND
59
+ `generateSigningKey()` ephemeral (so the kid embedded in every on-disk
60
+ receipt pointed at a key that didn't exist anywhere). Operators who
61
+ worked around the storage half by pointing `storagePath` at
62
+ `~/.strix-gateway` saw receipts they could `strix-gateway receipts list`
63
+ but never `verify` — `kid not in keyring: strix-<serverId>` on every
64
+ record. This release makes "the receipts I wrote yesterday are
65
+ verifiable today" the default.
66
+
67
+ ### Changed
68
+ - **Default `storagePath`** when `opts.storagePath` is undefined: now
69
+ resolves to `path.join(os.homedir(), ".strix-mcp-proxy", serverId)`
70
+ instead of leaving the value unset. Receipts persist as JSONL under
71
+ that path; the Ed25519 signing key persists at
72
+ `<storagePath>/keys/{signing-key.pem, public-jwk.json}` and is reused
73
+ across restarts. The default is intentionally **not**
74
+ `~/.strix-gateway`: the gateway CLI uses a multi-kid keyring layout
75
+ (`active` pointer + `<kid>/` subdirs) while the proxy uses
76
+ `loadOrCreateSigningKey`'s flat single-key layout, and sharing the
77
+ same `keys/` directory produces silently-broken receipts (the proxy's
78
+ flat key files are invisible to the keyring loader, or the v0.1 auto-
79
+ migration clobbers the gateway's `active` pointer to the proxy's kid).
80
+ - **Audit event `signing.key.resolved`** now distinguishes
81
+ `source: "persistent"` (operator-supplied `storagePath`) from
82
+ `source: "persistent-default"` (auto-resolved per-serverId default).
83
+
84
+ ### Added
85
+ - **`storagePath: null` opt-out.** Callers who genuinely want the pre-
86
+ 0.1.4 ephemeral behavior (CI smoke runs, demos that intentionally
87
+ produce throwaway receipts) pass `storagePath: null` explicitly.
88
+ Receipts within the session remain chain-coherent; signatures are
89
+ unverifiable across restarts.
90
+
91
+ ### Test
92
+ - `startTestProxy` helper now defaults to a per-test tmpdir so the
93
+ suite never writes to the developer's home directory.
94
+ - Old `"no storagePath ⇒ in-memory ephemeral key (preserves pre-0.1.2
95
+ default)"` test removed and replaced with two tests pinning the new
96
+ default behavior + the explicit-`null` opt-out path.
97
+
98
+ ### Migration notes for existing operators
99
+ - If your `notion-proxy-config.json` (or equivalent) sets
100
+ `"storagePath": "~/.strix-gateway"` to share storage with the
101
+ gateway CLI, **change it** — point at
102
+ `"~/.strix-mcp-proxy/<serverId>"` instead (or any path you control
103
+ that isn't aliased to `~/.strix-gateway`). Existing receipts at the
104
+ old location remain readable but their kid is unverifiable because
105
+ the proxy's old ephemeral key is gone. Future receipts written
106
+ under the new default ARE verifiable end-to-end.
107
+ - If your code passes no `storagePath` and depended on receipts
108
+ vanishing on exit, add `storagePath: null` to your `startProxy`
109
+ call to preserve that behavior.
110
+
111
+ ## [0.1.3]
112
+
113
+ Closes the headless-approval gap surfaced by the same dogfood run that
114
+ produced 0.1.1 / 0.1.2. With `approval.enabled: false` (the default)
115
+ every `APPROVAL_REQUIRED` call is silently denied; turning approval on
116
+ defaults to `terminalApprove`, which fails closed in any non-TTY context
117
+ including Claude Desktop's headless spawn. `@strixgov/tool-gateway`
118
+ already shipped a `fileApprover` that works in headless contexts, but
119
+ the proxy couldn't wire it up from JSON config because the gateway's
120
+ `approval.prompt` field expects a function — and JSON can't carry one.
121
+
122
+ ### Added
123
+ - **`approval.type` JSON config field** with three accepted values:
124
+ - **`"auto"`** — auto-approves every `APPROVAL_REQUIRED` call with a
125
+ signed `approval.approved: true` audit trail. Convenient for demos
126
+ where you want to show the approval-loop fires; **defeats real
127
+ governance** for non-trivial environments.
128
+ - **`"file"`** — wires up `fileApprover` from `@strixgov/tool-gateway`.
129
+ The proxy writes `<requestDir>/<requestId>.request.json` per
130
+ approval-pending call; an out-of-band approver writes
131
+ `<requestId>.response.json` with `{approved: true|false, approvedBy?, reason?}`.
132
+ Works under Claude Desktop spawn, CI, or any headless context.
133
+ `requestDir` defaults to `<storagePath>/approvals` when `storagePath`
134
+ is set, or `./.strix-approvals` otherwise. `~` and `~/` are expanded
135
+ to `os.homedir()` consistent with `storagePath`'s tilde handling.
136
+ Optional `timeoutMs` (default 5min) and `pollIntervalMs` (default
137
+ 250ms) tunables.
138
+ - **`"terminal"`** — explicit alias for the legacy default. Useful
139
+ when an operator is testing the proxy directly from a TTY and wants
140
+ to see the y/N prompt.
141
+ - Resolution order pinned and tested:
142
+ 1. Programmatic `approval.prompt` function (always wins)
143
+ 2. `approval.enabled === false`
144
+ 3. `approval.autoApprove === true` (long form of `type: "auto"`)
145
+ 4. `approval.type === "auto" | "file" | "terminal"`
146
+ 5. Unknown `type` ⇒ startup error (fail-fast, not silent fail-closed)
147
+ - 8 new tests covering each resolution branch.
148
+
149
+ ### Fixed
150
+ - Unknown `approval.type` values now throw at startup with the message
151
+ `unknown approval.type 'X' — expected "terminal", "file", or "auto"`,
152
+ instead of silently falling through to a deny-everything state.
153
+
154
+ ### Migration
155
+ - **`{enabled: false}`** (the docs-example default) is unchanged.
156
+ - **`{enabled: true}`** (no `type`) preserves the v0.1.2 behavior
157
+ (defaults to `terminalApprove`, fails closed in non-TTY contexts).
158
+ - New JSON-config-friendly shapes for headless deployments:
159
+ ```jsonc
160
+ "approval": {
161
+ "enabled": true,
162
+ "type": "file",
163
+ "requestDir": "~/.strix-gateway/approvals",
164
+ "timeoutMs": 300000
165
+ }
166
+ ```
167
+ or for demos:
168
+ ```jsonc
169
+ "approval": { "enabled": true, "type": "auto" }
170
+ ```
171
+
172
+ ### Verification
173
+ - 37 tests pass under `node --test`; 1 skipped (network-gated tarball
174
+ install gate, opt in via `STRIX_RUN_TARBALL_INSTALL=1`).
175
+
176
+ ## [0.1.2]
177
+
178
+ Closes the verifiability gap surfaced by a fresh dogfood run on Windows
179
+ (May 25 2026): receipts were correctly signed and written to JSONL on
180
+ disk, but the signing key was discarded the instant the proxy exited.
181
+ Every receipt produced under v0.1.0 / v0.1.1 carried a `signingKeyId`
182
+ (e.g. `strix-notion`) pointing at a key that no longer existed
183
+ anywhere — so `@strixgov/verifier` reported `KEY_NOT_FOUND` for every
184
+ local-mode receipt. Trust property was "tamper-evident at write time"
185
+ but not "externally re-verifiable after restart."
186
+
187
+ ### Fixed
188
+ - **`startProxy` now persists the signing keypair to disk when
189
+ `storagePath` is set.** Files land at:
190
+ - `<storagePath>/keys/signing-key.pem` (PKCS8 PEM, mode 0600)
191
+ - `<storagePath>/keys/public-jwk.json` (public JWK with `kid`, `kty`,
192
+ `crv`, `x` — mode 0644)
193
+ The same `kid` (e.g. `strix-notion`) is reused across restarts via
194
+ `loadOrCreateSigningKey()` from `@strixgov/tool-gateway`, so JSONL
195
+ receipts are independently verifiable after the proxy exits:
196
+ ```sh
197
+ npx @strixgov/verifier chain ~/.strix-gateway/receipts.jsonl \
198
+ --jwks ~/.strix-gateway/keys/public-jwk.json
199
+ ```
200
+ Caller-supplied `opts.signingKey` still wins over disk persistence —
201
+ operators integrating with a KMS or secrets manager are unaffected.
202
+ When neither `signingKey` nor `storagePath` is set, behavior matches
203
+ v0.1.1 (in-memory ephemeral key) — preserved as the default for tests
204
+ and embedded use cases.
205
+ - A new `signing.key.resolved` audit event fires on the persistent
206
+ path with `{source: "persistent", kid, keyPath}` so operators can
207
+ confirm the disk-load happened.
208
+
209
+ ### Breaking changes
210
+ - None. `startProxy` is already async; the new `loadOrCreateSigningKey`
211
+ call slots into the existing async flow without changing the
212
+ `governMCPServer` (synchronous) contract.
213
+
214
+ ### Notes for existing users
215
+ - **Receipts produced under v0.1.0 / v0.1.1 remain unverifiable.** Their
216
+ signing keys were ephemeral and are not recoverable. Starting with
217
+ 0.1.2, every new receipt written under a `storagePath` deployment is
218
+ verifiable for the life of the persisted key.
219
+ - The first start under 0.1.2 generates a fresh keypair. Subsequent
220
+ starts reuse it. To force key rotation, delete `<storagePath>/keys/`
221
+ before restart; the next start will regenerate.
222
+
223
+ ### Verification
224
+ - 4 new tests in `test/proxy-end-to-end.test.mjs`:
225
+ - Disk files (PEM + JWK) created with correct shape + kid
226
+ - kid survives a restart (the demo-story invariant)
227
+ - Caller-supplied `signingKey` bypasses disk persistence
228
+ - No-`storagePath` path preserves v0.1.1 in-memory default
229
+
230
+ ## [0.1.1]
231
+
232
+ Bug-fix release that closes the gap surfaced when a fresh user ran the
233
+ "5-line integration (Claude Desktop)" path end-to-end and the proxy
234
+ exited at startup with `Cannot find package '@strixgov/capabilities-mcp-common'`.
235
+
236
+ ### Fixed
237
+ - **`@strixgov/capabilities-mcp-common` is now a runtime `dependencies`
238
+ entry, not a `devDependencies`-only listing.** Previously, every user
239
+ who installed the proxy globally (`npm install -g @strixgov/mcp-proxy`)
240
+ also had to discover and run a second `npm install -g
241
+ @strixgov/capabilities-mcp-common` before the example config in the
242
+ README would load — because npm doesn't install devDependencies for
243
+ globally-installed packages. The README documents the companion pack
244
+ as the default capability source, so it's a hard runtime dependency.
245
+ This was a release blocker for the "zero-friction first run" claim
246
+ in the README.
247
+ - **Clearer error when a `capabilities` import path can't be resolved.**
248
+ `resolveCapabilities()` now catches `ERR_MODULE_NOT_FOUND` (and the
249
+ related `Cannot find package` shape) and re-throws with an actionable
250
+ hint pointing the operator at `npm install -g <pack>`. Previously
251
+ the operator saw the bare Node loader error, which doesn't tell them
252
+ how to fix it.
253
+
254
+ ### Verification
255
+ - All 25 tests under `node --test` continue to pass.
256
+ - Manual dogfood: `npm install -g @strixgov/mcp-proxy@0.1.1` followed
257
+ by `strix-mcp-proxy --config <path>` resolves
258
+ `@strixgov/capabilities-mcp-common/notion` without a second install.
259
+
8
260
  ## [0.1.0]
9
261
 
10
262
  Initial release. The Strix MCP Proxy — a standalone process that
@@ -64,8 +316,7 @@ rather than a v1.0 rewrite.
64
316
  expansion, companion-pack dynamic import resolution.
65
317
  - `test/cli.test.mjs` — `--help`, `--version`, missing-arg errors,
66
318
  missing-file errors.
67
- - Companion-pack lookup uses `cap.name` indexing (the P0 #2 fix
68
- shipped on `claude/strix-mcp-wrapping-IDXOy`) so server-prefixed
319
+ - Companion-pack lookup uses `cap.name` indexing so server-prefixed
69
320
  tool names (`notion-fetch`, `slack_send_message`) match the pack
70
321
  rather than falling through to the MEDIUM EXECUTE heuristic.
71
322
 
package/LICENSE CHANGED
@@ -13,7 +13,7 @@ to the limitations and conditions below.
13
13
 
14
14
  Limitations
15
15
 
16
- You may not provide the software to others as a hosted or managed service,
16
+ You may not provide the software to third parties as a hosted or managed service,
17
17
  where the service provides users with access to any substantial set of the
18
18
  features or functionality of the software.
19
19
 
package/README.md CHANGED
@@ -35,22 +35,44 @@ Tool implementations are untouched. The MCP client (Claude Desktop, mcp-cli,
35
35
  IDE integration, your own code) doesn't need to know Strix exists — it just
36
36
  spawns this proxy in place of the upstream server.
37
37
 
38
+ > **Transport scope (v0.1.x):** the proxy wraps MCP servers that speak
39
+ > the **stdio transport** — the `command` / `args` pattern in
40
+ > `claude_desktop_config.json`. It does **not** intercept HTTP-based
41
+ > hosted MCP servers (e.g. Notion's `https://mcp.notion.com/mcp`
42
+ > Claude Desktop "Connector" pattern). If your MCP server is reachable
43
+ > only over HTTPS, the proxy cannot govern it today; that's
44
+ > [Posture B](https://github.com/Strixgov/strix/tree/main/docs/architecture/mcp-mode-3-enforcement-v1.md)
45
+ > in the Mode 3 architecture and is deferred to a future release. Use
46
+ > the local stdio version of the upstream MCP server in the meantime.
47
+
38
48
  ## 5-line integration (Claude Desktop)
39
49
 
50
+ Claude Desktop's config file lives at:
51
+
52
+ - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
53
+ - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` (resolves to `C:\Users\<you>\AppData\Roaming\Claude\claude_desktop_config.json` — outside any OneDrive Known Folder Move)
54
+ - **Linux:** `~/.config/Claude/claude_desktop_config.json`
55
+
40
56
  ```jsonc
41
- // ~/Library/Application Support/Claude/claude_desktop_config.json
42
57
  {
43
58
  "mcpServers": {
44
59
  "notion": {
45
60
  "command": "strix-mcp-proxy",
46
- "args": ["--config", "/Users/you/strix-proxy/notion.json"]
61
+ "args": ["--config", "/absolute/path/to/notion-proxy-config.json"],
62
+ // Env vars set here propagate to the proxy and on to the upstream
63
+ // MCP server via process.env (the proxy passes them through by
64
+ // default — see `upstream.env` below to override explicitly).
65
+ "env": {
66
+ "NOTION_TOKEN": "secret_..."
67
+ }
47
68
  }
48
69
  }
49
70
  }
50
71
  ```
51
72
 
52
- The config file at `notion.json` points the proxy at the real Notion MCP
53
- server and sets your policy. See [`examples/notion-proxy-config.json`](./examples/notion-proxy-config.json)
73
+ The config file at `notion-proxy-config.json` points the proxy at the
74
+ real Notion MCP server and sets your policy. See
75
+ [`examples/notion-proxy-config.json`](./examples/notion-proxy-config.json)
54
76
  for a complete example.
55
77
 
56
78
  ## Config file shape
@@ -60,7 +82,14 @@ for a complete example.
60
82
  "serverId": "notion", // namespace for capability ids
61
83
  "upstream": {
62
84
  "command": "npx",
63
- "args": ["-y", "@notionhq/notion-mcp-server"]
85
+ "args": ["-y", "@notionhq/notion-mcp-server"],
86
+ // Optional. If omitted, the proxy passes process.env through to
87
+ // the upstream (so env vars set in Claude Desktop's `env` block
88
+ // reach the upstream automatically). Setting `upstream.env`
89
+ // overrides that default — only the keys you list are passed.
90
+ "env": {
91
+ "NOTION_TOKEN": "secret_..."
92
+ }
64
93
  },
65
94
  "capabilities": "@strixgov/capabilities-mcp-common/notion", // import path
66
95
  "policy": {
@@ -75,8 +104,13 @@ for a complete example.
75
104
  },
76
105
  "default": "DENY"
77
106
  },
78
- "approval": { "enabled": false },
79
- "storagePath": "~/.strix-mcp-proxy/notion" // optional; JSONL receipts on disk
107
+ "approval": {
108
+ "enabled": true,
109
+ "type": "file", // headless-friendly approver (see "Approval modes" below)
110
+ "requestDir": "~/.strix-mcp-proxy/notion/approvals",
111
+ "timeoutMs": 300000 // 5 minutes
112
+ },
113
+ "storagePath": "~/.strix-mcp-proxy/notion" // optional (this IS the default for serverId="notion"); set to `null` for ephemeral
80
114
  }
81
115
  ```
82
116
 
@@ -85,6 +119,85 @@ for a complete example.
85
119
  `@strixgov/capabilities-mcp-common/github`, etc.) — resolved at startup
86
120
  - an inline array of `McpCapability` objects (caller-managed classification)
87
121
 
122
+ ### Where the upstream's secrets should live
123
+
124
+ v0.1.x does **no env-var substitution inside the config file** — there
125
+ is no `${NOTION_TOKEN}` template syntax. Two supported patterns for
126
+ upstream secrets:
127
+
128
+ 1. **Set in Claude Desktop's `env` block** (recommended for
129
+ first-time setup) — the MCP client passes them to the proxy
130
+ process; the proxy passes `process.env` to the upstream by
131
+ default. Token never appears in `notion-proxy-config.json`, so
132
+ the config file is safe to check into source control (without the
133
+ policy values that depend on environment, of course).
134
+ 2. **Set in `upstream.env`** (recommended when the proxy is the
135
+ only consumer of the upstream) — explicit, narrower exposure
136
+ surface. Only the keys you list reach the upstream; everything
137
+ else from `process.env` is filtered out.
138
+
139
+ On Windows specifically, prefer placing `storagePath` outside any
140
+ OneDrive Known Folder Move directory. The default `~/.strix-mcp-proxy/`
141
+ resolves to `C:\Users\<you>\.strix-mcp-proxy\` (a dotfile-style
142
+ directory under the user root), which is **not** covered by Known
143
+ Folder Move and stays local.
144
+
145
+ ## Approval modes
146
+
147
+ The proxy's `approval` config field accepts a `type` string that maps to
148
+ the appropriate approver from `@strixgov/tool-gateway`. The gateway's
149
+ underlying `approval.prompt` field expects a function, which JSON config
150
+ can't carry — `type` is the JSON-friendly bridge.
151
+
152
+ | `approval.type` | What it does | When to use it |
153
+ |---|---|---|
154
+ | omitted, `approval.enabled: false` | No prompt — every `APPROVAL_REQUIRED` decision becomes `DENY` with `reason: "PROMPT_FAILED"`. | Default; safest if you haven't built an approval pathway yet. |
155
+ | `"auto"` | Auto-approves every call that hits the gate. Receipt records `approval.approved: true, approvedBy: "auto"`. | Demos; smoke tests; environments where you want to show the approval-loop wired up without a real human in the loop. **Defeats meaningful governance** for production. |
156
+ | `"file"` | Wires up `fileApprover`: proxy writes `<requestDir>/<requestId>.request.json`; an out-of-band approver writes `<requestId>.response.json` with `{approved, approvedBy?, reason?}`. | Production-leaning headless deployments — Claude Desktop, CI, containerised agents. Pair with whatever notification channel you want (Slack bot, GitHub Actions check, on-call rotation script) that ultimately writes the response file. |
157
+ | `"terminal"` | Interactive `y/N` prompt on stdin. Fails closed (`PROMPT_FAILED → DENY`) in any non-TTY context. | Direct-CLI testing where you're running the proxy yourself and want to manually approve. **Not viable under Claude Desktop spawn** — Claude Desktop owns the stdio channel; there's no terminal to prompt to. |
158
+
159
+ **Example: file-based approval under Claude Desktop.** In your
160
+ `notion-proxy-config.json`:
161
+
162
+ ```jsonc
163
+ "approval": {
164
+ "enabled": true,
165
+ "type": "file",
166
+ "requestDir": "~/.strix-gateway/approvals",
167
+ "timeoutMs": 300000 // 5 minutes
168
+ }
169
+ ```
170
+
171
+ Then write a tiny watcher that approves or denies. Example PowerShell
172
+ sketch (one approval at a time):
173
+
174
+ ```powershell
175
+ $watchDir = "$env:USERPROFILE\.strix-gateway\approvals"
176
+ while ($true) {
177
+ Get-ChildItem $watchDir -Filter "*.request.json" | ForEach-Object {
178
+ $req = Get-Content $_.FullName | ConvertFrom-Json
179
+ Write-Host "PENDING: $($req.invocation.capabilityId) actor=$($req.invocation.actorId)"
180
+ $answer = Read-Host "Approve? [y/N]"
181
+ $approved = $answer -in @("y","Y","yes")
182
+ $respPath = $_.FullName -replace ".request.json$", ".response.json"
183
+ @{ approved = $approved; approvedBy = $env:USERNAME } | ConvertTo-Json | Set-Content $respPath
184
+ }
185
+ Start-Sleep -Milliseconds 500
186
+ }
187
+ ```
188
+
189
+ Operator + audit channels (Slack, PagerDuty, ServiceNow, etc.) replace
190
+ the `Read-Host` line — same file-shape on both ends.
191
+
192
+ Resolution order if multiple approval fields are set (caller-supplied
193
+ programmatic config wins):
194
+
195
+ 1. `approval.prompt` is a function ⇒ pass through (caller intent wins)
196
+ 2. `approval.enabled === false` ⇒ pass through (gate disabled)
197
+ 3. `approval.autoApprove === true` ⇒ pass through (long form of `type: "auto"`)
198
+ 4. `approval.type ∈ {"auto","file","terminal"}` ⇒ resolved
199
+ 5. Unknown `type` ⇒ startup error (fail-fast)
200
+
88
201
  ## CLI flags (no config file needed for simple cases)
89
202
 
90
203
  ```sh
@@ -104,7 +217,7 @@ Run `strix-mcp-proxy --help` for the full flag list.
104
217
 
105
218
  ## What ships with the proxy
106
219
 
107
- | Feature | v0.1.0 | v0.2.0 (planned) |
220
+ | Feature | v0.1.x (shipped) | v0.2.0 (planned) |
108
221
  |---|---|---|
109
222
  | MCP stdio transport (consumer side) | ✓ | — |
110
223
  | MCP stdio transport (upstream side) | ✓ | — |
@@ -115,7 +228,7 @@ Run `strix-mcp-proxy --help` for the full flag list.
115
228
  | Per-capability rules + risk overrides | ✓ | — |
116
229
  | Approval gate (any approver implementation) | ✓ | — |
117
230
  | Local-mode signed receipts (in-memory or JSONL) | ✓ | — |
118
- | Connected mode (sync receipts to `strixgov.com`) | ✓ | — |
231
+ | Connected mode (sync receipts to `strixgov.com`) | ✓ programmatic only via `startProxy({ connectedMode })` | ✓ JSON config |
119
232
  | Mode 1 (Observed) | ✓ | — |
120
233
  | Mode 2 (Approval-Gated) | ✓ | — |
121
234
  | **Mode 3 (Capability-Enforced) — `execution_authorization_v1` token mint + transport** | — | ✓ |
@@ -128,7 +241,7 @@ at each enforcement strength.
128
241
  ## The upgrade path to Mode 3
129
242
 
130
243
  Mode 3 — Capability-Enforced — is the upgrade, not a rewrite. The
131
- v0.1.0 proxy already routes every outbound call through a single
244
+ v0.1.x proxy already routes every outbound call through a single
132
245
  `buildUpstreamIface()` seam in `src/proxy.mjs`. In v0.2.0 a new option
133
246
  (`mode: "enforced"`) will wrap that seam with the
134
247
  `execution_authorization_v1` token mint + transport step per
@@ -141,10 +254,14 @@ refuse to act on sensitive tools unless the token is present.
141
254
 
142
255
  The cryptographic primitive is already shipped — see
143
256
  [`@strixgov/mcp-token-validator`](https://www.npmjs.com/package/@strixgov/mcp-token-validator).
144
- v0.1.0 of the proxy does not mint these tokens yet; what it does is
257
+ v0.1.x of the proxy does not mint these tokens yet; what it does is
145
258
  make sure the *seam* exists so v0.2.0 plugs in cleanly.
146
259
 
147
- ## Listening to receipts
260
+ ## Listening to receipts (programmatic embedding)
261
+
262
+ For embedded use cases (your own MCP host, custom orchestration, etc.)
263
+ where you want a callback on every signed decision rather than
264
+ polling the JSONL file:
148
265
 
149
266
  ```js
150
267
  import { startProxy } from "@strixgov/mcp-proxy";
@@ -160,14 +277,77 @@ const handle = await startProxy({
160
277
  });
161
278
  ```
162
279
 
163
- Every receipt is byte-compatible with [`@strixgov/verifier`](https://www.npmjs.com/package/@strixgov/verifier):
280
+ For verifying those receipts independently, see the next section.
281
+
282
+ ## Verifying receipts
283
+
284
+ Every receipt the proxy emits is byte-compatible with
285
+ [`@strixgov/verifier`](https://www.npmjs.com/package/@strixgov/verifier)
286
+ — a zero-Strix-dependency package. The verifier needs two inputs: the
287
+ receipt JSONL file AND the public JWK that signed those receipts. How
288
+ you get the JWK depends on whether you set `storagePath` in your config.
289
+
290
+ ### Default (v0.1.4+)
291
+
292
+ As of v0.1.4, **`storagePath` defaults to `~/.strix-mcp-proxy/<serverId>`
293
+ when unset**. Receipts persist as JSONL under that path; the Ed25519
294
+ signing key persists at `<storagePath>/keys/{signing-key.pem,
295
+ public-jwk.json}` on first start (PKCS8 PEM at `0600` / public JWK at
296
+ `0644`) and is reused across restarts. The default partitions per
297
+ `serverId` so multiple wrapped upstreams (notion + github + filesystem,
298
+ say) each get their own receipts file and signing key.
299
+
300
+ The verification command is one line:
164
301
 
165
302
  ```sh
166
- npx @strixgov/verifier chain ~/.strix-mcp-proxy/notion/receipts.jsonl
303
+ # macOS / Linux
304
+ npx @strixgov/verifier chain ~/.strix-mcp-proxy/notion/receipts.jsonl \
305
+ --jwks ~/.strix-mcp-proxy/notion/keys/public-jwk.json
306
+
307
+ # Windows PowerShell
308
+ npx '@strixgov/verifier' chain "$env:USERPROFILE\.strix-mcp-proxy\notion\receipts.jsonl" `
309
+ --jwks "$env:USERPROFILE\.strix-mcp-proxy\notion\keys\public-jwk.json"
310
+ ```
311
+
312
+ The verifier resolves `signingKeyId` against the JWKS, recomputes the
313
+ canonical bytes, verifies the Ed25519 signature, walks the proof chain,
314
+ and prints `N/N receipts VERIFIED`. The verifier uses only `node:crypto`;
315
+ no Strix-operated service is in the trust path.
316
+
317
+ > **Do not alias `storagePath` to `~/.strix-gateway`.** That directory
318
+ > is owned by the `strix-gateway` CLI's keyring, which uses a multi-kid
319
+ > layout (`active` pointer file + `<kid>/` subdirectories). The proxy
320
+ > writes a flat single-key layout (`signing-key.pem` + `public-jwk.json`
321
+ > at the keys/ root) — sharing the same `keys/` dir collides silently:
322
+ > the proxy's flat key files are invisible to the keyring loader, or
323
+ > the v0.1 auto-migration clobbers the gateway's active pointer to the
324
+ > proxy's kid. The default keeps these stores cleanly separated.
325
+
326
+ ### Explicit ephemeral mode (`storagePath: null`)
327
+
328
+ If you want the pre-v0.1.4 behavior — fresh Ed25519 keypair in memory
329
+ at every startup, never persisted — pass `storagePath: null` explicitly
330
+ to `startProxy`. Receipts within a single proxy session remain chain-
331
+ coherent (each receipt links to the previous one's `proofChainHash`),
332
+ but signatures cannot be verified across restarts.
333
+
334
+ For a single-session demo, extract the JWK via the programmatic
335
+ `startProxy` handle before the proxy exits:
336
+
337
+ ```js
338
+ import { startProxy } from "@strixgov/mcp-proxy";
339
+ import fs from "node:fs/promises";
340
+
341
+ const handle = await startProxy({ storagePath: null, /* …your config… */ });
342
+ await fs.writeFile(
343
+ "./public-jwks.json",
344
+ JSON.stringify({ keys: [handle.signingKey.publicKeyJwk] }, null, 2),
345
+ );
167
346
  ```
168
347
 
169
- The verifier has zero Strix runtime dependency. Strix is not on the
170
- trust path of receipt verification.
348
+ Then point the verifier at that JWKS file. **For any operational use,
349
+ let `storagePath` default (or set it explicitly) instead** — persistent
350
+ keys are strictly better in every dimension that isn't a one-off demo.
171
351
 
172
352
  ## What this proxy is NOT
173
353
 
@@ -177,9 +357,39 @@ trust path of receipt verification.
177
357
  the library; the proxy is the standalone process. They share the
178
358
  same governance core (`@strixgov/tool-gateway`) and produce
179
359
  byte-identical receipts.
180
- - **Not Mode 3 today.** v0.1.0 ships Mode 1 + Mode 2. Mode 3
360
+ - **Not an interceptor of HTTP-transport MCP servers.** v0.1.x wraps
361
+ the stdio transport only. Hosted HTTP MCP servers (the Claude
362
+ Desktop "Add Connector" URL pattern, e.g.
363
+ `https://mcp.notion.com/mcp`) are not in scope — the client talks
364
+ to them over HTTPS directly and no local process exists to wrap.
365
+ Use the upstream's local stdio server in the meantime; the
366
+ HTTP-transport story is Posture B in
367
+ [`docs/architecture/mcp-mode-3-enforcement-v1.md`](https://github.com/Strixgov/strix/tree/main/docs/architecture/mcp-mode-3-enforcement-v1.md).
368
+ - **Not Mode 3 today.** v0.1.x ships Mode 1 + Mode 2. Mode 3
181
369
  enforcement is the v0.2.0 upgrade path, designed for, but not
182
370
  implemented in, this release.
371
+ - **Not a credential vault.** Upstream tokens (`NOTION_TOKEN`,
372
+ `GITHUB_TOKEN`, etc.) flow through `process.env` or
373
+ `upstream.env` and reach the upstream MCP server as-is. The
374
+ proxy does not store them, and they never appear in signed
375
+ receipts — receipts carry only the `invocationHash` (SHA-256 of
376
+ the canonical-JSON of the args), not the raw args themselves.
377
+ Token persistence is on the roadmap — a stopgap OS-keychain
378
+ integration and a full OAuth credential broker are both tracked.
379
+
380
+ ## Known issues from real-world dogfood
381
+
382
+ A handful of failure modes that real first-run integrations have hit.
383
+ Each one has a one-line fix below.
384
+
385
+ | Symptom | Root cause | Fix |
386
+ |---|---|---|
387
+ | Proxy exits at startup with `[strix-proxy] startup failed: Cannot find package '@strixgov/capabilities-mcp-common'` | Companion pack not installed alongside globally-installed proxy. Fixed in `0.1.1+`. | Upgrade: `npm install -g @strixgov/mcp-proxy@latest`. On older versions, install the pack the same way: `npm install -g @strixgov/capabilities-mcp-common`. |
388
+ | Proxy starts, lists tools, but every Notion tool call gets `"decision":"DENY"` even for benign reads | Upstream is the modern `@notionhq/notion-mcp-server` (emits `API-*` tool names), but `@strixgov/capabilities-mcp-common` is at `0.1.0` (only knew the older `notion-*` names). Every call falls through to the heuristic MEDIUM classifier and gets gated. | Upgrade the pack: `npm install -g @strixgov/capabilities-mcp-common@latest` — `0.1.1+` covers both naming conventions (38 Notion tools). |
389
+ | Proxy starts cleanly, exposes the tools list, but tool invocations silently fail (no `tools/call` in Claude Desktop logs, no receipt written) | Observed on Windows + nvm: the upstream MCP server can stall when `@modelcontextprotocol/sdk` isn't resolvable from the proxy's working directory. Cause is environmental (npx + nvm + the upstream's peer-dep resolution), not a proxy bug, but the symptom is opaque enough to be worth documenting. | Belt-and-braces: `cd <storagePath dir> && npm install @modelcontextprotocol/sdk`, then full-quit + relaunch Claude Desktop. |
390
+ | `npx @strixgov/verifier` prints `KEY_NOT_FOUND` for a local-mode receipt | The proxy generates a local signing key (e.g. `signingKeyId: "strix-notion"`) that's not in the public JWKS at `well-known.strixgov.com`. Local mode is intentionally self-rooted. From v0.1.4+ the public JWK is persisted by default at `~/.strix-mcp-proxy/<serverId>/keys/public-jwk.json`. | Point the verifier at the persisted JWK: `npx @strixgov/verifier chain ~/.strix-mcp-proxy/<serverId>/receipts.jsonl --jwks ~/.strix-mcp-proxy/<serverId>/keys/public-jwk.json`. Receipts written under v0.1.0–v0.1.3 with `storagePath` unset (or aliased to `~/.strix-gateway`) are NOT recoverable — their keys were ephemeral. For JWKS-resolvable kids set `STRIX_API_KEY` + `STRIX_TENANT_ID` (connected mode). |
391
+ | `strix-gateway verify` prints `kid not in keyring: strix-<serverId>` for every proxy receipt | You're running `strix-gateway` (gateway CLI) against receipts written by `@strixgov/mcp-proxy`. They are separate stores with incompatible key layouts: the gateway uses a multi-kid keyring (`active` pointer + `<kid>/` subdirs); the proxy writes a flat single-key file. If you also pointed the proxy's `storagePath` at `~/.strix-gateway`, the proxy's key was never registered in the keyring. | Use the package-matched verifier: `npx @strixgov/verifier chain ~/.strix-mcp-proxy/<serverId>/receipts.jsonl --jwks ~/.strix-mcp-proxy/<serverId>/keys/public-jwk.json`. Going forward, leave `storagePath` at its v0.1.4 default (or set it to a path you control that is NOT `~/.strix-gateway`). |
392
+ | Proxy starts, exposes tools, allows reads, but every write or otherwise `APPROVAL_REQUIRED`-tier call returns `"decision":"DENY"` with `reason:"PROMPT_FAILED"` even though `approval.enabled` is `true` | The gateway's default approver is `terminalApprove`, which needs a TTY on stdin. Under Claude Desktop spawn the proxy's stdio is owned by the MCP protocol — there's no terminal, so the prompt fails closed. Fixed in `0.1.3+` via `approval.type: "file"` / `"auto"`. | Upgrade: `npm install -g @strixgov/mcp-proxy@latest` and set `"approval": { "enabled": true, "type": "file", "requestDir": "~/.strix-gateway/approvals" }` (real approval, headless) or `"approval": { "enabled": true, "type": "auto" }` (demo only — auto-approves everything). See "Approval modes" above. |
183
393
 
184
394
  ## Security posture
185
395
 
@@ -195,7 +405,7 @@ posture, including:
195
405
  - **Process model** — what happens on crash / mid-approval / disk
196
406
  full. Fail-closed at every boundary; the proxy will not start
197
407
  without a loadable signing key.
198
- - **Multi-tenant boundary** — single-tenant by design in v0.1.0;
408
+ - **Multi-tenant boundary** — single-tenant by design in v0.1.x;
199
409
  run one proxy process per tenant for isolation.
200
410
  - **What the proxy does NOT govern** — out-of-wrapper traffic, the
201
411
  agent's internal state, the upstream's own argument handling
@@ -203,10 +413,7 @@ posture, including:
203
413
  responsibility).
204
414
 
205
415
  Report security issues via GitHub Security Advisory on
206
- `github.com/Strixgov/strix`. The companion operator runbook at
207
- [`docs/runbooks/operate-mcp-proxy.md`](https://github.com/Tarshann/strix-platform/tree/main/docs/runbooks/operate-mcp-proxy.md)
208
- covers daily operations, receipt-log rotation, and upgrade
209
- discipline.
416
+ `github.com/Strixgov/strix`.
210
417
 
211
418
  ## License
212
419
 
package/SECURITY.md CHANGED
@@ -6,9 +6,10 @@ It answers the questions a security review will ask in the first
6
6
  which threats does it cover or not cover.
7
7
 
8
8
  For the broader Strix threat model (the platform, the verifier,
9
- the kernel-binding guarantees), see
10
- [`docs/security/THREAT-MODEL.md`](https://github.com/Tarshann/strix-platform/tree/main/docs/security/THREAT-MODEL.md)
11
- in the source repository.
9
+ the kernel-binding guarantees), see the threat-model documentation
10
+ distributed with the Strix platform. Authorized reviewers and
11
+ prospective customers can request the current version via
12
+ security@strixgov.com.
12
13
 
13
14
  ---
14
15
 
@@ -197,7 +198,7 @@ The proxy is a single Node.js process. Crash semantics:
197
198
  | Proxy crashes mid-tool-call | Upstream call may or may not have started (depends on timing). No receipt is written. The MCP client sees a transport error. | Restart proxy. The action did not execute in any auditable sense. Re-running the same agent input replays the call from scratch. |
198
199
  | Upstream crashes during a call | Proxy receives the error from the upstream client. Receipt is written with `decision: "DENY"` (or `"ALLOW"` if policy already approved + upstream failed AFTER consume). Error propagated to client. | Standard MCP error handling on the client side. |
199
200
  | Proxy crashes mid-approval | Pending approval is lost (in-memory queue). The held action does not execute. | Restart proxy. The action would need to be re-attempted; the previous approval is gone. |
200
- | Disk fills up (with `storagePath` set) | New receipt writes fail. The proxy raises an error but does not fall back to silent ALLOW — fail-closed discipline. | Rotate / archive receipts.jsonl (see [`docs/runbooks/operate-mcp-proxy.md`](https://github.com/Tarshann/strix-platform/tree/main/docs/runbooks/operate-mcp-proxy.md) §3) before restarting. |
201
+ | Disk fills up (with `storagePath` set) | New receipt writes fail. The proxy raises an error but does not fall back to silent ALLOW — fail-closed discipline. | Rotate / archive `receipts.jsonl` before restarting. |
201
202
  | Signing key not loadable | Constructor throws at startup. **The proxy does not start.** | Fix the key configuration. There is no "governance degraded but tool still runs" state. |
202
203
 
203
204
  ### 4.1 Receipt-log rotation is operator-owned
@@ -214,8 +215,7 @@ operator-side:
214
215
  `receipts.jsonl` then becomes a transient buffer that can be
215
216
  rotated aggressively.
216
217
  - Anyone running the proxy at non-trivial volume MUST decide on a
217
- rotation policy before launching. See
218
- [`docs/runbooks/operate-mcp-proxy.md`](https://github.com/Tarshann/strix-platform/tree/main/docs/runbooks/operate-mcp-proxy.md) §3.
218
+ rotation policy before launching.
219
219
 
220
220
  This is intentionally not in the proxy itself — log rotation
221
221
  strategy is a deployment decision (Node-level `logrotate-stream`?
@@ -1,4 +1,5 @@
1
1
  {
2
+ "_comment_upstream_env": "Two patterns for the NOTION_TOKEN. Pattern A (recommended for Claude Desktop): omit upstream.env and set NOTION_TOKEN in Claude Desktop's env block — it propagates to the upstream via process.env. Pattern B (explicit): add upstream.env here, e.g. \"env\": { \"NOTION_TOKEN\": \"secret_...\" }. See README 'Where the upstream's secrets should live' for trade-offs.",
2
3
  "serverId": "notion",
3
4
  "upstream": {
4
5
  "command": "npx",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strixgov/mcp-proxy",
3
- "version": "0.1.0",
3
+ "version": "0.1.6",
4
4
  "description": "Strix MCP Proxy — the distribution wedge for governed agent tool execution. Point your MCP client at the proxy instead of the underlying server; every callTool is intercepted, evaluated against policy, and emits a signed Ed25519 receipt. Designed so Mode 3 (capability-enforced) is the v0.2.0 upgrade path, not a rewrite.",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -17,11 +17,9 @@
17
17
  "dependencies": {
18
18
  "@strixgov/mcp-adapter": "^0.1.0",
19
19
  "@strixgov/tool-gateway": "^0.4.1",
20
+ "@strixgov/capabilities-mcp-common": "^0.1.0",
20
21
  "@modelcontextprotocol/sdk": "^1.0.0"
21
22
  },
22
- "devDependencies": {
23
- "@strixgov/capabilities-mcp-common": "0.1.0"
24
- },
25
23
  "keywords": [
26
24
  "strix",
27
25
  "mcp",
package/src/config.mjs CHANGED
@@ -75,7 +75,32 @@ export async function resolveCapabilities(capabilities) {
75
75
  "resolveCapabilities: 'capabilities' must be a string import path or an array",
76
76
  );
77
77
  }
78
- const mod = await import(capabilities);
78
+ let mod;
79
+ try {
80
+ mod = await import(capabilities);
81
+ } catch (err) {
82
+ // ERR_MODULE_NOT_FOUND is the common case when a globally-installed
83
+ // proxy can't resolve a companion pack that wasn't installed alongside
84
+ // it. Surface an actionable error rather than the bare Node loader
85
+ // message, since "Cannot find package '...'" gives the operator no
86
+ // hint that the fix is `npm install -g <the-package>`.
87
+ if (err && (err.code === "ERR_MODULE_NOT_FOUND" || /Cannot find package/i.test(err.message))) {
88
+ // Best-effort: peel off the subpath so the suggested install
89
+ // command targets the bare package name. "@strixgov/capabilities-mcp-common/notion"
90
+ // → "@strixgov/capabilities-mcp-common".
91
+ const bare = capabilities.startsWith("@")
92
+ ? capabilities.split("/").slice(0, 2).join("/")
93
+ : capabilities.split("/")[0];
94
+ throw new Error(
95
+ `resolveCapabilities: cannot load capability pack '${capabilities}'. ` +
96
+ `If you installed @strixgov/mcp-proxy globally, install the pack the same way: ` +
97
+ `\`npm install -g ${bare}\`. ` +
98
+ `Otherwise add '${bare}' to your project's dependencies. ` +
99
+ `(underlying error: ${err.message})`,
100
+ );
101
+ }
102
+ throw err;
103
+ }
79
104
  // Look for the conventional name (foo → fooCapabilities, *Capabilities,
80
105
  // default, or the first exported array). The companion packs use the
81
106
  // `<server>Capabilities` convention, so check that first.
package/src/proxy.mjs CHANGED
@@ -29,7 +29,10 @@
29
29
  * maximum coverage of the actual MCP-call surface.
30
30
  */
31
31
 
32
+ import path from "node:path";
33
+ import os from "node:os";
32
34
  import { governMCPServer } from "@strixgov/mcp-adapter";
35
+ import { loadOrCreateSigningKey, fileApprover } from "@strixgov/tool-gateway";
33
36
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
34
37
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
35
38
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -72,6 +75,34 @@ export async function startProxy(opts) {
72
75
  }
73
76
  }
74
77
 
78
+ // v0.1.4: default storagePath to ~/.strix-mcp-proxy/<serverId> when unset.
79
+ //
80
+ // Pre-0.1.4 left opts.storagePath undefined, which caused governMCPServer
81
+ // to default both halves of persistence wrong:
82
+ // - storage = MemoryStorage (receipts vanish with the process)
83
+ // - signingKey = ephemeral generateSigningKey() (kid is in every receipt
84
+ // on disk, but the actual key is gone the moment the proxy exits)
85
+ // The combination silently produced "persistent receipts signed by a key
86
+ // that no longer exists anywhere", which is the worst possible state —
87
+ // operators who pointed storagePath at ~/.strix-gateway were getting
88
+ // receipts they could list but never verify.
89
+ //
90
+ // Per-serverId default (not a single shared dir) so multiple wrapped
91
+ // upstreams cleanly partition their receipts + keys. Intentionally NOT
92
+ // ~/.strix-gateway: the gateway CLI uses a multi-kid keyring layout
93
+ // (active pointer + <kid>/ subdirs) and the proxy uses loadOrCreateSigningKey's
94
+ // flat single-key layout — sharing the same keys/ dir collides silently
95
+ // (proxy's flat files are invisible to the keyring loader, OR a stale
96
+ // migration clobbers the gateway's active pointer to the proxy's kid).
97
+ //
98
+ // Opt out by passing `storagePath: null` (treated as "I really mean
99
+ // ephemeral"). Caller-supplied opts.signingKey still wins regardless of
100
+ // storage choice.
101
+ const resolvedStoragePath =
102
+ opts.storagePath === null
103
+ ? null
104
+ : opts.storagePath ?? path.join(os.homedir(), ".strix-mcp-proxy", serverId);
105
+
75
106
  // ─── 1. Connect to the upstream MCP server ─────────────────────────────────
76
107
  // Test mode lets callers pre-build the upstream client (e.g. with an
77
108
  // InMemoryTransport pair) so the proxy can be exercised end-to-end
@@ -97,14 +128,72 @@ export async function startProxy(opts) {
97
128
  // ─── 2. Build the iface mcp-adapter expects ───────────────────────────────
98
129
  const upstreamIface = buildUpstreamIface(upstreamClient, serverId);
99
130
 
100
- // ─── 3. Wrap with governMCPServer (Mode 1/2) ───────────────────────────────
131
+ // ─── 3a. Resolve a persistent signing key when storagePath is set ──────────
132
+ // Without this, governMCPServer falls back to an in-memory ephemeral key
133
+ // (`generateSigningKey(\`strix-${serverId}\`)`) — meaning every restart
134
+ // produces a new key with the same kid and the previous key (the one that
135
+ // signed receipts already on disk) is gone forever. JSONL receipts on disk
136
+ // would then be unverifiable: the kid points at a key that doesn't exist
137
+ // anywhere, including the proxy that wrote them.
138
+ //
139
+ // When storagePath is set the operator clearly wants persistent receipts;
140
+ // it's the natural moment to also persist the signing key alongside them.
141
+ // The keypair is written to `<storagePath>/keys/{signing-key.pem, public-jwk.json}`
142
+ // with PKCS8 PEM at 0600 / public JWK at 0644 (mirrors loadOrCreateSigningKey's
143
+ // own discipline). Caller-supplied `opts.signingKey` still wins.
144
+ let resolvedSigningKey = opts.signingKey;
145
+ if (!resolvedSigningKey && resolvedStoragePath) {
146
+ resolvedSigningKey = await loadOrCreateSigningKey({
147
+ keyPath: path.join(resolvedStoragePath, "keys"),
148
+ kid: `strix-${serverId}`,
149
+ });
150
+ audit(opts, {
151
+ kind: "signing.key.resolved",
152
+ detail: {
153
+ source: opts.storagePath ? "persistent" : "persistent-default",
154
+ kid: resolvedSigningKey.kid,
155
+ keyPath: path.join(resolvedStoragePath, "keys"),
156
+ },
157
+ });
158
+ }
159
+
160
+ // ─── 3b. Resolve approval config to an approver function ───────────────────
161
+ // The gateway's approval expects `prompt: (cap, inv, callOpts) => Promise<...>`
162
+ // — a function. JSON config can't carry a function, so the proxy translates
163
+ // a string `approval.type` to the right approver from @strixgov/tool-gateway.
164
+ //
165
+ // Why this matters: when the proxy is spawned by Claude Desktop (or any
166
+ // other MCP client that owns the stdio channel), the default `terminalApprove`
167
+ // fails closed because stdin/stdout aren't a TTY — every APPROVAL_REQUIRED
168
+ // call gets denied with `PROMPT_FAILED`. Operators have to pick a headless
169
+ // approver explicitly. `type: "file"` is the headless default; `type: "auto"`
170
+ // is the demo shortcut; `type: "terminal"` is the legacy default (works only
171
+ // when the proxy is run from a real terminal).
172
+ //
173
+ // Supported config shapes (all under `approval`):
174
+ //
175
+ // {enabled: false} ← gate disabled, every APPROVAL_REQUIRED → DENY
176
+ // {enabled: true} ← terminal (legacy default; fails closed in Claude Desktop)
177
+ // {enabled: true, type: "terminal"} ← terminal (explicit)
178
+ // {enabled: true, type: "auto"} ← shortcut for autoApprove: true (demos)
179
+ // {enabled: true, type: "file",
180
+ // requestDir: "~/.strix-gateway/approvals", ← request/response file polling (headless)
181
+ // timeoutMs: 300000}
182
+ // {enabled: true, autoApprove: true} ← long form of {type: "auto"}
183
+ // {enabled: true, prompt: <fn>} ← programmatic caller wins (always)
184
+ const resolvedApproval = resolveApproval(opts.approval, {
185
+ ...opts,
186
+ storagePath: resolvedStoragePath,
187
+ });
188
+
189
+ // ─── 3c. Wrap with governMCPServer (Mode 1/2) ──────────────────────────────
101
190
  const governed = governMCPServer(upstreamIface, {
102
191
  serverId,
103
192
  capabilities: opts.capabilities,
104
193
  policy: opts.policy ?? { default: "DENY" },
105
- approval: opts.approval ?? { enabled: false },
106
- storagePath: opts.storagePath,
107
- signingKey: opts.signingKey,
194
+ approval: resolvedApproval,
195
+ storagePath: resolvedStoragePath,
196
+ signingKey: resolvedSigningKey,
108
197
  connectedMode: opts.connectedMode,
109
198
  });
110
199
 
@@ -210,3 +299,85 @@ function audit(opts, event) {
210
299
  try { opts.onAudit(event); } catch { /* swallow audit-handler errors */ }
211
300
  }
212
301
  }
302
+
303
+ /**
304
+ * Translate the JSON-friendly `approval` config to the function-shaped
305
+ * approval object the gateway expects.
306
+ *
307
+ * Resolution rules (first match wins):
308
+ *
309
+ * 1. `approval.prompt` is a function → pass through unchanged
310
+ * (programmatic callers can always inject their own approver).
311
+ * 2. `approval.enabled === false` → pass through unchanged
312
+ * (no prompt needed; gateway denies every APPROVAL_REQUIRED).
313
+ * 3. `approval.autoApprove === true` → pass through unchanged
314
+ * (long-form alias for type: "auto").
315
+ * 4. `approval.type === "auto"` → set autoApprove: true.
316
+ * 5. `approval.type === "file"` → construct fileApprover.
317
+ * `requestDir` defaults to
318
+ * `<storagePath>/approvals` if
319
+ * storagePath is set, otherwise
320
+ * `./.strix-approvals`.
321
+ * `~` and `$HOME` are expanded.
322
+ * 6. `approval.type === "terminal"` → pass through unchanged
323
+ * (gateway defaults to terminalApprove). Fails closed in non-TTY
324
+ * environments — fine for direct-CLI testing, broken under
325
+ * Claude Desktop spawn.
326
+ * 7. Unknown `approval.type` → throw at startup so the
327
+ * operator can't misconfigure silently into a fail-closed loop.
328
+ */
329
+ function resolveApproval(approval, opts) {
330
+ if (!approval) return { enabled: false };
331
+
332
+ // Programmatic callers always win — JSON-config strings are a
333
+ // convenience for operators, not the source of truth.
334
+ if (typeof approval.prompt === "function") return approval;
335
+ if (approval.enabled === false) return approval;
336
+ if (approval.autoApprove === true) return approval;
337
+
338
+ const type = approval.type;
339
+ if (type === undefined || type === null) return approval;
340
+
341
+ if (type === "auto") {
342
+ return { ...approval, autoApprove: true };
343
+ }
344
+
345
+ if (type === "file") {
346
+ const requestDirRaw =
347
+ approval.requestDir ??
348
+ (opts.storagePath ? path.join(opts.storagePath, "approvals") : "./.strix-approvals");
349
+ const requestDir = expandHomeTilde(requestDirRaw);
350
+ return {
351
+ ...approval,
352
+ prompt: fileApprover({
353
+ requestDir,
354
+ timeoutMs: approval.timeoutMs,
355
+ pollIntervalMs: approval.pollIntervalMs,
356
+ }),
357
+ };
358
+ }
359
+
360
+ if (type === "terminal") {
361
+ // Pass through; gateway's default is terminalApprove. Operators who
362
+ // type this explicitly are accepting the non-TTY fail-closed behavior.
363
+ return approval;
364
+ }
365
+
366
+ throw new Error(
367
+ `startProxy: unknown approval.type '${type}' — expected "terminal", "file", or "auto"`,
368
+ );
369
+ }
370
+
371
+ /**
372
+ * Expand a leading `~` or `~/` to the OS user-home directory. Mirrors the
373
+ * tilde-expansion behavior in loadConfig so `~/.strix-gateway/approvals`
374
+ * resolves consistently across config locations.
375
+ */
376
+ function expandHomeTilde(p) {
377
+ if (typeof p !== "string") return p;
378
+ if (p === "~") return os.homedir();
379
+ if (p.startsWith("~/") || p.startsWith("~\\")) {
380
+ return path.join(os.homedir(), p.slice(2));
381
+ }
382
+ return p;
383
+ }