@bridge_gpt/mcp-server 0.2.37 → 0.2.39

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 (91) hide show
  1. package/README.md +193 -16
  2. package/build/agent-capabilities/probe-context.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +392 -0
  4. package/build/agent-launchers/executor-adapter-inspection.js +163 -0
  5. package/build/agent-launchers/executor-adapter-registry.js +90 -0
  6. package/build/agent-launchers/executor-adapter.js +136 -0
  7. package/build/agent-registry.js +28 -0
  8. package/build/agents.generated.js +1 -1
  9. package/build/claude-login.js +85 -0
  10. package/build/claude-user-config-doctor.js +59 -33
  11. package/build/commands.generated.js +12 -11
  12. package/build/conduct-epic/bridge-client.js +345 -0
  13. package/build/conduct-epic/checkpoint-store.js +423 -0
  14. package/build/conduct-epic/cli.js +1732 -0
  15. package/build/conduct-epic/lock.js +302 -0
  16. package/build/conduct-epic/pr-state.js +197 -0
  17. package/build/conduct-epic/spawn.js +101 -0
  18. package/build/conductor/bridge-api-client.js +37 -2
  19. package/build/conductor/doctor.js +11 -1
  20. package/build/conductor/install-doctor.js +184 -10
  21. package/build/conductor-bin.js +7 -7
  22. package/build/credential-store.js +10 -4
  23. package/build/credentials-cli.js +34 -19
  24. package/build/docs.generated.js +1 -1
  25. package/build/doctor.js +579 -88
  26. package/build/executor/agent-identity.js +32 -0
  27. package/build/executor/cli.js +50 -39
  28. package/build/executor/deps.js +15 -1
  29. package/build/executor/env.js +56 -45
  30. package/build/executor/index.js +9 -1
  31. package/build/executor/install-preflight.js +138 -0
  32. package/build/executor/job-errors.js +200 -0
  33. package/build/executor/job-runner.js +619 -268
  34. package/build/executor/observation.js +165 -0
  35. package/build/executor/permissions.js +163 -36
  36. package/build/executor/platform.js +54 -0
  37. package/build/executor/preflight.js +175 -67
  38. package/build/executor/process.js +39 -7
  39. package/build/executor/runner.js +19 -0
  40. package/build/executor/service-lifecycle.js +269 -0
  41. package/build/executor/service-unit.js +121 -12
  42. package/build/executor/stale-artifacts.js +70 -0
  43. package/build/executor/test-clock.js +188 -24
  44. package/build/executor/worker-command.js +22 -58
  45. package/build/executor/worker-log.js +82 -0
  46. package/build/executor/worktree-lock.js +264 -0
  47. package/build/index.js +527 -357
  48. package/build/install-bridge-conductor.js +376 -38
  49. package/build/install-bridge.js +414 -114
  50. package/build/install-doctor.js +13 -0
  51. package/build/install-reexec.js +5 -3
  52. package/build/mcp-install-state.js +130 -0
  53. package/build/mcp-profile.js +11 -2
  54. package/build/mcp-provisioning.js +15 -0
  55. package/build/merge-pull-request.js +562 -0
  56. package/build/phase-result-artifacts.js +450 -0
  57. package/build/pipeline-orchestrator.js +4 -0
  58. package/build/pipeline-utils.js +16 -0
  59. package/build/pipelines.generated.js +7 -7
  60. package/build/plane/preflight.js +18 -14
  61. package/build/plane/supervisor.js +8 -1
  62. package/build/project-root.js +34 -0
  63. package/build/readme.generated.js +1 -1
  64. package/build/run-unit-tests-launcher.js +36 -9
  65. package/build/setup-epic.js +57 -4
  66. package/build/sfcc/ocapi-shape.js +23 -4
  67. package/build/sfcc/permissions.js +25 -6
  68. package/build/sfcc/read-body.js +92 -0
  69. package/build/sfcc/read-projection.js +6 -2
  70. package/build/sfcc/reads-custom-object-def.js +33 -21
  71. package/build/sfcc/reads-site-preference.js +20 -7
  72. package/build/sfcc/reads-system-object.js +11 -5
  73. package/build/sfcc/register.js +61 -23
  74. package/build/sfcc/registration-inventory.js +89 -0
  75. package/build/sfcc/setup-status.js +18 -34
  76. package/build/sfcc/tool-wrapper.js +294 -17
  77. package/build/sfcc/write-grants.js +33 -1
  78. package/build/sfcc/write-guard.js +41 -12
  79. package/build/sfcc/write-result.js +16 -7
  80. package/build/sfcc/writes-custom-object-def.js +12 -4
  81. package/build/sfcc/writes-site-preference.js +6 -1
  82. package/build/sfcc/writes-system-object.js +11 -2
  83. package/build/sfcc/writes.js +13 -8
  84. package/build/start-tickets-prereqs.js +25 -15
  85. package/build/start-tickets.js +123 -21
  86. package/build/version.generated.js +1 -1
  87. package/build/worktree-core.js +9 -3
  88. package/docs/install/mcp-tool-integrations.md +54 -9
  89. package/docs/install/sfcc-integration.md +71 -24
  90. package/package.json +3 -3
  91. package/build/executor/worker-config-isolation.js +0 -287
@@ -5,10 +5,32 @@
5
5
  * SFCC tools with a single call adjacent to `registerConductorTools`. All
