@nanobpm/nano-workforce 0.171.10 → 0.171.11

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
@@ -1,3 +1,9 @@
1
+ ## [0.171.11](https://github.com/nanobpm/nano-workforce/compare/v0.171.10...v0.171.11) (2026-09-01)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **mcp:** declare x-nano-secret-env on hookSecret so urban_debug_* mutations authorize remotely ([#700](https://github.com/nanobpm/nano-workforce/issues/700)) ([bd46ac6](https://github.com/nanobpm/nano-workforce/commit/bd46ac6e235fc9a13cab27351108cea9570f387b)), closes [#698](https://github.com/nanobpm/nano-workforce/issues/698) [#698](https://github.com/nanobpm/nano-workforce/issues/698) [#698](https://github.com/nanobpm/nano-workforce/issues/698) [pre-#698](https://github.com/nanobpm/pre-/issues/698)
6
+
1
7
  ## [0.171.10](https://github.com/nanobpm/nano-workforce/compare/v0.171.9...v0.171.10) (2026-09-01)
2
8
 
3
9
  ### Bug Fixes
@@ -133,6 +133,41 @@ header on **both reads and mutations** — read endpoints like `GET /app/api/age
133
133
  `/app/mcp` follows the same `network.bind` manifest setting as the rest of the app's
134
134
  HTTP surface.
135
135
 
136
+ ### Framework mutation guard — `urban_debug_*` mutations need `x-hook-secret` too
137
+
138
+ The framework-owned **mutating** `urban_debug_*` tools (`set_variables`, `retry_job`,
139
+ `resolve_incident`, `cancel_instance`) are gated by the Urban runtime's *own* mutation
140
+ guard (`authorizeMutation`), which is separate from nwf's app-operation guard above. It
141
+ authorizes a mutation one of two ways:
142
+
143
+ 1. **Loopback bypass** — `URBAN_MCP_ALLOW_MUTATIONS=true` **and** `mcp.allowRemote` off.
144
+ This is the credential-free, local-only path; it is disabled the moment the surface is
145
+ remote-exposed (`allowRemote` on), by design — the guard never drops for a non-loopback
146
+ caller.
147
+ 2. **Shared-secret scheme** — an apiKey *header* security scheme that declares an
148
+ `x-nano-secret-env` extension naming the env var holding the secret. nwf declares this
149
+ on **`hookSecret`** (`x-nano-secret-env: NANO_PR_WEBHOOK_SECRET`), so a mutating
150
+ `urban_debug_*` call carrying `x-hook-secret: <NANO_PR_WEBHOOK_SECRET>` is authorized on
151
+ a remote-exposed instance.
152
+
153
+ This is the **same secret and same header** as nwf's app-operation guard (both key on
154
+ `NANO_PR_WEBHOOK_SECRET` via `x-hook-secret`), so reads and mutations — app-operation and
155
+ framework — share one credential. The MCP server entry's `headers` already carries it (§1);
156
+ nothing extra is needed for `urban_debug_*` mutations once the secret is set.
157
+
158
+ **Fail-closed caveat.** When `NANO_PR_WEBHOOK_SECRET` is **unset** there is no credential to
159
+ present, so on a remote-exposed instance (`allowRemote` on) framework mutations remain
160
+ **closed** — this is correct fail-closed behavior, not a regression. Because the scheme is now
161
+ *declared* (it names the env var) but the secret is absent, the runtime surfaces the attempt as a
162
+ `500` *security-misconfigured* ("secret env `NANO_PR_WEBHOOK_SECRET` is not set") rather than a
163
+ plain refusal — either way no mutation occurs. An operator who needs remote mutation repair (e.g.
164
+ patching a wedged instance's variable + retrying a `JOB_NO_RETRIES` job) must set
165
+ `NANO_PR_WEBHOOK_SECRET`; then a `urban_debug_*` mutation carrying `x-hook-secret: <secret>`
166
+ succeeds, while a missing or wrong header `401`s. The loopback bypass
167
+ (`URBAN_MCP_ALLOW_MUTATIONS=true` with `allowRemote` off) remains the credential-free
168
+ local-only alternative. Only `hookSecret` may carry `x-nano-secret-env` — the runtime
169
+ throws on more than one shared-secret scheme.
170
+
136
171
  **Operator-only doors stay operator-only.** The staged delivery-graph lifecycle —
137
172
  `stageDeliveryGraph`, `dispatchDeliveryGraph`, `dismissProposal` — is `x-mcp`-excluded
138
173
  from the projected tool surface (ADR 0067 §2): the human clicking **Dispatch** in the
@@ -184,12 +184,18 @@ describe("MCP surface e2e — the runtime-served /app/mcp handshake, per tool (S
184
184
  });
185
185
 
186
186
  test("mutating framework tools are gated without the shared secret (set-variables)", async () => {
187
+ // This harness boots WITHOUT `NANO_PR_WEBHOOK_SECRET`. Since #698 declares the `hookSecret`
188
+ // shared-secret scheme (`x-nano-secret-env`), a remote-exposed mutation now fails CLOSED
189
+ // DETERMINISTICALLY as a misconfiguration ("secret env … is not set") — the credential the guard
190
+ // requires cannot exist until the operator sets the env var. Pin exactly that new shape (NOT the
191
+ // pre-#698 no-scheme "shared secret"/"allowMutations" refusal), so the test actually proves #698's
192
+ // `x-nano-secret-env` declaration took effect rather than accepting the old behavior.
187
193
  const res = await h.callTool("urban_debug_set_variables", { processInstanceKey: "1", variables: {} });
188
194
  assert.ok(res.isError, "urban_debug_set_variables must refuse a credential-free mutation");
189
195
  assert.match(
190
196
  res.text,
191
- /shared secret|allowMutations/i,
192
- `the refusal must name the guard: ${res.text}`,
197
+ /secret env .* is not set|misconfigured/i,
198
+ `the refusal must be the #698 misconfiguration shape: ${res.text}`,
193
199
  );
