fix(validation): close gaps in --global-arg/--input unknown-key check#1315
fix(validation): close gaps in --global-arg/--input unknown-key check#1315
Conversation
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>
2fd96bb to
afaa7c8
Compare
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
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.tsvariant uses a different prefix structure. Not blocking — all three are clear and actionable. -
run.tsuses "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.
There was a problem hiding this comment.
Code Review
Blocking Issues
None.
Suggestions
-
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 likefindUnknownKeys(args, schema): { unknown: string[], valid: string[] } | nullthat pairs withgetObjectShapecould 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. -
coerceMethodArgsbracket lookup onshape(zod_type_coercion.ts:122) —shape[key]still uses bracket notation, which walks the prototype chain. Ifkeyis"constructor",shape["constructor"]returns theObjectconstructor (truthy), andgetSchemaType(unwrapSchema(...))is called on it. This is harmless in practice (it gets""and skips coercion, then the downstream validation rejects the key viaObject.hasOwn), but adding anObject.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 shape → Object.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.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
method_execution_service.ts:435-445—hasUnresolvedbranch skips unknown-key checkWhen some global args contain expressions (
hasUnresolved = true), the code coerces resolved fields and applies them viasetGlobalArgumentwithout running the newObject.hasOwn-based unknown-key check. If a user passes--global-arg constructor=oops --global-arg name=${{inputs.foo}}, theconstructorkey lands inresolvedGlobalArgs, gets coerced (no-op), and is set on the definition — bypassing the prototype-chain check that theelsebranch 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 theelsebranch), 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
hasUnresolvedconditional, filtering only against resolved keys.
Low
-
zod_type_coercion.ts:122—shape[key]withoutObject.hasOwnincoerceMethodArgscoerceMethodArgslooks up field schemas viashape[key](line 122), which walks the prototype chain. For a key like"constructor",shape["constructor"]resolves toObject.prototype.constructor. This is harmless in practice — theObjectfunction has no_def, sogetSchemaTypereturns""and no coercion happens — but it's inconsistent with the PR's stated fix of usingObject.hasOwneverywhere. Not a security issue since callers always run theObject.hasOwncheck afterward. -
unwrapSchemadoes not handleZodPipeline(.pipe())The PR description mentions
.pipe()alongside.refine()and.transform(), butunwrapSchemaonly handles theeffectstype (covering.refine()and.transform()). AZodPipelinewrapper has type"pipeline"and stores its inner schema as_def.in, not_def.schema. If an extension wraps arguments in.pipe(),getObjectShapereturnsundefinedand 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 (typeName → type, 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.
Follow-ups from the #1312 adversarial review.
Summary
k in shape(walksObject.prototype) withObject.hasOwn(shape, k), so--input constructor=oops(and similar) is now rejected instead of silently dropped..refine()/.transform()/.pipe()lack.strict()and.shapeon the outer wrapper, so the previous duck-typed cast fell back to strip mode. AddedgetObjectShape()(using the existingunwrapSchemahelper) so the unknown-key check now works against the inner ZodObject. The original schema then runs normal validation, preserving refinements.npm:zod@3(uses_def.typeNameand_def.shape()as a function) ornpm:zod@4(uses_def.typeand_def.shapeas 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.model createerror 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
🤖 Generated with Claude Code