@bridge_gpt/mcp-server 0.2.50 → 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 (76) 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/bridge-client.js +115 -1
  7. package/build/conduct-epic/cli.js +351 -33
  8. package/build/conduct-epic/cut-protocol.js +65 -0
  9. package/build/conductor/bridge-api-client.js +171 -5
  10. package/build/conductor/deny-enforcement-preflight.js +107 -10
  11. package/build/conductor/local-merge.js +170 -11
  12. package/build/conductor-bin.js +2 -2
  13. package/build/connect-bitbucket-api.js +370 -0
  14. package/build/connect-bitbucket.js +437 -0
  15. package/build/docs.generated.js +1 -1
  16. package/build/doctor.js +230 -1
  17. package/build/drive-epic.js +423 -11
  18. package/build/env-file-link.js +164 -0
  19. package/build/epic-integration-pr.js +290 -0
  20. package/build/executor/cli.js +41 -6
  21. package/build/executor/deps.js +5 -1
  22. package/build/executor/env-file-guard.js +113 -0
  23. package/build/executor/env.js +78 -1
  24. package/build/executor/heartbeat.js +9 -0
  25. package/build/executor/http-client.js +90 -22
  26. package/build/executor/job-errors.js +43 -2
  27. package/build/executor/job-runner.js +137 -29
  28. package/build/executor/merge-job.js +102 -6
  29. package/build/executor/permissions.js +106 -0
  30. package/build/executor/preflight.js +38 -13
  31. package/build/executor/resume-pre-spawn.js +2 -1
  32. package/build/executor/runner.js +175 -4
  33. package/build/executor/service-unit.js +15 -0
  34. package/build/executor/terminal-mutation.js +22 -1
  35. package/build/executor/types.js +86 -0
  36. package/build/executor/worker-command.js +21 -5
  37. package/build/executor/worker-guard-hook.js +939 -0
  38. package/build/executor/worker-log.js +56 -0
  39. package/build/executor/worktree.js +11 -0
  40. package/build/git-reachability.js +147 -0
  41. package/build/index.js +535 -95
  42. package/build/install-bridge.js +95 -0
  43. package/build/pipelines.generated.js +10 -2
  44. package/build/plan-epic-conductor-eligibility.js +213 -0
  45. package/build/plane/cli.js +78 -15
  46. package/build/plane/defaults.js +165 -0
  47. package/build/plane/manifest.js +63 -8
  48. package/build/plane/member-logs.js +6 -0
  49. package/build/plane/member-roster.js +195 -11
  50. package/build/plane/preflight.js +43 -0
  51. package/build/plane/shutdown.js +25 -3
  52. package/build/plane/status.js +11 -0
  53. package/build/plane/supervisor.js +343 -14
  54. package/build/plane/test-fakes.js +43 -0
  55. package/build/plane/types.js +82 -11
  56. package/build/pr-base-contract.js +20 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/review-synthesis-config.js +60 -0
  59. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  60. package/build/setup-epic.js +592 -139
  61. package/build/sfcc/log-query.js +2 -1
  62. package/build/sfcc/reads-custom-object-def.js +10 -13
  63. package/build/sfcc/reads-site-preference.js +5 -5
  64. package/build/sfcc/reads-system-object.js +4 -4
  65. package/build/sfcc/writes-custom-object-def.js +7 -7
  66. package/build/sfcc/writes-site-preference.js +4 -3
  67. package/build/sfcc/writes-system-object.js +7 -6
  68. package/build/start-tickets-conductor.js +11 -2
  69. package/build/start-tickets.js +69 -2
  70. package/build/version.generated.js +3 -3
  71. package/build/worker-containment-diagnostic.js +97 -0
  72. package/build/worker-guard-hook-bin.js +6 -0
  73. package/docs/CONDUCTOR.md +27 -0
  74. package/docs/install/mcp-tool-integrations.md +3 -2
  75. package/package.json +5 -3
  76. package/pipelines/plan-epic.json +5 -0
@@ -277,6 +277,67 @@ export const SCOPE_LIFECYCLE_LABELS = Object.freeze({
277
277
  ready: "Ready",
278
278
  failed: "Failed",
279
279
  });
