fix: make input validation method-aware for extension models (#626)#638
fix: make input validation method-aware for extension models (#626)#638
Conversation
Input validation previously enforced all `required` inputs unconditionally
before method execution. This caused methods like `delete` to fail on
extension models with CRUD patterns because create-time inputs (e.g.,
`dropletName`, `region`) weren't provided, even though `delete` doesn't
use them.
The fix uses a three-layer approach:
1. CLI validation (model_method_run.ts): scan method YAML arguments for
`${{ inputs.* }}` references and only require inputs the method
actually uses
2. CEL evaluation (expression_evaluation_service.ts): skip evaluating
expressions that reference inputs not provided by the user, leaving
raw `${{ inputs.* }}` strings for methods that don't need them
3. Runtime guard (method_execution_service.ts): wrap `context.globalArgs`
in a Proxy that throws a clear error if a method accesses a field
containing an unresolved input expression; also strip unresolved
fields before Zod schema validation
Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Review Summary
Verdict: Approved ✅
This PR implements a well-designed three-layer fix for #626, making input validation method-aware for extension models.
Code Quality
| Criteria | Status |
|---|---|
| TypeScript strict mode | ✅ No new any types |
| Named exports | ✅ All new exports are named |
| License headers | ✅ All files have AGPLv3 header |
| Unit tests | ✅ 7 new tests in expression_parser_test.ts |
| Integration tests | ✅ 3 new tests in inputs_test.ts |
| CI checks | ✅ Lint, Test, Format passed |
| Security | ✅ No vulnerabilities |
Domain-Driven Design
- Domain services (
ExpressionEvaluationService,MethodExecutionService) are stateless and appropriately placed - Pure functions (
extractInputReferences,extractInputReferencesFromCel) are well-isolated in the expression parser module - Changes follow existing architectural patterns
Suggestions (non-blocking)
-
The third integration test validates success when globalArgs have unresolved inputs, but doesn't explicitly test the error case when a method accesses an unresolved globalArg. An automated test for this edge case could be added for regression protection (manual testing confirmed it works per the PR description).
-
Consider adding a unit test for the regex handling of
model.*.inputs.*patterns to ensure negative lookbehind handles bothinputandinputsin cross-model paths.
Highlights
- The three-layer approach is thorough: CLI validation, CEL evaluation, and runtime Proxy guard
- Well-documented comments explaining the "why" behind each layer
- Clear error messages via the Proxy pattern for missing inputs
- Comprehensive test coverage including edge cases (dot/bracket notation, deduplication, cross-model exclusion)
Great work! 🎉
There was a problem hiding this comment.
Adversarial Review
Medium
-
Nested globalArguments bypass unresolved expression detection (method_execution_service.ts:221-232)
The loop that identifies unresolved input expressions only checks top-level string values:
for (const [key, value] of Object.entries(rawGlobalArgs)) { if (typeof value === "string" && containsExpression(value) && ...) {
Breaking example: A model with nested globalArguments:
globalArguments: config: apiToken: "${{ inputs.apiToken }}"
When running a method without providing
apiToken:configis an object, sotypeof value === "string"fails- The nested expression isn't detected,
hasUnresolvedstays false - Full Zod validation runs and passes (the expression is a valid string)
- The method receives
context.globalArgs.config.apiTokenas the raw string"${{ inputs.apiToken }}"
The Proxy guard (lines 138-155) also only intercepts top-level property access, so
context.globalArgs.config.apiTokenbypasses the check entirely.Impact: Methods receive raw expression strings instead of values. Most likely causes runtime failures or incorrect behavior. Could leak expression syntax to external systems (API calls, logs).
Suggested fix: Either recursively check nested structures, or use a recursive Proxy that guards all levels.
-
Expressions referencing both provided and unprovided inputs skip entirely (expression_evaluation_service.ts:214-229)
If an expression references multiple inputs and ANY is missing, the entire expression is skipped:
if (hasMissing) { continue; // Skip the entire expression }
Breaking example:
globalArguments: combined: "${{ inputs.provided + ' ' + inputs.unprovided }}"
With only
providedgiven:- The expression is skipped (not evaluated)
combinedretains the raw expression string- Method may receive/use raw expression string
Impact: Same as issue #1 - raw expressions may reach runtime code. This is intentional per the PR design (defer to runtime Proxy), but the Proxy doesn't catch all cases (nested objects, bracket access patterns like
context.globalArgs["combined"]).
Low
-
Single-quoted bracket notation not detected (expression_parser.ts:233)
INPUTS_BRACKET_PATTERNonly matches double quotes:inputs["field"]. Single quotesinputs['field']won't be detected. CEL typically uses double quotes, so low risk, but inconsistent with CEL's actual syntax support. -
Escaped quotes in input names parsed incorrectly (expression_parser.ts:233)
Pattern
[^"]+stops at first quote.inputs["field\"name"]extractsfield\notfield"name. Edge case. -
Unicode input names not supported (expression_parser.ts:227)
Pattern
[a-zA-Z_][a-zA-Z0-9_]*is ASCII-only. If Unicode input names are valid elsewhere in the system, they won't be detected here.
Verdict
PASS — No critical or high severity issues.
The medium issues represent edge cases (nested globalArguments) that are unlikely in typical usage. The core fix for #626 is sound: method arguments are scanned correctly, and top-level globalArguments are guarded by the Proxy. The integration tests cover the intended scenarios.
The gaps in nested object handling could cause confusing failures in complex extension models, but won't cause data loss, security vulnerabilities, or crashes in production. The failure mode (method receives raw expression string) will typically manifest as an obvious runtime error when the method tries to use the invalid value.
Consider adding a follow-up task to recursively guard nested globalArguments structures if extension models with nested configs become common.
Summary
Fixes #626. Input validation previously enforced all
requiredinputs unconditionally before method execution. This caused methods likedeleteto fail on extension models with CRUD patterns because create-time inputs (e.g.,dropletName,region) weren't provided — even thoughdeletedoesn't use them.Problem
Extension models define inputs in
globalArgumentswith${{ inputs.* }}expressions. TheInputValidationServicechecked ALL required inputs before any method could run, making it impossible to run methods that don't need those inputs.For example, a
@test/dropletextension model with:inputs.required: [dropletName, region, tagName, vpcName]globalArgumentsreferencing all four inputscreatemethod usingcontext.globalArgs(needs all inputs)deletemethod not accessingcontext.globalArgs(needs no inputs)Running
swamp model method run web-droplet deletewould fail with input validation errors fordropletName,region, etc.Approach: Three-Layer Fix
The fix required changes at three different layers because inputs flow through multiple stages before reaching method code:
Layer 1: CLI Validation (
src/cli/commands/model_method_run.ts)${{ inputs.* }}expression referencesrequiredarray to only include inputs the method referencesLayer 2: CEL Expression Evaluation (
src/domain/expressions/expression_evaluation_service.ts)No such key: apiTokenwhen trying to resolve${{ inputs.apiToken }}for a method that doesn't need itLayer 3: Runtime Guard (
src/domain/models/method_execution_service.ts)context.globalArgsin a Proxy that throws a clear error if a method accesses a field containing an unresolved${{ inputs.* }}expressioncontext.globalArgs.dropletNamebut the user didn't provide that inputSupporting Code (
src/domain/expressions/expression_parser.ts)extractInputReferencesFromCel()— extracts input field names from a single CEL expression stringextractInputReferences()— scans a data structure for${{ }}expressions and returns the set ofinputs.*fields referencedinputs.fieldName) and bracket notation (inputs["field-name"])model.foo.input.bar)Why all three layers?
A single-layer fix was insufficient:
globalArguments→ TypeScript code, not YAML method argumentsexecuteWorkflow()fails on raw${{ inputs.* }}strings, and if that's bypassed,createsilently produces data with unresolved expression strings instead of failingBinary Testing
After compiling, tested the binary against a reproduction repo (
/tmp/swamp-626-test/) with a@test/dropletextension model matching the exact scenario from issue #626:swamp model method run web-droplet delete(no inputs)swamp model method run web-droplet create(no inputs)Missing required input(s): dropletName (needed by globalArguments.dropletName)swamp model method run web-droplet create --input dropletName=test --input region=nyc3 --input tagName=demo --input vpcName=my-vpc.swamp/data/Test plan
extractInputReferences()inexpression_parser_test.ts— dot/bracket notation, deduplication, cross-model exclusion, nested structuresinputs_test.ts— unreferenced required inputs succeed, referenced required inputs still validated, globalArguments inputs don't block unrelated methodsdeno check— passeddeno lint— passeddeno fmt— passeddeno run compile— passed🤖 Generated with Claude Code