Skip to content

fix(native): type the config boundary instead of coercing it (#525) - #536

Merged
lionello merged 1 commit into
masterfrom
codex/p1-525-strict-native-config
Sep 3, 2026
Merged

fix(native): type the config boundary instead of coercing it (#525)#536
lionello merged 1 commit into
masterfrom
codex/p1-525-strict-native-config

Conversation

@defangdevs

Copy link
Copy Markdown
Owner

Motivation

bin/agentbox's configuration boundary coerced values with Python constructors instead of validating the declared shape, so a malformed config.yaml was accepted with different semantics from what the operator wrote. Reproduced at 7c762f2 against the golden native config:

what the operator wrote what the box did
protectMemory: "false" memory protection on (bool("false") is True)
users.agent.root: "false" user is root-capable - the settings page and the root vhost
agents: claude six one-character agents (list("claude"))
sudoAllowlist: /bin/true nine one-character sudo entries
protectMemry: false silently ignored; reads as the default
users: [agent] TypeError: list indices must be integers
users.robot.environment: "A=1" ValueError: dictionary update sequence element #0 has length 1
users.robot.sessions: [main] AttributeError: 'list' object has no attribute 'items'

The first two reverse explicit intent on a privileged setting, which is why this is a P1. The rest make an ordinary typo either invisible or a traceback with no field name in it.

What changed

bin/agentbox gains a closed, typed description of the config - BOX_SCHEMA, USER_SCHEMA, SESSION_SCHEMA - and Spec.__init__ validates against it as its first statement, before it reads a single value:

  • Declared types only. No bool(...), list(...) or dict(...) as a validator. null is still accepted wherever a value is, and still means "not written" (and for the booleans, off, as bool(None) always did) - so no existing config changes meaning.
  • Unknown keys refused at every object, with the keys that do exist named: users.robot: unknown key 'agnet' (known keys: agent, environment, environmentFiles, home, root, seedMainSession, sessions, ttydPort).
  • Path-aware messages everywhere: users.robot.environment.PORT must be a string, got 8080.
  • Non-string mapping keys refused rather than fed to sorted(), USER_RE and a file name.
  • schemaVersion accepted and pinned to 1, so a future incompatible change can say so instead of half-rendering. Absent means 1; nothing in the field carries the key.
  • load_config names the file for a parse error or a non-mapping top level, in both dialects (the JSON branch checked neither).
  • The per-field isinstance guards that grew one at a time (fix(native): one canonical guide per user, and point the agents at it #404, Native boxes: ship the webhook payloads and default the feature on (#425, second half) #431, feat(native): patch the base OS unattended, without a reboot #453) are gone - the schema does that for every field, and phrases the messages identically, so those errors are unchanged.

Validation happens in Spec, which cmd_apply builds before first_boot and before rendering, so no file is written, no user created and no unit touched after a config is refused; main() already turns ConfigError into one line on stderr and exit 2.

Kept Spec/User as the decoded models rather than adding parallel BoxSpec/UserSpec dataclasses: they already are the normalized view the renderer reads, the issue's acceptance criteria are all about the boundary in front of them, and a second set of models would be a large refactor with no behavioural gain.

User-visible and security effects

  • A config that used to be silently reinterpreted is now refused with a message naming the field. That is the point, and it is the one behaviour change: a box whose config.yaml carries a typo'd key or a quoted boolean will fail to apply where it previously applied something else. Every config the deployment templates write (aws/lightsail-template.yaml, azure/agent-box.bicep) and both committed fixtures validate unchanged.
  • Security: the root: "false" case is the one that mattered - a quoted boolean can no longer hand a user the settings page, the root vhost and the reboot grant against the operator's written intent.
  • No rendered bytes change.

Checks run

  • python3 tests/test_agentbox.py - 95 tests, OK (85 before; the native fixture is unmoved)
  • nix build -L .#checks.aarch64-linux.agentbox-render - OK
  • nix build -L .#checks.aarch64-linux.one-spec-both-backends - OK
  • nix build -L .#checks.aarch64-linux.backend-parity - OK (incl. negative control)
  • nix build -L .#checks.aarch64-linux.runtime-profile - OK (this is the flake8 gate on bin/agentbox)
  • nix build -L .#checks.aarch64-linux.module-generated-up-to-date - OK
  • python3 scripts/check_one_spec.py - PARTIAL natively, full under the flake check above

No AWS cost, IAM, networking or migration impact. No UI change, so no screenshots.

Regression coverage

ConfigSchemaTest in tests/test_agentbox.py, ten cases: every example from the issue with its exact message, unknown keys at each of the six object boundaries, malformed nesting asserted as ConfigError rather than a traceback, osUpdates: false still naming the spelling that turns it off, null still meaning "not written", the schema version, a non-mapping file naming the file, and apply --root leaving the tree uncreated when the config is refused.

Plus a drift guard: it reads every .get("field") in Spec.__init__, User.__init__ and Renderer.user_seed out of the source with ast and fails if no schema declares it. Without it, adding a field to the renderer without declaring it would no longer be a quietly-ignored knob - it would be a refused config on every box that sets it. The receiver table is exhaustive, so a new local also fails the test until it is classified.

Closes #525

`agentbox`'s config boundary ran values through Python constructors rather
than checking them, and the coercions went the wrong way on exactly the
fields where that is dangerous:

  * `protectMemory: "false"` and `users.agent.root: "false"` both came out
    True, because `bool("false")` is - the config configuring the opposite of
    what the operator wrote, and for `root` that is the account the settings
    page and the root vhost belong to.
  * `agents: claude` became six one-character agents (`list("claude")`), and
    a string `sudoAllowlist` the same way.
  * An unknown top-level key was ignored, so a misspelled knob and an absent
    one were indistinguishable.
  * A wrong type that did not coerce escaped as an AttributeError or
    ValueError traceback with no field name in it.

So declare every documented field once, in BOX_SCHEMA / USER_SCHEMA /
SESSION_SCHEMA, and check a value against its declared shape before Spec
reads anything out of it. Unknown keys are refused at every object and the
message names the keys that exist; every message carries the full path
(`users.robot.environment.PORT must be a string, got 8080`); a non-string
mapping key is refused rather than sorted against a string. Validation is
the first statement of `Spec.__init__`, which `cmd_apply` builds before
first_boot and before rendering, so nothing is written, no user is created
and no unit is touched after a config is refused.

The per-field isinstance guards that grew one at a time (#404, #431, #453)
are now what the schema does for every field, so they go - their messages
are unchanged, since the schema phrases them identically. `schemaVersion` is
accepted and pinned to 1, so a future incompatible change can say so instead
of half-rendering. `load_config` names the FILE for a parse error or a
non-mapping top level, in both dialects.

No rendered byte changes: the golden native fixture, one-spec-both-backends
and backend-parity are all unmoved.

Tests: ten new cases in tests/test_agentbox.py covering every example from
the issue, unknown keys at each boundary, malformed nesting (ConfigError,
not a traceback), null still meaning "not written", the schema version, and
that a refused config renders nothing. Plus a drift guard that reads every
`.get("field")` in Spec/User/user_seed out of the source and fails if the
schema does not declare it - an undeclared field is now a refused config,
not an ignored one.

Closes #525

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0183GHX69wCnET2cA2puwzfr
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 943eaa68-e27b-4599-99ad-689cd2a92e23

📥 Commits

Reviewing files that changed from the base of the PR and between 6d35b1d and 40e8293.

📒 Files selected for processing (2)
  • bin/agentbox
  • tests/test_agentbox.py

Included review availability: Your plan provides up to 8 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The native configuration boundary now reports path-specific ConfigErrors, validates documented fields and schema versions, rejects unknown or incorrectly typed values, and passes validated data to Spec and User. Tests cover malformed configurations and prevent rendering after validation failure.

Changes

Native configuration validation

Layer / File(s) Summary
Configuration parsing and schema boundary
bin/agentbox, tests/test_agentbox.py
load_config reports parse and top-level shape failures as ConfigError. A closed schema validates nested objects, dynamic maps, scalar and list types, unknown keys, null values, and schema versions. Tests cover rejection paths, file errors, rendering refusal, and schema coverage.
Validated configuration consumption
bin/agentbox
Spec and User use schema-validated webhook, update, hook, access, restart, and root settings. Existing defaults, gating, and reboot handling remain in place.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 40e82

Native configuration is now strictly validated before rendering or host mutation, preventing malformed or misleading values from silently changing behavior. Existing valid configurations remain supported, with no identified merge-blocking risk.

Sequence Diagram(s)

sequenceDiagram
  participant ConfigFile
  participant load_config
  participant ConfigSchema
  participant Spec
  participant Renderer
  ConfigFile->>load_config: provide JSON or YAML
  load_config->>ConfigSchema: validate parsed mapping
  ConfigSchema-->>load_config: validated configuration or ConfigError
  load_config->>Spec: pass validated values
  Spec->>Renderer: render specification
Loading

Suggested reviewers: lionello

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the configuration validation changes, security motivation, behavior, testing, and regression coverage.
Linked Issues check ✅ Passed The changes address issue #525 by adding strict typed validation, rejecting unknown keys, reporting path-aware ConfigErrors, validating before mutations, preserving valid configurations, supporting sc…
Out of Scope Changes check ✅ Passed The implementation and tests are directly related to the configuration-boundary requirements in issue #525. No unrelated code changes are identified.
Title check ✅ Passed The title accurately and concisely describes the main change: strict typing at the native configuration boundary instead of coercion.
Docstring Coverage ✅ Passed Docstring coverage is 92.86% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 1 files. (1 skipped: 1 …
Full details: Linked Issues check

Explanation

The changes address issue #525 by adding strict typed validation, rejecting unknown keys, reporting path-aware ConfigErrors, validating before mutations, preserving valid configurations, supporting schemaVersion, and adding regression tests.

Full details: Docstring Coverage

Explanation

Docstring coverage is 92.86% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 1 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/p1-525-strict-native-config

Comment @coderabbitai help to get the list of available commands.

@lionello
lionello merged commit ad8a580 into master Sep 3, 2026
3 checks passed
@lionello
lionello deleted the codex/p1-525-strict-native-config branch September 3, 2026 02:32
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Agent-Box Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Native config parser silently coerces invalid values

2 participants