fix: validate the variables namespace instead of letting it through unchecked - #40
Merged
Conversation
The config schema never declared `secrets`. It survived because the `variables`
structure used otherItems(), which accepts any key - so a misspelled section
name was valid config. `secrests:` loaded without a word of complaint, every
secret it declared resolved to null, and the task ran on with an empty argument
and exited 0. Nothing was masked because no value existed. Verified against the
binary before and after:
before: value=[NULL], "> printf %s token " (exit 0)
after: Unexpected item 'variables > secrests', did you mean 'secrets'?
`secrets` is now declared as a map of either a literal scalar or a provider with
type command|script|env, and `variables` no longer accepts unknown keys. That
also moves an unsupported secret type from first access - inside a task run,
after other work has happened - to config load.
This is a change worth making before 0.2 rather than after: tightening a schema
later rejects configs that load today, which would be a breaking change for
anyone whose config has a typo in it.
Two test configs used a flat `variables: app_name: value` form that the schema
happened to tolerate. The variable resolver never read it - it reads
variables.constants - so those tests were asserting raw config access through a
shape no feature supports. They use constants now.
Also corrects two things in the docs I got wrong in #37: ExecutionResult's
getOutput() and getErrorOutput() return the properties unchanged, not trimmed,
and `timeout: 0` removes the limit just as `tty: true` does (Symfony's
validateTimeout turns 0.0 into null).
This was referenced Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
From the focused pre-0.2 review of the surface 0.2 freezes:
TaskContext, the config schema,ExecutorInterface, and whether the masking guarantee on the new docs page matches the code.The config schema never declared
secretsvariableswasExpect::structure([...])->otherItems(), andotherItems()accepts any key. So a misspelled section name was valid config. Verified against the binary, before and after:Nothing was masked, because no value ever existed - and nothing said so. For the one feature whose entire purpose is not printing a value, a silent no-op is the wrong failure mode.
To be precise about the blast radius, since it would be easy to overstate: this is not a leak. The secret is not printed in clear, it is simply absent. The damage is a command that runs without its credential and reports success.
secretsis now declared as a map of either a literal scalar or a provider withtypeincommand|script|env, andvariablesno longer accepts unknown keys. That also moves an unsupported type (git,system) from first access - inside a task run, after other work has already happened - to config load.The same hole one level down
My first pass fixed the top-level
variablesand leftcontexts.*.variablesasExpect::anyOf(array, null)- soconstatns:under a context was still valid config and the override silently never applied. Same bug, same fix:The context body stays open on purpose: a listener may read a key of its own out of the raw config, and nothing in Sputnik reads a context beyond
descriptionandvariables.constants. Only the variables namespace is closed, and a test pins that distinction so it does not get closed by accident later.Reading
resolve()for this turned up a second silent no-op, named in its own comment: the secrets guard says secrets are not context-overridable "matching dynamics" - but dynamics had no guard, so acontexts.*.variables.dynamicsblock was ignored without a word. Both now raise the same explanatory error, which is also why the schema still accepts those keys: a generic unexpected-item message would be worse than the one the resolver gives.The guard is
assertOnlyConstantsAreContextOverridden()now, sinceassertNoContextLevelSecretshad stopped describing what it checks.Why before 0.2 and not after
Tightening a schema later rejects configs that load today. After the release that is a breaking change for anyone whose config contains a typo; now it is simply the behaviour 0.2 ships with.
Two tests were asserting a shape no feature supports
KernelTesthad configs in a flat form:The schema tolerated it, but
VariableResolvernever reads it - it readsvariables.constants. Those tests were checking raw config access through a shape that resolves to nothing. They useconstantsnow, and were the only two places in the suite affected.Docs corrections, both mine from #37
ExecutionResult::getOutput()/getErrorOutput()return the properties unchanged. I documented them as "trimmed"; they are not.timeout: 0removes the limit, same astty: true- Symfony'svalidateTimeout()turns0.0intonull.Plus the new rule where a reader meets it:
contexts.mdnow states that only constants are overridable, instead of leaving it to a runtime error.Reviewed and deliberately left alone
TaskContext::exec()/shell()had nocwd- that one turned out to be worth doing rather than deferring, in feat: let exec() and shell() take a cwd #41.TaskContextstill takes?OutputInterfaceand?SputnikOutputalongside theOutputChannelfrom feat: give listeners the same output channel as tasks #32. Breaking to change, and deferred because breaking changes are fine before 1.0 - see feat: let exec() and shell() take a cwd #41 for the corrected reasoning.ExecutorInterfacereads well as the extension point: the type selects the mode, and the options shape lives on the interface.docs/secrets.mdhold - rechecked the echoed command line, program output and a task-written message through the binary with a real secret.Verified
🤖 Generated with Claude Code
https://claude.ai/code/session_018CTvnzcNYmFgm2HQcm821A