@bridge_gpt/mcp-server 0.2.51 → 0.2.52

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.
Files changed (67) hide show
  1. package/README.md +24 -8
  2. package/build/agent-capabilities/probe-context.js +15 -7
  3. package/build/agent-capabilities/probes.js +42 -6
  4. package/build/agent-launchers/claude-executor-adapter.js +98 -14
  5. package/build/commands.generated.js +1 -1
  6. package/build/conduct-epic/cut-protocol.js +17 -3
  7. package/build/conductor/bridge-api-client.js +171 -5
  8. package/build/conductor/deny-enforcement-preflight.js +107 -10
  9. package/build/conductor/local-merge.js +170 -11
  10. package/build/conductor-bin.js +2 -2
  11. package/build/connect-bitbucket-api.js +370 -0
  12. package/build/connect-bitbucket.js +437 -0
  13. package/build/docs.generated.js +1 -1
  14. package/build/doctor.js +40 -1
  15. package/build/drive-epic.js +423 -11
  16. package/build/env-file-link.js +164 -0
  17. package/build/epic-integration-pr.js +10 -0
  18. package/build/executor/cli.js +41 -6
  19. package/build/executor/deps.js +5 -1
  20. package/build/executor/env-file-guard.js +113 -0
  21. package/build/executor/env.js +78 -1
  22. package/build/executor/heartbeat.js +9 -0
  23. package/build/executor/http-client.js +90 -22
  24. package/build/executor/job-errors.js +43 -2
  25. package/build/executor/job-runner.js +130 -28
  26. package/build/executor/merge-job.js +67 -16
  27. package/build/executor/permissions.js +106 -0
  28. package/build/executor/preflight.js +38 -13
  29. package/build/executor/resume-pre-spawn.js +2 -1
  30. package/build/executor/runner.js +175 -4
  31. package/build/executor/service-unit.js +15 -0
  32. package/build/executor/terminal-mutation.js +22 -1
  33. package/build/executor/types.js +86 -0
  34. package/build/executor/worker-command.js +21 -5
  35. package/build/executor/worker-guard-hook.js +939 -0
  36. package/build/executor/worker-log.js +56 -0
  37. package/build/executor/worktree.js +11 -0
  38. package/build/git-reachability.js +147 -0
  39. package/build/index.js +514 -121
  40. package/build/install-bridge.js +95 -0
  41. package/build/pipelines.generated.js +5 -3
  42. package/build/plan-epic-conductor-eligibility.js +37 -7
  43. package/build/plane/cli.js +78 -15
  44. package/build/plane/defaults.js +165 -0
  45. package/build/plane/manifest.js +63 -8
  46. package/build/plane/member-logs.js +6 -0
  47. package/build/plane/member-roster.js +195 -11
  48. package/build/plane/preflight.js +43 -0
  49. package/build/plane/shutdown.js +25 -3
  50. package/build/plane/status.js +11 -0
  51. package/build/plane/supervisor.js +343 -14
  52. package/build/plane/test-fakes.js +43 -0
  53. package/build/plane/types.js +82 -11
  54. package/build/pr-base-contract.js +20 -0
  55. package/build/readme.generated.js +1 -1
  56. package/build/review-synthesis-config.js +60 -0
  57. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  58. package/build/setup-epic.js +560 -139
  59. package/build/sfcc/log-query.js +2 -1
  60. package/build/start-tickets-conductor.js +11 -2
  61. package/build/start-tickets.js +69 -2
  62. package/build/version.generated.js +3 -3
  63. package/build/worker-containment-diagnostic.js +97 -0
  64. package/build/worker-guard-hook-bin.js +6 -0
  65. package/docs/CONDUCTOR.md +27 -0
  66. package/docs/install/mcp-tool-integrations.md +3 -2
  67. package/package.json +3 -2