6
6
  * SFCC business logic lives inside the sfcc/ module; index.ts stays a
7
7
  * thin registration/composition layer.
8
+ *
9
+ * ---------------------------------------------------------------------------
10
+ * Two structural properties this module owns (BAPI-817)
11
+ * ---------------------------------------------------------------------------
12
+ *
13
+ * **1. The target boundary is unavoidable.** The raw host registrar is used in
14
+ * exactly one place — `registerSfccTool`, the adapter below — and every other
15
+ * registration in this module and in every child registrar goes through that
16
+ * adapter. So the resolved-hostname sandbox check runs for all twenty tools:
17
+ * the two always-on diagnostics, the eight reads (including the five with no
18
+ * `instance` parameter at all), the nine writes, and `sfcc_log_query`. Asking
19
+ * each handler to remember a check is what produced the unguarded reads in the
20
+ * first place; wrapping the registrar removes the opportunity to forget.
21
+ *
22
+ * **2. Reads and writes are separately gated.** Before BAPI-817 one boolean
23
+ * (`includeReadTools`, from `ACTIVE_GROUPS.has("sfcc")`) registered eighteen
24
+ * tools, nine of them destructive — while the README described the group as
25
+ * "the 7 heavy SFCC read tools" and stated "All SFCC tools are read-only".
26
+ * Enabling introspection silently enabled mutation. This module now takes two
27
+ * independent flags so the `sfcc` profile is genuinely read-oriented and the
28
+ * nine writes require the deliberate `sfcc-write` opt-in (the ticket's
29
+ * Decision 2(c)).
8
30
  */
9
31
  import { z } from "zod";
10
32
  import { buildSfccSetupStatusHandler } from "./setup-status.js";
11
- import { withSfccGate } from "./tool-wrapper.js";
33
+ import { withSfccGate, withSfccTargetBoundary } from "./tool-wrapper.js";
12
34
  import { checkPermissionsTool } from "./permissions.js";
13
35
  import { registerSystemObjectReadTools } from "./reads-system-object.js";
14
36
  import { registerSfccCustomObjectDefReadTools } from "./reads-custom-object-def.js";
@@ -22,7 +44,9 @@ import { registerSfccLogQueryTool } from "./log-query.js";
22
44
  * Register all SFCC tools through the host's registerTool wrapper.
23
45
  *
24
46
  * Call this once in index.ts, immediately after `registerConductorTools(registerTool)`.
25
- * Sub-task tickets (2–4) add their read tools here; they never touch index.ts.
47
+ * Read and write surfaces are selected independently by `deps.includeReadTools`
48
+ * and `deps.includeWriteTools`; the two always-on diagnostics register
49
+ * regardless. Every path goes through `registerSfccTool`.
26
50
  */
