@bridge_gpt/mcp-server 0.2.38 → 0.2.41

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 (85) hide show
  1. package/README.md +189 -14
  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 +479 -0
  14. package/build/conduct-epic/cli.js +1765 -0
  15. package/build/conduct-epic/lock.js +302 -0
  16. package/build/conduct-epic/pr-state.js +286 -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/permissions.js +25 -6
  67. package/build/sfcc/reads-site-preference.js +6 -0
  68. package/build/sfcc/register.js +61 -23
  69. package/build/sfcc/registration-inventory.js +89 -0
  70. package/build/sfcc/setup-status.js +18 -34
  71. package/build/sfcc/tool-wrapper.js +294 -17
  72. package/build/sfcc/write-grants.js +33 -1
  73. package/build/sfcc/write-guard.js +41 -12
  74. package/build/sfcc/writes-custom-object-def.js +6 -2
  75. package/build/sfcc/writes-site-preference.js +6 -1
  76. package/build/sfcc/writes-system-object.js +11 -2
  77. package/build/sfcc/writes.js +13 -8
  78. package/build/start-tickets-prereqs.js +25 -15
  79. package/build/start-tickets.js +123 -21
  80. package/build/version.generated.js +1 -1
  81. package/build/worktree-core.js +9 -3
  82. package/docs/install/mcp-tool-integrations.md +54 -9
  83. package/docs/install/sfcc-integration.md +71 -24
  84. package/package.json +3 -3
  85. package/build/executor/worker-config-isolation.js +0 -287
@@ -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
  }
@@ -27,6 +27,38 @@ export const OCAPI_WRITE_RESOURCE_IDS = [
27
27
  "/custom_object_definitions/**",
28
28
  "/site_preferences/**",
29
29
  ];
30
+ /**
31
+ * The exact HTTP methods each resource family needs, derived from the methods
32
+ * the nine shipped write handlers actually call (BAPI-817).
33
+ *
34
+ * Previously every family was granted `["get","put","patch","delete"]`. **No
35
+ * shipped handler performs a DELETE** — the write surface is create/update only
36
+ * (`ocapiPut`, `ocapiPatch`, `ocapiPatchDirect` in `client.ts`; verified across
37
+ * `writes-system-object.ts`, `writes-custom-object-def.ts`, and
38
+ * `writes-site-preference.ts`). Granting `delete` handed an operator's OCAPI
39
+ * client a destructive capability that Bridge cannot even invoke, on a grant
40
+ * they were told to paste once and forget. The narrower set below is what the
41
+ * code demonstrably needs and nothing more.
42
+ *
43
+ * `get` is retained where a write path genuinely reads: `ocapiPatch` performs a
44
+ * GET-then-If-Match ETag round trip before the PATCH, so removing `get` would
45
+ * break every update tool.
46
+ *
47
+ * /system_object_definitions get — the check_permissions probe.
48
+ * /system_object_definitions/** put, patch (+get for ETag) — attribute and
49
+ * attribute-group create/update, assignment,
50
+ * custom preference create.
51
+ * /custom_object_definitions/** put, patch (+get for ETag) — attribute
52
+ * definition create/update.
53
+ * /site_preferences/** patch (+get) — site_preference_values_set,
54
+ * a direct PATCH with no ETag round trip.
55
+ */
56
+ export const OCAPI_WRITE_RESOURCE_METHODS = {
57
+ "/system_object_definitions": ["get"],
58
+ "/system_object_definitions/**": ["get", "put", "patch"],
59
+ "/custom_object_definitions/**": ["get", "put", "patch"],
60
+ "/site_preferences/**": ["get", "patch"],
61
+ };
30
62
  // ---------------------------------------------------------------------------
31
63
  // Grant JSON builders
32
64
  // ---------------------------------------------------------------------------