194
200
  });
195
201
 
@@ -264,3 +270,71 @@ describe("MCP surface e2e — the runtime-served /app/mcp handshake, per tool (S
264
270
  assert.deepEqual(json?.proposals, [], "no live staged proposals may remain");
265
271
  });
266
272
  });
273
+
274
+ // Framework mutation-guard authorization (issue #698).
275
+ // --------------------------------------------------------------------------
276
+ // The framework-owned mutating `urban_debug_*` tools (set_variables/retry_job/resolve_incident/
277
+ // cancel_instance) are gated by @nanobpm/urban's OWN mutation guard (`authorizeMutation`), distinct
278
+ // from nwf's app-operation guard. On a REMOTE-exposed instance (the harness always boots with
279
+ // `URBAN_MCP_ALLOW_REMOTE: "true"`) the loopback bypass is off, so the ONLY door left is the
280
+ // shared-secret apiKey scheme — which the guard recognizes only when an apiKey *header* scheme
281
+ // declares `x-nano-secret-env`. nwf now declares `x-nano-secret-env: NANO_PR_WEBHOOK_SECRET` on the
282
+ // `hookSecret` scheme (this issue), so a mutating call carrying `x-hook-secret: <secret>` is
283
+ // authorized past the guard, while a missing/wrong header stays 401. This pins that contract.
284
+ const HOOK_SECRET = "issue-698-shared-secret";
285
+
286
+ describe("MCP surface e2e — framework mutation guard authorizes with the shared secret (#698)", () => {
287
+ let h: McpHarness;
288
+
289
+ before(async () => {
290
+ // Remote-exposed (harness default) + a shared secret set: the exact condition of #698, where the
291
+ // loopback bypass is off and the shared-secret scheme is the only authorization door.
292
+ h = await bootMcpHarness({ env: { NANO_PR_WEBHOOK_SECRET: HOOK_SECRET } });
293
+ });
294
+
295
+ after(async () => {
296
+ await h?.stop();
297
+ });
298
+
299
+ test("a mutating urban_debug_* call is refused WITHOUT the shared-secret header", async () => {
300
+ const res = await h.callTool("urban_debug_set_variables", { processInstanceKey: "1", variables: {} });
301
+ assert.ok(res.isError, "a credential-free mutation must be refused on a remote-exposed instance");
302
+ assert.match(
303
+ res.text,
304
+ /unauthorized|401/i,
305
+ `a missing shared-secret header must 401: ${res.text}`,
306
+ );
307
+ });
308
+
309
+ test("a mutating urban_debug_* call is refused WITH A WRONG shared-secret header", async () => {
310
+ const res = await h.callTool(
311
+ "urban_debug_set_variables",
312
+ { processInstanceKey: "1", variables: {} },
313
+ { "x-hook-secret": "not-the-secret" },
314
+ );
315
+ assert.ok(res.isError, "a wrong-secret mutation must still be refused");
316
+ assert.match(
317
+ res.text,
318
+ /unauthorized|401/i,
319
+ `a wrong shared-secret header must 401: ${res.text}`,
320
+ );
321
+ });
322
+
323
+ test("a mutating urban_debug_* call carrying the correct x-hook-secret is authorized past the guard", async () => {
324
+ const res = await h.callTool(
325
+ "urban_debug_set_variables",
326
+ { processInstanceKey: "1", variables: {} },
327
+ { "x-hook-secret": HOOK_SECRET },
328
+ );
329
+ // The correct credential clears the shared-secret guard. The call may still fail downstream (the
330
+ // hermetic engine has no instance `1`), but it must NO LONGER be the shared-secret refusal NOR a
331
+ // generic authorization failure (401/unauthorized) — excluding those is the falsifiable proof the
332
+ // `x-nano-secret-env` declaration made the scheme authorizable (rather than the credential silently
333
+ // still being rejected).
334
+ assert.doesNotMatch(
335
+ res.text,
336
+ /shared secret|allowMutations|NO_SHARED_SECRET|unauthorized|401/i,
337
+ `the authorized call must clear the shared-secret guard, got: ${res.text}`,
338
+ );
339
+ });
340
+ });
@@ -125,8 +125,9 @@ export interface McpHarness {
125
125
  readonly sessionId: string;
126
126
  /** `tools/list` — the projected tool catalogue (app operations + framework debug tools). */
127
127
  listTools(): Promise<McpTool[]>;
128
- /** `tools/call` — invoke a tool by name with its argument object. */
129
- callTool(name: string, args?: Record<string, unknown>): Promise<McpToolResult>;
128
+ /** `tools/call` — invoke a tool by name with its argument object. Optional `extraHeaders` are
129
+ * overlaid on the POST (e.g. an `x-hook-secret` shared-secret credential for a gated mutation). */
130
+ callTool(name: string, args?: Record<string, unknown>, extraHeaders?: Record<string, string>): Promise<McpToolResult>;
130
131
  /** A raw JSON-RPC request against `/app/mcp` (escape hatch for a bespoke case). `params` omitted →
131
132
  * no `params` field; a `notifications/*` method is sent as a notification (no `id`, no response). */
132
133
  rpc(method: string, params?: unknown): Promise<McpRpcResult>;
@@ -175,13 +176,22 @@ export async function bootMcpHarness(opts: BootMcpHarnessOptions = {}): Promise<
175
176
  }
