fix(dci-cli): validate typed path parameters before sending the request - #68
fix(dci-cli): validate typed path parameters before sending the request#68ektasawant wants to merge 2 commits into
Conversation
…9366] restish substitutes a path argument into the URI verbatim: Param.Parse is a stub that returns its input unchanged, so the type the OpenAPI spec declares is loaded and then ignored. Agents passing `ticket-id: 318240` instead of `318240` sent malformed URLs that could only fail as a 404, which reads as "ticket not found" rather than "you sent garbage". Check each positional argument against its declared type before the request is built, and suggest the corrected invocation when the rejected value still carries a recoverable identifier. Validation fails open when operation metadata is unavailable, so a valid command never fails because the spec could not be loaded. Co-authored-by: Cursor <cursoragent@cursor.com>
Recovery read a label hyphen as a minus sign, so `ticket-id-318240` suggested `dci get-ticket -318240` — pflag reads that as shorthand flag `-3`, meaning an agent following the hint failed a second time and the self-correcting purpose was lost. Match digits only. Also reject integers whose literal form is not canonical. A leading + or 0 parses cleanly but reaches the API exactly as written and 404s there, which is the defect class this validation exists to close. Suggestions are now canonicalized so they cannot be rejected on the retry. Co-authored-by: Cursor <cursoragent@cursor.com>
Manual test steps + verification evidenceVerified against Result
The 18 captured on On this PR all 18 exit 2 with no request built, while
Reproduce itRequires
|
| result | |
|---|---|
| URLs byte-identical on both | 86 |
URLs present on main only |
7 — exactly the ticket commands |
| URLs present on this PR only | 0 |
Exit codes differ on those 7 commands and nowhere else. The spec census behind this: 109 path parameters total, 102 string and 7 integer, and the 7 integers are precisely the ticket commands — so there is no other command this change can reach.
Edge cases
41 cases derived from the branches in path_validation.go, asserting both the exit code and whether a request was built:
- Canonical form — int64 min/max and ±1 past each,
0,-0,+318240,0318240, empty, leading/trailing whitespace,318240.0,1e5,0x4dae0,3_18240,318,240, Arabic-Indic and fullwidth digits. - Recovery — zero, one, and two digit-candidate inputs.
ticket 318240 v2(two candidates) and99999999999999999999(overflows) correctly decline to guess and fall back to the generic hint;ticket-id: 0318240canonicalizes to318240. - Integration — the
id-of-ticket-getalias, flags before and after the positional,-D,--help(wins over a bad value), and__complete(not blocked). - Ordering —
update-ticketfed a stdin pipe that never produces data exits 2 immediately instead of hanging, which demonstrates rejection precedes body buffering more directly than reading the diff does.
Everything behaved as designed. Negative integers are the one branch that is unreachable in practice: dci get-ticket -1 is rejected by the flag parser (unknown shorthand flag: '1' in -1) identically on main, so the validator never sees it.
Known limitation — the hint is agent-only
In an interactive terminal a human sees one line and no suggestion:
Error: invalid value for path argument "ticket-id": "ticket-id: 318240" is not an integer
The hint above is only rendered in agent mode's JSON envelope, so a human gets told what is wrong but not how to fix it, whereas other error classes in the CLI do print a Hint: line. This is not a regression — pre-existing usage errors such as the arg-count error behave identically on main — and all 14 production occurrences came from agent sessions, so the agent path is the one that matters. Flagging it rather than hiding it; happy to surface the hint in human mode here or in a follow-up.
Test suite
go test ./... failure set is identical to main for the same invocation: TestCustomerContextFlag subtests, pre-existing and environment-dependent (they time out without network access to api.doit.com). Note status_shows_oauth_by_default also fails whenever DCI_API_KEY is set in the environment — unset it before baselining or it reads as a real failure on both branches. The suite is also order-dependent: a subset run fails TestCustomerContextFlagOverride instead, on main too, so compare like-for-like invocations. go vet clean, gofmt clean.
Live API check — done
dci get-ticket <a-real-ticket-id> # must still return 200Run against a real ticket with a live OAuth session. get-ticket, list-ticket-comments and list-ticket-tags all exit 0, and each response is byte-identical to main. The valid-ID URL is unchanged before and after, so this confirms the substitution logic end to end against production.
Closes CMP-49366.
Summary
dcisubstituted whatever string it was given into a path parameter slot and sent the request, with no check against the parameter's declared type. restish loads that type onto everyParambut never consults it —cli/param.go:40-44,Param.Parse, is a stub that returns its input unchanged — sodci get-ticket 'ticket-id: 318240'builtGET /support/v1/tickets/ticket-id:%20318240and could only fail as a 404. Every 404 the CLI has received on the ticket routes in the last 12 months (14 of 1,156 requests) was this defect, and all of them came from agent-mode sessions, where a 404 reads as "no such ticket" rather than "your identifier was malformed".New
path_validation.gochecks each positional argument against its declared type before the request is built:integerviaParseInt(v, 10, 64), which bounds to 64 bits and so matches the spec'sformat: int64exactly,numberviaParseFloat,booleanviaParseBool, and anything else — includingstring— accepted unchanged. It handles restish'sarray[...]type notation by validating each comma-separated element.Rejection is a
USAGE_ERRORwith exit code 2 andretryable: false. When the rejected value still carries a recoverable identifier, the error suggests the corrected invocation, which all nine malformed URLs observed in production do:Three properties worth calling out:
string, which accepts anything, so validation cannot start rejecting values for a parameter the spec leaves untyped. Behaviour changes for the 7 ticket commands and cannot regress the other 102 string-typed path parameters.addOutputFlag'sPersistentPreRunE, ahead of both stdin buffering for body validation and the destructive-confirmation gate, so--dry-runvalidates too and no HTTP request is ever built.Integers must also be in canonical form. A leading
+or0parses cleanly but reaches the API exactly as written and 404s there, which is the same defect class — so+318240is rejected and the suggestion is canonicalized, ensuring it cannot be rejected again on the retry.No server, API-contract, or data changes. The optional server-side complement in omni and the upstream
Param.Parseimplementation are tracked separately and deliberately not blocking.Test plan
go test ./...andgo vet ./...pass;gofmtclean. Failure set is identical tomainfor the same invocation:TestCustomerContextFlagsubtests, which are pre-existing and environment-dependent (they time out without network access toapi.doit.com).status_shows_oauth_by_defaultalso fails, but only whenDCI_API_KEYis set in the environment — unset it before baselining, or it looks like a real failure on both branches.-1/0accepted,99999999999999999999rejected viaErrRange, and anarray[integer]case. The same table asserts every value is accepted forType: "string"— the regression guard for the 102 string-typed parameters.USAGE_ERROR, usage exit code,retryable: false, and the suggestion recovering318240fromticket-id: 318240.PersistentPreRunEand asserts the operation'sRunEis never reached, so the validator cannot be silently disconnected from the command tree.remove-ticket-tags(a DELETE), which confirms validation precedes the destructive gate./support/v1/tickets/318240, the string-typedget-allocation finops:squad:customersis untouched, and--dry-runrejects a bad value while still previewing a good one.dci get-ticket <real-ticket-id>returns 200 against the live API. Verified against a real ticket with a live OAuth session:get-ticket,list-ticket-commentsandlist-ticket-tagsall exit 0 and return responses byte-identical tomain.path_validation.go— int64 min/max ±1,+/leading-zero/-0non-canonical forms, whitespace,1e5/hex/underscore/thousands-separator notations, non-ASCII digits, zero/one/two recovery candidates, command aliases, flags interleaved with positionals,--help, shell completion, and a blocking stdin pipe to prove rejection precedes body buffering.11443580that no malformedticketIdappears from a CLI version at or above the release containing this fix. Scope to that version: historical events are permanent and older builds keep emitting bad requests until users upgrade, so the total will not drop to zero.