package/README.md CHANGED
@@ -352,7 +352,7 @@ later boot.
352
352
  "mcpServers": {
353
353
  "bridge": {
354
354
  "command": "npx",
355
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.51", "serve"],
355
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.52", "serve"],
356
356
  "env": {
357
357
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
358
358
  "BAPI_REPO_NAME": "your-repo",
@@ -374,7 +374,7 @@ later boot.
374
374
  "bridge": {
375
375
  "type": "stdio",
376
376
  "command": "npx",
377
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.51", "serve"],
377
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.52", "serve"],
378
378
  "env": {
379
379
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
380
380
  "BAPI_REPO_NAME": "your-repo",
@@ -396,7 +396,7 @@ later boot.
396
396
  "bridge": {
397
397
  "type": "stdio",
398
398
  "command": "npx",
399
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.51", "serve"],
399
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.52", "serve"],
400
400
  "env": {
401
401
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
402
402
  "BAPI_REPO_NAME": "your-repo",
@@ -421,7 +421,7 @@ you select `copilot-cli`; the shape below is what it produces.
421
421
  "bridge": {
422
422
  "type": "local",
423
423
  "command": "npx",
424
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.51", "serve"],
424
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.52", "serve"],
425
425
  "tools": ["*"],
426
426
  "env": {
427
427
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
@@ -445,7 +445,7 @@ Windsurf only supports global MCP configuration.
445
445
  "mcpServers": {
446
446
  "bridge": {
447
447
  "command": "npx",
448
- "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.51", "serve"],
448
+ "args": ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.52", "serve"],
449
449
  "env": {
450
450
  "BAPI_BASE_URL": "https://bridgegpt-api.com",
451
451
  "BAPI_REPO_NAME": "your-repo",
@@ -464,7 +464,7 @@ Windsurf only supports global MCP configuration.
464
464
  ```toml
465
465
  [mcp_servers.bridge]
466
466
  command = "npx"
467
- args = ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.51", "serve"]
467
+ args = ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.52", "serve"]
468
468
 
469
469
  [mcp_servers.bridge.env]
470
470
  BAPI_BASE_URL = "https://bridgegpt-api.com"
@@ -991,7 +991,7 @@ Each `KEY` must match `[A-Z]+-[0-9]+` (e.g., `BAPI-248`). The CLI creates/switch
991
991
  npx -y @bridge_gpt/mcp-server start-tickets --agent cursor-agent BAPI-248
992
992
  ```
993
993
 
994
- **Difficulty-based model routing.** Before launching each agent, the CLI selects an implementation **model tier** from the ticket's `difficulty` (1-2 → cheap, 3-5 → basic, 6+ → premium) and injects it as a `--model` flag at the spawn boundary. The Python backend returns only the coarse tier (`GET /jira/tickets/{KEY}/model-tier`, computing + caching difficulty on demand); this CLI alone maps a tier to the agent-specific alias (`claude`: `haiku`/`sonnet`/`opus`; `cursor-agent`: version-suffixed strings validated against `cursor-agent --list-models`). It is gated per repo by `difficulty_model_routing_enabled` (default **ON**) with an optional `difficulty_model_tier_overrides` JSON map (tier → alias). Routing is **fail-open**: missing credentials, an evaluation failure/timeout, a backend `fallback`, an invalid/unavailable alias, an unadvertised Cursor model, or an agent without `--model` support all default to the **premium** (Opus) model — and when even the premium alias cannot be resolved/validated for the agent, `--model` is omitted (the agent uses its default) — each surfaced as a per-ticket warning rather than failing the spawn. `--dry-run` does **not** create worktrees or open tabs, but it **does** resolve routing read-only to preview the `--model` each tab would use.
994
+ **Difficulty-based model routing.** Before launching each agent, the CLI selects an implementation **model tier** from the ticket's `difficulty` (1-2 → cheap, 3-6 → basic, 7-10 → premium) and injects it as a `--model` flag at the spawn boundary. The Python backend returns only the coarse tier (`GET /jira/tickets/{KEY}/model-tier`, computing + caching difficulty on demand); this CLI alone maps a tier to the agent-specific alias (`claude`: `haiku`/`sonnet`/`opus`; `cursor-agent`: version-suffixed strings validated against `cursor-agent --list-models`). It is gated per repo by `difficulty_model_routing_enabled` (default **ON**) with an optional `difficulty_model_tier_overrides` JSON map (tier → alias). Routing is **fail-open**: missing credentials, an evaluation failure/timeout, a backend `fallback`, an invalid/unavailable alias, an unadvertised Cursor model, or an agent without `--model` support all default to the **premium** (Opus) model — and when even the premium alias cannot be resolved/validated for the agent, `--model` is omitted (the agent uses its default) — each surfaced as a per-ticket warning rather than failing the spawn. `--dry-run` does **not** create worktrees or open tabs, but it **does** resolve routing read-only to preview the `--model` each tab would use.
995
995
 
996
996
  **Coarse `--tier` override.** Passing `--tier cheap|basic|premium` bypasses **only** the per-ticket difficulty lookup above and applies that one tier to every ticket; the tier is still resolved to an alias through the same agent registry + `difficulty_model_tier_overrides` and validated the same way (including the live `cursor-agent --list-models` check). It is never treated as a raw `--model` alias, and no API key or credential belongs in the spawned command (the CLI resolves credentials itself via `resolveBapiCredentials`). A malformed/unrecognized `--tier` value is **fail-open**: the CLI logs one concise warning and routes every ticket on the premium (Opus) fallback rather than aborting. `/review-and-implement` uses this flag to hand its review-time tier snapshot to the fresh implementation session.
997
997
 
@@ -1059,6 +1059,10 @@ unauthorized, or malformed — it escalates and prints no conductor invocation a
1059
1059
  all, because an unknown owner is not the same as a not-ready one. No branch,
1060
1060
  including every error path, ever offers you two paths.
1061
1061
 
1062
+ `drive-epic` owns no branch behavior of its own — it forwards `--feature-branch
1063
+ <name>` and `--into-base` verbatim to `setup-epic`, which decides the strategy (see
1064
+ below). With neither flag, a multi-ticket epic runs on `epic/<KEY>`.
1065
+
1062
1066
  Two conductors is a transitional state. When one is eliminated, `drive-epic` is
1063
1067
  the only thing that changes.
1064
1068
 
@@ -1072,6 +1076,18 @@ npx -y @bridge_gpt/mcp-server setup-epic --epic-key <KEY> --plan-file <path-to-e
1072
1076
 
1073
1077
  The plan sidecar is produced by the `decompose-epic` pipeline step. `setup-epic` validates it locally (unique ticket keys, resolvable dependency references, acyclicity) before sending anything, so a malformed plan fails legibly instead of as a bare HTTP 400. It is **idempotent**: re-running it on an epic that already has a live run reuses that run rather than minting a second one. `--dry-run` validates and previews the calls without mutating anything; `--json` emits a single machine-readable result object.
1074
1078
 
1079
+ **The command above uses an epic branch.** For a plan with **two or more** nodes and
1080
+ no branch flag, `setup-epic` derives `epic/<KEY>`: every child-ticket PR targets that
1081
+ branch, and one draft integration PR targets the repository base branch. This holds on
1082
+ every path, CI and `--json` included. A single-node plan keeps the repository base
1083
+ branch, matching plain `start-tickets`.
1084
+
1085
+ > **Behavior change (BAPI-1009).** The same no-flag command previously ran children
1086
+ > directly into the repository base branch. **Pass `--into-base` to restore that**, if
1087
+ > your automation depends on it. Pass `--feature-branch <name>` to override the derived
1088
+ > name, or to force a branch for a single-node plan. Passing both is a parse error,
1089
+ > refused before any file read, credential resolution, or network call.
1090
+
1075
1091
  Once the plan is approved, the **server-side reconciler** picks the run up within ~30s. To execute claimed jobs on your machine, run `executor`:
1076
1092
 
1077
1093
  ```
@@ -1441,7 +1457,7 @@ The full surface, for when you need the complete enumeration. Day-to-day, use [U
1441
1457
 
1442
1458
  ### MCP tools
1443
1459
 
1444
- The authoritative tool catalog covers **70 tools** (enumerated below). What's actually registered in a session depends on `BRIDGE_MCP_PROFILE`: the default `core` profile loads a trimmed subset, and the conductor/pipeline-authoring/SFCC tools are added only under their respective profiles (see [Environment Variables](#environment-variables)). Async AI tools follow a request/get pattern: call the `request_*` tool to kick off generation, then the matching `get_*` tool to retrieve the result (or pass `wait_for_result: true` to poll automatically).
1460
+ The authoritative tool catalog covers **72 tools** (enumerated below). What's actually registered in a session depends on `BRIDGE_MCP_PROFILE`: the default `core` profile loads a trimmed subset, and the conductor/pipeline-authoring/SFCC tools are added only under their respective profiles (see [Environment Variables](#environment-variables)). Async AI tools follow a request/get pattern: call the `request_*` tool to kick off generation, then the matching `get_*` tool to retrieve the result (or pass `wait_for_result: true` to poll automatically).
1445
1461
 
1446
1462
  - **Connectivity & identity** — `ping` (its JSON also carries `docs_dir`, `role`, and `customer_type`)
1447
1463
  - **Team & access** — `invite_member` (admin-only; mints a scoped access key for a teammate on an already-configured project — the plaintext key is shown exactly once)
@@ -20,8 +20,10 @@ import { DEFAULT_PROBE_TIMEOUT_MS, } from "./types.js";
20
20
  * `--trust` is MANDATORY headless or the workspace-trust prompt hangs; cursor has
21
21
  * its own `--workspace` cwd flag. All `OutputFormat` values (including `stream-json`)
22
22
  * pass through the generic `--output-format <fmt>` slot.
23
- * - claude: `-p [--dangerously-skip-permissions] [--model <m>] [--output-format json|
24
- * stream-json --verbose] <prompt>`. Claude has NO working-dir flag — cwd is set via
23
+ * - claude: `-p [--permission-mode auto|acceptEdits | --dangerously-skip-permissions]
24
+ * [--model <m>] [--output-format json|stream-json --verbose] <prompt>`. The
25
+ * worker default is `--permission-mode auto` (BAPI-1020); bypass is emitted only
26
+ * for the explicit revert posture and for the probe that must still measure it. Claude has NO working-dir flag — cwd is set via
25
27
  * the spawn options, never an argument. `stream-json` REQUIRES `--verbose`. The
26
28
  * prompt is ALWAYS the final argv element so mocked prompt extraction stays valid.
27
29
  */
@@ -32,11 +34,17 @@ export function buildHeadlessArgs(agentName, opts) {
32
34
  }
33
35
  // claude (and any positional-prompt agent without a cwd flag)
34
36
  const args = ["-p"];
35
- // BAPI-725: exactly ONE permission form, matching the Claude executor
36
- // adapter's argv builder (`agent-launchers/claude-executor-adapter.ts`).
37
- // `permissionMode` wins when both are supplied — emitting both would let the
38
- // skip flag take effect and the probe would report on a posture it never ran.
39
- if (opts.permissionMode === "acceptEdits") {
37
+ // BAPI-725, widened by BAPI-1020: exactly ONE permission form, matching the
38
+ // Claude executor adapter's argv builder
39
+ // (`agent-launchers/claude-executor-adapter.ts`). `permissionMode` wins when
40
+ // both are supplied emitting both would let the skip flag take effect and the
41
+ // probe would report on a posture it never ran. That precedence is UNCHANGED;
42
+ // only the set of accepted modes grew, so a probe can now measure the `auto`
43
+ // posture every worker actually runs under.
44
+ if (opts.permissionMode === "auto") {
45
+ args.push("--permission-mode", "auto");
46
+ }
47
+ else if (opts.permissionMode === "acceptEdits") {
40
48
  args.push("--permission-mode", "acceptEdits");
41
49
  }
42
50
  else if (opts.skipPermissions === true) {
@@ -419,10 +419,15 @@ function evaluateDenyRun(run, allowedMarker) {
419
419
  export async function runDenyEnforcementCheck(ctx, opts) {
420
420
  const marker = ctx.marker("DENY_ALLOWED");
421
421
  const prompt = denyProbePrompt();
422
- const acceptEdits = opts?.permissionPosture === "accept_edits";
422
+ // Exactly ONE permission form per posture, mirroring the adapter's argv map.
423
+ const permissionMode = opts?.permissionPosture === "auto"
424
+ ? "auto"
425
+ : opts?.permissionPosture === "accept_edits"
426
+ ? "acceptEdits"
427
+ : undefined;
423
428
  const runOpts = {
424
- skipPermissions: !acceptEdits,
425
- permissionMode: acceptEdits ? "acceptEdits" : undefined,
429
+ skipPermissions: permissionMode === undefined,
430
+ permissionMode,
426
431
  model: opts?.model,
427
432
  timeoutMs: opts?.timeoutMs,
428
433
  };
@@ -493,7 +498,8 @@ export async function runDenyEnforcementCheck(ctx, opts) {
493
498
  return {
494
499
  result: withDenyLayer({
495
500
  status: "fail",
496
- detail: "deny is NOT enforced for this installed Claude version under --dangerously-skip-permissions " +
501
+ detail: "deny is NOT enforced for this installed Claude version under the probed " +
502
+ `permission posture (${opts?.permissionPosture ?? "skip_permissions"}) ` +
497
503
  "(neither settings permissions.deny nor a PreToolUse hook rejected the denied call)",
498
504
  elapsedMs: hookRun.elapsedMs,
499
505
  evidence: h.evidence,
@@ -507,8 +513,10 @@ const denyEnforcement = {
507
513
  description: "Runs a no-deny control run first (proving the agent actually attempts the canary call), then spawns " +
508
514
  "claude headless with --dangerously-skip-permissions in a disposable temp project carrying a " +
509
515
  "permissions.deny rule, attempts the denied call, and asserts it is rejected — reporting which layer " +
510
- "enforced (settings-deny vs a PreToolUse hook fallback). A fail means deny is NOT enforced on the " +
511
- "installed CLI (or cannot be verified) and is a load-bearing executor preflight signal.",
516
+ "enforced (settings-deny vs a PreToolUse hook fallback). Since BAPI-1020 this is the EXPLICIT REVERT " +
517
+ "posture rather than the worker default, and it is still probed for exactly that reason: " +
518
+ "`skip_permissions` remains selectable and must stay guarded. A fail means deny is NOT enforced on " +
519
+ "the installed CLI (or cannot be verified) and is a load-bearing executor preflight signal.",
512
520
  tier: "heavy",
513
521
  appliesTo: ["claude"],
514
522
  spawnsAgent: true,
@@ -517,6 +525,33 @@ const denyEnforcement = {
517
525
  return result;
518
526
  },
519
527
  };
528
+ /**
529
+ * The same check under the posture every worker actually runs (BAPI-1020).
530
+ *
531
+ * Registered as a SEPARATE probe rather than replacing the bypass probe, because
532
+ * the two answer different questions and one cannot stand in for the other. Under
533
+ * bypass the CLI's own classifier is out of the picture entirely, so the probe
534
+ * measures the deny layer alone; under `auto` the classifier is in the path and a
535
+ * denied call could in principle wait for an approval that headless `-p` mode can
536
+ * never supply. The control-run discipline is identical and applies unchanged: a
537
+ * marker-free run is a REFUSAL, never enforcement.
538
+ */
539
+ const denyEnforcementAuto = {
540
+ id: "deny-enforcement-auto",
541
+ title: "Enforces a permissions.deny rule under --permission-mode auto",
542
+ description: "The deny-enforcement check under the DEFAULT worker posture (BAPI-1020): a no-deny control run " +
543
+ "first, then claude headless with --permission-mode auto in a disposable temp project carrying a " +
544
+ "permissions.deny rule, asserting the denied call is rejected and reporting which layer enforced. " +
545
+ "This is the posture every conductor worker spawns under, so a fail here means the executor would " +
546
+ "be claiming jobs whose deny layer is unverified for the posture they actually run in.",
547
+ tier: "heavy",
548
+ appliesTo: ["claude"],
549
+ spawnsAgent: true,
550
+ async run(ctx) {
551
+ const { result } = await runDenyEnforcementCheck(ctx, { permissionPosture: "auto" });
552
+ return result;
553
+ },
554
+ };
520
555
  /** All probes in deterministic report order. */
521
556
  export const ALL_PROBES = [
522
557
  binaryResolves,
@@ -526,6 +561,7 @@ export const ALL_PROBES = [
526
561
  preambleMidPrompt,
527
562
  outputFormat,
528
563
  denyEnforcement,
564
+ denyEnforcementAuto,
529
565
  workspaceFlag,
530
566
  noCwdFlag,
531
567
  ];
@@ -2,7 +2,9 @@ import { isModelTier, isValidModelAlias, resolveModelAlias } from "../agent-regi
2
2
  import { evaluateClaudeMcpShadowingPolicy, inspectClaudeUserConfigForMcpShadowing, resolveClaudeUserConfigPath, } from "../claude-user-config-doctor.js";
3
3
  import { runDenyEnforcementPreflight } from "../conductor/deny-enforcement-preflight.js";
4
4
  import { buildExecutorBaseWorkerEnv } from "../executor/env.js";
5
+ import { ExecutorNamedError } from "../executor/job-errors.js";
5
6
  import { provisionExecutorDenyLayer } from "../executor/permissions.js";
7
+ import { WORKER_GUARD_ROOT_FLAG } from "../executor/worker-guard-hook.js";
6
8
  import { SUPPORTED_EXECUTOR_PLATFORMS, evaluateExecutorPlatform } from "../executor/platform.js";
7
9
  import { normalizeMcpServerNames } from "../executor/observation.js";
8
10
  import { supported } from "./executor-adapter.js";
@@ -179,17 +181,31 @@ export function resolveClaudeModelAlias(spec, payload) {
179
181
  * Build the exact headless Claude argv.
180
182
  *
181
183
  * Moved verbatim from `worker-command.ts`'s `buildClaudeExecutorArgv`. The
182
- * ordering is the golden shape the tests pin:
184
+ * ordering is the golden shape the tests pin, unchanged by BAPI-1020:
183
185
  *
184
186
  * -p <prompt> --output-format stream-json --verbose
185
187
  * [--model <alias>]
186
- * (--dangerously-skip-permissions | --permission-mode acceptEdits)
188
+ * (--permission-mode auto | --permission-mode acceptEdits | --dangerously-skip-permissions)
187
189
  * --strict-mcp-config --mcp-config <verified path>
188
190
  *
191
+ * The DEFAULT form is now `--permission-mode auto` (BAPI-1020): Claude's own
192
+ * classifier runs, a flagged call is denied with a reason, and the worker
193
+ * continues. Claude CLI 2.1.258 headless behavior was confirmed during design —
194
+ * under `auto` a denied call does not wait for an approval that can never arrive
195
+ * in `-p` mode. `--dangerously-skip-permissions` is now emitted ONLY for the
196
+ * explicit `skip_permissions` revert.
197
+ *
189
198
  * Exactly ONE permission form is emitted. Emitting both would let the skip flag
190
199
  * win silently and make the `accept_edits` policy look effective while doing
191
- * nothing. The posture changes how Claude PROMPTS, not what it may do — the deny
192
- * layer is provisioned in both postures.
200
+ * nothing. The branch is an EXHAUSTIVE three-way map rather than the two-way
201
+ * `if/else` it replaced, whose `else` was the skip flag: under the old shape a
202
+ * posture the union grew but this function did not would have fallen through into
203
+ * bypass, silently. It now throws the same fixed, secret-free contract error the
204
+ * payload resolver uses, so an unmapped posture fails loudly instead of buying
205
+ * full bypass.
206
+ *
207
+ * The posture changes how Claude PROMPTS, not what it may do — the deny layer and
208
+ * the deterministic worker guard are provisioned in EVERY posture.
193
209
  *
194
210
  * Every entry is pushed SEPARATELY and never joined into a command string, so a
195
211
  * prompt or path containing a space or a shell metacharacter is just a value.
@@ -199,15 +215,34 @@ export function buildClaudeArgv(prompt, alias, mcpConfigPath, posture) {
199
215
  if (alias) {
200
216
  argv.push("--model", alias);
201
217
  }
202
- if (posture === "accept_edits") {
203
- argv.push("--permission-mode", "acceptEdits");
204
- }
205
- else {
206
- argv.push("--dangerously-skip-permissions");
207
- }
218
+ argv.push(...claudePermissionArgs(posture));
208
219
  argv.push("--strict-mcp-config", "--mcp-config", mcpConfigPath);
209
220
  return argv;
210
221
  }
222
+ /**
223
+ * The single permission form for one posture — the whole policy→argv mapping,
224
+ * in one exhaustive place.
225
+ *
226
+ * Returns an ARRAY so every caller appends exactly what this function decided;
227
+ * there is no path on which a caller can add a second form. The thrown message
228
+ * names the FIELD and the accepted spellings and never the supplied value,
229
+ * matching `resolveWorkerPermissionPosture` — this text can reach the server
230
+ * through `/fail`, so echoing an operator's mistyped policy value would turn a
231
+ * typo into an exfiltration path.
232
+ */
233
+ function claudePermissionArgs(posture) {
234
+ switch (posture) {
235
+ case "auto":
236
+ return ["--permission-mode", "auto"];
237
+ case "accept_edits":
238
+ return ["--permission-mode", "acceptEdits"];
239
+ case "skip_permissions":
240
+ return ["--dangerously-skip-permissions"];
241
+ default:
242
+ throw new ExecutorNamedError("ContractError.PermissionPosture", "worker_permission_posture is not a supported posture " +
243
+ "(expected 'auto', 'skip_permissions', or 'accept_edits').");
244
+ }
245
+ }
211
246
  /**
212
247
  * Build the Claude worker environment: the generic executor base, plus the one
213
248
  * conditional OAuth passthrough.
@@ -224,9 +259,11 @@ export function buildClaudeArgv(prompt, alias, mcpConfigPath, posture) {
224
259
  * and cannot reach a worker environment through any path in this function.
225
260
  *
226
261
  * `options` is forwarded to the generic builder unchanged, including BAPI-844's
227
- * `indexScope`. This adapter neither reads nor validates the scope: it is not an
228
- * agent-specific credential decision, so it stays owned by the generic builder,
229
- * where the deny-list and the shared shape check both live.
262
+ * `indexScope` and BAPI-1020's `workerBranch`. This adapter neither reads nor
263
+ * validates either one: neither is an agent-specific credential decision, so both
264
+ * stay owned by the generic builder, where the deny-list and the shared shape
265
+ * check both live. In particular `workerBranch` is never read from `parentEnv`
266
+ * here — the generic builder denies that key outright.
230
267
  */
231
268
  export function buildClaudeWorkerEnv(parentEnv, options = {}) {
232
269
  const env = buildExecutorBaseWorkerEnv(parentEnv, options);
@@ -236,6 +273,43 @@ export function buildClaudeWorkerEnv(parentEnv, options = {}) {
236
273
  }
237
274
  return env;
238
275
  }
276
+ /**
277
+ * Shell-quote a path for embedding in a hook command string.
278
+ *
279
+ * Same convention as `start-tickets-conductor.ts`'s conductor-hook command:
280
+ * single-quote for POSIX safety, escaping any embedded single quote. Claude Code
281
+ * interprets a hook `command` through a shell, so a worktree path containing a
282
+ * space — or an apostrophe — must survive that interpretation intact.
283
+ */
284
+ function shellQuoteHookPath(value) {
285
+ return `'${value.replace(/'/g, "'\\''")}'`;
286
+ }
287
+ /**
288
+ * The PreToolUse command that runs the packaged deterministic worker guard.
289
+ *
290
+ * Invoked through the EXECUTOR's own Node executable rather than a bare shebang,
291
+ * for the same reason `conductorNodePath` exists (BAPI-527): the worker's `PATH`
292
+ * may resolve a different Node — or none — and a guard that fails to launch is a
293
+ * guard that is not there. A launch failure still fails open, but it should not be
294
+ * something the environment can cause casually.
295
+ *
296
+ * The command carries exactly ONE argument: the trusted worktree root. Templating
297
+ * it here is what makes every path-containment answer trustworthy — provisioning
298
+ * already HOLDS the authoritative worktree path, whereas the hook process's own
299
+ * `cwd` is not guaranteed by the PreToolUse contract to be the project root, and a
300
+ * containment check measured from the wrong root reports success rather than
301
+ * failing safe. The path is not a secret (it is already visible in `ps` for the
302
+ * worker itself) and it is shell-quoted, so a directory containing a space or an
303
+ * apostrophe survives intact.
304
+ *
305
+ * The BRANCH is deliberately NOT an argument: it travels in `BAPI_WORKER_BRANCH`,
306
+ * where the executor's env deny-list can guarantee an ambient value never
307
+ * substitutes for the job's own.
308
+ */
309
+ export function buildWorkerGuardHookCommand(binPath, execPath, worktreePath) {
310
+ return (`${shellQuoteHookPath(execPath)} ${shellQuoteHookPath(binPath)} ` +
311
+ `${WORKER_GUARD_ROOT_FLAG} ${shellQuoteHookPath(worktreePath)}`);
312
+ }
239
313
  // ---------------------------------------------------------------------------
240
314
  // The adapter factory
241
315
  // ---------------------------------------------------------------------------
@@ -268,6 +342,9 @@ export function createClaudeExecutorAdapter(spec, deps = {}) {
268
342
  if (input.indexScope !== undefined) {
269
343
  envOptions.indexScope = input.indexScope;
270
344
  }
345
+ if (input.workerBranch !== undefined) {
346
+ envOptions.workerBranch = input.workerBranch;
347
+ }
271
348
  return {
272
349
  executable: spec.command,
273
350
  argv: buildClaudeArgv(input.prompt, input.modelAlias, input.mcpConfigPath, input.posture),
@@ -323,7 +400,14 @@ export function createClaudeExecutorAdapter(spec, deps = {}) {
323
400
  "continuing fail-open",
324
401
  };
325
402
  }
326
- return provision(input.worktreePath, { baseBranch: input.baseBranch }, provisioningDeps);
403
+ const options = {
404
+ baseBranch: input.baseBranch,
405
+ workerGuardHookCommand: buildWorkerGuardHookCommand(input.workerGuardHookBinPath, deps.execPath ?? process.execPath, input.worktreePath),
406
+ };
407
+ if (input.workerBranch !== undefined) {
408
+ options.workerBranch = input.workerBranch;
409
+ }
410
+ return provision(input.worktreePath, options, provisioningDeps);
327
411
  },
328
412
  async probeEnforcement(options = {}) {
329
413
  const probe = deps.runDenyPreflight ?? runDenyEnforcementPreflight;