176
177
 
177
178
  let idCounter = 0;
178
- const rpc = async (method: string, params?: unknown, sessionId?: string): Promise<McpRpcResult> => {
179
+ const rpc = async (
180
+ method: string,
181
+ params?: unknown,
182
+ sessionId?: string,
183
+ extraHeaders?: Record<string, string>,
184
+ ): Promise<McpRpcResult> => {
179
185
  const headers: Record<string, string> = {
180
186
  "content-type": "application/json",
181
187
  // The Streamable-HTTP transport inspects Accept; a real client offers both even when the
182
188
  // server answers JSON (the runtime sets `enableJsonResponse`).
183
189
  accept: "application/json, text/event-stream",
184
190
  };
191
+ // Apply caller-supplied overlay headers FIRST so the session header stays authoritative — a
192
+ // caller passing auth headers (e.g. `x-hook-secret`) must not be able to clobber `mcp-session-id`
193
+ // and break the MCP handshake for this request.
194
+ if (extraHeaders) Object.assign(headers, extraHeaders);
185
195
  if (sessionId) headers[SESSION_HEADER] = sessionId;
186
196
  const isNotification = method.startsWith("notifications/");
187
197
  const message: Record<string, unknown> = { jsonrpc: "2.0", method };
@@ -252,8 +262,8 @@ export async function bootMcpHarness(opts: BootMcpHarnessOptions = {}): Promise<
252
262
  }
253
263
  return body.result.tools;
254
264
  },
