Skip to content

Commit b1d3945

Browse files
os-elon-muskclaude
andauthored
spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) (#19147)
Fixes #17852 Fixes #18847 ## What Implements maintainer ruling **A, narrow** (comment 5725370319, batch #154 item 1) verbatim. `$ZodRecord`'s open-key branch (zod v4 core) runs `if (key === "__proto__") continue;` **above** `def.keyType._zod.run`, so no key schema — regex, `.refine()`, `.superRefine()`, or one that rejects every string — can ever see a `__proto__` key. `ObjectSchema.fields` used to accept a document whose `fields` carried a `__proto__` own key and hand back a document without it: success, silent, irreversible into whatever `os build` writes. Two mechanisms, one per name class, at the two sites the ruling names: - **`packages/spec/src/data/object.zod.ts:1964` (`ObjectSchema.fields`)** — wrapped in a new pre-parse guard (`refuseRecordProtoKey`, `packages/spec/src/shared/record-proto-key-guard.ts`) that reads the raw input's own keys via `z.preprocess` and refuses a `__proto__` key with a named, located issue (`fields.__proto__`) before the record ever parses. `constructor` and `prototype` — which **do** reach the key schema unskipped (today's regex admits them as ordinary lowercase words) — are refused by the key grammar itself, via a `.refine()` beside the existing snake_case regex. - **`packages/spec/src/automation/builtin-node-config.zod.ts:923` (`AssignmentConfigSchema.assignments`)** — the same pre-parse guard, `__proto__` **only**. This slot's key type (`z.string().min(1)`) carries no grammar; `constructor` and `prototype` are legal flow-variable names today and are left legal — no ruling narrows this slot's accept set for those two names. - **`packages/spec/src/stack.zod.ts:3027-3029`** — corrected the false `// Post-parse and advisory: the stack is valid and is returned unchanged.` comment. It was false twice over: the parse could drop a `__proto__` key, and `:3032` returns `mergeActionsIntoObjects(data)`, not `data`. Region-disjoint from draft PR #18482 (its hunks are old lines 2853-2924), confirmed against the real PR file diff before editing; nothing else in this file was touched. ## A side effect the wrapping caused, and its fix `z.preprocess`'s `in` half is a `ZodTransform`, which unconditionally hardcodes `_zod.optin = "optional"` — a preprocess accepts any input, including `undefined`, regardless of what the wrapped schema does. Left alone, that made `ObjectSchema.fields` (which carries no `.optional()`) report as optional to `$ZodObject`'s own JSON-Schema requiredness check (`objectProcessor`, `io === 'input'`), so the published `data/Object` schema silently dropped `fields` from its `required` array while the **runtime** parse still correctly refused a missing `fields`. `refuseRecordProtoKey` now patches `optin`/`optout` on the pipe's inner `def.in` (not the outer pipe, which every `.describe()`/`.optional()` a caller chains afterward clones away) to mirror the wrapped schema's own values — verified before/after with `z.toJSONSchema(ObjectSchema, { io: 'input' })`. See the docblock in `record-proto-key-guard.ts` for the full mechanism. ## Two things flagged by the dispatching seat, answered directly **`compose-stacks-merge-collection-refusal.test.ts`** — this is a direct, mechanical consequence of the guard, not a defect found next door, and it stays in this PR. The test's own independent `isCollection` walker structurally pattern-matches `ObjectSchema.shape.fields`'s zod type; before this change `fields` was a bare `ZodRecord`, and wrapping it in `z.preprocess` necessarily makes it a `ZodPipe`. The walker's `pipe` case only recursed into `def.in` (correct for a `.pipe()` combo, where `in` is the original type) and missed the record hidden in `def.out` (the convention `z.preprocess(fn, schema)` actually uses). Fixed to check both sides of a pipe. The **production** merge/refuse logic in `stack.zod.ts` (`declaresCollection`/`objectCollectionKeys`) has the identical `def.in`-only blind spot, but it is functionally unaffected here because `fields` is excluded from that logic **by literal key name**, before `declaresCollection` is ever consulted — confirmed with an end-to-end `composeStacks({ objectConflict: 'merge' })` probe that still shallow-merges `fields` correctly. That production blind spot is a real, separate, dormant defect for any *future* collection-typed key that gets wrapped in `z.preprocess` (not `fields` — that one is safe by name) and is reported below as an out-of-scope finding rather than fixed here, since `stack.zod.ts` outside the 3027-3029 region is explicitly fenced off this card. **Regenerated spec artifacts** — three, all produced by the repo's own generators, none hand-edited: - `content/docs/references/{api/metadata,data/object,system/migration}.mdx` — via `pnpm --filter @objectstack/spec gen:docs`, reflecting the new `.describe()` text on `ObjectSchema.fields` (and, before the `optin`/`optout` fix above, briefly and incorrectly downgraded `fields` to "optional" — caught and fixed before this diff, confirmed by the requiredness fix and a full rebuild). - `packages/spec/dropped-refinements.baseline.json` — **hand-edited**, not generated (it has no `gen:` script by design; `check:generated`'s underlying `build-schemas.ts` prints the exact corrected `sites` arrays on a mismatch, and this edit pastes those verbatim, extracted programmatically from the build's own output rather than transcribed by hand). Nine entries gained a `fields.out.keyType` / `assignments.out.valueType`-shaped site: the new `.refine()` on `ObjectSchema.fields`' key type, and the `.out` path segment the `z.preprocess` wrapper's pipe structure introduces, neither of which projects into the published JSON Schema (see "Known gap" below) — `measured.droppedRefinementSites` moved from 553 to 562 accordingly. ## Known gap (stated by the ruling, not closed here) The guard does not project into the published JSON Schema (`packages/spec/json-schema/**`) — that general gap is #18670 and this card does not wait on it. ## Tests - `packages/spec/src/shared/record-proto-key-guard.test.ts` (new) — pins the guard in isolation against a minimal record: refuses `__proto__` with a named, located issue; a **control** proves the underlying unguarded record really would have silently dropped it; leaves ordinary keys, non-object input, `.optional()` composition and a caller's own `{ error }` option untouched. - `packages/spec/src/data/object.test.ts` — pins `ObjectSchema.fields` refusing `__proto__` (named issue, never falls through to the key-grammar's regex message), refusing `constructor`/`prototype` via the key grammar (`invalid_key`, nested refine message), and still accepting an ordinary document. - `packages/spec/src/automation/builtin-node-config.test.ts` — pins `AssignmentConfigSchema.assignments` refusing `__proto__`, and a **preservation** pin that `constructor`/`prototype` remain accepted as flow-variable names. - `packages/spec/src/compose-stacks-merge-collection-refusal.test.ts` — updated per the scope note above; all 62 cases pass. Every pin is a behaviour pin against the pinned `zod@^4.4.3`, not a version-string pin, per the dispatch's instruction. ## Gates run on this PR's head - `pnpm --filter @objectstack/spec build` — clean. - `pnpm --filter @objectstack/spec check:generated` — **all 16 generated artifacts up to date**, including `check:api-surface` ✓ and `check:authorable-surface` ✓ (both named by the ruling). - `pnpm --filter @objectstack/spec test` — 498 files / 14569 tests, all pass. - `pnpm --filter @objectstack/spec typecheck` — clean (`tsc --noEmit`, `check:scripts-typecheck`, `check:test-typecheck`; the pre-existing 259-error/144-signature test-typecheck debt ledger is unchanged). - `node scripts/check-adr-0087-registration.mjs --base origin/main` — the changeset's `not-required (no-migration-prescription)` disposition verified against the census (zero authored use anywhere reached). - `node scripts/pm/dispatch-gates.mjs --commands` derivation for this diff: **102 families derived, 99 run and green, 3 correctly NOT-MEASURED** (`check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt` — each refuses on `PREREQUISITE NOT MET`/exit 3, requiring a full ~80-package workspace build outside this card's local scope; not a finding). - Confirmed the fix reaches the rebuilt `dist/`, not only `src/` (imported `dist/data/index.mjs` directly and re-probed). - Rebased onto `origin/main` mid-flight (an unrelated `spec` PR landed); rebuilt, re-ran `check:generated`, the full test suite and typecheck again on the merged tree — all clean. ## Out-of-scope findings (not filed, not fixed here) - **To file** (class a, reproducible): `stack.zod.ts`'s `declaresCollection` (`case 'pipe': return declaresCollection(def.in, ...)`) only reads the `in` side of a pipe. For `z.preprocess(fn, schema)` the real type sits in `out`, so a *future* collection-typed key on `ObjectSchema.shape` wrapped in `z.preprocess` would silently stop being refused by `objectConflict: 'merge'`'s collision guard (#14848's own shape). Harmless for `fields` today only because it is excluded by literal key name first. Dedupe words: `declaresCollection`, `objectCollectionKeys`, `z.preprocess`, `pipe def.in`, `objectConflict merge`. - **Noted, not filed**: the measurement lead in the dispatch (whether `AssignmentConfigSchema`'s own `.catchall(z.unknown())` drops a top-level `__proto__` variable the same way) was re-measured: `$ZodObject`'s catchall branch (`handleCatchall`, zod v4 core) carries the identical `if (key === "__proto__") continue;` skip, with its own comment ("skip `__proto__` so it can't replace the result prototype via the assignment setter"). So the lead **holds** — a variable literally named `__proto__` at the top level of an assignment node config is silently dropped by the catchall the same way. Per the dispatch's instruction this is reported, not fixed, and not widened into this PR. Carrier: whoever files it — dedupe words `AssignmentConfigSchema catchall`, `handleCatchall __proto__`, `top-level assignment variable`. Clause-②: yes (narrowing) --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9120837 commit b1d3945

13 files changed

Lines changed: 464 additions & 40 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING** for authored metadata — `ObjectSchema.fields` refuses a key named `__proto__`, `constructor` or `prototype`, and `AssignmentConfigSchema.assignments` (the `assignment` flow node's variable map) refuses a key named `__proto__` — both refused with a named, located error at parse time, rather than silently accepted and then silently mishandled (objectstack#17852, objectstack#18847).
6+
7+
## Why
8+
9+
zod's `z.record()` skips a `__proto__` own key entirely, above its own key schema — the record parser's `if (key === "__proto__") continue;` runs before `def.keyType._zod.run`, so no key grammar (a regex, `.refine()`, `.superRefine()`, even a key schema that rejects every string) can ever see that key. A document whose `fields` (or `assignments`) carried a `__proto__` own key — which `JSON.parse` produces routinely — used to parse as SUCCESS with that key silently missing from the output: the validator accepted a document and handed back a *different* document. `os build` writes the release artifact from that returned document, so the failure shape is success, silent, and irreversible into the shipped artifact.
10+
11+
Two independent mechanisms close this, one per name class, because they are not reachable the same way:
12+
13+
- `__proto__` is refused by a **pre-parse guard** that reads the raw input's own keys before the record ever parses, at both `ObjectSchema.fields` and `AssignmentConfigSchema.assignments`.
14+
- `constructor` and `prototype` — which, unlike `__proto__`, DO reach the key schema unskipped — are refused by `ObjectSchema.fields`' own key grammar (they were ordinary lowercase words its regex already admitted). They are **not** refused at `AssignmentConfigSchema.assignments`: that slot's key type carries no grammar at all (`z.string().min(1)`), both names are legal flow-VARIABLE names measured to survive parse intact today, and no ruling narrows that slot's accept set for them — only its `__proto__` half moves.
15+
16+
Measured: zero authored use of any of the three names as a `fields` key or an `assignments` variable name, across this repo, `examples/` and `objectui`.
17+
18+
## Known gap, left open on purpose
19+
20+
The guard runs at parse time only. It does not project into the published JSON Schema (`packages/spec/json-schema/**`) — the general gap that closes is tracked separately (objectstack#18670) and stays open after this change.
21+
22+
Clause-②: yes (narrowing)
23+
24+
<!-- adr-0087: not-required (no-migration-prescription) zero authored use of `__proto__`, `constructor` or `prototype` as a `fields` key or an `assignments` variable name across this repo, examples/ and objectui — nobody has anything to rewrite, so there is no prescription to give. -->

‎content/docs/references/api/metadata.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -945,7 +945,7 @@ Metadata query with filtering, sorting, and pagination
945945
| **systemFields** | `false \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. |
946946
| **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. |
947947
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
948-
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. |
948+
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
949949
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
950950
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
951951
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ const result = ApiMethod.parse(data);
150150
| **systemFields** | `false \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. |
151151
| **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. |
152152
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
153-
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. |
153+
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
154154
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
155155
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
156156
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |

‎content/docs/references/system/migration.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ Create a new object
326326
| **systemFields** | `false \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. |
327327
| **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. |
328328
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
329-
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. |
329+
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
330330
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
331331
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
332332
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
@@ -611,7 +611,7 @@ Create a new object
611611
| **systemFields** | `false \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. |
612612
| **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. |
613613
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
614-
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. |
614+
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
615615
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
616616
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
617617
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"measured": {
44
"zod": "4.4.3",
55
"publishedSchemasWithDroppedRefinements": 204,
6-
"droppedRefinementSites": 560,
6+
"droppedRefinementSites": 569,
77
"refinementSitesThatDidProject": 357,
88
"refinementSitesWithNoJsonFormToCompare": 9
99
},
@@ -66,8 +66,9 @@
6666
"manifest.objectExtensions.element",
6767
"manifest.objects.element",
6868
"manifest.objects.element.fieldGroups",
69-
"manifest.objects.element.fields.valueType",
70-
"manifest.objects.element.fields.valueType.currencyConfig",
69+
"manifest.objects.element.fields.out.keyType",
70+
"manifest.objects.element.fields.out.valueType",
71+
"manifest.objects.element.fields.out.valueType.currencyConfig",
7172
"manifest.objects.element.lifecycle",
7273
"manifest.pages.element",
7374
"manifest.pages.element.slots.header.options[0].in.type",
@@ -157,8 +158,9 @@
157158
"data.options[1].manifest.objectExtensions.element",
158159
"data.options[1].manifest.objects.element",
159160
"data.options[1].manifest.objects.element.fieldGroups",
160-
"data.options[1].manifest.objects.element.fields.valueType",
161-
"data.options[1].manifest.objects.element.fields.valueType.currencyConfig",
161+
"data.options[1].manifest.objects.element.fields.out.keyType",
162+
"data.options[1].manifest.objects.element.fields.out.valueType",
163+
"data.options[1].manifest.objects.element.fields.out.valueType.currencyConfig",
162164
"data.options[1].manifest.objects.element.lifecycle",
163165
"data.options[1].manifest.pages.element",
164166
"data.options[1].manifest.pages.element.slots.header.options[0].in.type",
@@ -238,8 +240,9 @@
238240
"options[1].manifest.objectExtensions.element",
239241
"options[1].manifest.objects.element",
240242
"options[1].manifest.objects.element.fieldGroups",
241-
"options[1].manifest.objects.element.fields.valueType",
242-
"options[1].manifest.objects.element.fields.valueType.currencyConfig",
243+
"options[1].manifest.objects.element.fields.out.keyType",
244+
"options[1].manifest.objects.element.fields.out.valueType",
245+
"options[1].manifest.objects.element.fields.out.valueType.currencyConfig",
243246
"options[1].manifest.objects.element.lifecycle",
244247
"options[1].manifest.pages.element",
245248
"options[1].manifest.pages.element.slots.header.options[0].in.type",
@@ -280,8 +283,9 @@
280283
"data.packages.element.options[1].manifest.objectExtensions.element",
281284
"data.packages.element.options[1].manifest.objects.element",
282285
"data.packages.element.options[1].manifest.objects.element.fieldGroups",
283-
"data.packages.element.options[1].manifest.objects.element.fields.valueType",
284-
"data.packages.element.options[1].manifest.objects.element.fields.valueType.currencyConfig",
286+
"data.packages.element.options[1].manifest.objects.element.fields.out.keyType",
287+
"data.packages.element.options[1].manifest.objects.element.fields.out.valueType",
288+
"data.packages.element.options[1].manifest.objects.element.fields.out.valueType.currencyConfig",
285289
"data.packages.element.options[1].manifest.objects.element.lifecycle",
286290
"data.packages.element.options[1].manifest.pages.element",
287291
"data.packages.element.options[1].manifest.permissions.element.objects.valueType.out",
@@ -319,9 +323,10 @@
319323
"data.actions.element.in",
320324
"data.actions.element.in.params.element.in",
321325
"data.fieldGroups",
322-
"data.fields.valueType",
323-
"data.fields.valueType.currencyConfig",
324-
"data.fields.valueType.relatedListFilter.lazy",
326+
"data.fields.out.keyType",
327+
"data.fields.out.valueType",
328+
"data.fields.out.valueType.currencyConfig",
329+
"data.fields.out.valueType.relatedListFilter.lazy",
325330
"data.lifecycle",
326331
"data.listViews.valueType",
327332
"data.listViews.valueType.bulkActionDefs.element",
@@ -378,7 +383,7 @@
378383
},
379384
"automation/AssignmentConfig": {
380385
"sites": [
381-
"assignments.valueType"
386+
"assignments.out.valueType"
382387
]
383388
},
384389
"automation/AssignmentValue": {
@@ -678,9 +683,10 @@
678683
"actions.element.in",
679684
"actions.element.in.params.element.in",
680685
"fieldGroups",
681-
"fields.valueType",
682-
"fields.valueType.currencyConfig",
683-
"fields.valueType.relatedListFilter.lazy",
686+
"fields.out.keyType",
687+
"fields.out.valueType",
688+
"fields.out.valueType.currencyConfig",
689+
"fields.out.valueType.relatedListFilter.lazy",
684690
"lifecycle",
685691
"listViews.valueType",
686692
"listViews.valueType.bulkActionDefs.element",
@@ -948,7 +954,8 @@
948954
"operations.element.options[3].object.actions.element.in",
949955
"operations.element.options[3].object.actions.element.in.params.element.in",
950956
"operations.element.options[3].object.fieldGroups",
951-
"operations.element.options[3].object.fields.valueType",
957+
"operations.element.options[3].object.fields.out.keyType",
958+
"operations.element.options[3].object.fields.out.valueType",
952959
"operations.element.options[3].object.lifecycle",
953960
"operations.element.options[3].object.listViews.valueType",
954961
"operations.element.options[3].object.listViews.valueType.bulkActionDefs.element",
@@ -965,9 +972,10 @@
965972
"object.actions.element.in",
966973
"object.actions.element.in.params.element.in",
967974
"object.fieldGroups",
968-
"object.fields.valueType",
969-
"object.fields.valueType.currencyConfig",
970-
"object.fields.valueType.relatedListFilter.lazy",
975+
"object.fields.out.keyType",
976+
"object.fields.out.valueType",
977+
"object.fields.out.valueType.currencyConfig",
978+
"object.fields.out.valueType.relatedListFilter.lazy",
971979
"object.lifecycle",
972980
"object.listViews.valueType",
973981
"object.listViews.valueType.bulkActionDefs.element",
@@ -1007,7 +1015,8 @@
10071015
"options[3].object.actions.element.in",
10081016
"options[3].object.actions.element.in.params.element.in",
10091017
"options[3].object.fieldGroups",
1010-
"options[3].object.fields.valueType",
1018+
"options[3].object.fields.out.keyType",
1019+
"options[3].object.fields.out.valueType",
10111020
"options[3].object.lifecycle",
10121021
"options[3].object.listViews.valueType",
10131022
"options[3].object.listViews.valueType.bulkActionDefs.element",

‎packages/spec/src/automation/builtin-node-config.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,3 +610,58 @@ describe('assignment value envelope — an evaluated slot requires what the engi
610610
expect(ExpressionSchema.safeParse(BLANK_SOURCE).success).toBe(true);
611611
});
612612
});
613+
614+
/**
615+
* `AssignmentConfigSchema.assignments` — the `__proto__` half of #17852,
616+
* filed on its own as #18847 and folded back into this ruling once PR #18688
617+
* released this file (maintainer ruling A/narrow, comment 5725370319).
618+
*
619+
* `__proto__` ONLY. This slot's key type is `z.string().min(1)` — no
620+
* grammar — so unlike `ObjectSchema.fields` there is no key-refusal half to
621+
* add: `constructor` and `prototype` are legal flow-VARIABLE names today and
622+
* this ruling does not narrow that accept set. `__proto__` is refused for the
623+
* same structural reason as the sibling slot: `z.record()`'s open-key branch
624+
* skips it before any key schema — including `.min(1)` — ever runs.
625+
*/
626+
describe('AssignmentConfigSchema.assignments — __proto__ pre-parse guard, constructor/prototype UNCHANGED (#17852 / #18847)', () => {
627+
it('refuses `assignments` carrying a `__proto__` own key, named at `assignments.__proto__`', () => {
628+
// `JSON.parse` is what makes `__proto__` an OWN enumerable key — an
629+
// object literal's `{ __proto__: ... }` sets the actual prototype
630+
// instead, and would never reach `z.record()`'s open-key loop as a key
631+
// at all.
632+
const config = JSON.parse('{"assignments":{"total":"{amount}","__proto__":"{evil}"}}');
633+
const result = AssignmentConfigSchema.safeParse(config);
634+
expect(result.success).toBe(false);
635+
if (result.success) return;
636+
const issue = result.error.issues.find((i) => i.path.join('.') === 'assignments.__proto__');
637+
expect(issue).toBeDefined();
638+
expect(issue?.code).toBe('custom');
639+
expect(issue?.message).toMatch(/__proto__/);
640+
});
641+
642+
it('refuses `assignments` that is `__proto__` ALONE — no sibling key masks the drop', () => {
643+
const config = JSON.parse('{"assignments":{"__proto__":"{evil}"}}');
644+
const result = AssignmentConfigSchema.safeParse(config);
645+
expect(result.success).toBe(false);
646+
if (result.success) return;
647+
expect(result.error.issues.some((i) => i.path.join('.') === 'assignments.__proto__')).toBe(true);
648+
});
649+
650+
it.each(['constructor', 'prototype'])(
651+
'PRESERVATION: `%s` remains a legal flow-variable name — no ruling narrowed this slot\'s accept set',
652+
(name) => {
653+
const result = AssignmentConfigSchema.safeParse({ assignments: { [name]: '{x}' } });
654+
expect(result.success).toBe(true);
655+
if (!result.success) return;
656+
expect((result.data.assignments as Record<string, unknown> | undefined)?.[name]).toBe('{x}');
657+
},
658+
);
659+
660+
it('an absent `assignments` key still parses (the slot stays optional)', () => {
661+
expect(AssignmentConfigSchema.safeParse({}).success).toBe(true);
662+
});
663+
664+
it('an ordinary `assignments` map with no reserved names still parses', () => {
665+
expect(AssignmentConfigSchema.safeParse({ assignments: { total: '{amount}' } }).success).toBe(true);
666+
});
667+
});

0 commit comments

Comments
 (0)