Skip to content

fix(validation): close gaps in --global-arg/--input unknown-key check#1315

Merged
stack72 merged 1 commit intomainfrom
fix/issue-244-followups
May 5, 2026
Merged

fix(validation): close gaps in --global-arg/--input unknown-key check#1315
stack72 merged 1 commit intomainfrom
fix/issue-244-followups

Conversation

@stack72
Copy link
Copy Markdown
Contributor

@stack72 stack72 commented May 5, 2026

Follow-ups from the #1312 adversarial review.

Summary

  • Prototype-chain bypass: replaced k in shape (walks Object.prototype) with Object.hasOwn(shape, k), so --input constructor=oops (and similar) is now rejected instead of silently dropped.
  • ZodEffects unwrapping: schemas built with .refine()/.transform()/.pipe() lack .strict() and .shape on the outer wrapper, so the previous duck-typed cast fell back to strip mode. Added getObjectShape() (using the existing unwrapSchema helper) so the unknown-key check now works against the inner ZodObject. The original schema then runs normal validation, preserving refinements.
  • Zod v3 + v4 compatibility: extensions may import npm:zod@3 (uses _def.typeName and _def.shape() as a function) or npm:zod@4 (uses _def.type and _def.shape as a value). The helpers now normalize both. Without this, Zod-v3-based extensions silently dropped unknown keys even with the fix(validation): reject unknown keys in --global-arg and override --input flags #1312 fix in place.
  • CLI UX: the model create error no longer shows the confusing leading \: ` prefix from Zod's empty-path \unrecognized_keys` issue. Errors are now explicit and name the valid keys.

Test plan

  • Unit tests in `zod_type_coercion_test.ts`: `getObjectShape` works for plain ZodObject, `.refine()`-wrapped, `.optional()`-wrapped, primitive (returns undefined), and Zod v3 `typeName`/`shape()` shapes
  • Unit tests in `run_test.ts`: rejects `constructor` key (prototype bypass) and rejects unknown key on `.refine()`-wrapped method arguments
  • Unit tests in `create_test.ts`: rejects `constructor` key as global arg and rejects unknown key on `.refine()`-wrapped global arguments
  • Manual repro against `/tmp/swamp-repro-issue-244` (which uses `npm:zod@3`): `--global-arg typoKey=oops` and `--global-arg constructor=oops` both now error with a clear message naming the valid keys

🤖 Generated with Claude Code

Three follow-ups from the #1312 adversarial review:

1. Prototype-chain bypass: the unknown-key check used `k in shape` which
   walks Object.prototype, so flag keys like `--input constructor=oops`
   bypassed the check. Switch to `Object.hasOwn(shape, k)`.

2. ZodEffects not unwrapped: schemas defined with `.refine()`,
   `.transform()`, or `.pipe()` lack `.strict()` and `.shape` on the
   outer wrapper, so the previous duck-typed cast silently fell back to
   strip mode. Introduce `getObjectShape()` that uses the existing
   `unwrapSchema` helper to find the inner ZodObject's shape, then do an
   explicit unknown-key check against it. The original schema then
   handles normal validation, preserving refinements/transforms.

3. Zod v3 vs v4 internals: extensions may import `npm:zod@3` (where
   `_def.typeName` is e.g. `"ZodObject"` and `_def.shape` is a function)
   or `npm:zod@4` (where `_def.type` is `"object"` and `_def.shape` is
   a value). Normalize both in `getSchemaType()` and `readShape()`.

Also fixes the CLI UX issue from the #1312 review: the `model create`
error no longer shows a leading `: ` prefix because the unknown-key
error is now produced explicitly with a clear message naming valid
arguments, instead of via Zod's empty-path `unrecognized_keys` issue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@stack72 stack72 force-pushed the fix/issue-244-followups branch from 2fd96bb to afaa7c8 Compare May 5, 2026 19:06
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLI UX Review

Blocking

None.