255
- async callTool(name, args = {}): Promise<McpToolResult> {
256
- const res = await rpc("tools/call", { name, arguments: args }, sessionId);
265
+ async callTool(name, args = {}, extraHeaders): Promise<McpToolResult> {
266
+ const res = await rpc("tools/call", { name, arguments: args }, sessionId, extraHeaders);
257
267
  const body = res.body as
258
268
  | { result?: { isError?: boolean; content?: Array<{ type: string; text?: string }> }; error?: { message?: string } }
259
269
  | undefined;
package/openapi.yaml CHANGED
@@ -24,9 +24,16 @@ components:
24
24
  type: apiKey
25
25
  in: header
26
26
  name: x-hook-secret
27
- description: Optional shared secret. Enforced by the delegate (NOT the runtime) only when
28
- NANO_PR_WEBHOOK_SECRET is set; unset means the endpoint is open. Declared here for
29
- documentation.
27
+ x-nano-secret-env: NANO_PR_WEBHOOK_SECRET
28
+ description: Shared secret. Enforced by the delegate (NOT the runtime) on this app's own
29
+ operations only when NANO_PR_WEBHOOK_SECRET is set; unset means those endpoints are open.
30
+ The `x-nano-secret-env` extension additionally makes this the app's canonical shared-secret
31
+ scheme for the Urban runtime's framework mutation guard (`authorizeMutation`), so the
32
+ framework-owned mutating `urban_debug_*` MCP tools (set_variables, retry_job,
33
+ resolve_incident, cancel_instance) are authorizable on a remote-exposed instance by
34
+ presenting the `x-hook-secret` header set to NANO_PR_WEBHOOK_SECRET. One shared secret, one
35
+ header, for both app-operation reads/mutations and framework mutations. Must remain the sole
36
+ apiKey scheme carrying `x-nano-secret-env` — the runtime throws on ambiguity.
30
37
  schemas:
31
38
  ErrorBody:
32
39
  type: object
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.171.10",
3
+ "version": "0.171.11",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -84,7 +84,7 @@
84
84
  "title": "Point your agent at Nano Workforce",
85
85
  "description": "Copy this prompt and paste it into your coding agent (Copilot, Claude, etc.). It connects the agent to this instance's MCP server \u2014 the workforce's operations become native tools (including the live operator guide as `getAgentInstructions`, or its addressable companion `getAgentGuide(section?)` \u2014 preferred over the ~43KB blob to avoid tool-result overrun), so it can drive and debug your workforce; agents with no MCP client fall back to fetching the operator skill. Note: `/app/mcp` is served on the same HTTP surface as the rest of the app \u2014 reachable on loopback by default, and from a remote instance (merlin, an ngrok tunnel) only when the app is bound wide (`network.bind`) or fronted by a reverse proxy, per the MCP runbook.",
86
86
  "copyLabel": "Copy prompt",
87
- "copyText": "Add this running Nano Workforce instance as an MCP server, then help me drive and debug my workforce \u2014 a durable orchestration app that drives pull requests to review convergence against an automated reviewer, merges them, and can take a whole issue and plan \u2192 implement \u2192 converge it across a fleet of coding agents.\n\nRegister ONE MCP server entry per instance (streamable-HTTP transport, URL {{appBase}}app/mcp). Naming the instance makes targeting the wrong one structurally impossible \u2014 its tools are namespaced under that name:\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp\n\nGive each instance its own entry (e.g. `workforce-merlin` for a LAN/remote node). In config form (~/.copilot/mcp-config.json or repo-scoped .mcp.json):\n\n {\n \"mcpServers\": {\n \"workforce-local\": {\n \"type\": \"http\",\n \"url\": \"{{appBase}}app/mcp\",\n \"tools\": [\"*\"]\n }\n }\n }\n\nIf this instance is secured with a shared secret (NANO_PR_WEBHOOK_SECRET), pass it on the MCP connection \u2014 reads AND mutations both require it:\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp \\\n --header \"x-hook-secret: $NANO_PR_WEBHOOK_SECRET\"\n\nIn config form, add a `headers` entry alongside `url` on that server (per the MCP runbook) \u2014 the config path has no header flag, so omitting this yields 401s:\n\n \"headers\": { \"x-hook-secret\": \"$NANO_PR_WEBHOOK_SECRET\" }\n\nA Basic-Auth-fronted instance (behind a reverse proxy) additionally needs `Authorization: Basic \u2026` on the connection.\n\nMCP servers register at host startup \u2014 add the entry, THEN start a new session so its tools load. The `workforce-*` tools then appear (the full set of operations projected from this instance's OpenAPI contract, including the live operator guide itself as the `getAgentInstructions` read tool \u2014 or its addressable companion `getAgentGuide(section?)`, which the MCP runbook recommends over the ~43KB blob to avoid tool-result overrun \u2014 plus the `urban_debug_*` family for inspecting a wedged instance's process instances, wait states, and incidents).\n\nThe instance's operations are now native tools. Ask, naming the instance: \"Using workforce-local, show what's in flight and any open escalations.\" It should call the status tool, not curl. Operator-only doors (the delivery-graph stage/dispatch/dismiss lifecycle \u2014 the human click IS the approval) are deliberately not tools.\n\nNo MCP client? The curl path is unchanged \u2014 fetch and follow the live guide (the response is JSON with a `skill` markdown field), adding `-H \"x-hook-secret: <secret>\"` if this instance is secured:\n\n curl -sS {{appBase}}app/api/agent/skill\n\nThat skill bootstraps you to this instance's live operator guide at {{appBase}}app/api/agent (the same guide MCP exposes as `getAgentInstructions`, or section-addressably as `getAgentGuide`). If you find a bug or a stuck process, the guide explains how to raise an issue or a PR against nanobpm/nano-workforce."
87
+ "copyText": "Add this running Nano Workforce instance as an MCP server, then help me drive and debug my workforce \u2014 a durable orchestration app that drives pull requests to review convergence against an automated reviewer, merges them, and can take a whole issue and plan \u2192 implement \u2192 converge it across a fleet of coding agents.\n\nRegister ONE MCP server entry per instance (streamable-HTTP transport, URL {{appBase}}app/mcp). Naming the instance makes targeting the wrong one structurally impossible \u2014 its tools are namespaced under that name:\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp\n\nGive each instance its own entry (e.g. `workforce-merlin` for a LAN/remote node). In config form (~/.copilot/mcp-config.json or repo-scoped .mcp.json):\n\n {\n \"mcpServers\": {\n \"workforce-local\": {\n \"type\": \"http\",\n \"url\": \"{{appBase}}app/mcp\",\n \"tools\": [\"*\"]\n }\n }\n }\n\nIf this instance is secured with a shared secret (NANO_PR_WEBHOOK_SECRET), pass it on the MCP connection \u2014 reads AND mutations both require it (including the framework-owned mutating urban_debug_* tools \u2014 set_variables, retry_job, resolve_incident, cancel_instance \u2014 which authorize against this same x-hook-secret on a remote-exposed instance):\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp \\\n --header \"x-hook-secret: $NANO_PR_WEBHOOK_SECRET\"\n\nIn config form, add a `headers` entry alongside `url` on that server (per the MCP runbook) \u2014 the config path has no header flag, so omitting this yields 401s:\n\n \"headers\": { \"x-hook-secret\": \"$NANO_PR_WEBHOOK_SECRET\" }\n\nA Basic-Auth-fronted instance (behind a reverse proxy) additionally needs `Authorization: Basic \u2026` on the connection.\n\nMCP servers register at host startup \u2014 add the entry, THEN start a new session so its tools load. The `workforce-*` tools then appear (the full set of operations projected from this instance's OpenAPI contract, including the live operator guide itself as the `getAgentInstructions` read tool \u2014 or its addressable companion `getAgentGuide(section?)`, which the MCP runbook recommends over the ~43KB blob to avoid tool-result overrun \u2014 plus the `urban_debug_*` family for inspecting a wedged instance's process instances, wait states, and incidents).\n\nThe instance's operations are now native tools. Ask, naming the instance: \"Using workforce-local, show what's in flight and any open escalations.\" It should call the status tool, not curl. Operator-only doors (the delivery-graph stage/dispatch/dismiss lifecycle \u2014 the human click IS the approval) are deliberately not tools.\n\nNo MCP client? The curl path is unchanged \u2014 fetch and follow the live guide (the response is JSON with a `skill` markdown field), adding `-H \"x-hook-secret: <secret>\"` if this instance is secured:\n\n curl -sS {{appBase}}app/api/agent/skill\n\nThat skill bootstraps you to this instance's live operator guide at {{appBase}}app/api/agent (the same guide MCP exposes as `getAgentInstructions`, or section-addressably as `getAgentGuide`). If you find a bug or a stuck process, the guide explains how to raise an issue or a PR against nanobpm/nano-workforce."
88
88
  }
89
89
  }
90
90
  },