27
51
  export function registerSfccTools(registerTool, deps) {
28
52
  const gateDeps = {
@@ -30,8 +54,17 @@ export function registerSfccTools(registerTool, deps) {
30
54
  getGetHeaders: deps.getGetHeaders,
31
55
  repoName: deps.repoName,
32
56
  };
57
+ /**
58
+ * The one and only bridge between the host registrar and the SFCC surface.
59
+ *
60
+ * Every SFCC tool is registered through this, so every SFCC tool crosses the
61
+ * resolved-hostname boundary before its own handler runs. `registerTool` (the
62
+ * raw host registrar) must not be referenced anywhere else in this module or
63
+ * passed to a child registrar — `registration-boundary.test.ts` pins that.
64
+ */
65
+ const registerSfccTool = (name, config, handler) => registerTool(name, config, withSfccTargetBoundary(handler));
33
66
  // sfcc_setup_status — aggregate prerequisite reporter.
34
- registerTool("sfcc_setup_status", {
67
+ registerSfccTool("sfcc_setup_status", {
35
68
  description: "Report on every SFCC prerequisite: Bridge API key, repo name, version config, " +
36
69
  "dw.json presence/uniqueness, and AM token acquisition. Always-registered; " +
37
70
  "returns status without requiring full SFCC configuration to be complete.",
@@ -40,53 +73,58 @@ export function registerSfccTools(registerTool, deps) {
40
73
  }, buildSfccSetupStatusHandler(deps.buildGetUrl, deps.getGetHeaders, deps.repoName, deps.getResolvedApiKey));
41
74
  // check_permissions — OCAPI access probe + settings JSON printer.
42
75
  const gatedCheckPermissions = withSfccGate(gateDeps, async (_args, credentials) => checkPermissionsTool(credentials));
43
- registerTool("check_permissions", {
76
+ registerSfccTool("check_permissions", {
44
77
  description: "Probe SFCC OCAPI access via GET /system_object_definitions. " +
45
78
  "On 200: reports OK and the detected OCAPI version. " +
46
79
  "On 401/403: prints the exact OCAPI Settings JSON to paste in Business Manager " +
47
- "(split read-only vs. write/import grants).",
80
+ "(read/search and mutation grants).",
48
81
  inputSchema: z.object({
49
82
  instance: z
50
83
  .string()
84
+ .min(1)
51
85
  .optional()
52
- .describe("Explicit sandbox hostname to use instead of dw.json auto-detection."),
86
+ .describe("Sandbox hostname to use instead of dw.json. Still sandbox-validated."),
53
87
  }),
54
88
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
55
89
  }, gatedCheckPermissions);
56
- // Heavy read tools are gated behind ACTIVE_GROUPS.has("sfcc") (BAPI-453).
57
- // sfcc_setup_status and check_permissions above remain always-on (D-4 decision).
90
+ // The read profile eight OCAPI reads plus sfcc_log_query — is gated behind
91
+ // ACTIVE_GROUPS.has("sfcc") (BAPI-453). sfcc_setup_status and
92
+ // check_permissions above remain always-on (D-4 decision).
58
93
  if (deps.includeReadTools) {
59
94
  // system_object_list, system_object_get, system_object_attribute_search (BAPI-401 T1–T3)
60
- registerSystemObjectReadTools(registerTool, {
95
+ registerSystemObjectReadTools(registerSfccTool, {
61
96
  gateDeps,
62
97
  getDocsDir: deps.getDocsDir,
63
98
  });
64
99
  // custom_object_definition_attributes_get, custom_object_definition_attribute_search (BAPI-402 T4–T5)
65
- registerSfccCustomObjectDefReadTools(registerTool, {
66
- gateDeps,
67
- getDocsDir: deps.getDocsDir,
68
- });
69
- // site_preference_get, site_preference_search (BAPI-403 T6 — optional)
70
- registerSitePreferenceTools(registerTool, {
100
+ registerSfccCustomObjectDefReadTools(registerSfccTool, {
71
101
  gateDeps,
72
102
  getDocsDir: deps.getDocsDir,
73
103
  });
74
- // SFCC write registration seam (BAPI-582). No write tools ship yet — future
75
- // write-surface tickets register here. Gated behind the same includeReadTools
76
- // branch so writes become default-on with reads whenever the sfcc profile
77
- // group is active.
78
- registerSfccWriteTools(registerTool, {
104
+ // site_preference_group_list, site_preference_get, site_preference_search (BAPI-403 T6)
105
+ registerSitePreferenceTools(registerSfccTool, {
79
106
  gateDeps,
80
107
  getDocsDir: deps.getDocsDir,
81
108
  });
82
109
  // sfcc_log_query (BAPI-556 T7) — gated, on-demand redacted/filtered log query.
83
- // Uses its OWN call-time gate (log capability = WebDAV Basic auth), NOT the
84
- // OCAPI version/dw.json/AM-token gate above.
85
- registerSfccLogQueryTool(registerTool, {
110
+ // Uses its OWN call-time capability gate (log capability = WebDAV Basic
111
+ // auth), NOT the OCAPI version/dw.json/AM-token gate above. It is read-only,
112
+ // so it belongs to the read profile.
113
+ registerSfccLogQueryTool(registerSfccTool, {
86
114
  buildGetUrl: deps.buildGetUrl,
87
115
  getGetHeaders: deps.getGetHeaders,
88
116
  getPostHeaders: deps.getPostHeaders,
89
117
  repoName: deps.repoName,
90
118
  });
91
119
  }
120
+ // The nine destructive write tools are gated INDEPENDENTLY, behind
121
+ // ACTIVE_GROUPS.has("sfcc-write") (BAPI-817). This branch is deliberately not
122
+ // nested inside the read branch: `sfcc-write` alone is a valid selection, and
123
+ // `sfcc` alone must never register a tool carrying WRITE_ANNOTATIONS.
124
+ if (deps.includeWriteTools) {
125
+ registerSfccWriteTools(registerSfccTool, {
126
+ gateDeps,
127
+ getDocsDir: deps.getDocsDir,
128
+ });
129
+ }
92
130
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Canonical inventory of every SFCC MCP tool name, by registration mode
3
+ * (BAPI-816).
4
+ *
5
+ * `register.ts` (`registerSfccTools`) decides WHICH tools register; this
6
+ * module is the single readable description of what that decision produces.
7
+ * Registrar tests compare captured registrations against these composed
8
+ * arrays instead of independently maintained numeric pins, which is exactly
9
+ * what let `register-site-preference.integration.test.ts` pin a stale `10`
10
+ * while the real registrar had already grown to 20 — a full write surface's
11
+ * worth of drift that no test caught (BAPI-816).
12
+ *
13
+ * `includeReadTools` is the legacy `SfccHostDeps` flag name (`register.ts`).
14
+ * Since BAPI-817 it gates the READ profile only — the eight OCAPI reads plus
15
+ * the log-query tool — while `includeWriteTools` independently gates the nine
16
+ * writes. The name predates both surfaces and was kept for call-site stability.
17
+ */
18
+ import { SFCC_WRITE_TOOL_NAMES } from "./writes.js";
19
+ /**
20
+ * Registered unconditionally, regardless of `includeReadTools` (D-4 decision
21
+ * in register.ts): the aggregate prerequisite reporter and the OCAPI access
22
+ * probe.
23
+ */
24
+ export const SFCC_ALWAYS_REGISTERED_TOOL_NAMES = [
25
+ "sfcc_setup_status",
26
+ "check_permissions",
27
+ ];
28
+ /**
29
+ * The eight read tools registered by the three read-module registrars
30
+ * (`registerSystemObjectReadTools`, `registerSfccCustomObjectDefReadTools`,
31
+ * `registerSitePreferenceTools`) when `includeReadTools` is true.
32
+ */
33
+ export const SFCC_READ_TOOL_NAMES = [
34
+ "system_object_list",
35
+ "system_object_get",
36
+ "system_object_attribute_search",
37
+ "custom_object_definition_attributes_get",
38
+ "custom_object_definition_attribute_search",
39
+ "site_preference_group_list",
40
+ "site_preference_get",
41
+ "site_preference_search",
42
+ ];
43
+ /** The on-demand redacted/filtered log-query tool (BAPI-556 T7). */
44
+ export const SFCC_LOG_QUERY_TOOL_NAME = "sfcc_log_query";
45
+ /**
46
+ * The `sfcc` READ profile: the eight OCAPI reads plus the log-query tool
47
+ * (BAPI-817).
48
+ *
49
+ * This is the exact set `includeReadTools` registers. Every member is read-only
50
+ * and carries read-only annotations; by construction no tool annotated
51
+ * `WRITE_ANNOTATIONS` can appear here, which is what makes "enabling `sfcc`
52
+ * cannot enable a mutation" checkable rather than merely intended.
53
+ */
54
+ export const SFCC_READ_PROFILE_TOOL_NAMES = [
55
+ ...SFCC_READ_TOOL_NAMES,
56
+ SFCC_LOG_QUERY_TOOL_NAME,
57
+ ];
58
+ /**
59
+ * The `sfcc-write` WRITE profile: the nine destructive tools (BAPI-817).
60
+ *
61
+ * Reused from `writes.ts#SFCC_WRITE_TOOL_NAMES` rather than duplicated, so the
62
+ * profile inventory and the registrar's own list cannot drift apart.
63
+ */
64
+ export const SFCC_WRITE_PROFILE_TOOL_NAMES = [
65
+ ...SFCC_WRITE_TOOL_NAMES,
66
+ ];
67
+ /**
68
+ * Every profile-gated tool, across both groups.
69
+ *
70
+ * Retained as the aggregate for consumers that care about "gated vs always-on"
71
+ * rather than which group gates it — but composed from the two profile
72
+ * inventories above, never hand-maintained. Order matches registration order in
73
+ * `register.ts`: the read branch runs first, then the write branch.
74
+ */
75
+ export const SFCC_PROFILE_GATED_TOOL_NAMES = [
76
+ ...SFCC_READ_PROFILE_TOOL_NAMES,
77
+ ...SFCC_WRITE_PROFILE_TOOL_NAMES,
78
+ ];
79
+ /**
80
+ * The complete SFCC registration surface: always-on plus both gated profiles.
81
+ *
82
+ * This is the twenty-tool surface a `BRIDGE_MCP_PROFILE=sfcc,sfcc-write` (or
83
+ * `full`) session exposes, and the iteration source for the boundary-coverage
84
+ * test — so a newly added tool cannot silently evade that coverage.
85
+ */
86
+ export const SFCC_ALL_REGISTERED_TOOL_NAMES = [
87
+ ...SFCC_ALWAYS_REGISTERED_TOOL_NAMES,
88
+ ...SFCC_PROFILE_GATED_TOOL_NAMES,
89
+ ];
@@ -6,7 +6,6 @@
6
6
  * prevent the others from running.
7
7
  */
8
8
  import { SFCC_VERSIONS, getSfccVersionConfig } from "./config.js";
9
- import { resolveSfccCredentials } from "./credentials.js";
10
9
  import { getAmToken } from "./client.js";
11
10
  // ---------------------------------------------------------------------------
12
11
  // Implementation
@@ -57,39 +56,20 @@ export async function sfccSetupStatusTool(deps) {
57
56
  versionStatus = "— Skipped (Bridge API not configured)";
58
57
  }
59
58
  lines.push(`3. SFCC Version: ${versionStatus}`);
60
- // 4. dw.json / credential resolution
61
- let credStatus = "✗ Missing";
62
- let resolvedCredentials = null;
63
- try {
64
- const result = await resolveSfccCredentials();
65
- if (result.ok) {
66
- // Report source and instance ID only — never the secret values.
67
- resolvedCredentials = {
68
- hostname: result.credentials.hostname.split(".")[0] ?? result.credentials.hostname,
69
- source: result.credentials.source,
70
- };
71
- credStatus = `✓ Found (${resolvedCredentials.source})`;
72
- }
73
- else {
74
- credStatus = `✗ ${result.error}`;
75
- }
76
- }
77
- catch (err) {
78
- credStatus = `✗ Resolution error: ${err instanceof Error ? err.message : String(err)}`;
79
- }
80
- lines.push(`4. dw.json / Credentials: ${credStatus}`);
81
- // 5. AM token acquisition (only if credentials were resolved)
59
+ // 4. dw.json / credential resolution — read from the boundary-approved target
60
+ // rather than resolved again here (BAPI-817).
61
+ const target = deps.target;
62
+ // Report source and instance ID only — never the secret values.
63
+ const instanceLabel = target.approved
64
+ ? target.hostname.split(".")[0] ?? target.hostname
65
+ : null;
66
+ lines.push(`4. dw.json / Credentials: ${target.approved ? `✓ Found (${target.credentials.source})` : `✗ ${target.error}`}`);
67
+ // 5. AM token acquisition (only when the boundary approved a target).
82
68
  let tokenStatus = "— Skipped (credentials not available)";
83
- if (resolvedCredentials) {
69
+ if (target.approved) {
84
70
  try {
85
- const credResult = await resolveSfccCredentials();
86
- if (credResult.ok) {
87
- await getAmToken(credResult.credentials);
88
- tokenStatus = `✓ Token acquired for instance ${resolvedCredentials.hostname}`;
89
- }
90
- else {
91
- tokenStatus = "✗ Credentials not resolved";
92
- }
71
+ await getAmToken(target.credentials);
72
+ tokenStatus = `✓ Token acquired for instance ${instanceLabel}`;
93
73
  }
94
74
  catch (err) {
95
75
  // Sanitize: strip any token/secret-looking content from error message.
@@ -135,10 +115,14 @@ export async function sfccSetupStatusTool(deps) {
135
115
  }
136
116
  /**
137
117
  * Build the sfcc_setup_status handler bound to the host's runtime deps.
118
+ *
119
+ * The returned handler takes the `SfccTargetContext` the registration-level
120
+ * sandbox boundary supplies (BAPI-817) and forwards it, so this tool reports on
121
+ * the same target the boundary approved and never resolves one of its own.
138
122
  */
139
123
  export function buildSfccSetupStatusHandler(buildGetUrl, getGetHeaders, repoName, getApiKey) {
140
- return async (_args) => {
124
+ return async (_args, target) => {
141
125
  const apiKey = await getApiKey();
142
- return sfccSetupStatusTool({ buildGetUrl, getGetHeaders, repoName, apiKey });
126
+ return sfccSetupStatusTool({ buildGetUrl, getGetHeaders, repoName, apiKey, target });
143
127
  };
144
128
  }
@@ -1,19 +1,290 @@
1
1
  /**
2
- * Call-time SFCC configuration gate.
2
+ * The SFCC target boundary and the call-time SFCC configuration gate.
3
3
  *
4
- * Wraps every SFCC tool handler. Evaluated at call time (NOT startup) so all
5
- * SFCC tools are always registered — the gate decides at invocation whether the
6
- * repo is configured for SFCC and has valid local credentials.
4
+ * This module owns two distinct, layered checks. They run in this order, and
5
+ * the order is the whole point:
6
+ *
7
+ * 1. `withSfccTargetBoundary` — WHICH INSTANCE are we about to contact?
8
+ * Resolves credentials through the real precedence chain and validates the
9
+ * hostname those credentials actually select. Fails closed.
10
+ * 2. `withSfccGate` — is this repo CONFIGURED for SFCC at all? Reads the
11
+ * Bridge `version` config field and consumes the already-approved target
12
+ * rather than resolving a second time.
7
13
  *
8
14
  * The "not configured" response distinguishes three failure classes:
9
15
  * (a) Bridge-auth/config failure — could not read /config-field/version.
10
16
  * (b) version-not-SFCC — version is set but is not an SFCC version.
11
17
  * (c) missing/ambiguous dw.json — credential resolution failed.
18
+ *
19
+ * ---------------------------------------------------------------------------
20
+ * DECISION 1(a) — validate the RESOLVED hostname (BAPI-817)
21
+ * ---------------------------------------------------------------------------
22
+ *
23
+ * The README promised in five places that "non-sandbox instances are rejected".
24
+ * Before BAPI-817 nothing validated the target host. The only two guards
25
+ * (`write-guard.ts`, `reads-site-preference.ts`) compared a CALLER-SUPPLIED
26
+ * string argument to the literal `"sandbox"` — and `write-guard.ts` defaulted an
27
+ * omitted `instance` to `"sandbox"`, so the guard passed on its single most
28
+ * common call shape while the request went to whatever host `dw.json` named.
29
+ * Point `dw.json` or `SFCC_HOSTNAME` at production and every read and all nine
30
+ * writes executed against it.
31
+ *
32
+ * BAPI-817 resolves the ticket's Decision 1 as **(a): validate the actual
33
+ * resolved target hostname**, not (b) per-instance opt-in. (a) is the fix that
34
+ * makes the existing documentation true rather than lowering the promise to
35
+ * match the code. Two properties make it load-bearing:
36
+ *
37
+ * - **It validates `credentials.hostname`**, the value the OCAPI client will
38
+ * literally put in the URL — never `args.instance`, never `SFCC_HOSTNAME`,
39
+ * never a raw `dw.json` field read independently. Every credential
40
+ * precedence tier converges on one resolved host, so validating that one
41
+ * value covers all of them. A check on any input *upstream* of resolution
42
+ * can be contradicted by a later tier; a check on the output cannot.
43
+ * - **It fails closed.** An unrecognized, malformed, or unparseable hostname
44
+ * is refused. The accepted forms are an anchored allowlist built only from
45
+ * hostname forms this repository actually evidences (see
46
+ * `APPROVED_SANDBOX_HOST_FORMS`); a substring test such as
47
+ * `hostname.includes(".sandbox.")` is NOT used, because it also matches
48
+ * hosts like `prod.sandbox.attacker.example` where the label is not in the
49
+ * position the form requires.
50
+ *
51
+ * Caller-supplied context can no longer authorize a target. Omitting `instance`
52
+ * and passing `instance: "sandbox"` are now equivalent to passing nothing: they
53
+ * do not enter the decision. A dotted `instance` still *selects* a host through
54
+ * the existing explicit-override precedence, but the host it selects is then
55
+ * validated like any other, so `check_permissions` can no longer be aimed at an
56
+ * arbitrary named production instance.
57
+ *
58
+ * `register.ts` makes this structurally unavoidable: it wraps the host
59
+ * registrar once, and every SFCC tool — the always-on diagnostics, the eight
60
+ * reads (including the five that have no `instance` parameter at all), the nine
61
+ * writes, and `sfcc_log_query` — is registered through that adapter. There is
62
+ * no per-tool opt-in and no exemption flag.
12
63
  */
13
64
  import { SFCC_VERSIONS, getSfccVersionConfig } from "./config.js";
14
65
  import { resolveSfccCredentials } from "./credentials.js";
15
66
  import { formatSfccFailure } from "./sfcc-result.js";
16
67
  // ---------------------------------------------------------------------------
68
+ // Hostname policy
69
+ // ---------------------------------------------------------------------------
70
+ /**
71
+ * The DNS suffix every approved sandbox form shares.
72
+ *
73
+ * Every SFCC developer-sandbox hostname evidenced anywhere in this repository —
74
+ * `credentials.ts`'s JSDoc, the install guide's prerequisites, the BAPI-399
75
+ * live smoke-test record, and the existing test fixtures — lives under this
76
+ * suffix. Legacy `*.demandware.net` hosts are deliberately NOT accepted: that
77
+ * domain covers sandbox, staging, development, and production instances alike
78
+ * with no suffix that separates them, so admitting it would reintroduce exactly
79
+ * the unbounded target this boundary exists to close. It appears in this repo
80
+ * only as a test fixture, never in production code or the SFCC install docs.
81
+ */
82
+ const SFCC_DX_SUFFIX = ".dx.commercecloud.salesforce.com";
83
+ /**
84
+ * First labels that name a non-sandbox environment on the B2C platform.
85
+ *
86
+ * Checked against the leading label of every candidate, so a host like
87
+ * `production-zzrf.dx.commercecloud.salesforce.com` or
88
+ * `staging-zzrf.sandbox.us01.dx.commercecloud.salesforce.com` is refused even
89
+ * though it would otherwise satisfy a form below. This is belt-and-braces: form
90
+ * B alone would already admit `production-001`, and a named production instance
91
+ * is precisely the target the ticket was filed about.
92
+ */
93
+ const RESERVED_ENVIRONMENT_LABELS = [
94
+ "production",
95
+ "prod",
96
+ "staging",
97
+ "stg",
98
+ "development",
99
+ ];
100
+ /**
101
+ * Human-readable description of every accepted form, for the refusal envelope
102
+ * and the documentation contract test. Keep in sync with
103
+ * `isApprovedSfccSandboxHost` — the tests assert both directions.
104
+ */
105
+ export const APPROVED_SANDBOX_HOST_FORMS = [
106
+ "<realm>-<nnn>.sandbox.<region>.dx.commercecloud.salesforce.com",
107
+ "<realm>-<nnn>.sandbox.dx.commercecloud.salesforce.com",
108
+ "<realm>-<nnn>.dx.commercecloud.salesforce.com",
109
+ ];
110
+ /** One DNS label: 1–63 chars, alphanumeric, internal hyphens only. */
111
+ const DNS_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
112
+ /** Form B's leading label: an SFCC realm id followed by a numeric instance. */
113
+ const REALM_INSTANCE_LABEL = /^[a-z0-9]+-\d{3}$/;
114
+ /**
115
+ * Reduce a raw hostname to a canonical comparable form, or `null` if it is not
116
+ * a bare hostname at all.
117
+ *
118
+ * Only DNS-equivalent normalization is applied — lowercasing and stripping a
119
+ * single trailing root dot. Everything else is a rejection rather than a
120
+ * repair: a value carrying a scheme, port, path, query, credentials, or
121
+ * whitespace is not a hostname, and "cleaning it up" would mean guessing which
122
+ * part the operator meant. Guessing is how a boundary gets bypassed, so this
123
+ * returns `null` and the caller fails closed.
124
+ */
125
+ export function canonicalizeSfccHostname(raw) {
126
+ if (typeof raw !== "string")
127
+ return null;
128
+ let value = raw.trim().toLowerCase();
129
+ if (value.endsWith("."))
130
+ value = value.slice(0, -1);
131
+ if (value === "" || value.length > 253)
132
+ return null;
133
+ // Anything that is not strictly `label(.label)*` — scheme, port, path, query,
134
+ // userinfo, whitespace, IPv6 brackets, wildcards.
135
+ if (!/^[a-z0-9.-]+$/.test(value))
136
+ return null;
137
+ if (value.includes(".."))
138
+ return null;
139
+ const labels = value.split(".");
140
+ if (labels.length < 2)
141
+ return null;
142
+ if (!labels.every((label) => DNS_LABEL.test(label)))
143
+ return null;
144
+ // A bare IPv4 address is not a sandbox hostname and must not be canonicalized
145
+ // into one; the suffix check below would reject it anyway, but rejecting here
146
+ // keeps "is this a hostname?" and "is this host approved?" separate concerns.
147
+ if (/^\d+(\.\d+)*$/.test(value))
148
+ return null;
149
+ return value;
150
+ }
151
+ /**
152
+ * Decide whether a canonical hostname is an approved developer sandbox.
153
+ *
154
+ * Matching is ANCHORED on both ends: the host must END with the platform suffix
155
+ * and the labels in front of it must form one of the shapes below. This is the
156
+ * property a substring test lacks — `.includes(".sandbox.")` matches
157
+ * `zzzz-001.sandbox.evil.example.com`, whose registrable domain is not
158
+ * Salesforce's at all.
159
+ *
160
+ * Form A — explicit sandbox label, with or without a region:
161
+ * `zzzz-001.sandbox.us01.dx.commercecloud.salesforce.com`
162
+ * `zzzz-001.sandbox.dx.commercecloud.salesforce.com`
163
+ * Form B — realm + numeric instance, the on-demand sandbox form this project
164
+ * actually smoke-tested against (`docs/BAPI-399-sfcc-mcp-smoke-test.md`
165
+ * records sandbox `zyis-001`):
166
+ * `zyis-001.dx.commercecloud.salesforce.com`
167
+ *
168
+ * Form B is required for correctness, not convenience: without it the boundary
169
+ * would refuse the very sandbox this repository documents itself using, and a
170
+ * safety check that blocks the intended workflow gets disabled rather than
171
+ * obeyed. It is kept safe by requiring the `<realm>-<nnn>` shape (named
172
+ * environments such as `production-zzrf` do not match) and by the reserved-label
173
+ * check.
174
+ *
175
+ * Anything not matching — including every `*.demandware.net` host and any other
176
+ * registrable domain — is refused. Widening this set is a deliberate decision,
177
+ * not a bug fix: the ticket's Decision 1(b), an explicit per-instance opt-in, is
178
+ * the sanctioned way to reach an instance this policy does not recognize.
179
+ */
180
+ export function isApprovedSfccSandboxHost(canonical) {
181
+ if (!canonical.endsWith(SFCC_DX_SUFFIX))
182
+ return false;
183
+ const prefix = canonical.slice(0, -SFCC_DX_SUFFIX.length);
184
+ if (prefix === "")
185
+ return false;
186
+ const labels = prefix.split(".");
187
+ const first = labels[0];
188
+ for (const reserved of RESERVED_ENVIRONMENT_LABELS) {
189
+ if (first === reserved || first.startsWith(`${reserved}-`))
190
+ return false;
191
+ }
192
+ // Form A: <label>.sandbox[.<region>]
193
+ if ((labels.length === 2 || labels.length === 3) && labels[1] === "sandbox")
194
+ return true;
195
+ // Form B: <realm>-<nnn>
196
+ if (labels.length === 1 && REALM_INSTANCE_LABEL.test(first))
197
+ return true;
198
+ return false;
199
+ }
200
+ /**
201
+ * The one refusal envelope every SFCC tool returns for an unapproved target.
202
+ *
203
+ * Built through the shared `formatSfccFailure` so it is detectable by
204
+ * `result.isError === true` and parseable by the single SFCC error-parse path,
205
+ * exactly like every other SFCC failure.
206
+ *
207
+ * `403` is the honest status: Bridge understood the request and refused to
208
+ * perform it. `source: "gate"` is likewise honest — this is Bridge's own
209
+ * decision, not something OCAPI or WebDAV returned, so no upstream status is
210
+ * supplied.
211
+ *
212
+ * Only the rejected hostname and the accepted forms appear in the body. The
213
+ * hostname is not a secret and the operator cannot fix the problem without
214
+ * seeing it; the credential source string, client id, client secret, token, and
215
+ * raw `dw.json` contents never enter this envelope.
216
+ */
217
+ function targetNotSandbox(rejectedHostname) {
218
+ // A malformed value is echoed back bounded, so a pathological configuration
219
+ // value cannot inflate the response.
220
+ const safeRejected = rejectedHostname.length > 120 ? `${rejectedHostname.slice(0, 120)}…` : rejectedHostname;
221
+ return formatSfccFailure({
222
+ code: "TARGET_NOT_SANDBOX",
223
+ status: 403,
224
+ message: `SFCC tools are restricted to a developer sandbox. Refusing to contact ` +
225
+ `'${safeRejected}', which is not a recognized sandbox instance. Point dw.json ` +
226
+ `or SFCC_HOSTNAME at a developer sandbox and retry.`,
227
+ source: "gate",
228
+ details: {
229
+ failure_class: "target-not-sandbox",
230
+ rejected_hostname: safeRejected,
231
+ approved_hostname_forms: APPROVED_SANDBOX_HOST_FORMS,
232
+ },
233
+ });
234
+ }
235
+ // ---------------------------------------------------------------------------
236
+ // Target boundary
237
+ // ---------------------------------------------------------------------------
238
+ /**
239
+ * Wrap an SFCC handler with the resolved-hostname sandbox boundary.
240
+ *
241
+ * `register.ts` applies this to EVERY SFCC tool by wrapping the host registrar
242
+ * once, so no tool can be added that skips it — the alternative, asking each
243
+ * handler to remember a check, is what produced the five reads that had no
244
+ * `instance` parameter and therefore no guard at all.
245
+ *
246
+ * The returned handler:
247
+ * 1. Derives an explicit hostname override from `args.instance`, preserving
248
+ * the existing "a hostname always contains a dot, the OCAPI instance-context
249
+ * enum never does" rule.
250
+ * 2. Resolves credentials through the real precedence chain.
251
+ * 3. Refuses when the RESOLVED hostname is not an approved sandbox.
252
+ * 4. Otherwise calls the handler with the approved target.
253
+ *
254
+ * A resolution failure is passed through rather than refused — see
255
+ * `SfccTargetContext`. The refusal is returned as a normal MCP result and never
256
+ * thrown, so it cannot escape to the stdio transport.
257
+ */
258
+ export function withSfccTargetBoundary(handler, deps = {}) {
259
+ const resolve = deps.resolveCredentials ?? resolveSfccCredentials;
260
+ return async (args) => {
261
+ const safeArgs = args ?? {};
262
+ // A hostname always contains a dot; the OCAPI instance-context enum
263
+ // (e.g. "sandbox") never does — only treat args.instance as an explicit
264
+ // hostname override when it looks like one. The override still only
265
+ // *selects* a candidate; approval below is based on what resolution
266
+ // actually returned.
267
+ const rawInstance = typeof safeArgs.instance === "string" ? safeArgs.instance : undefined;
268
+ const explicitHostname = rawInstance && rawInstance.includes(".") ? rawInstance : undefined;
269
+ const credResult = await resolve(explicitHostname);
270
+ if (!credResult.ok) {
271
+ return handler(safeArgs, { approved: false, error: credResult.error });
272
+ }
273
+ const rawHostname = credResult.credentials.hostname;
274
+ const canonical = canonicalizeSfccHostname(rawHostname);
275
+ if (canonical === null || !isApprovedSfccSandboxHost(canonical)) {
276
+ return targetNotSandbox(typeof rawHostname === "string" ? rawHostname : String(rawHostname));
277
+ }
278
+ return handler(safeArgs, {
279
+ approved: true,
280
+ // The canonical host is substituted so every downstream URL is built from
281
+ // the exact value that was approved.
282
+ credentials: { ...credResult.credentials, hostname: canonical },
283
+ hostname: canonical,
284
+ });
285
+ };
286
+ }
287
+ // ---------------------------------------------------------------------------
17
288
  // Not-configured envelope helpers
18
289
  // ---------------------------------------------------------------------------
19
290
  /**
@@ -71,14 +342,24 @@ function notConfigured(failureClass, message) {
71
342
  * The returned handler:
72
343
  * 1. Reads /config-field/version via Bridge API.
73
344
  * 2. Validates the value is an SFCC version.
74
- * 3. Resolves dw.json credentials.
345
+ * 3. Consumes the credentials the target boundary already approved.
75
346
  * 4. Calls the inner handler only if all checks pass.
76
347
  *
348
+ * **This gate no longer resolves credentials itself** (BAPI-817). It receives an
349
+ * `SfccTargetContext` that `withSfccTargetBoundary` produced, and uses the
350
+ * credentials in it. Resolving a second time here would let the gate hand a
351
+ * handler a *different* host than the one that was approved — a
352
+ * time-of-check/time-of-use split in the middle of the boundary this ticket
353
+ * exists to establish. `missing-dw-json` is still this gate's own failure class
354
+ * and keeps its exact prior envelope; it is now driven by the unresolved target
355
+ * context rather than by a duplicate resolution call.
356
+ *
77
357
  * Every early rejection is the unified `isError: true` result — unconditionally,
78
358
  * with no tool-name check, per-tool branch, exemption flag, or gate-mode
79
359
  * parameter. `sfcc_setup_status`, the one SFCC tool whose successful output *is*
80
360
  * a not-configured report, is exempt structurally: `register.ts` registers it
81
- * outside this gate, so no code here needs to know about it.
361
+ * outside this gate (though still inside the target boundary), so no code here
362
+ * needs to know about it.
82
363
  *
83
364
  * On the success path the inner handler's result is returned directly — not
84
365
  * cloned, wrapped, or re-formatted — so write handlers keep ownership of their
@@ -86,7 +367,7 @@ function notConfigured(failureClass, message) {
86
367
  * type through the gate.
87
368
  */
88
369
  export function withSfccGate(deps, handler) {
89
- return async (args) => {
370
+ return async (args, target) => {
90
371
  // (a) Read /config-field/version
91
372
  const version = await getSfccVersionConfig(deps.buildGetUrl, deps.getGetHeaders, deps.repoName);
92
373
  if (version === null) {
@@ -100,18 +381,14 @@ export function withSfccGate(deps, handler) {
100
381
  `Expected one of: ${SFCC_VERSIONS.join(", ")}. ` +
101
382
  `Update the version field in your Bridge API project settings.`);
102
383
  }
103
- // (c) Resolve credentials from dw.json
104
- // A hostname always contains a dot; the OCAPI instance-context enum
105
- // (e.g. "sandbox") never does only treat args.instance as an explicit
106
- // hostname override when it looks like one.
107
- const rawInstance = typeof args.instance === "string" ? args.instance : undefined;
108
- const explicitHostname = rawInstance && rawInstance.includes(".") ? rawInstance : undefined;
109
- const credResult = await resolveSfccCredentials(explicitHostname);
110
- if (!credResult.ok) {
111
- return notConfigured("missing-dw-json", `SFCC credential resolution failed: ${credResult.error} ` +
384
+ // (c) Consume the boundary-approved target. An unapproved *host* never
385
+ // reaches here the boundary already refused it — so the only remaining
386
+ // case is credential resolution having failed outright.
387
+ if (!target.approved) {
388
+ return notConfigured("missing-dw-json", `SFCC credential resolution failed: ${target.error} ` +
112
389
  "Ensure a dw.json file exists in your project root with " +
113
390
  "hostname, client-id, and client-secret fields.");
114
391
  }
115
- return handler(args, credResult.credentials);
392
+ return handler(args, target.credentials);
116
393
  };
117
394
  }