Suggestions

  1. Slight message phrasing inconsistency across error sites — The three new unknown-key error messages use slightly different prefixes:

    • create.ts: Unknown global argument(s) for type 'X': ...
    • validation_service.ts: Unknown global argument(s): ...
    • method_execution_service.ts: Global arguments validation failed: Unknown argument(s): ...

    None of these are confusing, but the method_execution_service.ts variant uses a different prefix structure. Not blocking — all three are clear and actionable.

  2. run.ts uses "Valid inputs are:" while the new messages use "Valid arguments are:" — This inconsistency pre-dates this PR (the run.ts message wasn't changed here), but worth noting for a future cleanup pass.

Verdict

PASS — The primary UX fix (removing the confusing leading : from empty-path Zod errors in create.ts) is a clear improvement. All new unknown-key error messages are explicit, name the offending key(s), and list valid alternatives. No help text, flags, or JSON output shapes were changed.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Blocking Issues

None.

Suggestions

  1. Repeated unknown-key check pattern — The same shape → filter unknown keys → format error pattern appears in 5 call sites (method_execution_service.ts, validation_service.ts ×2, create.ts, run.ts). A small helper like findUnknownKeys(args, schema): { unknown: string[], valid: string[] } | null that pairs with getObjectShape could reduce the duplication. Each site would only need to handle its own error-reporting mechanism. Not blocking since the current code is correct and each site has different control flow.

  2. coerceMethodArgs bracket lookup on shape (zod_type_coercion.ts:122) — shape[key] still uses bracket notation, which walks the prototype chain. If key is "constructor", shape["constructor"] returns the Object constructor (truthy), and getSchemaType(unwrapSchema(...)) is called on it. This is harmless in practice (it gets "" and skips coercion, then the downstream validation rejects the key via Object.hasOwn), but adding an Object.hasOwn(shape, key) guard here would be consistent with the fix applied everywhere else.

Summary

Solid security fix closing three real gaps from the adversarial review: prototype-chain key bypass (k in shapeObject.hasOwn), ZodEffects unwrapping for .refine()/.transform() schemas, and Zod v3/v4 normalization. The getObjectShape helper is well-placed in the domain layer and the test coverage is thorough — covers plain objects, refined schemas, optional wrappers, non-object schemas, and Zod v3 internal structure simulation. No import boundary violations; libswamp-internal code correctly imports from the domain layer. DDD layering is clean.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial Review

Critical / High

None found.

Medium

  1. method_execution_service.ts:435-445hasUnresolved branch skips unknown-key check

    When some global args contain expressions (hasUnresolved = true), the code coerces resolved fields and applies them via setGlobalArgument without running the new Object.hasOwn-based unknown-key check. If a user passes --global-arg constructor=oops --global-arg name=${{inputs.foo}}, the constructor key lands in resolvedGlobalArgs, gets coerced (no-op), and is set on the definition — bypassing the prototype-chain check that the else branch now correctly performs.

    Impact: The stray key has no functional effect (the method won't access it, and the schema doesn't declare it), so this is a UX gap, not a security issue. The user silently loses their typo instead of getting the helpful "Unknown argument(s)" error. This gap existed before this PR (the old .strict() path was also only in the else branch), so it's not a regression — but since this PR is specifically about closing these gaps, it's worth noting.

    Suggested fix: Extract the unknown-key check into a helper and call it in both branches of the hasUnresolved conditional, filtering only against resolved keys.

Low

  1. zod_type_coercion.ts:122shape[key] without Object.hasOwn in coerceMethodArgs

    coerceMethodArgs looks up field schemas via shape[key] (line 122), which walks the prototype chain. For a key like "constructor", shape["constructor"] resolves to Object.prototype.constructor. This is harmless in practice — the Object function has no _def, so getSchemaType returns "" and no coercion happens — but it's inconsistent with the PR's stated fix of using Object.hasOwn everywhere. Not a security issue since callers always run the Object.hasOwn check afterward.

  2. unwrapSchema does not handle ZodPipeline (.pipe())

    The PR description mentions .pipe() alongside .refine() and .transform(), but unwrapSchema only handles the effects type (covering .refine() and .transform()). A ZodPipeline wrapper has type "pipeline" and stores its inner schema as _def.in, not _def.schema. If an extension wraps arguments in .pipe(), getObjectShape returns undefined and the unknown-key check is skipped, falling back to Zod's silent strip mode. This is a pre-existing limitation (.pipe() on argument schemas is unusual) but the PR description's mention of it could mislead readers into thinking it's covered.

Verdict

PASS — The core fixes are correct and well-tested: Object.hasOwn closes the prototype-chain bypass, getObjectShape + unwrapSchema correctly handles ZodEffects wrappers, and the Zod v3/v4 normalization (typeNametype, shape()shape) is sound. The test coverage is thorough, including mock v3-style schemas. The medium finding is a pre-existing gap in a partial-validation branch, not a regression.

@stack72 stack72 merged commit ae9b1c1 into main May 5, 2026
11 checks passed
@stack72 stack72 deleted the fix/issue-244-followups branch May 5, 2026 19:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant