@strixgov/mcp-proxy 0.1.6 → 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 +23 -1
- package/README.md +13 -1
- 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,7 +5,29 @@ 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.
|
|
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
|
|
9
31
|
|
|
10
32
|
Documentation + license-fidelity patch. No functional change to the proxy,
|
|
11
33
|
the receipt format, the signing flow, or any runtime behavior.
|
package/README.md
CHANGED
|
@@ -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
|
|
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
|
+
}
|