@@ -44,7 +76,7 @@ export function buildOcapiWriteGrantSettings(ocapiVersion, clientIdPlaceholder =
44
76
  client_id: clientIdPlaceholder,
45
77
  resources: OCAPI_WRITE_RESOURCE_IDS.map((resource_id) => ({
46
78
  resource_id,
47
- methods: ["get", "put", "patch", "delete"],
79
+ methods: [...(OCAPI_WRITE_RESOURCE_METHODS[resource_id] ?? [])],
48
80
  read_attributes: "(**)",
49
81
  write_attributes: "(**)",
50
82
  })),
@@ -1,11 +1,36 @@
1
1
  /**
2
- * Reusable sandbox-only write guard (BAPI-582 foundation).
2
+ * Secondary OCAPI instance-CONTEXT check for writes (BAPI-582 foundation).
3
3
  *
4
- * Future SFCC write handlers MUST call `rejectIfNotSandboxForWrite`
5
- * immediately after input parsing and BEFORE any payload construction or OCAPI
6
- * mutation. A non-null return value is
7
- * the pre-formatted MCP rejection to return directly; a null return means the
8
- * effective instance is a sandbox and the handler may proceed.
4
+ * ⚠️ **This is NOT the sandbox security boundary.** It validates a
5
+ * caller-supplied *input string*, not the instance the request will reach. The
6
+ * hostname boundary lives in `tool-wrapper.ts`
7
+ * (`withSfccTargetBoundary` / `isApprovedSfccSandboxHost`), runs at
8
+ * registration level for all twenty SFCC tools, and validates the hostname
9
+ * credential resolution actually selected.
10
+ *
11
+ * The distinction is the entire subject of BAPI-817. This guard was previously
12
+ * described — in the README and in its own comments — as the thing that made
13
+ * SFCC writes sandbox-only. It never could be: `instance` is an argument the
14
+ * caller chooses, the target came from `dw.json`/`SFCC_HOSTNAME`, and the two
15
+ * were never compared. Worse, the guard **defaulted an omitted `instance` to
16
+ * `"sandbox"` and passed**, so it approved its single most common call shape
17
+ * while the request went to whatever host was configured. Nine destructive
18
+ * tools sat behind that.
19
+ *
20
+ * The behavior below is deliberately UNCHANGED, and is kept only as
21
+ * defence-in-depth on the OCAPI instance-context input: it rejects a caller who
22
+ * explicitly asks for `"staging"` or `"production"` context, which is still
23
+ * worth refusing. What changed is what it is allowed to *mean*. Its acceptance
24
+ * no longer authorizes anything — a write reaching this guard has already had
25
+ * its target hostname independently approved, and omitting `instance` cannot
26
+ * bypass that approval because the hostname check does not consult `instance`
27
+ * at all.
28
+ *
29
+ * SFCC write handlers still call `rejectIfNotSandboxForWrite` immediately after
30
+ * input parsing and BEFORE any payload construction or OCAPI mutation. A
31
+ * non-null return value is the pre-formatted MCP rejection to return directly;
32
+ * a null return means the effective instance context is `sandbox` and the
33
+ * handler may proceed.
9
34
  *
10
35
  * BAPI-766 moved the rejection's *representation* to the shared SFCC formatter
11
36
  * so the guardrail fails at the protocol level like every other SFCC failure.
@@ -17,13 +42,17 @@ import { formatSfccWriteFailure } from "./sfcc-result.js";
17
42
  // Guard
18
43
  // ---------------------------------------------------------------------------
19
44
  /**
20
- * Reject any write targeting a non-sandbox instance.
45
+ * Reject a write whose OCAPI instance CONTEXT is not `sandbox`.
46
+ *
47
+ * The effective context defaults to `"sandbox"` when `undefined` (some schemas
48
+ * default the value elsewhere), so an omitted instance is allowed here. That
49
+ * permissive default is safe only because it is no longer load-bearing: the
50
+ * resolved-hostname boundary has already approved the target before any handler
51
+ * runs, and it never consults this argument. Returning `null` therefore means
52
+ * "this input is not objectionable", never "this target is a sandbox".
21
53
  *
22
- * SFCC write tools are sandbox-only in this foundation. The effective instance
23
- * defaults to `"sandbox"` when `undefined` (some schemas default the value
24
- * elsewhere), so an omitted instance is allowed. Any other value returns the
25
- * unified validation failure naming the rejected instance; a sandbox returns
26
- * `null` so the caller proceeds.
54
+ * Any other value returns the unified validation failure naming the rejected
55
+ * context.
27
56
  *
28
57
  * `details` carries non-sensitive guard metadata only — the rejected value is
29
58
  * caller-supplied.
@@ -28,8 +28,12 @@ import { WRITE_ANNOTATIONS, encodedSegment, preTransportErrorEnvelope, unexpecte
28
28
  // ---------------------------------------------------------------------------
29
29
  // Input schemas
30
30
  // ---------------------------------------------------------------------------
31
- const INSTANCE_DESCRIBE = "OCAPI instance context. SFCC writes are sandbox-only; omit for sandbox. Any " +
32
- "other value is rejected before OCAPI is called.";
31
+ // OCAPI instance CONTEXT, not a target selector: the instance actually
32
+ // contacted is decided by the resolved-hostname boundary (BAPI-817), which this
33
+ // value cannot influence. "omit for sandbox" is gone because omitting it never
34
+ // selected anything.
35
+ const INSTANCE_DESCRIBE = "OCAPI instance context. Omit or pass \"sandbox\"; any other value is " +
36
+ "rejected before OCAPI is called.";
33
37
  const createCustomObjectAttributeDefinitionInput = z.object({
34
38
  object_type: z
35
39
  .string()
@@ -28,8 +28,13 @@ import { WRITE_ANNOTATIONS, encodedSegment, preTransportErrorEnvelope, unexpecte
28
28
  // Mirror reads-site-preference.ts so callers see the same instance enum, while
29
29
  // the write remains sandbox-only (enforced by the guard + the literal path).
30
30
  const INSTANCE_ENUM = z.enum(["staging", "development", "sandbox", "production"]);
31
+ // OCAPI instance CONTEXT only. The trailing "Defaults to 'sandbox'" is gone:
32
+ // it read as though the default selected a safe target, when the target is
33
+ // decided by the resolved-hostname boundary (BAPI-817) and this value cannot
34
+ // influence it. The forced `/sandbox` path segment below remains as an extra
35
+ // safeguard on the OCAPI route.
31
36
  const INSTANCE_DESCRIBE = "OCAPI instance context. v1 supports the 'sandbox' context only; any other " +
32
- "value is rejected before OCAPI is called. Defaults to 'sandbox'.";
37
+ "value is rejected before OCAPI is called.";
33
38
  const sitePreferenceValuesSetInput = z.object({
34
39
  group: z.string().describe("Custom site preference group id, e.g. 'LLMIntegration'."),
35
40
  instance: INSTANCE_ENUM.optional().default("sandbox").describe(INSTANCE_DESCRIBE),
@@ -50,11 +50,20 @@ function preferenceObjectTypeForScope(scope) {
50
50
  // ---------------------------------------------------------------------------
51
51
  // Input schemas
52
52
  // ---------------------------------------------------------------------------
53
- /** Optional sandbox instance context. Writes are sandbox-only (see write-guard). */
53
+ /**
54
+ * Optional OCAPI instance CONTEXT — not a target selector.
55
+ *
56
+ * The old description ("Sandbox-only: omit (defaults to sandbox) or pass
57
+ * \"sandbox\"") told the model that omitting this field selected a sandbox. It
58
+ * never did: the host came from `dw.json`/`SFCC_HOSTNAME`, and omitting the
59
+ * field only made `write-guard.ts` default to `"sandbox"` and pass. Which
60
+ * instance is actually contacted is decided by the resolved-hostname boundary in
61
+ * `tool-wrapper.ts` (BAPI-817), which this value cannot influence.
62
+ */
54
63
  const instanceSchema = z
55
64
  .string()
56
65
  .optional()
57
- .describe("Sandbox-only: omit (defaults to sandbox) or pass \"sandbox\".");
66
+ .describe("OCAPI instance context. Omit or pass \"sandbox\".");
58
67
  const createAttributeDefinitionInput = z.object({
59
68
  object_type: z.string().describe("System object type, e.g. \"Product\"."),
60
69
  attribute_id: z.string().describe("Attribute id (URL id); the body id must match it."),
@@ -3,9 +3,10 @@
3
3
  * populated by BAPI-584).
4
4
  *
5
5
  * This is the registration seam that write-surface tickets register through.
6
- * `register.ts` wires this behind the existing `includeReadTools` SFCC gate, so
7
- * the write tools become default-on with the reads without touching
8
- * `register.ts` or `index.ts`.
6
+ * `register.ts` wires it behind its OWN `includeWriteTools` gate, driven by the
7
+ * `sfcc-write` profile group (BAPI-817). The nine tools here are destructive, so
8
+ * they are no longer default-on with the reads: enabling `sfcc` registers the
9
+ * read profile and nothing from this module.
9
10
  *
10
11
  * Each write handler follows the foundation recipe:
11
12
  * 1. Parse inputs.
@@ -25,7 +26,8 @@ import { registerSitePreferenceWriteTools, SITE_PREFERENCE_WRITE_TOOL_NAMES, } f
25
26
  * Write tool names registered by this seam (centralized inventory). Extended by
26
27
  * each write-surface ticket — BAPI-583 adds the six system-object write tools,
27
28
  * BAPI-584 adds the custom-object attribute-definition and site-preference value
28
- * write tools.
29
+ * write tools. These nine have SHIPPED; `registration-inventory.ts` reuses this
30
+ * array as the `sfcc-write` profile inventory rather than duplicating it.
29
31
  */
30
32
  export const SFCC_WRITE_TOOL_NAMES = [
31
33
  ...SYSTEM_OBJECT_WRITE_TOOL_NAMES,
@@ -47,14 +49,17 @@ export const SFCC_WRITE_TOOL_NAMES = [
47
49
  * 4. Call `ocapiPut` or `ocapiPatch` (client.ts) for transport.
48
50
  * 5. Route the result through `formatOcapiWriteToolResult` (write-result.ts).
49
51
  *
50
- * Each handler is additionally call-time gated by `withSfccGate` and
51
- * sandbox-guarded, so registering them here (default-on with the reads) does
52
- * not weaken the per-call safety model.
52
+ * Every handler registered here crosses three checks before it can mutate
53
+ * anything: the resolved-hostname sandbox boundary (registration-level,
54
+ * `tool-wrapper.ts`), the call-time `withSfccGate` version check, and the
55
+ * secondary instance-context guard in `write-guard.ts`. Only the first of those
56
+ * decides which instance is contacted.
53
57
  *
54
58
  * BAPI-583 registers the system-object write surface here; BAPI-584 adds the
55
59
  * custom-object attribute-definition and site-preference value write surfaces
56
60
  * alongside it. Later write-surface tickets add their modules the same way,
57
- * without touching `register.ts`/`index.ts`.
61
+ * without touching `register.ts`/`index.ts` — a new tool added here is
62
+ * automatically inside the `sfcc-write` group and inside the target boundary.
58
63
  */
59
64
  export function registerSfccWriteTools(registerTool, deps) {
60
65
  // System-object attribute/group definition writes (BAPI-583).