Skip to content

fix: validate the variables namespace instead of letting it through unchecked - #40

Merged
refsz merged 1 commit into
mainfrom
fix/validate-secrets-schema
Aug 19, 2026
Merged

fix: validate the variables namespace instead of letting it through unchecked#40
refsz merged 1 commit into
mainfrom
fix/validate-secrets-schema

Conversation

@refsz

@refsz refsz commented Aug 19, 2026

Copy link
Copy Markdown
Owner

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 secrets

variables was Expect::structure([...])->otherItems(), and otherItems() accepts any key. So a misspelled section name was valid config. Verified against the binary, before and after:

before   value=[NULL]
         > printf %s token          <- empty argument
         Done (exit 0)

after    Error: Configuration validation failed:
         - Unexpected item 'variables > secrests', did you mean 'secrets'?

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.

secrets is now declared as a map of either a literal scalar or a provider with type in command|script|env, and variables no 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 variables and left contexts.*.variables as Expect::anyOf(array, null) - so constatns: under a context was still valid config and the override silently never applied. Same bug, same fix:

before   value=[NULL]  (exit 0)
after    Unexpected item 'contexts > local > variables > constatns', did you mean 'constants'?

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 description and variables.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 a contexts.*.variables.dynamics block 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.

Context 'local' declares a 'variables.dynamics' block, but only constants are context-overridable

The guard is assertOnlyConstantsAreContextOverridden() now, since assertNoContextLevelSecrets had 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

KernelTest had configs in a flat form:

variables:
    app_name: MyApp

The schema tolerated it, but VariableResolver never reads it - it reads variables.constants. Those tests were checking raw config access through a shape that resolves to nothing. They use constants now, 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: 0 removes the limit, same as tty: true - Symfony's validateTimeout() turns 0.0 into null.

Plus the new rule where a reader meets it: contexts.md now states that only constants are overridable, instead of leaving it to a runtime error.

Reviewed and deliberately left alone

  • TaskContext::exec() / shell() had no cwd - that one turned out to be worth doing rather than deferring, in feat: let exec() and shell() take a cwd #41.
  • TaskContext still takes ?OutputInterface and ?SputnikOutput alongside the OutputChannel from 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.
  • ExecutorInterface reads well as the extension point: the type selects the mode, and the options shape lives on the interface.
  • The masking guarantees on docs/secrets.md hold - rechecked the echoed command line, program output and a task-written message through the binary with a real secret.

Verified

vendor/bin/phpunit                        753 tests, 1282 assertions, OK (6 new)
vendor/bin/phpstan analyse                [OK] No errors
vendor/bin/php-cs-fixer fix --dry-run     0 of 153 files
vendor/bin/rector --dry-run               [OK]
mkdocs build --strict                     clean

🤖 Generated with Claude Code

https://claude.ai/code/session_018CTvnzcNYmFgm2HQcm821A

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).
@refsz
refsz merged commit 3fbda3e into main Aug 19, 2026
10 checks passed
@refsz refsz changed the title fix: validate variables.secrets instead of letting it through unchecked fix: validate the variables namespace instead of letting it through unchecked Aug 19, 2026
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