Skip to content

feat(defs): throw on malformed BSL arguments instead of coercing (audit theme 6) - #139

Open
passcod wants to merge 4 commits into
mainfrom
claude/pr-115-theme-6-bsl-strict
Open

feat(defs): throw on malformed BSL arguments instead of coercing (audit theme 6)#139
passcod wants to merge 4 commits into
mainfrom
claude/pr-115-theme-6-bsl-strict

Conversation

@passcod

@passcod passcod commented Aug 2, 2026

Copy link
Copy Markdown
Member

Closes cross-cutting theme 6 from the logic bug audit: silent coercion in the BSL defs layer.

The sequencing constraint below is now satisfied: #138 (theme 2) has merged, this PR is based on main, and its diff is theme 6 only.

The class

The defs layer had two styles for consuming a rhai::Dynamic. The checked one — take_retries, Port::new, validate_scale — converts, names the argument and the actual type, and throws, so rhai reports the script line that is wrong. The coercing one — into_string().unwrap_or_default(), filter_map(try_cast), bare as casts — produces not a failure but a different meaning, surfacing far from the cause at reconcile or container-start time:

  • select(#{ types: ResourceType.Service }) — array brackets forgotten — dropped the criterion, and a select with no surviving criteria matches every resource in the app, so a following rt.stop stopped every workload.
  • pids_limit(4294967297) wrapped to 1; the container cannot start a workload.
  • command(["nginx", 8080]) produced an argv containing "".
  • A param schema entry that was not a map was skipped, so the param simply did not exist; a non-string kind fell back to the default, quietly installing a password param as plain text.

The coercing style was never asserted by a test or licensed by the spec.

The change

crates/core/src/defs/take.rs is the checked style extracted — take_string, take_bool, take_map, take_array, take_string_array, take_array_of, take_int_in_range — keeping the conventions of the sites it came from: backtick-quote the argument as the script author wrote it, state what was expected, report what arrived, and give the index for array elements. The ~17 coercing sites across collection/selector.rs, container.rs, ingress.rs, app/install.rs and app.rs now use it, so the path of least resistance for the next builder is take_string(...)? rather than a silent default.

Selector::from_map becomes fallible and additionally rejects unknown criteria — l[collection.select] says "All possible keys are defined in this spec", so a typo'd key is a criterion that constrains nothing. Collection::select and App::select return Result, which rhai supports directly.

Two adjacent s2 findings ride along, since they are validation gaps in the same functions: the inverted scale(5..2) range (H20) and a digest hex check using is_ascii_lowercase, which accepts gz. The three remaining hand-rolled range checks (validate_scale, validate_scale_lower, take_retries) fold into take_int_in_range, so no unchecked narrowing cast is left in the layer.

Findings closed

Finding Severity
Malformed select criteria silently invert to select-everything (or nothing) medium
pids_limit and stop_timeout silently truncate i64 → u32 medium
Redirect status code unvalidated and wrapped i64 → u16 medium
H20 — inverted scale(5..2) range accepted high
Non-string kind and non-map entries in param schemas silently accepted low
Non-string array elements silently become empty strings in command/arg/env/healthcheck cmd low
Digest "hex" check accepts gz low

Behaviour change — needs a release note

Scripts that "worked" via coercion now throw at evaluation. That is the intended outcome — any script relying on it was already misbehaving, matching everything, running with empty argv elements, or storing a password param in plain text — but an /apps/update that previously succeeded can now fail.

The ordering constraint this created has been discharged: until AppRegistry::reload stopped publishing a partially-evaluated definition (#138), a newly-throwing script during /apps/update would have triggered the destructive post-reload path — volume hold, scaling wipe, forward teardown. That fix is on main, so this is safe to land.

Enforcement

  • Spec: new l[bsl.args.strict] in language.md — malformed input is never coerced, defaulted, or silently ignored, and the error names the argument, the expectation and what was supplied. Explicitly carves out spec-defined coercions such as col. l[ingress.redirect] now pins the accepted code range at 300–399, which was previously unstated and therefore unenforceable.
  • Tests: one run_test_script_err case per converted builder, asserting both that it throws and that the message names the argument — select with a scalar types, a non-ResourceType element, a non-string names element, an unknown criterion, pids_limit/stop_timeout past u32, command/arg with a non-string element, a non-map env entry, a non-hex digest, scale(5..2), redirect(80, 200).
  • CI: etc/ci/check-defs-coercion.sh fails on the idiom inside crates/core/src/defs/. Genuine type dispatch that ends in a throw — col()'s successive casts, the string-vs-array dispatch in take_command_cmd — is exempt via a // take: dispatch marker; neither needs one today.

Not in scope

Value-level gaps where the type is right and the domain check is missing are only fixed where they sit in a touched function. The s2 semantic findings — frozen-reference semantics for external_service, the col(action) leftover branch, the secret(false) tri-state — are model bugs, not coercion. And nothing here catches a type-valid mistake such as names: ["sevrice-a"] selecting nothing.

claude added 2 commits August 2, 2026 00:44
The defs layer had two styles for consuming a script value. The checked one
converts, names the argument and the actual type, and throws at the script
line that is wrong. The coercing one — into_string().unwrap_or_default(),
filter_map(try_cast), bare as casts — turns a type error into a different
meaning: select(#{ types: ResourceType.Service }) with the brackets forgotten
dropped the criterion and matched every resource in the app, so a following
rt.stop stopped every workload; pids_limit(4294967297) wrapped to 1;
command(["nginx", 8080]) produced an argv containing "".

crates/core/src/defs/take.rs is the checked style extracted, and the ~17
coercing sites in selector, container, ingress, install and app now use it.
Selector::from_map is fallible and rejects unknown criteria, since the spec
says all possible keys are defined in it. Two adjacent validation gaps ride
along: an inverted scale(5..2) range and a digest hex check that accepted
g-z. A CI grep keeps the idiom out of the layer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
Tracey requires every rule to carry an impl annotation; the take module had
only verify references from the tests.
@github-code-quality

github-code-quality Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: TypeScript, Rust

TypeScript / code-coverage/vitest

The overall coverage in commit 246b0b4 in the claude/pr-115-theme-... branch remains at 66%, unchanged from commit c2f174b in the main branch.

Rust / code-coverage/rust

The overall coverage in commit 246b0b4 in the claude/pr-115-theme-... branch remains at 59%, unchanged from commit c2f174b in the main branch.

Show a code coverage summary of the most impacted files.
File main c2f174b claude/pr-115-theme-... 246b0b4 +/-
crates/core/src...fs/container.rs 88% 87% -1%
crates/core/src/oi/server.rs 60% 60% 0%
crates/core/src...s/collection.rs 60% 60% 0%
crates/core/src/defs/app.rs 92% 92% 0%
crates/core/src...defs/ingress.rs 94% 94% 0%
crates/core/src...p/collection.rs 100% 100% 0%
crates/core/src.../app/install.rs 99% 100% +1%
crates/core/src...ion/selector.rs 62% 64% +2%
crates/core/src...s/deployment.rs 86% 89% +3%
crates/core/src/defs/take.rs 0% 96% +96%

Updated August 02, 2026 05:16 UTC

@passcod
passcod marked this pull request as ready for review August 2, 2026 02:31
Base automatically changed from claude/pr-115-theme-2-partial-state to main August 2, 2026 02:34
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
@passcod
passcod added this pull request to the merge queue Aug 2, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 2, 2026
Both add a guard step to the lint job, so the two land side by side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
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.

2 participants