@strixgov/mcp-proxy 0.1.5 → 0.1.7
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 +48 -0
- package/LICENSE +1 -1
- package/README.md +21 -9
- package/bin/strix-mcp-proxy.mjs +1 -0
- package/package.json +9 -9
- package/src/config.mjs +43 -1
- package/src/proxy.mjs +79 -2
- package/src/types.d.ts +8 -0
- package/src/webhook-approver.mjs +159 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,54 @@ 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.7]
|
|
9
|
+
|
|
10
|
+
Adds the webhook approval channel. No change to the receipt format or the
|
|
11
|
+
signing flow.
|
|
12
|
+
|
|
13
|
+
(Version note: this feature was originally staged under 0.1.6, but
|
|
14
|
+
`0.1.6` had already been published to npm on 2026-06-09 as the
|
|
15
|
+
documentation + license-fidelity patch below — so the webhook channel
|
|
16
|
+
ships as 0.1.7.)
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **`approval.type: "webhook"`** — Slack-compatible approval notifications
|
|
20
|
+
layered on the existing file approver (`src/webhook-approver.mjs`). Each
|
|
21
|
+
APPROVAL_REQUIRED call still writes the request file and awaits the
|
|
22
|
+
response file (timeout → DENY, unchanged); the webhook POSTs a
|
|
23
|
+
`{ text, strix }` payload telling a human what is blocked and how to
|
|
24
|
+
decide (`npx @strixgov/guard approve <requestId>`). Failure semantics are
|
|
25
|
+
load-bearing and tested: a failed notification never approves and never
|
|
26
|
+
auto-denies; a missing/invalid `webhookUrl` fails at startup, not at first
|
|
27
|
+
call. New config keys: `approval.webhookUrl` (required for the type),
|
|
28
|
+
`approval.requestDir`, `approval.pollIntervalMs`.
|
|
29
|
+
|
|
30
|
+
## [0.1.6] — published 2026-06-09
|
|
31
|
+
|
|
32
|
+
Documentation + license-fidelity patch. No functional change to the proxy,
|
|
33
|
+
the receipt format, the signing flow, or any runtime behavior.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- **`LICENSE` corrected to canonical Elastic License 2.0.** The Limitations
|
|
37
|
+
clause read "provide the software to **others** as a hosted or managed
|
|
38
|
+
service"; the canonical ELv2 wording is "to **third parties**." Corrected
|
|
39
|
+
byte-for-byte against Elastic's published license. The `0.1.0` release note
|
|
40
|
+
flagged this exact verification ("verify the bundled LICENSE against
|
|
41
|
+
canonical ELv2 before tagging") — the deviation shipped anyway in 0.1.0 and
|
|
42
|
+
0.1.5. Legal-text fidelity only; no change to intent or the MIT/ELv2 split.
|
|
43
|
+
**Counsel should confirm before publish.**
|
|
44
|
+
- **README version labels.** Notes describing *current* behavior were tagged
|
|
45
|
+
`v0.1.0` (the floor where they were first true); a reader on npm sees
|
|
46
|
+
`0.1.5+`. Relabeled the current-behavior notes (transport scope, env-var
|
|
47
|
+
substitution, feature-table column, Mode-3 seam, single-tenant boundary) to
|
|
48
|
+
`v0.1.x`. Genuinely historical references (the `v0.1.0–v0.1.3` ephemeral-key
|
|
49
|
+
known issue, `v0.1.4+` default `storagePath`) are unchanged.
|
|
50
|
+
|
|
51
|
+
### Note
|
|
52
|
+
- Closes findings F-3 and F-4 of
|
|
53
|
+
`docs/reviews/mcp-proxy-publish-readiness-2026-06-07.md`. Repeatable
|
|
54
|
+
publish gate: `scripts/mcp-proxy-preflight.mjs`.
|
|
55
|
+
|
|
8
56
|
## [0.1.5]
|
|
9
57
|
|
|
10
58
|
Documentation-hygiene patch. No functional change to the proxy, the
|
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
|
|
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,7 +35,7 @@ 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.
|
|
38
|
+
> **Transport scope (v0.1.x):** the proxy wraps MCP servers that speak
|
|
39
39
|
> the **stdio transport** — the `command` / `args` pattern in
|
|
40
40
|
> `claude_desktop_config.json`. It does **not** intercept HTTP-based
|
|
41
41
|
> hosted MCP servers (e.g. Notion's `https://mcp.notion.com/mcp`
|
|
@@ -110,7 +110,19 @@ for a complete example.
|
|
|
110
110
|
"requestDir": "~/.strix-mcp-proxy/notion/approvals",
|
|
111
111
|
"timeoutMs": 300000 // 5 minutes
|
|
112
112
|
},
|
|
113
|
-
"storagePath": "~/.strix-mcp-proxy/notion"
|
|
113
|
+
"storagePath": "~/.strix-mcp-proxy/notion", // optional (this IS the default for serverId="notion"); set to `null` for ephemeral
|
|
114
|
+
|
|
115
|
+
// Optional. Explicit directory for the persistent Ed25519 signing key,
|
|
116
|
+
// independent of storagePath. When set, the proxy generates or loads
|
|
117
|
+
// the key from this path on startup (PKCS8 PEM at 0600 / public JWK
|
|
118
|
+
// at 0644). Tilde is expanded via os.homedir().
|
|
119
|
+
//
|
|
120
|
+
// Useful when you want the signing key in a fixed, predictable location
|
|
121
|
+
// (e.g. a secrets volume) that is separate from the receipts store.
|
|
122
|
+
// Takes precedence over the storagePath-derived key location when both
|
|
123
|
+
// are set. If neither keyPath nor storagePath is set the key is generated
|
|
124
|
+
// in memory and not persisted.
|
|
125
|
+
"keyPath": "~/.strix-mcp-proxy/my-server/keys"
|
|
114
126
|
}
|
|
115
127
|
```
|
|
116
128
|
|
|
@@ -121,7 +133,7 @@ for a complete example.
|
|
|
121
133
|
|
|
122
134
|
### Where the upstream's secrets should live
|
|
123
135
|
|
|
124
|
-
v0.1.
|
|
136
|
+
v0.1.x does **no env-var substitution inside the config file** — there
|
|
125
137
|
is no `${NOTION_TOKEN}` template syntax. Two supported patterns for
|
|
126
138
|
upstream secrets:
|
|
127
139
|
|
|
@@ -217,7 +229,7 @@ Run `strix-mcp-proxy --help` for the full flag list.
|
|
|
217
229
|
|
|
218
230
|
## What ships with the proxy
|
|
219
231
|
|
|
220
|
-
| Feature | v0.1.
|
|
232
|
+
| Feature | v0.1.x (shipped) | v0.2.0 (planned) |
|
|
221
233
|
|---|---|---|
|
|
222
234
|
| MCP stdio transport (consumer side) | ✓ | — |
|
|
223
235
|
| MCP stdio transport (upstream side) | ✓ | — |
|
|
@@ -241,7 +253,7 @@ at each enforcement strength.
|
|
|
241
253
|
## The upgrade path to Mode 3
|
|
242
254
|
|
|
243
255
|
Mode 3 — Capability-Enforced — is the upgrade, not a rewrite. The
|
|
244
|
-
v0.1.
|
|
256
|
+
v0.1.x proxy already routes every outbound call through a single
|
|
245
257
|
`buildUpstreamIface()` seam in `src/proxy.mjs`. In v0.2.0 a new option
|
|
246
258
|
(`mode: "enforced"`) will wrap that seam with the
|
|
247
259
|
`execution_authorization_v1` token mint + transport step per
|
|
@@ -254,7 +266,7 @@ refuse to act on sensitive tools unless the token is present.
|
|
|
254
266
|
|
|
255
267
|
The cryptographic primitive is already shipped — see
|
|
256
268
|
[`@strixgov/mcp-token-validator`](https://www.npmjs.com/package/@strixgov/mcp-token-validator).
|
|
257
|
-
v0.1.
|
|
269
|
+
v0.1.x of the proxy does not mint these tokens yet; what it does is
|
|
258
270
|
make sure the *seam* exists so v0.2.0 plugs in cleanly.
|
|
259
271
|
|
|
260
272
|
## Listening to receipts (programmatic embedding)
|
|
@@ -357,7 +369,7 @@ keys are strictly better in every dimension that isn't a one-off demo.
|
|
|
357
369
|
the library; the proxy is the standalone process. They share the
|
|
358
370
|
same governance core (`@strixgov/tool-gateway`) and produce
|
|
359
371
|
byte-identical receipts.
|
|
360
|
-
- **Not an interceptor of HTTP-transport MCP servers.** v0.1.
|
|
372
|
+
- **Not an interceptor of HTTP-transport MCP servers.** v0.1.x wraps
|
|
361
373
|
the stdio transport only. Hosted HTTP MCP servers (the Claude
|
|
362
374
|
Desktop "Add Connector" URL pattern, e.g.
|
|
363
375
|
`https://mcp.notion.com/mcp`) are not in scope — the client talks
|
|
@@ -365,7 +377,7 @@ keys are strictly better in every dimension that isn't a one-off demo.
|
|
|
365
377
|
Use the upstream's local stdio server in the meantime; the
|
|
366
378
|
HTTP-transport story is Posture B in
|
|
367
379
|
[`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.
|
|
380
|
+
- **Not Mode 3 today.** v0.1.x ships Mode 1 + Mode 2. Mode 3
|
|
369
381
|
enforcement is the v0.2.0 upgrade path, designed for, but not
|
|
370
382
|
implemented in, this release.
|
|
371
383
|
- **Not a credential vault.** Upstream tokens (`NOTION_TOKEN`,
|
|
@@ -405,7 +417,7 @@ posture, including:
|
|
|
405
417
|
- **Process model** — what happens on crash / mid-approval / disk
|
|
406
418
|
full. Fail-closed at every boundary; the proxy will not start
|
|
407
419
|
without a loadable signing key.
|
|
408
|
-
- **Multi-tenant boundary** — single-tenant by design in v0.1.
|
|
420
|
+
- **Multi-tenant boundary** — single-tenant by design in v0.1.x;
|
|
409
421
|
run one proxy process per tenant for isolation.
|
|
410
422
|
- **What the proxy does NOT govern** — out-of-wrapper traffic, the
|
|
411
423
|
agent's internal state, the upstream's own argument handling
|
package/bin/strix-mcp-proxy.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strixgov/mcp-proxy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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",
|
|
@@ -14,11 +14,14 @@
|
|
|
14
14
|
"bin": {
|
|
15
15
|
"strix-mcp-proxy": "bin/strix-mcp-proxy.mjs"
|
|
16
16
|
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test test/*.test.mjs"
|
|
19
|
+
},
|
|
17
20
|
"dependencies": {
|
|
18
|
-
"@
|
|
19
|
-
"@strixgov/
|
|
20
|
-
"@strixgov/
|
|
21
|
-
"@
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
22
|
+
"@strixgov/capabilities-mcp-common": "workspace:^0.1.0",
|
|
23
|
+
"@strixgov/mcp-adapter": "workspace:^0.1.0",
|
|
24
|
+
"@strixgov/tool-gateway": "workspace:^0.4.1"
|
|
22
25
|
},
|
|
23
26
|
"keywords": [
|
|
24
27
|
"strix",
|
|
@@ -52,8 +55,5 @@
|
|
|
52
55
|
},
|
|
53
56
|
"engines": {
|
|
54
57
|
"node": ">=18.0.0"
|
|
55
|
-
},
|
|
56
|
-
"scripts": {
|
|
57
|
-
"test": "node --test test/*.test.mjs"
|
|
58
58
|
}
|
|
59
|
-
}
|
|
59
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -15,9 +15,17 @@
|
|
|
15
15
|
* "default": "DENY"
|
|
16
16
|
* },
|
|
17
17
|
* "approval": { "enabled": false },
|
|
18
|
-
* "storagePath": "~/.strix-mcp-proxy/receipts"
|
|
18
|
+
* "storagePath": "~/.strix-mcp-proxy/receipts",
|
|
19
|
+
* "keyPath": "~/.strix-mcp-proxy/my-server/keys"
|
|
19
20
|
* }
|
|
20
21
|
*
|
|
22
|
+
* `keyPath` (optional): directory for the persistent Ed25519 signing key.
|
|
23
|
+
* When present, the proxy calls loadOrCreateSigningKey({ keyPath }) on startup:
|
|
24
|
+
* - If the key files don't exist they are generated + written there.
|
|
25
|
+
* - If they do exist they are loaded.
|
|
26
|
+
* Tilde is expanded the same way as storagePath.
|
|
27
|
+
* Takes precedence over the storagePath-derived key location when both are set.
|
|
28
|
+
*
|
|
21
29
|
* The `capabilities` field is either:
|
|
22
30
|
* - a bare import path (resolved via dynamic import; must export
|
|
23
31
|
* a named or default ReadonlyArray<McpCapability>), or
|
|
@@ -56,6 +64,9 @@ export async function loadConfig(filePath) {
|
|
|
56
64
|
if (typeof cfg.storagePath === "string" && cfg.storagePath.startsWith("~")) {
|
|
57
65
|
cfg.storagePath = cfg.storagePath.replace(/^~/, os.homedir());
|
|
58
66
|
}
|
|
67
|
+
if (typeof cfg.keyPath === "string" && cfg.keyPath.startsWith("~")) {
|
|
68
|
+
cfg.keyPath = cfg.keyPath.replace(/^~/, os.homedir());
|
|
69
|
+
}
|
|
59
70
|
return cfg;
|
|
60
71
|
}
|
|
61
72
|
|
|
@@ -133,4 +144,35 @@ function validateConfig(cfg, abs) {
|
|
|
133
144
|
if (cfg.policy !== undefined && (typeof cfg.policy !== "object" || cfg.policy === null)) {
|
|
134
145
|
throw new Error(`loadConfig: '${abs}' 'policy' must be an object if present`);
|
|
135
146
|
}
|
|
147
|
+
if (cfg.keyPath !== undefined && typeof cfg.keyPath !== "string") {
|
|
148
|
+
throw new Error(`loadConfig: '${abs}' 'keyPath' must be a string if present`);
|
|
149
|
+
}
|
|
150
|
+
if (cfg.upstreamCredentials !== undefined) {
|
|
151
|
+
if (
|
|
152
|
+
typeof cfg.upstreamCredentials !== "object" ||
|
|
153
|
+
cfg.upstreamCredentials === null ||
|
|
154
|
+
Array.isArray(cfg.upstreamCredentials)
|
|
155
|
+
) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`loadConfig: '${abs}' 'upstreamCredentials' must be an object if present`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
for (const [envKey, spec] of Object.entries(cfg.upstreamCredentials)) {
|
|
161
|
+
if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`loadConfig: '${abs}' 'upstreamCredentials["${envKey}"]' must be a spec object with a "from" field`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (spec.from !== "keychain" && spec.from !== "env") {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`loadConfig: '${abs}' 'upstreamCredentials["${envKey}"].from' must be "keychain" or "env"`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (typeof spec.key !== "string" || !spec.key) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`loadConfig: '${abs}' 'upstreamCredentials["${envKey}"].key' must be a non-empty string`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
136
178
|
}
|
package/src/proxy.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import path from "node:path";
|
|
|
33
33
|
import os from "node:os";
|
|
34
34
|
import { governMCPServer } from "@strixgov/mcp-adapter";
|
|
35
35
|
import { loadOrCreateSigningKey, fileApprover } from "@strixgov/tool-gateway";
|
|
36
|
+
import { webhookApprover } from "./webhook-approver.mjs";
|
|
36
37
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
37
38
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
38
39
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -53,6 +54,7 @@ import {
|
|
|
53
54
|
* policy?: object,
|
|
54
55
|
* approval?: object,
|
|
55
56
|
* storagePath?: string,
|
|
57
|
+
* keyPath?: string,
|
|
56
58
|
* signingKey?: object,
|
|
57
59
|
* connectedMode?: object,
|
|
58
60
|
* onReceipt?: (receipt: object) => void,
|
|
@@ -75,6 +77,41 @@ export async function startProxy(opts) {
|
|
|
75
77
|
}
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
// ─── 0. Resolve upstream credentials ─────────────────────────────────────
|
|
81
|
+
// When `upstreamCredentials` is supplied (from a JSON config or programmatic
|
|
82
|
+
// caller), materialise each credential from its declared source — OS keychain
|
|
83
|
+
// or env var — and merge the result into `upstream.env` before the upstream
|
|
84
|
+
// process starts. This keeps secrets out of config files while still feeding
|
|
85
|
+
// them into the upstream server's environment at the last possible moment.
|
|
86
|
+
//
|
|
87
|
+
// Credentials are resolved before the storagePath default / signing-key logic
|
|
88
|
+
// so that any audit events emitted below see the final env shape.
|
|
89
|
+
//
|
|
90
|
+
// @strixgov/mcp-credentials is an optional peer: loaded dynamically so the
|
|
91
|
+
// proxy itself doesn't hard-depend on keytar. When the field is absent we
|
|
92
|
+
// skip this step entirely (no import, no overhead).
|
|
93
|
+
let resolvedUpstreamEnv = opts.upstream?.env;
|
|
94
|
+
if (opts.upstreamCredentials && transportKind === "stdio") {
|
|
95
|
+
let resolveUpstreamCredentials;
|
|
96
|
+
try {
|
|
97
|
+
const mod = await import("@strixgov/mcp-credentials");
|
|
98
|
+
resolveUpstreamCredentials = mod.resolveUpstreamCredentials;
|
|
99
|
+
} catch {
|
|
100
|
+
throw new Error(
|
|
101
|
+
"startProxy: 'upstreamCredentials' requires @strixgov/mcp-credentials. " +
|
|
102
|
+
"Install it alongside the proxy: npm install @strixgov/mcp-credentials",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const credEnv = await resolveUpstreamCredentials(opts.upstreamCredentials);
|
|
106
|
+
// Merge: explicit upstream.env takes precedence over resolved credentials
|
|
107
|
+
// so operators can still override individual values at the shell level.
|
|
108
|
+
resolvedUpstreamEnv = { ...credEnv, ...(opts.upstream?.env ?? {}) };
|
|
109
|
+
audit(opts, {
|
|
110
|
+
kind: "credentials.resolved",
|
|
111
|
+
detail: { envKeys: Object.keys(credEnv) },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
78
115
|
// v0.1.4: default storagePath to ~/.strix-mcp-proxy/<serverId> when unset.
|
|
79
116
|
//
|
|
80
117
|
// Pre-0.1.4 left opts.storagePath undefined, which caused governMCPServer
|
|
@@ -119,7 +156,7 @@ export async function startProxy(opts) {
|
|
|
119
156
|
const upstreamTransport = new StdioClientTransport({
|
|
120
157
|
command: opts.upstream.command,
|
|
121
158
|
args: opts.upstream.args ?? [],
|
|
122
|
-
env:
|
|
159
|
+
env: resolvedUpstreamEnv ?? process.env,
|
|
123
160
|
});
|
|
124
161
|
await upstreamClient.connect(upstreamTransport);
|
|
125
162
|
audit(opts, { kind: "upstream.connected", detail: { command: opts.upstream.command } });
|
|
@@ -142,6 +179,19 @@ export async function startProxy(opts) {
|
|
|
142
179
|
// with PKCS8 PEM at 0600 / public JWK at 0644 (mirrors loadOrCreateSigningKey's
|
|
143
180
|
// own discipline). Caller-supplied `opts.signingKey` still wins.
|
|
144
181
|
let resolvedSigningKey = opts.signingKey;
|
|
182
|
+
// keyPath takes precedence over storagePath-derived key location when both
|
|
183
|
+
// are present (operators who want a stable key location independent of the
|
|
184
|
+
// receipt store can set keyPath without setting storagePath).
|
|
185
|
+
if (!resolvedSigningKey && opts.keyPath) {
|
|
186
|
+
resolvedSigningKey = await loadOrCreateSigningKey({
|
|
187
|
+
keyPath: opts.keyPath,
|
|
188
|
+
kid: `strix-${serverId}`,
|
|
189
|
+
});
|
|
190
|
+
audit(opts, {
|
|
191
|
+
kind: "signing.key.resolved",
|
|
192
|
+
detail: { source: "keyPath", kid: resolvedSigningKey.kid, keyPath: opts.keyPath },
|
|
193
|
+
});
|
|
194
|
+
}
|
|
145
195
|
if (!resolvedSigningKey && resolvedStoragePath) {
|
|
146
196
|
resolvedSigningKey = await loadOrCreateSigningKey({
|
|
147
197
|
keyPath: path.join(resolvedStoragePath, "keys"),
|
|
@@ -179,6 +229,9 @@ export async function startProxy(opts) {
|
|
|
179
229
|
// {enabled: true, type: "file",
|
|
180
230
|
// requestDir: "~/.strix-gateway/approvals", ← request/response file polling (headless)
|
|
181
231
|
// timeoutMs: 300000}
|
|
232
|
+
// {enabled: true, type: "webhook",
|
|
233
|
+
// webhookUrl: "https://hooks.slack.com/...", ← file polling + Slack-compatible
|
|
234
|
+
// requestDir: "...", timeoutMs: 300000} notification per pending request
|
|
182
235
|
// {enabled: true, autoApprove: true} ← long form of {type: "auto"}
|
|
183
236
|
// {enabled: true, prompt: <fn>} ← programmatic caller wins (always)
|
|
184
237
|
const resolvedApproval = resolveApproval(opts.approval, {
|
|
@@ -363,8 +416,32 @@ function resolveApproval(approval, opts) {
|
|
|
363
416
|
return approval;
|
|
364
417
|
}
|
|
365
418
|
|
|
419
|
+
if (type === "webhook") {
|
|
420
|
+
// Slack-compatible notification layered on the file primitive: the
|
|
421
|
+
// request/response files stay the decision channel (timeout → DENY
|
|
422
|
+
// unchanged); the webhook only tells a human where to look. A failed
|
|
423
|
+
// notification never approves and never auto-denies — see
|
|
424
|
+
// webhook-approver.mjs for the full failure semantics.
|
|
425
|
+
const requestDirRaw =
|
|
426
|
+
approval.requestDir ??
|
|
427
|
+
(opts.storagePath ? path.join(opts.storagePath, "approvals") : "./.strix-approvals");
|
|
428
|
+
const requestDir = expandHomeTilde(requestDirRaw);
|
|
429
|
+
return {
|
|
430
|
+
...approval,
|
|
431
|
+
prompt: webhookApprover({
|
|
432
|
+
webhookUrl: approval.webhookUrl,
|
|
433
|
+
serverId: opts.serverId,
|
|
434
|
+
requestDir,
|
|
435
|
+
timeoutMs: approval.timeoutMs,
|
|
436
|
+
pollIntervalMs: approval.pollIntervalMs,
|
|
437
|
+
onAudit: (event) => audit(opts, event),
|
|
438
|
+
fetchImpl: approval.fetchImpl, // programmatic/test seam; not JSON-configurable
|
|
439
|
+
}),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
366
443
|
throw new Error(
|
|
367
|
-
`startProxy: unknown approval.type '${type}' — expected "terminal", "file", or "auto"`,
|
|
444
|
+
`startProxy: unknown approval.type '${type}' — expected "terminal", "file", "webhook", or "auto"`,
|
|
368
445
|
);
|
|
369
446
|
}
|
|
370
447
|
|
package/src/types.d.ts
CHANGED
|
@@ -20,7 +20,15 @@ export interface ProxyOptions {
|
|
|
20
20
|
approval?: {
|
|
21
21
|
enabled?: boolean;
|
|
22
22
|
prompt?: (cap: object, invocation: object, opts: object) => Promise<{ approved: boolean; approver?: string }>;
|
|
23
|
+
/** JSON-config approver selector: "terminal" | "file" | "webhook" | "auto". */
|
|
24
|
+
type?: "terminal" | "file" | "webhook" | "auto";
|
|
25
|
+
autoApprove?: boolean;
|
|
26
|
+
/** type "file" | "webhook": request/response directory (~ expanded). */
|
|
27
|
+
requestDir?: string;
|
|
28
|
+
/** type "webhook": Slack-compatible notification URL (http/https). Decision still flows via the response file. */
|
|
29
|
+
webhookUrl?: string;
|
|
23
30
|
timeoutMs?: number;
|
|
31
|
+
pollIntervalMs?: number;
|
|
24
32
|
};
|
|
25
33
|
storagePath?: string;
|
|
26
34
|
signingKey?: object;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook approval channel — Slack-compatible notification on top of the
|
|
3
|
+
* gateway's file approver.
|
|
4
|
+
*
|
|
5
|
+
* Design (deliberate): the tool-gateway `fileApprover` is the headless
|
|
6
|
+
* approval PRIMITIVE — request file written, response file awaited,
|
|
7
|
+
* timeout → DENY. Its contract explicitly says network adapters belong
|
|
8
|
+
* ON TOP of that primitive, not inside it. This module is that adapter:
|
|
9
|
+
*
|
|
10
|
+
* 1. fileApprover writes `<requestId>.request.json` as always.
|
|
11
|
+
* 2. `onRequestWritten` fires a POST to `webhookUrl` with a
|
|
12
|
+
* Slack-incoming-webhook-compatible body (`{ text }`) plus a
|
|
13
|
+
* structured `strix` block for non-Slack consumers.
|
|
14
|
+
* 3. The DECISION still arrives as the response file — written by a
|
|
15
|
+
* human via `npx @strixgov/guard approve <requestId>`, a Slack
|
|
16
|
+
* workflow, a bot, or any out-of-band channel.
|
|
17
|
+
*
|
|
18
|
+
* Failure semantics (load-bearing):
|
|
19
|
+
* - Notification failure NEVER approves and NEVER auto-denies. The
|
|
20
|
+
* request file is already on disk; the approver keeps waiting and
|
|
21
|
+
* the existing timeout → DENY applies. A lost Slack message must
|
|
22
|
+
* not silently deny a legitimate call, and must never allow one.
|
|
23
|
+
* - Missing/invalid `webhookUrl` fails at STARTUP (config error),
|
|
24
|
+
* not at first call — an operator can't misconfigure silently
|
|
25
|
+
* into a notification-less loop.
|
|
26
|
+
*
|
|
27
|
+
* No new dependency: uses global fetch (Node >= 18).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { fileApprover } from "@strixgov/tool-gateway";
|
|
31
|
+
|
|
32
|
+
/** Max characters of the args preview embedded in the notification. */
|
|
33
|
+
const ARGS_PREVIEW_MAX = 240;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the Slack-compatible notification body for a pending approval.
|
|
37
|
+
* Exported for tests (pure).
|
|
38
|
+
*
|
|
39
|
+
* @param {{ requestId: string, serverId?: string, capability: object, invocation: object }} req
|
|
40
|
+
* @returns {{ text: string, strix: object }}
|
|
41
|
+
*/
|
|
42
|
+
export function buildWebhookPayload(req) {
|
|
43
|
+
const cap = req.capability ?? {};
|
|
44
|
+
const inv = req.invocation ?? {};
|
|
45
|
+
let argsPreview = "<none>";
|
|
46
|
+
if (inv.args !== undefined && inv.args !== null) {
|
|
47
|
+
try {
|
|
48
|
+
const s = JSON.stringify(inv.args);
|
|
49
|
+
argsPreview = s.length > ARGS_PREVIEW_MAX ? `${s.slice(0, ARGS_PREVIEW_MAX - 3)}...` : s;
|
|
50
|
+
} catch {
|
|
51
|
+
argsPreview = "<unserializable>";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const lines = [
|
|
55
|
+
":shield: *Strix approval required*",
|
|
56
|
+
`*Server:* ${req.serverId ?? "unknown"}`,
|
|
57
|
+
`*Capability:* ${cap.id ?? inv.capabilityId ?? "unknown"} (risk: ${cap.risk ?? "unknown"})`,
|
|
58
|
+
`*Action:* ${inv.action ?? "unknown"}`,
|
|
59
|
+
`*Args:* \`${argsPreview}\``,
|
|
60
|
+
`*Request:* \`${req.requestId}\``,
|
|
61
|
+
"",
|
|
62
|
+
`Approve: \`npx @strixgov/guard approve ${req.requestId}\``,
|
|
63
|
+
`Deny: \`npx @strixgov/guard deny ${req.requestId}\``,
|
|
64
|
+
];
|
|
65
|
+
return {
|
|
66
|
+
text: lines.join("\n"),
|
|
67
|
+
// Structured mirror for non-Slack webhook consumers (Slack ignores
|
|
68
|
+
// unknown top-level keys). Contains no more than the request file does.
|
|
69
|
+
strix: {
|
|
70
|
+
kind: "approval.requested",
|
|
71
|
+
requestId: req.requestId,
|
|
72
|
+
serverId: req.serverId,
|
|
73
|
+
capabilityId: cap.id ?? inv.capabilityId,
|
|
74
|
+
risk: cap.risk,
|
|
75
|
+
action: inv.action,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Construct the webhook approver prompt function.
|
|
82
|
+
*
|
|
83
|
+
* @param {{
|
|
84
|
+
* webhookUrl: string,
|
|
85
|
+
* serverId?: string,
|
|
86
|
+
* requestDir: string,
|
|
87
|
+
* timeoutMs?: number,
|
|
88
|
+
* pollIntervalMs?: number,
|
|
89
|
+
* onAudit?: (event: { kind: string, detail: object }) => void,
|
|
90
|
+
* fetchImpl?: typeof fetch, // test seam only
|
|
91
|
+
* }} opts
|
|
92
|
+
*/
|
|
93
|
+
export function webhookApprover(opts) {
|
|
94
|
+
const { webhookUrl } = opts ?? {};
|
|
95
|
+
if (typeof webhookUrl !== "string" || webhookUrl.length === 0) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
'webhookApprover: approval.type "webhook" requires approval.webhookUrl',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = new URL(webhookUrl);
|
|
103
|
+
} catch {
|
|
104
|
+
throw new Error(`webhookApprover: approval.webhookUrl is not a valid URL: '${webhookUrl}'`);
|
|
105
|
+
}
|
|
106
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`webhookApprover: approval.webhookUrl must be http(s), got '${parsed.protocol}'`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
112
|
+
const audit = (event) => {
|
|
113
|
+
if (typeof opts.onAudit === "function") {
|
|
114
|
+
try { opts.onAudit(event); } catch { /* never let audit break approval */ }
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
let pendingRequest = null;
|
|
119
|
+
const inner = fileApprover({
|
|
120
|
+
requestDir: opts.requestDir,
|
|
121
|
+
timeoutMs: opts.timeoutMs,
|
|
122
|
+
pollIntervalMs: opts.pollIntervalMs,
|
|
123
|
+
onRequestWritten: async (_requestPath, requestId) => {
|
|
124
|
+
const payload = buildWebhookPayload({ ...pendingRequest, requestId });
|
|
125
|
+
try {
|
|
126
|
+
const res = await doFetch(webhookUrl, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "content-type": "application/json" },
|
|
129
|
+
body: JSON.stringify(payload),
|
|
130
|
+
});
|
|
131
|
+
if (res.ok) {
|
|
132
|
+
audit({ kind: "approval.webhook.notified", detail: { requestId, status: res.status } });
|
|
133
|
+
} else {
|
|
134
|
+
audit({
|
|
135
|
+
kind: "approval.webhook.notify_failed",
|
|
136
|
+
detail: { requestId, status: res.status },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
// Best-effort notification: the request file is the source of
|
|
141
|
+
// truth; the decision path (response file / timeout DENY) is
|
|
142
|
+
// unaffected by a lost notification.
|
|
143
|
+
audit({
|
|
144
|
+
kind: "approval.webhook.notify_failed",
|
|
145
|
+
detail: { requestId, error: err?.message ?? String(err) },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return async function webhookApproverPrompt(capability, invocation, callOpts) {
|
|
152
|
+
pendingRequest = { serverId: opts.serverId, capability, invocation };
|
|
153
|
+
try {
|
|
154
|
+
return await inner(capability, invocation, callOpts);
|
|
155
|
+
} finally {
|
|
156
|
+
pendingRequest = null;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|