280
+ /**
281
+ * The fixed lifecycle label a heartbeat uses when the status READ itself failed
282
+ * (BAPI-963).
283
+ *
284
+ * A read failure is not a lifecycle state, and the raw error is deliberately not
285
+ * interpolated into a heartbeat: a per-poll line repeated for twenty minutes is
286
+ * the worst possible place to smuggle unbounded server text.
287
+ */
288
+ export const SCOPE_BOOTSTRAP_UNREADABLE_STATE = "unreadable";
289
+ /**
290
+ * Format one bootstrap heartbeat line (BAPI-963).
291
+ *
292
+ * Shared with `setup-epic` at this seam so both conductors compute progress the
293
+ * same way; RENDERING stays with each caller, because the pilot writes to its own
294
+ * stderr advisory channel and v2 reports progress server-side.
295
+ *
296
+ * The shape is fixed and grep-friendly — elapsed first, state second — because a
297
+ * ~30-minute seed that printed nothing was externally indistinguishable from a
298
+ * hang (sleeping process, 0% CPU, a frozen `updated_at`). Elapsed seconds are
299
+ * clamped at zero so a clock adjustment cannot render a negative age.
300
+ */
301
+ export function formatScopeBootstrapHeartbeat(elapsedMs, state) {
302
+ const seconds = Math.max(0, Math.floor(elapsedMs / 1000));
303
+ // `lifecycle_state` is a required non-empty string on the wire, not a closed
304
+ // set. Bounding it to the known labels (plus the fixed `unreadable` and a
305
+ // catch-all) keeps an unvalidated server string out of a line that repeats
306
+ // every interval for up to twenty minutes.
307
+ return `Seeding scope: elapsed=${seconds}s state=${boundScopeLifecycleLabel(state)}`;
308
+ }
309
+ /** The label an unrecognized lifecycle state collapses to. Never `null`, never raw. */
310
+ export const SCOPE_BOOTSTRAP_UNKNOWN_STATE = "unknown";
311
+ /**
312
+ * Collapse any lifecycle state to a bounded, safe label (BAPI-1054).
313
+ *
314
+ * `lifecycle_state` is a required non-empty string on the wire, not a closed set,
315
+ * so an unrecognized value is possible and an error-bearing one
316
+ * (`unreadable (<server error>)`) is routine. Extracted from
317
+ * {@link formatScopeBootstrapHeartbeat}, which has always applied this rule, so
318
+ * every surface that renders a state applies the SAME one — a second spelling of
319
+ * "bounded" is how raw server text eventually reaches a terminal.
320
+ */
321
+ export function boundScopeLifecycleLabel(state) {
322
+ return state === SCOPE_BOOTSTRAP_UNREADABLE_STATE || state in SCOPE_LIFECYCLE_LABELS
323
+ ? state
324
+ : SCOPE_BOOTSTRAP_UNKNOWN_STATE;
325
+ }
326
+ /**
327
+ * Describe the nominal polling window up front (BAPI-963).
328
+ *
329
+ * Computed from the interval and cap rather than hard-coded, so a change to
330
+ * either constant cannot leave the operator-facing duration claim stale. The
331
+ * window is NOMINAL: the observed pilot seed outran even this bound, which is
332
+ * why the wording promises a poll cadence rather than a completion time.
333
+ */
334
+ export function describeScopeBootstrapWindow(intervalMs = SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, maxPolls = SCOPE_BOOTSTRAP_MAX_POLLS) {
335
+ const intervalSeconds = Math.max(1, Math.round(intervalMs / 1000));
336
+ const windowMinutes = Math.max(1, Math.round((intervalMs * maxPolls) / 60_000));
337
+ return (`Seeding the epic's index scope. This copies the repository's whole parse cache and ` +
338
+ `verifies it, and commonly takes many minutes. Progress is reported every ` +
339
+ `${intervalSeconds}s; the poll gives up after about ${windowMinutes} minutes.`);
340
+ }
280
341
  /**
281
342
  * Poll a scope's lifecycle until it is `ready`, `failed`, or the bounded wait
282
343
  * elapses, reporting each NEWLY observed lifecycle transition exactly once, in
@@ -296,6 +357,8 @@ export async function pollIndexScopeLifecycle(deps, access, scopeId, options = {
296
357
  const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
297
358
  const maxPolls = options.maxPolls ?? SCOPE_BOOTSTRAP_MAX_POLLS;
298
359
  const intervalMs = options.intervalMs ?? SCOPE_BOOTSTRAP_POLL_INTERVAL_MS;
360
+ const now = options.now ?? (() => new Date());
361
+ const startedAtMs = now().getTime();
299
362
  let lastState = "unknown";
300
363
  let lastStatus = null;
301
364
  let lastReportedState = null;
@@ -304,10 +367,12 @@ export async function pollIndexScopeLifecycle(deps, access, scopeId, options = {
304
367
  const status = await getIndexScopeStatus(access, scopeId, deps.fetchImpl);
305
368
  if (!status.ok) {
306
369
  lastState = `unreadable (${status.error})`;
370
+ options.onPoll?.(now().getTime() - startedAtMs, SCOPE_BOOTSTRAP_UNREADABLE_STATE);
307
371
  continue;
308
372
  }
309
373
  lastStatus = status.value;
310
374
  lastState = status.value.lifecycle_state;
375
+ options.onPoll?.(now().getTime() - startedAtMs, lastState);
311
376
  if (lastState !== lastReportedState) {
312
377
  lastReportedState = lastState;
313
378
  options.onTransition?.(lastState, status.value);
@@ -769,6 +769,79 @@ export async function fetchEpicRunState(access, epicKey, fetchImpl = globalThis.
769
769
  const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
770
770
  return parsed;
771
771
  }
772
+ // ---------------------------------------------------------------------------
773
+ // Executor wind-down: read-only completion-state projection (BAPI-1010)
774
+ // ---------------------------------------------------------------------------
775
+ /** Closed runtime vocabulary mirroring the Python `EpicRunStatus` Literal exactly. */
776
+ export const EPIC_RUN_STATUS_VALUES = [
777
+ "planning",
778
+ "pending_approval",
779
+ "active",
780
+ "blocked",
781
+ "abandoned",
782
+ "done",
783
+ ];
784
+ function isEpicRunStatus(value) {
785
+ return typeof value === "string" && EPIC_RUN_STATUS_VALUES.includes(value);
786
+ }
787
+ /**
788
+ * GET the epic run's completion state (`status` plus the derived feature
789
+ * branch) through the existing {@link fetchEpicRunState} transport, then
790
+ * validate the payload as untrusted boundary input. Never throws — every
791
+ * failure, transport or validation, resolves to the closed failure member of
792
+ * {@link EpicRunCompletionStateResult} rather than propagating a raw error,
793
+ * response body, header, or credential.
794
+ *
795
+ * `epicRunId` may be an epic run id or an epic key — the underlying endpoint
796
+ * accepts either, exactly like {@link fetchEpicRunState}'s existing callers.
797
+ */
798
+ export async function readEpicRunCompletionState(access, epicRunId, fetchImpl = globalThis.fetch) {
799
+ if (typeof epicRunId !== "string" || epicRunId.trim().length === 0) {
800
+ return { ok: false, reason: "invalid-input" };
801
+ }
802
+ let parsed;
803
+ try {
804
+ parsed = await fetchEpicRunState(access, epicRunId, fetchImpl);
805
+ }
806
+ catch (err) {
807
+ if (err instanceof ConductorBridgeApiError) {
808
+ if (err.kind === "http" && err.status === 404)
809
+ return { ok: false, reason: "not-found" };
810
+ if (err.kind === "unauthorized")
811
+ return { ok: false, reason: "unauthorized" };
812
+ if (err.kind === "timeout")
813
+ return { ok: false, reason: "timeout" };
814
+ if (err.kind === "server")
815
+ return { ok: false, reason: "server" };
816
+ if (err.kind === "network")
817
+ return { ok: false, reason: "network" };
818
+ if (err.kind === "invalid-input")
819
+ return { ok: false, reason: "invalid-input" };
820
+ return { ok: false, reason: "malformed" };
821
+ }
822
+ return { ok: false, reason: "network" };
823
+ }
824
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
825
+ return { ok: false, reason: "malformed" };
826
+ }
827
+ const epicRun = parsed["epic_run"];
828
+ if (!epicRun || typeof epicRun !== "object" || Array.isArray(epicRun)) {
829
+ return { ok: false, reason: "malformed" };
830
+ }
831
+ const status = epicRun["status"];
832
+ if (!isEpicRunStatus(status)) {
833
+ return { ok: false, reason: "malformed" };
834
+ }
835
+ const policyJson = epicRun["policy_json"];
836
+ let featureBranch;
837
+ if (policyJson && typeof policyJson === "object" && !Array.isArray(policyJson)) {
838
+ const baseBranch = policyJson["base_branch"];
839
+ if (typeof baseBranch === "string" && baseBranch.trim().length > 0) {
840
+ featureBranch = baseBranch;
841
+ }
842
+ }
843
+ return { ok: true, state: featureBranch ? { status, featureBranch } : { status } };
844
+ }
772
845
  /**
773
846
  * GET `/jira/epic-runs/runs?repo_name=<repo>&status=active` and return the
774
847
  * list of active epic runs. Caps at 20 results — enough for typical deployments
@@ -957,8 +1030,9 @@ function parseAdvanceEpicTicketStatusResult(parsed) {
957
1030
  * PATCH the per-ticket CAS status endpoint
958
1031
  * (`PATCH /runs/{epic_run_id}/tickets/{ticket_key}`). Surfaces structured CAS
959
1032
  * conflicts as a distinct non-throwing outcome where the backend returns one;
960
- * the current backend instead raises a 400 VALIDATION on a stale row_version,
961
- * which surfaces as a thrown `ConductorBridgeApiError("http", 400)`.
1033
+ * the current backend instead raises a 400 CONFLICT on a stale row_version
1034
+ * (BAPI-1016 the status stays 400; only the body error_code changed from the
1035
+ * former VALIDATION), which surfaces as a thrown `ConductorBridgeApiError("http", 400)`.
962
1036
  * Transport/auth/server failures still throw a sanitized {@link ConductorBridgeApiError}.
963
1037
  */
