@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 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 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,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.0):** the proxy wraps MCP servers that speak
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" // optional (this IS the default for serverId="notion"); set to `null` for ephemeral
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.0 does **no env-var substitution inside the config file** — there
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.0 | v0.2.0 (planned) |
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.0 proxy already routes every outbound call through a single
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.0 of the proxy does not mint these tokens yet; what it does is
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.0 wraps
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.0 ships Mode 1 + Mode 2. Mode 3
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.0;
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
@@ -87,6 +87,7 @@ async function parseArgs(argv) {
87
87
  policy: cfg.policy,
88
88
  approval: cfg.approval,
89
89
  storagePath: cfg.storagePath,
90
+ keyPath: cfg.keyPath,
90
91
  };
91
92
  }
92
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strixgov/mcp-proxy",
3
- "version": "0.1.5",
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
- "@strixgov/mcp-adapter": "^0.1.0",
19
- "@strixgov/tool-gateway": "^0.4.1",
20
- "@strixgov/capabilities-mcp-common": "^0.1.0",
21
- "@modelcontextprotocol/sdk": "^1.0.0"
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: opts.upstream.env ?? process.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
+ }