mandrel-platform 1.11.0 → 1.13.0

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.
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-semgrep-lockfile.test.mjs — guards the SAST toolchain lockfile
4
+ * (Story #477).
5
+ *
6
+ * What this pins, and why each part earned a test:
7
+ *
8
+ * 1. **The advisories stay gone.** The lockfile carried protobuf 4.25.9
9
+ * (CVE-2026-0994, CVSS 8.2) and setuptools 80.9.0 (GHSA-h35f-9h28-mq5c).
10
+ * Neither could be fixed in place: every protobuf 4.x is affected, and
11
+ * semgrep 1.97.0's `opentelemetry-*~=1.25.0` pin capped protobuf below
12
+ * every patched release. A future bump that lands back inside an affected
13
+ * range would reintroduce a high with no other signal until the next
14
+ * scheduled OSV scan.
15
+ *
16
+ * 2. **The pin sites cannot drift.** The semgrep version lives in THREE
17
+ * places — this lockfile, `SEMGREP_PIN` in pr-quality.yml, and
18
+ * `DEFAULT_SEMGREP_PIN` in update-semgrep-rules.mjs — plus the
19
+ * `SEMGREP_HASHES` map that must carry digests for whatever the default is.
20
+ * Nothing detected disagreement between them before this file.
21
+ *
22
+ * 3. **`--require-hashes` stays satisfiable.** Every entry must be `==`-pinned
23
+ * with a sha256, or the install fails at CI time rather than here.
24
+ *
25
+ * The end-to-end proof (a real `pip install --require-hashes` on linux/cp312)
26
+ * cannot run in this suite — it needs that platform and a network. It is a
27
+ * `verify[]` step on the Story instead; these are the invariants checkable
28
+ * from the tree.
29
+ *
30
+ * Run: node --test scripts/check-semgrep-lockfile.test.mjs
31
+ */
32
+
33
+ import assert from "node:assert/strict";
34
+ import { test } from "node:test";
35
+ import { readFileSync } from "node:fs";
36
+
37
+ const LOCKFILE = "scripts/semgrep-requirements.txt";
38
+ const WORKFLOW = ".github/workflows/pr-quality.yml";
39
+ const UPDATER = "scripts/update-semgrep-rules.mjs";
40
+
41
+ const lockfile = readFileSync(LOCKFILE, "utf8");
42
+ const workflow = readFileSync(WORKFLOW, "utf8");
43
+
44
+ // semgrep's own `requires_python`, recorded per release. Verified on PyPI
45
+ // 2026-09-10: 1.97.0 was `>=3.8`, 1.136.0 `>=3.9`, and 1.137.0 raised it to
46
+ // `>=3.10` — which is why a runner on macOS system Python (3.9.6) could not
47
+ // install the 1.176.1 pin at all (issue #480).
48
+ //
49
+ // This table is what makes the floor in pr-quality.yml checkable without a
50
+ // network call: a bump to a release with no entry here fails loudly, so
51
+ // "look up the new requires_python" becomes a step of the bump rather than
52
+ // something discovered by a consumer's red CI.
53
+ const SEMGREP_PYTHON_FLOORS = new Map([["1.176.1", "3.10"]]);
54
+
55
+ /**
56
+ * Parse `name==version` requirement lines, ignoring comments and hash
57
+ * continuations. Keyed by lowercased name.
58
+ */
59
+ function requirements(text) {
60
+ /** @type {Map<string, string>} */
61
+ const out = new Map();
62
+ for (const line of text.split("\n")) {
63
+ const trimmed = line.trim();
64
+ if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("--hash")) {
65
+ continue;
66
+ }
67
+ const m = trimmed.match(/^([A-Za-z0-9._-]+)==([^\s\\]+)/);
68
+ if (m) {
69
+ out.set(m[1].toLowerCase(), m[2]);
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
75
+ const REQS = requirements(lockfile);
76
+
77
+ /** Compare dotted release segments numerically. Returns -1 / 0 / 1. */
78
+ function compareVersions(a, b) {
79
+ const pa = a.split(".").map((n) => Number.parseInt(n, 10));
80
+ const pb = b.split(".").map((n) => Number.parseInt(n, 10));
81
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
82
+ const x = Number.isNaN(pa[i]) || pa[i] === undefined ? 0 : pa[i];
83
+ const y = Number.isNaN(pb[i]) || pb[i] === undefined ? 0 : pb[i];
84
+ if (x !== y) return x < y ? -1 : 1;
85
+ }
86
+ return 0;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // 1. The advisories that put this Story on the board
91
+ // ---------------------------------------------------------------------------
92
+
93
+ test("protobuf is outside every CVE-2026-0994 affected range", () => {
94
+ // Affected: `< 5.29.6` and `>= 6.30.0rc1, <= 6.33.4`. Patched: 5.29.6, 6.33.5.
95
+ // A DoS in google.protobuf.json_format.ParseDict — 8.2, and the finding that
96
+ // opened tracking issue #472.
97
+ const version = REQS.get("protobuf");
98
+ assert.ok(version, `${LOCKFILE}: protobuf must be pinned`);
99
+ assert.doesNotMatch(version, /[a-zA-Z]/, "expected a final release, not a pre-release");
100
+
101
+ assert.ok(
102
+ compareVersions(version, "5.29.6") >= 0,
103
+ `protobuf ${version} is below the 5.29.6 patch — inside the "< 5.29.6" affected range`,
104
+ );
105
+ const inSixLine = compareVersions(version, "6.0.0") >= 0;
106
+ if (inSixLine) {
107
+ assert.ok(
108
+ compareVersions(version, "6.33.5") >= 0,
109
+ `protobuf ${version} is inside the ">= 6.30.0rc1, <= 6.33.4" affected range`,
110
+ );
111
+ }
112
+ });
113
+
114
+ test("setuptools is absent from the closure", () => {
115
+ // It was pinned to 80.9.0 only because semgrep 1.97.0's transitive
116
+ // opentelemetry-instrumentation imported pkg_resources at load. 0.58b0 does
117
+ // not, so the package — and its own advisory — leaves the graph entirely.
118
+ assert.equal(
119
+ REQS.has("setuptools"),
120
+ false,
121
+ "setuptools carries its own advisories and is no longer needed; do not re-add it without a stated reason",
122
+ );
123
+ });
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // 2. Drift between the three pin sites
127
+ // ---------------------------------------------------------------------------
128
+
129
+ test("the lockfile, the workflow, and the rules updater pin the same semgrep", () => {
130
+ const lockVersion = REQS.get("semgrep");
131
+ assert.ok(lockVersion, `${LOCKFILE}: semgrep must be pinned`);
132
+
133
+ const wf = workflow.match(/SEMGREP_PIN='semgrep==([^']+)'/);
134
+ assert.ok(wf, `${WORKFLOW}: SEMGREP_PIN not found`);
135
+ assert.equal(wf[1], lockVersion, "workflow SEMGREP_PIN disagrees with the lockfile");
136
+
137
+ const updater = readFileSync(UPDATER, "utf8");
138
+ const up = updater.match(/const DEFAULT_SEMGREP_PIN = "semgrep==([^"]+)";/);
139
+ assert.ok(up, `${UPDATER}: DEFAULT_SEMGREP_PIN not found`);
140
+ assert.equal(up[1], lockVersion, "updater DEFAULT_SEMGREP_PIN disagrees with the lockfile");
141
+ });
142
+
143
+ test("the rules updater carries artifact hashes for the version it defaults to", () => {
144
+ // SEMGREP_HASHES is a fail-fast supply-chain guard: a version with no entry
145
+ // is rejected rather than installed unverified. A bump that moved the default
146
+ // without adding digests would turn that guard into a hard stop.
147
+ const updater = readFileSync(UPDATER, "utf8");
148
+ const version = REQS.get("semgrep");
149
+ const map = updater.slice(updater.indexOf("const SEMGREP_HASHES = {"));
150
+ const block = map.slice(0, map.indexOf("\n};"));
151
+ assert.ok(
152
+ block.includes(`"${version}": [`),
153
+ `${UPDATER}: SEMGREP_HASHES has no entry for ${version}`,
154
+ );
155
+ });
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // 3. --require-hashes remains satisfiable
159
+ // ---------------------------------------------------------------------------
160
+
161
+ test("every requirement is == pinned and carries a sha256 hash", () => {
162
+ const pins = lockfile
163
+ .split("\n")
164
+ .filter((l) => /^[A-Za-z0-9._-]+==/.test(l.trim())).length;
165
+ const hashes = lockfile.split("\n").filter((l) => l.trim().startsWith("--hash=sha256:")).length;
166
+
167
+ assert.ok(pins > 0, "expected at least one pinned requirement");
168
+ assert.equal(REQS.size, pins, "every pinned line must parse to a requirement");
169
+ assert.ok(
170
+ hashes >= pins,
171
+ `${hashes} hash line(s) for ${pins} requirement(s) — --require-hashes needs at least one each`,
172
+ );
173
+ });
174
+
175
+ test("no requirement is pinned with a loose operator", () => {
176
+ // `--require-hashes` rejects these at install time; catching it here names
177
+ // the offending line instead of failing inside CI's pip.
178
+ //
179
+ // The operator is matched by string comparison, NOT by a regex alternating
180
+ // `<` and `>`. CodeQL reads such a pattern as an attempted HTML-tag filter
181
+ // and raises js/bad-tag-filter at HIGH — which blocks the merge, since
182
+ // code-scanning gates on high. Comparing prefixes says the same thing with
183
+ // nothing for that query to match on.
184
+ const LOOSE_OPERATORS = [">=", "<=", "~=", "!=", ">", "<"];
185
+ for (const line of lockfile.split("\n")) {
186
+ const trimmed = line.trim();
187
+ if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("--hash")) continue;
188
+ const name = trimmed.match(/^[A-Za-z0-9._-]+/);
189
+ if (!name) continue;
190
+ const operator = trimmed.slice(name[0].length).trimStart();
191
+ for (const loose of LOOSE_OPERATORS) {
192
+ assert.ok(
193
+ !operator.startsWith(loose),
194
+ `loose pin (${loose}) — --require-hashes needs an exact ==: ${trimmed}`,
195
+ );
196
+ }
197
+ }
198
+ });
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // 4. The regeneration trap
202
+ // ---------------------------------------------------------------------------
203
+
204
+ test("the header warns about the manylinux_2_34 wheel tag", () => {
205
+ // semgrep moved its Linux wheel tag after 1.157.0. A `pip download` whose
206
+ // --platform list omits the new tag resolves NOTHING newer and reports only
207
+ // "No matching distribution found", never naming the tag as the cause.
208
+ const header = lockfile.slice(0, lockfile.indexOf("\n\n\n") + 1 || 4000);
209
+ assert.match(header, /manylinux_2_34_x86_64/, "the header must name the current wheel tag");
210
+ assert.match(header, /1\.157\.0/, "the header must say which version the tag changed after");
211
+ assert.match(
212
+ lockfile,
213
+ /pip download semgrep/,
214
+ "the header must carry a regeneration command",
215
+ );
216
+ });
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // 5. The interpreter floor on the non-lockfile install path (Story #482)
220
+ // ---------------------------------------------------------------------------
221
+
222
+ test("pr-quality.yml declares an interpreter floor matching the pinned semgrep", () => {
223
+ const version = REQS.get("semgrep");
224
+ const declared = workflow.match(/SEMGREP_PYTHON_FLOOR='([^']+)'/);
225
+ assert.ok(
226
+ declared,
227
+ `${WORKFLOW}: SEMGREP_PYTHON_FLOOR must be declared beside SEMGREP_PIN — without it the SAST step cannot tell a too-old interpreter from a working one`,
228
+ );
229
+
230
+ const recorded = SEMGREP_PYTHON_FLOORS.get(version);
231
+ assert.ok(
232
+ recorded,
233
+ `no requires_python floor recorded for semgrep ${version} — read it off PyPI and add it to SEMGREP_PYTHON_FLOORS before bumping SEMGREP_PIN`,
234
+ );
235
+ assert.equal(
236
+ declared[1],
237
+ recorded,
238
+ `SEMGREP_PYTHON_FLOOR is ${declared[1]} but semgrep ${version} requires Python >= ${recorded}`,
239
+ );
240
+ });
241
+
242
+ test("the selector rides the same side-checkout as the lockfile", () => {
243
+ // The sparse-checkout is NON-CONE and lists exact paths, so a script the
244
+ // SAST step sources is absent at run time unless it is named here — and a
245
+ // missing `source` target kills the security tier for every consumer at
246
+ // once. Both files must sit in the one list.
247
+ const start = workflow.indexOf("- name: Checkout Semgrep lockfile");
248
+ assert.notEqual(start, -1, `${WORKFLOW}: the Semgrep side-checkout step was renamed or removed`);
249
+ const step = workflow.slice(start, workflow.indexOf("path: _mandrel-platform-semgrep", start));
250
+
251
+ assert.ok(
252
+ step.includes("scripts/semgrep-requirements.txt"),
253
+ "the side-checkout must still carry the lockfile",
254
+ );
255
+ assert.ok(
256
+ step.includes("scripts/select-semgrep-python.sh"),
257
+ "the side-checkout must carry the interpreter selector the SAST step sources",
258
+ );
259
+ assert.ok(
260
+ step.includes("sparse-checkout-cone-mode: false"),
261
+ "non-cone mode is what makes the exact-path list meaningful",
262
+ );
263
+ });
264
+
265
+ test("the SAST step selects an interpreter before it creates the venv", () => {
266
+ // A venv inherits the interpreter that built it, so a floor enforced after
267
+ // `-m venv` cannot fix anything. Order is the whole guarantee.
268
+ const select = workflow.indexOf("select-semgrep-python.sh");
269
+ const venv = workflow.indexOf("-m venv");
270
+ assert.notEqual(select, -1, `${WORKFLOW}: the SAST step must source the interpreter selector`);
271
+ assert.notEqual(venv, -1, `${WORKFLOW}: the SAST step must still create a venv`);
272
+ assert.ok(select < venv, "the selector must be sourced BEFORE the venv is created");
273
+
274
+ assert.ok(
275
+ workflow.includes('"${SEMGREP_PYTHON}" -m venv'),
276
+ "the venv must be built from the selected interpreter, not from bare python3",
277
+ );
278
+ });
279
+
280
+ test("the non-lockfile path installs exactly SEMGREP_PIN, never a resolved older release", () => {
281
+ // Downgrading to fit an old interpreter re-admits CVE-2026-0994: the newest
282
+ // py3.9-compatible semgrep (1.136.0) pins opentelemetry ~=1.25.0, which caps
283
+ // protobuf below 5.0, and every protobuf 4.x is affected. This path is not
284
+ // hash-pinned and its closure is not OSV-scanned, so it would be silent.
285
+ assert.ok(
286
+ workflow.includes('--retries 3 "${SEMGREP_PIN}"'),
287
+ "the fallback must install the exact pin",
288
+ );
289
+
290
+ // Matched as literal substrings rather than a regex alternating the
291
+ // comparison operators: CodeQL reads such a pattern as an attempted HTML-tag
292
+ // filter and raises js/bad-tag-filter at HIGH, which blocks the merge.
293
+ const LOOSE = ["semgrep<", "semgrep>", "semgrep~=", "semgrep!=", 'semgrep=="${'];
294
+ for (const loose of LOOSE) {
295
+ assert.ok(
296
+ !workflow.includes(loose),
297
+ `${WORKFLOW}: '${loose}' would let pip resolve a semgrep other than the pin`,
298
+ );
299
+ }
300
+ });
301
+
302
+ test("the Linux hash-pinned branch keeps its exact cp312 equality", () => {
303
+ // Widening this to a `>=` (proposed in issue #480) would route a cp313
304
+ // interpreter onto the lockfile's cp312-only wheels under
305
+ // `--only-binary :all:`, with no sdist fallback — the fleet-red the fallback
306
+ // exists to avoid. The equality is the guard, not the oversight.
307
+ assert.ok(
308
+ workflow.includes('[ "${pyver}" = "312" ]'),
309
+ `${WORKFLOW}: the cp312 test must stay an equality`,
310
+ );
311
+ assert.ok(
312
+ workflow.includes('The `= "312"` below is an EQUALITY on purpose'),
313
+ "the equality must carry a comment saying why a >= test would be wrong",
314
+ );
315
+ assert.ok(
316
+ workflow.includes("sdist fallback"),
317
+ "that comment must name the missing sdist fallback as the mechanism",
318
+ );
319
+ });
320
+
321
+ test("the floor added no workflow_call input and no new job permission", () => {
322
+ // A consumer-set semgrep pin would re-open the same un-scanned downgrade
323
+ // hole operator-side; `enable-sast: false` is the escape hatch. And a new
324
+ // job-level permission is a COMPILE-TIME break for every caller of this
325
+ // reusable workflow, not a runtime one.
326
+ assert.ok(!workflow.includes("semgrep-pin:"), "no semgrep-pin input — see the Story's non-goals");
327
+ assert.ok(!workflow.includes("python-version:"), "no python-version input — see the Story's non-goals");
328
+
329
+ const start = workflow.indexOf("name: Security (secret scan + SAST)");
330
+ assert.notEqual(start, -1, `${WORKFLOW}: the security job was renamed`);
331
+ const header = workflow.slice(start, workflow.indexOf("steps:", start));
332
+ const granted = header
333
+ .split("\n")
334
+ .map((l) => l.trim())
335
+ .filter((l) => l === "contents: read" || l === "actions: write");
336
+ assert.equal(
337
+ granted.length,
338
+ 2,
339
+ "the security job's permissions must remain exactly contents: read + actions: write",
340
+ );
341
+ assert.ok(!header.includes("id-token:"), "no new permission was needed for an interpreter floor");
342
+ assert.ok(!header.includes("packages:"), "no new permission was needed for an interpreter floor");
343
+ });
@@ -133,7 +133,7 @@ export const KEY_SCHEMA = Object.freeze({
133
133
  kind: "'var' | 'secret'",
134
134
  sensitivity: "'public' | 'secret'",
135
135
  residency:
136
- "object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers,kind}|null}",
136
+ "object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers: (string | {worker, environments})[], kind}|null}",
137
137
  infisical:
138
138
  "{folder, environments} | {folders: (string | {folder, environments})[]} | 'unmanaged'",
139
139
  shape: `string? — one of ${SHAPE_NAMES.join(", ")}`,
@@ -456,6 +456,103 @@ function normalizeInfisicalResidency(raw, { at, name, environments }) {
456
456
  return { folders };
457
457
  }
458
458
 
459
+ /**
460
+ * Normalize `residency.cloudflare` to its canonical
461
+ * `{workers: [{worker, environments}], kind}` form.
462
+ *
463
+ * `workers` accepts a bare worker id — the shape every manifest written before
464
+ * Story #483 uses — or a `{worker, environments}` object, because a key can be
465
+ * deliberately resident on one Worker in one environment only: a peer-database
466
+ * credential scoped that tightly to bound its blast radius, or a recipient
467
+ * allowlist that exists only where non-production sending is gated. With no
468
+ * per-entry `environments`, `probeCloudflare` reconciled ONE expected-name list
469
+ * against EVERY environment, so a deliberate single-environment placement had
470
+ * to report `missing` from the others — ten findings on one consumer's correct
471
+ * manifest, every one of them false (Story #481).
472
+ *
473
+ * A bare entry keeps meaning "every environment". That is the load-bearing
474
+ * constraint rather than a convenience: every manifest in existence declares
475
+ * `workers` as a bare string array, so any other reading would break them all.
476
+ *
477
+ * Both authored shapes normalize to one array of `{worker, environments}` with
478
+ * `environments` defaulted and materialized to `manifest.environments`, so
479
+ * `probeCloudflare` has exactly one shape to read — the same
480
+ * normalize-at-parse treatment `residency.github` received in Story #459 and
481
+ * `infisical` in Story #464. Cloudflare is the surface that never got it, and
482
+ * matching them matters more than the field shape itself: three expressive
483
+ * residencies with one idiom, not three.
484
+ *
485
+ * One deliberate divergence from those two: an **empty** `environments` array
486
+ * is rejected rather than read as "resident nowhere". That state is
487
+ * indistinguishable from omitting the residency altogether, and silently
488
+ * accepting it is precisely how a manifest author comes to believe they have
489
+ * scoped something they have not — the same fail-closed posture this module
490
+ * takes on an unknown `shape`.
491
+ *
492
+ * @param {unknown} raw
493
+ * @param {{at: string, name: string, workers: Record<string, object>, environments: string[]}} ctx
494
+ * @returns {{workers: Array<{worker: string, environments: string[]}>, kind: string} | null}
495
+ */
496
+ function normalizeCloudflareResidency(raw, { at, name, workers, environments }) {
497
+ if (raw === undefined || raw === null) return null;
498
+ if (typeof raw !== "object" || Array.isArray(raw)) {
499
+ throw new Error(`${at}.residency.cloudflare must be an object with {workers, kind} (key ${name})`);
500
+ }
501
+ if (!Array.isArray(raw.workers) || raw.workers.length === 0) {
502
+ throw new Error(`${at}.residency.cloudflare.workers must be a non-empty array of worker ids (key ${name})`);
503
+ }
504
+ if (raw.kind !== "secret" && raw.kind !== "var") {
505
+ throw new Error(`${at}.residency.cloudflare.kind must be "secret" or "var" (key ${name})`);
506
+ }
507
+
508
+ const seenWorkers = new Set();
509
+ const normalized = raw.workers.map((entry, j) => {
510
+ const where = `${at}.residency.cloudflare.workers[${j}]`;
511
+ let worker;
512
+ let authoredEnvs;
513
+ if (typeof entry === "string") {
514
+ worker = entry;
515
+ } else if (entry && typeof entry === "object" && !Array.isArray(entry)) {
516
+ worker = entry.worker;
517
+ authoredEnvs = entry.environments;
518
+ } else {
519
+ throw new Error(`${where} must be a worker id string or {worker, environments} (key ${name})`);
520
+ }
521
+
522
+ if (typeof worker !== "string" || !Object.hasOwn(workers, worker)) {
523
+ throw new Error(
524
+ `${at}.residency.cloudflare.workers references unknown worker id ${JSON.stringify(worker)} (key ${name})`
525
+ );
526
+ }
527
+ if (seenWorkers.has(worker)) {
528
+ throw new Error(
529
+ `${at}.residency.cloudflare repeats the worker "${worker}" — declare one entry per worker (key ${name})`
530
+ );
531
+ }
532
+ seenWorkers.add(worker);
533
+
534
+ if (authoredEnvs !== undefined) {
535
+ if (!Array.isArray(authoredEnvs) || !authoredEnvs.every((e) => typeof e === "string")) {
536
+ throw new Error(`${where}.environments must be an array of environment slugs (key ${name})`);
537
+ }
538
+ if (authoredEnvs.length === 0) {
539
+ throw new Error(
540
+ `${where}.environments must not be empty — omit it to mean every environment, or drop the entry (key ${name})`
541
+ );
542
+ }
543
+ for (const e of authoredEnvs) {
544
+ if (!environments.includes(e)) {
545
+ throw new Error(`${where}.environments names "${e}", absent from manifest.environments (key ${name})`);
546
+ }
547
+ }
548
+ }
549
+
550
+ return { worker, environments: authoredEnvs ? [...authoredEnvs] : [...environments] };
551
+ });
552
+
553
+ return { workers: normalized, kind: raw.kind };
554
+ }
555
+
459
556
  /**
460
557
  * @param {unknown} entry
461
558
  * @param {number} index
@@ -494,20 +591,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
494
591
 
495
592
  const github = normalizeGitHubResidency(residency.github, { at, name, environments });
496
593
 
497
- const cloudflare = residency.cloudflare ?? null;
498
- if (cloudflare !== null) {
499
- if (!Array.isArray(cloudflare.workers) || cloudflare.workers.length === 0) {
500
- throw new Error(`${at}.residency.cloudflare.workers must be a non-empty array of worker ids (key ${name})`);
501
- }
502
- for (const id of cloudflare.workers) {
503
- if (!Object.hasOwn(workers, id)) {
504
- throw new Error(`${at}.residency.cloudflare.workers references unknown worker id "${id}" (key ${name})`);
505
- }
506
- }
507
- if (cloudflare.kind !== "secret" && cloudflare.kind !== "var") {
508
- throw new Error(`${at}.residency.cloudflare.kind must be "secret" or "var" (key ${name})`);
509
- }
510
- }
594
+ const cloudflare = normalizeCloudflareResidency(residency.cloudflare, { at, name, workers, environments });
511
595
 
512
596
  const infisical = normalizeInfisicalResidency(entry.infisical, { at, name, environments });
513
597
 
@@ -530,7 +614,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
530
614
  residency: {
531
615
  local,
532
616
  github,
533
- cloudflare: cloudflare ? { workers: [...cloudflare.workers], kind: cloudflare.kind } : null,
617
+ cloudflare,
534
618
  },
535
619
  infisical,
536
620
  shape: entry.shape ?? null,
@@ -980,7 +1064,13 @@ export function runOfflineChecks({ manifest, repoRoot }) {
980
1064
  checked.push(`wrangler:${id}`);
981
1065
  const present = new Set(parseWranglerVars(readFileSync(configPath, "utf8"), configPath));
982
1066
  const expected = manifest.keys.filter(
983
- (k) => k.residency.cloudflare?.kind === "var" && k.residency.cloudflare.workers.includes(id)
1067
+ // Environment-agnostic by design: this check reports `environment: null`
1068
+ // and `parseWranglerVars` flattens `[env.X.vars]` into one set, so there
1069
+ // is no environment axis to narrow against. A var declared for ANY
1070
+ // environment stays expected in that worker's config.
1071
+ (k) =>
1072
+ k.residency.cloudflare?.kind === "var" &&
1073
+ k.residency.cloudflare.workers.some((w) => w.worker === id)
984
1074
  );
985
1075
  for (const key of expected) {
986
1076
  if (!present.has(key.name)) {
@@ -1417,10 +1507,37 @@ async function probeGitHub({ manifest, environments, github, surfaces, findings,
1417
1507
  }
1418
1508
 
1419
1509
  /**
1510
+ * Reconcile the Cloudflare surface per `(worker, environment)` pair.
1511
+ *
1512
+ * `expected` is narrowed to the keys whose residency names BOTH this worker
1513
+ * and this environment, so a deliberate single-environment placement no longer
1514
+ * reports `missing` from the environments it never claimed (Story #481).
1515
+ *
1516
+ * Two consequences of that narrowing are load-bearing, and neither is
1517
+ * incidental:
1518
+ *
1519
+ * 1. **A worker is still probed in an environment it expects nothing in**,
1520
+ * as long as it expects something SOMEWHERE. Skipping it would take the
1521
+ * surface's most interesting finding with it: a production-only key
1522
+ * turning up in staging is undeclared presence, and only an
1523
+ * empty-`expected` reconcile against a non-empty `present` reports it.
1524
+ * Cross-environment orphans go unsuppressed here exactly as they do on
1525
+ * the GitHub and Infisical surfaces. A worker that declares nothing in
1526
+ * any environment is still skipped entirely — that is the manifest
1527
+ * saying it has no opinion, which is not the same statement.
1528
+ * 2. **A 404 is only a finding where something WAS expected.** A worker
1529
+ * deployed to one environment by design 404s in the other, and with
1530
+ * nothing declared there that agrees with the manifest rather than
1531
+ * contradicting it. Reporting it would re-introduce, one layer down, the
1532
+ * same false failure this narrowing removes.
1533
+ *
1420
1534
  * @param {object} ctx
1421
1535
  */
1422
1536
  async function probeCloudflare({ manifest, environments, cloudflare, surfaces, findings, unavailability = {} }) {
1423
1537
  const cfKeys = manifest.keys.filter((k) => k.residency.cloudflare?.kind === "secret");
1538
+ /** Does any key declare this worker in any environment at all? */
1539
+ const declaresWorker = (id) =>
1540
+ cfKeys.some((k) => k.residency.cloudflare.workers.some((w) => w.worker === id));
1424
1541
  if (!cloudflare) {
1425
1542
  surfaces.push({
1426
1543
  surface: "cloudflare",
@@ -1432,8 +1549,12 @@ async function probeCloudflare({ manifest, environments, cloudflare, surfaces, f
1432
1549
  try {
1433
1550
  for (const environment of environments) {
1434
1551
  for (const [id, worker] of Object.entries(manifest.workers)) {
1435
- const expected = cfKeys.filter((k) => k.residency.cloudflare.workers.includes(id)).map((k) => k.name);
1436
- if (expected.length === 0) continue;
1552
+ const expected = cfKeys
1553
+ .filter((k) =>
1554
+ k.residency.cloudflare.workers.some((w) => w.worker === id && w.environments.includes(environment))
1555
+ )
1556
+ .map((k) => k.name);
1557
+ if (!declaresWorker(id)) continue;
1437
1558
  const scriptName = resolveScriptName(worker.scriptName, environment);
1438
1559
  let present;
1439
1560
  try {
@@ -1441,15 +1562,20 @@ async function probeCloudflare({ manifest, environments, cloudflare, surfaces, f
1441
1562
  } catch (err) {
1442
1563
  if (!isAbsentStatus(err)) throw err;
1443
1564
  // A 404 is the one status that legitimately means "absent": the
1444
- // Worker has not been deployed to this environment yet.
1445
- findings.push({
1446
- severity: "fail",
1447
- kind: "missing",
1448
- key: null,
1449
- surface: "cloudflare",
1450
- environment,
1451
- detail: `Worker script "${scriptName}" does not exist (404) — ${expected.length} declared secret(s) cannot be verified`,
1452
- });
1565
+ // Worker has not been deployed to this environment yet. That is only
1566
+ // drift where the manifest expected something here; a worker
1567
+ // deliberately absent from an environment it declares nothing in is
1568
+ // agreement, not a finding.
1569
+ if (expected.length > 0) {
1570
+ findings.push({
1571
+ severity: "fail",
1572
+ kind: "missing",
1573
+ key: null,
1574
+ surface: "cloudflare",
1575
+ environment,
1576
+ detail: `Worker script "${scriptName}" does not exist (404) — ${expected.length} declared secret(s) cannot be verified`,
1577
+ });
1578
+ }
1453
1579
  continue;
1454
1580
  }
1455
1581
  findings.push(