964
1038
  export async function advanceEpicTicketStatus(access, request, fetchImpl = globalThis.fetch) {
@@ -1283,6 +1357,8 @@ export async function validateEpicPlan(access, request, fetchImpl = globalThis.f
1283
1357
  };
1284
1358
  if (request.epicKey !== undefined)
1285
1359
  body.epic_key = request.epicKey;
1360
+ if (request.trackedPaths !== undefined)
1361
+ body.tracked_paths = request.trackedPaths;
1286
1362
  const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify(body), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
1287
1363
  return parseValidateEpicPlanResult(parsed);
1288
1364
  }
@@ -1295,7 +1371,8 @@ function parseValidateEpicPlanResult(parsed) {
1295
1371
  const planHash = p["plan_hash"];
1296
1372
  const serializationEnabled = p["serialization_enabled"];
1297
1373
  const insertedEdges = p["inserted_edges"];
1298
- if (p["valid"] !== true ||
1374
+ const valid = p["valid"];
1375
+ if (typeof valid !== "boolean" ||
1299
1376
  typeof planHash !== "string" ||
1300
1377
  planHash.trim() === "" ||
1301
1378
  typeof serializationEnabled !== "boolean" ||
@@ -1304,12 +1381,35 @@ function parseValidateEpicPlanResult(parsed) {
1304
1381
  insertedEdges < 0) {
1305
1382
  throw new ConductorBridgeApiError("server");
1306
1383
  }
1384
+ // BAPI-1027: `valid: false` is now a FIRST-CLASS, fully-verified answer rather
1385
+ // than a protocol error. The historical strictness (throw on anything but
1386
+ // `valid === true`) existed because a body claiming a verdict without carrying
1387
+ // one is false assurance — but a rejection body carries its reasons, so the
1388
+ // caller can verify exactly what it is being told. That guard is preserved
1389
+ // where it still applies: a `valid: false` with no parseable rejection asserts
1390
+ // a failure it does not substantiate, and is still a protocol error.
1391
+ const rejections = parsePlanLintRejections(p["rejections"]);
1392
+ const notices = parsePlanLintNotices(p["notices"]);
1393
+ if (!valid && rejections.length === 0) {
1394
+ throw new ConductorBridgeApiError("server");
1395
+ }
1307
1396
  // BAPI-848 — the coverage diagnostics are read TOLERANTLY, unlike the fields
1308
1397
  // above. A server that predates them is not a protocol error, and the CLI must
1309
1398
  // keep working against one; the defaults below are the honest reading of an
1310
1399
  // absent field (nothing reported), and the renderer states the scope it was
1311
1400
  // actually given rather than inventing coverage it cannot see.
1312
1401
  return {
1402
+ valid,
1403
+ rejections,
1404
+ notices,
1405
+ // BAPI-1027: read TOLERANTLY like the coverage fields, but defaulted to the
1406
+ // SAFE reading. A server that predates this field reports nothing about
1407
+ // completeness, and "we cannot tell whether every rule ran" must not render
1408
+ // as a warning on every single validate against an older deployment — so an
1409
+ // absent field means complete, while a present `false` is honored exactly.
1410
+ evaluationComplete: typeof p["evaluation_complete"] === "boolean"
1411
+ ? p["evaluation_complete"]
1412
+ : true,
1313
1413
  planHash,
1314
1414
  serializationEnabled,
1315
1415
  insertedEdges,
@@ -1321,6 +1421,69 @@ function parseValidateEpicPlanResult(parsed) {
1321
1421
  : "unreported",
1322
1422
  };
1323
1423
  }
1424
+ /** Strict-but-tolerant parse of the `rejections` array; malformed entries are dropped. */
1425
+ function parsePlanLintRejections(value) {
1426
+ if (!Array.isArray(value))
1427
+ return [];
1428
+ const out = [];
1429
+ for (const raw of value) {
1430
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
1431
+ continue;
1432
+ const entry = raw;
1433
+ const reasonCode = entry["reason_code"];
1434
+ const explanation = entry["explanation"];
1435
+ // A finding without a code or an explanation cannot be rendered usefully and
1436
+ // must not be counted as one: a phantom rejection would block a plan while
1437
+ // telling the operator nothing about why.
1438
+ if (typeof reasonCode !== "string" || reasonCode.trim() === "")
1439
+ continue;
1440
+ if (typeof explanation !== "string" || explanation.trim() === "")
1441
+ continue;
1442
+ const rejection = {
1443
+ reasonCode,
1444
+ explanation,
1445
+ ticketKeys: safeStringArray(entry["ticket_keys"]),
1446
+ };
1447
+ if (typeof entry["path"] === "string" && entry["path"] !== "") {
1448
+ rejection.path = entry["path"];
1449
+ }
1450
+ if (typeof entry["migration_id"] === "string" && entry["migration_id"] !== "") {
1451
+ rejection.migrationId = entry["migration_id"];
1452
+ }
1453
+ out.push(rejection);
1454
+ }
1455
+ return out;
1456
+ }
1457
+ /** Strict-but-tolerant parse of the `notices` array; malformed entries are dropped. */
1458
+ function parsePlanLintNotices(value) {
1459
+ if (!Array.isArray(value))
1460
+ return [];
1461
+ const out = [];
1462
+ for (const raw of value) {
1463
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
1464
+ continue;
1465
+ const entry = raw;
1466
+ const noticeCode = entry["notice_code"];
1467
+ const explanation = entry["explanation"];
1468
+ if (typeof noticeCode !== "string" || noticeCode.trim() === "")
1469
+ continue;
1470
+ if (typeof explanation !== "string" || explanation.trim() === "")
1471
+ continue;
1472
+ out.push({
1473
+ noticeCode,
1474
+ explanation,
1475
+ ticketKeys: safeStringArray(entry["ticket_keys"]),
1476
+ paths: safeStringArray(entry["paths"]),
1477
+ });
1478
+ }
1479
+ return out;
1480
+ }
1481
+ /** Non-empty strings from an array, or `[]` for anything else. */
1482
+ function safeStringArray(value) {
1483
+ if (!Array.isArray(value))
1484
+ return [];
1485
+ return value.filter((v) => typeof v === "string" && v !== "");
1486
+ }
1324
1487
  /** Non-negative safe integer, or 0 for anything else (absent field included). */
1325
1488
  function safeCount(value) {
1326
1489
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
@@ -1341,12 +1504,15 @@ export async function storeEpicPlan(access, request, fetchImpl = globalThis.fetc
1341
1504
  throw new ConductorValidationError(`planVersion mismatch: request.planVersion=${request.planVersion} but planBlob.plan_version=${blobVersion}`);
1342
1505
  }
1343
1506
  const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/plan`);
1344
- const body = JSON.stringify({
1507
+ const storeBody = {
1345
1508
  repo_name: access.repoName,
1346
1509
  plan_version: request.planVersion,
1347
1510
  plan_blob: request.planBlob,
1348
1511
  plan_hash: request.planHash,
1349
- });
1512
+ };
1513
+ if (request.trackedPaths !== undefined)
1514
+ storeBody.tracked_paths = request.trackedPaths;
1515
+ const body = JSON.stringify(storeBody);
1350
1516
  return fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
1351
1517
  }
1352
1518
  /**
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Executor deny-enforcement preflight (TDD §7 / §11, R8).
3
3
  *
4
- * v2's permission model is "skip-permissions + a deterministic deny-layer": workers
5
- * run `claude -p --dangerously-skip-permissions` with a small stable deny set. But
6
- * whether `permissions.deny` is actually enforced under `--dangerously-skip-permissions`
7
- * is version-specific and must be PROBED, never assumed. This module exports the single
4
+ * v2's permission model since BAPI-1020 is "the `auto` posture + a deterministic
5
+ * deny layer + an always-on argument guard": workers run
6
+ * `claude -p … --permission-mode auto` with a small stable deny set, and
7
+ * `--dangerously-skip-permissions` is the explicit REVERT value rather than the
8
+ * default. Whether `permissions.deny` is actually enforced under a given posture is
9
+ * version-specific and must be PROBED, never assumed — and BOTH postures are probed,
10
+ * for different reasons: `auto` because it is what every worker runs under, and
11
+ * bypass because it stays selectable and must stay guarded. A posture that is not
12
+ * enforced is fatal regardless of which one it is. This module exports the single
8
13
  * reusable predicate the T3a executor's claim loop calls at startup/preflight before
9
14
  * claiming any job: on a failed deny probe with no working fallback it returns
10
15
  * `enforced: false`, and the executor must refuse to claim jobs (a fatal finding).
@@ -19,15 +24,28 @@ import { resolveAgentSpec } from "../agent-registry.js";
19
24
  import { createProbeContext } from "../agent-capabilities/probe-context.js";
20
25
  import { createDefaultAgentCapabilitiesDeps } from "../agent-capabilities/default-deps.js";
21
26
  import { runDenyEnforcementCheck } from "../agent-capabilities/probes.js";
27
+ /**
28
+ * The postures probed when a caller names none (BAPI-1020).
29
+ *
30
+ * `auto` FIRST, deliberately: it is the posture every worker runs under, so when
31
+ * enforcement is broken the run that proves it is the one that matters, and the
32
+ * short-circuit below then skips the second probe entirely rather than spending
33
+ * minutes of real headless Claude runs re-confirming a fatal result.
34
+ */
35
+ export const REQUIRED_DENY_ENFORCEMENT_POSTURES = [
36
+ "auto",
37
+ "skip_permissions",
38
+ ];
22
39
  /** Canonical refuse-to-claim directive included in every fatal/degraded-fatal result. */
23
40
  const REFUSE_TO_CLAIM_WARNING = "The executor claim loop MUST refuse to claim jobs until settings permissions.deny " +
24
41
  "or the PreToolUse fallback enforces the deny layer.";
25
42
  /**
26
- * Run the deny-enforcement preflight and map the shared deny-check outcome onto the
27
- * standard inspection shape. Never throws — unexpected exceptions become a fatal
28
- * `enforced: false` result. Always cleans up the probe context's temp dirs.
43
+ * Run the deny-enforcement preflight for ONE posture and map the shared
44
+ * deny-check outcome onto the standard inspection shape. Never throws —
45
+ * unexpected exceptions become a fatal `enforced: false` result. Always cleans up
46
+ * the probe context's temp dirs.
29
47
  */
30
- export async function runDenyEnforcementPreflight(opts = {}) {
48
+ async function runDenyEnforcementPreflightForPosture(posture, opts) {
31
49
  try {
32
50
  const agent = resolveAgentSpec("claude");
33
51
  if (!agent) {
@@ -47,7 +65,7 @@ export async function runDenyEnforcementPreflight(opts = {}) {
47
65
  const { result, layer } = await runDenyEnforcementCheck(ctx, {
48
66
  model: opts.model,
49
67
  timeoutMs: opts.timeoutMs,
50
- permissionPosture: opts.permissionPosture,
68
+ permissionPosture: posture,
51
69
  });
52
70
  if (result.status === "pass" && layer === "settings-deny") {
53
71
  return {
@@ -65,7 +83,8 @@ export async function runDenyEnforcementPreflight(opts = {}) {
65
83
  layer: "pretooluse-hook",
66
84
  degraded: true,
67
85
  warnings: [
68
- "settings permissions.deny was not enforced under --dangerously-skip-permissions; relying on PreToolUse fallback.",
86
+ `settings permissions.deny was not enforced under the ${posture ?? "skip_permissions"} ` +
87
+ "posture; relying on PreToolUse fallback.",
69
88
  ],
70
89
  status: result.status,
71
90
  detail: result.detail,
@@ -95,3 +114,81 @@ export async function runDenyEnforcementPreflight(opts = {}) {
95
114
  };
96
115
  }
97
116
  }
117
+ /**
118
+ * Run the deny-enforcement preflight and return the executor's claim gate.
119
+ *
120
+ * With an explicit `permissionPosture`, probes exactly that posture and returns
121
+ * its result unchanged — the single-posture behavior every existing caller and
122
+ * test seam relies on.
123
+ *
124
+ * With no posture named, probes {@link REQUIRED_DENY_ENFORCEMENT_POSTURES} in
125
+ * order and combines (BAPI-1020). Three properties of that combination matter:
126
+ *
127
+ * - FATAL IF EITHER FAILS. `enforced` is the conjunction, so a posture that is
128
+ * not enforced blocks claiming even when the other one is. There is no
129
+ * "mostly enforced".
130
+ * - SHORT-CIRCUITS ON THE FIRST FAILURE. Each posture costs several real
131
+ * headless Claude runs and takes minutes; once the answer is fatal, the
132
+ * remaining postures cannot change it, and burning the time to re-confirm it
133
+ * would delay every claim on a machine that is already refusing to claim.
134
+ * - REPORTS EACH POSTURE SEPARATELY. `postures` carries every probed posture's
135
+ * own layer and detail. The top-level `layer` is the FIRST required posture's
136
+ * (`auto`, the one workers run under) rather than a merged string, so the
137
+ * existing consumers — which read `layer` as "what enforced for a worker" —
138
+ * keep reading a true answer, and the per-posture facts stay available beside
139
+ * it instead of being collapsed into it.
140
+ */
141
+ export async function runDenyEnforcementPreflight(opts = {}) {
142
+ if (opts.permissionPosture !== undefined) {
143
+ return runDenyEnforcementPreflightForPosture(opts.permissionPosture, opts);
144
+ }
145
+ const postures = [];
146
+ const warnings = [];
147
+ let combined;
148
+ let degraded = false;
149
+ for (const posture of REQUIRED_DENY_ENFORCEMENT_POSTURES) {
150
+ const result = await runDenyEnforcementPreflightForPosture(posture, opts);
151
+ postures.push({
152
+ posture,
153
+ enforced: result.enforced,
154
+ layer: result.layer,
155
+ ...(result.status === undefined ? {} : { status: result.status }),
156
+ ...(result.detail === undefined ? {} : { detail: result.detail }),
157
+ });
158
+ for (const warning of result.warnings) {
159
+ if (!warnings.includes(warning))
160
+ warnings.push(warning);
161
+ }
162
+ if (result.degraded)
163
+ degraded = true;
164
+ if (combined === undefined)
165
+ combined = result;
166
+ if (!result.enforced) {
167
+ // Fatal already. Naming the posture is the diagnostic that matters: "deny is
168
+ // unenforced" is not actionable until an operator knows WHICH posture, since
169
+ // the fix for the worker default and the fix for the revert value differ.
170
+ return {
171
+ enforced: false,
172
+ layer: result.layer,
173
+ degraded: true,
174
+ warnings: [
175
+ `Deny-layer enforcement is not verified for the '${posture}' permission posture.`,
176
+ ...warnings,
177
+ ],
178
+ ...(result.status === undefined ? {} : { status: result.status }),
179
+ ...(result.detail === undefined ? {} : { detail: result.detail }),
180
+ postures,
181
+ };
182
+ }
183
+ }
184
+ const primary = combined;
185
+ return {
186
+ enforced: true,
187
+ layer: primary.layer,
188
+ degraded,
189
+ warnings,
190
+ ...(primary.status === undefined ? {} : { status: primary.status }),
191
+ ...(primary.detail === undefined ? {} : { detail: primary.detail }),
192
+ postures,
193
+ };
194
+ }