Skip to content

Harden the trust boundary and validate untrusted daemon input - #19

Merged
quantizor merged 3 commits into
fix/installer-path-and-one-copyfrom
harden/trust-and-input-validation
Aug 9, 2026
Merged

Harden the trust boundary and validate untrusted daemon input#19
quantizor merged 3 commits into
fix/installer-path-and-one-copyfrom
harden/trust-and-input-validation

Conversation

@quantizor

Copy link
Copy Markdown
Owner

Closes the security and input-validation gaps in the daemon's wire surface. devctl's one standing invariant is that it never acts on a project's committed config until you approve it, and the SessionStart hook never puts attacker-influenceable output into an agent's context. This PR makes that invariant real and hardens the paths around it. Stacked on #18; review that first.

The trust boundary, actually enforced

Boot restore was the one autonomous spawn path that skipped the trust check: after a reboot it would bring back a committed server for a project that was never approved, so a cloned repo's devservers.json could start itself. The trust gate now lives in one place, prepareSpawn, which every start-shaped path funnels through. An explicit start/ensure/up records approval (the command is the approval, as before); an autonomous boot restore or watch sweep refuses an unapproved project. A server registered ad-hoc through register carries its own approval and is never gated.

Every config entry point validates

  • register was the one way a spec entered the daemon without running the file validator, so a spec config check would reject (an out-of-range port, an empty command, a name containing the reserved ::) could be registered and then started. It is screened now, through a new ServerSpec.validationErrors shared with the file path.
  • switch validated nothing before running the branch's lifecycle commands, and lifecycle was the one config field the validator skipped. Both are fixed: the branch config is validated after checkout and before its argv reaches the shell, and an empty lifecycle command is now a config check error.
  • writeConfig took the project path raw, so a crafted request could drop a devservers.json at any path (AtomicFile.write creates intermediate directories). It now writes only for a project devctl already tracks. The five project-scoped arms that skipped it (writeConfig, logs, mark, events, why) canonicalize the path at the decode seam.

Denial-of-service and hangs

  • A logs --grep pattern that nests one unbounded quantifier inside another (the (a+)+ family) makes Swift's backtracking Regex run for minutes on a single line, on the log actor. I measured it: 1s at 16 characters, 7s at 18, and an input cap cannot help because the blowup is exponential. Such a pattern is now refused before it runs, with a message that names the fix.
  • A wedged daemon used to hang devctl and the menu bar app forever with no output. The client now sets a response deadline (raised to cover a command's own timeout), so a request fails in bounded time and points at devctl daemon restart, while a long but healthy ensure, wait, or group rollout is given the room it needs.

Verification

  • make test passes (10 new tests). The trust gate has a true red/green pair: untrustedConfigDefinedServerIsNotRestored versus the existing restoresConfigDefinedServerWithResumeIntent, identical setup, only the approval differs, opposite outcomes. New tests also cover the register and writeConfig gates, the lifecycle validation, and the ReDoS screen against both catastrophic and safe patterns.
  • scripts/smoke.sh passes; the release build is clean; each of the three commits builds in isolation.

Resolves seven backlog items: trust enforcement, the writeConfig path, the grep ReDoS, the switch lifecycle validation, register validation, the missing canonicalization, and the client receive timeout.

ServerSpec.validationErrors is the per-spec check the register seam runs, so
a directly-registered spec is screened like a committed one (bad port, empty
command, a name carrying the reserved '::'). ProjectConfigLoader.validate now
also rejects an empty lifecycle command, the one config field it skipped.

grepRejection refuses a pattern that nests one unbounded quantifier inside
another (the (a+)+ family), measured to run for seconds on a short line and
never on a long one under Swift's backtracking Regex; the match runs on the
log actor, so screening it up front is the only defense.

DaemonClient gains a SO_RCVTIMEO response deadline, raised for a command that
carries its own timeout, so a wedged daemon fails a request in bounded time
instead of hanging the CLI and the app forever.
…point

prepareSpawn is now the one home for the trust boundary. Its userInitiated
flag records trust for an explicit command acting on a committed server, and
refuses an autonomous boot restore or watch sweep of a project whose config
was never approved, so a cloned repo's devservers.json cannot start itself
after a reboot. The scattered recordTrustIfNeeded calls collapse into it.

register validates the spec before storing it, closing the one entry point
that skipped the file validator. writeConfig refuses to write for a project
devctl does not already track, so a wire client cannot drop a devservers.json
at an arbitrary path. The remaining project-scoped arms (writeConfig, logs
query, logs mark, events, why) canonicalize the path at the decode seam.
devctl switch validates the branch's devservers.json after the checkout and
before running its lifecycle argv, so a config the daemon would refuse is no
longer fed to the shell. ensure, wait, up, down, restart, and switch pass
their own timeout to the client so a long but healthy run is given room while
a wedged daemon still fails in bounded time.

Documents the register/logs/switch/restore behavior in the CLI contract, maps
the trust gate and the client deadline in AGENTS, and adds the changeset.
Copilot AI lite review requested due to automatic review settings August 9, 2026 19:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@quantizor
quantizor merged commit 73280a7 into main Aug 9, 2026
4 checks passed
@quantizor
quantizor deleted the harden/trust-and-input-validation branch August 9, 2026 20:53
@github-actions github-actions Bot mentioned this pull request Aug 9, 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.

2 participants