Home & Runtime Hardening: canonical ~/.leveler layout + sandbox lease - #9
Merged
Conversation
Read-only cross-crate audit (no code changed) for the Home & Runtime Hardening phase. Inventories every CodeLeveler-owned path (global home, per-project state, per-project config, sockets/locks/sandboxes, cache/remote/web/logs, legacy migration), answers the 16 audit questions, and proposes a single LevelerHome authority in leveler-core. Two decisions are surfaced for sign-off before implementation: - D1: Zero-Workspace-Pollution scope — machine-written state moves out of the repo; user-authored .leveler config (config.yaml/rules/skills/instructions.md, like AGENTS.md) is recommended to stay committable in the repo. - D2: sandbox lifecycle — a run/sandboxes reaper needs an owner marker (pidfile/flock) and must fail closed, so it never reaps a live daemon-hosted background sandbox.
Adds leveler-core::LevelerHome: resolves the global home once (LEVELER_HOME ->
HOME/.leveler -> USERPROFILE/.leveler -> process-local temp) and derives every
canonical sub-path (config.toml, state/{projects,remote,web}, run/{sockets,locks,
sandboxes}, cache/tools, runtimes, logs/{leveler.log,crash,daemon}) from it.
Placed in leveler-core (the lowest crate everyone already depends on) so a later
step can thread it through app/cli/execution/web/tui/remote-agent without a
back-edge. No consumer yet — behaviour is unchanged until the threading commits.
Layout now derives every runtime path from the single LevelerHome
authority instead of ad-hoc home joins:
- state_dir = home.project_state_dir(encode_repo_path(repo))
- socket_path = home.sockets_dir() (short per-repo hash, SUN_LEN-safe)
- target_lock_path / known_repositories go through LevelerHome too
Drop the legacy migration surface entirely — it predated the canonical
layout and only added a bypass: removed resolve_leveler_home,
repo_state_dir_in, encode_repo_path_legacy, migrate_legacy_*,
legacy_repo_state_paths, plus the `sessions migrate-state` CLI command
and its tests.
Downstream:
- eval_cmd seal_read_denials resolves projects_dir/config_file via
LevelerHome (was a hand-built path that ignored LEVELER_HOME)
- web historical_repositories uses known_repositories(&home)
- Layout::from_parts gives tests an explicit-path constructor now that
`home` is a private field; 17 struct-literal call sites migrated
leveler-project 24/24, full workspace --all-targets builds green.
Importing a file through the WebUI wrote it to `<repo>/.leveler/uploads`
— exactly the workspace mutation Zero Workspace Pollution forbids. The
uploaded bytes are already delivered to the runtime base64-inline; the
on-disk copy is a side record, so its location is free to move.
- LevelerHome::web_uploads_dir() → `state/web/uploads` (single authority)
- AppState resolves the uploads dir once at the composition root (like
dist_dir) and hands it to the handler, so no handler reaches for a
process global and the path is injectable for tests
- prepare_upload_directory now roots its cap-std symlink/reparse guard at
the home uploads parent instead of the repo; the containment boundary
moves with it
Tests:
- the repo-rooted symlink/junction escape cases moved to isolated
in-crate unit tests of prepare_upload_directory (the old attack surface
— a malicious `<repo>/.leveler/uploads` — no longer exists)
- server.rs installs an isolated temp home so uploads never touch the real
`~/.leveler`; assertions are workspace-exclusion + own-residue checks
Also fix a commit-3 regression the leveler-web tests caught: discovery
fixtures still wrote state under `<home>/projects/` after project state
moved to `<home>/state/projects/`.
leveler-web 39 lib + 30 integration green; fmt + clippy clean.
Several call sites resolved the home as
`leveler_home_dir_from(..).unwrap_or_else(|| PathBuf::from(".leveler"))`.
When no HOME is set that fallback is a *cwd-relative* `.leveler` — the
runtime would read and write user config/state inside whatever directory
it launched from, the exact workspace pollution this initiative forbids.
Replaced with `LevelerHome::resolve`, whose fallback is a process-local
temp home, never the cwd.
Home-owned paths now go through a LevelerHome accessor instead of ad-hoc
joins, so the layout has one authority (and the tripwire can enforce it):
- new accessors: agents_dir(), skills_dir() (user-authored global config)
- config.toml → config_file() (global_config, tui theme)
- crash records → crash_dir() (now logs/crash, was <home>/crash)
- remote state → remote_state_dir() (now state/remote, was <home>/remote)
- web registry → web_projects_registry() (state/web/projects.json)
Canonicalize the multi-project registry: it lived at the flat
`<home>/web-projects.json` while the web reader already expected
`state/web/projects.json`. ProjectManager now carries the home root
explicitly and derives the registry from it, so historical discovery scans
the real `state/projects/` instead of guessing home from the registry
path's parent (which broke the moment the registry moved into `state/web`).
Docs adjusted to the canonical locations; leveler-web / core / skills
green, clippy clean.
…hans
Per-command scratch dirs were created directly under the cache owner —
i.e. straight in `~/.leveler/` — so every command littered the home root,
and a crash (where `TempDir::drop` never runs) left the scratch behind with
nothing able to reclaim it.
D2: owner lease + fail-closed reaper.
- scratch now lives under `<owner>/run/sandboxes/<name>`, reusing the
existing outside-the-workspace owner validation so the write-granted
scratch can never land inside the repo
- each scratch carries an exclusive advisory lock on its `<name>.lock`
sidecar (fs2 flock, the same primitive the daemon election lock uses),
held for the command's lifetime. RAII is the primary cleanup: the guard
removes the lock file and the flock releases on drop — including on crash
- a fail-closed reaper runs once per process (opportunistically, before the
first scratch is created — never on a timer): a `<name>.lock` it can flock
proves the owner is gone, so it removes that scratch tree and lock; a live
lock, or a dir with no lock sidecar, is left untouched
`into_scratch` still hands back the `TempDir` for backgrounded commands, so
no cross-platform field churn; the lease releases gracefully when its
`SandboxPaths` is consumed.
Tests: S1–S4 cover lock lifecycle, orphan reclaim, live-lease survival, and
fail-closed-without-a-lock. leveler-execution 210+ green, clippy clean.
A test walks `crates/*/src` and fails on either decay idiom:
`from(".leveler")` (a cwd-relative fallback) or `|home| home.join(` (a raw
join onto the home root). Both are how the single-authority layout erodes;
new sub-paths must get a LevelerHome accessor instead. Passes clean on the
current tree.
- LevelerHome: touching every accessor on a pristine root creates no directory (the layout's lazy promise). - Layout: resolving against a real repo + home tempdir writes nothing into the repository — no `.leveler`, no state — the filesystem-level statement of Zero Workspace Pollution alongside the existing path-shape assertions.
- ARCHITECTURE (en + zh): add a "Home & runtime layout (CURRENT)" section — Zero Workspace Pollution, the LevelerHome authority + tripwire, the canonical tree, and the sandbox lease/reaper; fix the stale web-projects.json path. - HOME_RUNTIME_HARDENING_REPORT.md: the closeout (decisions D1/D2, per-commit changes, enforced invariants, accepted residuals, regression result). - daemon_e2e / permissions tests: point their fixtures at the canonical layout (run/sockets, state/projects) — they had assumed the pre-canonical home dirs and were only exposed once the full suite ran end to end. Regression: fmt --check clean; workspace test 2675 pass / 0 fail; clippy --all-targets -D warnings clean.
…atch life
Two MAJOR review findings.
H-1 — the sandbox/cache path bypassed LevelerHome. prepare_sandbox_paths
resolved its owner with the raw leveler_home_dir() + a multi-candidate
fallback, so the tool cache became `<owner>/tool-cache/<hash>` — not
`cache/tools` — and could land in `$HOME/.cache/codeleveler-private` or a
temp dir, outside the one authoritative home.
- resolve a single LevelerHome; validate its root is outside the workspace,
rejecting even a home reachable *through* a workspace-planted symlink
(checked on the raw path before any canonicalize resolves it away)
- derive the scratch/tool-cache roots from sandboxes_dir()/tool_cache_dir()
(run/sandboxes, cache/tools) via the same race-safe capability chain — no
codeleveler-private, no bare tool-cache, no silent fallback
- a poisoned LEVELER_HOME now fails closed instead of inventing a namespace
- tripwire strengthened: also bans the bare `leveler_home_dir(` resolver and
the `"tool-cache"` literal in business src (environment.rs joins home.rs as
the exempt authority); skills/theme migrated to the from_root bridge
H-2 — into_scratch() returned a naked TempDir, dropping the lease while a
backgrounded command still used the scratch (permanent orphan on a later
crash). It now returns an owned SandboxScratch carrying BOTH the TempDir and
the lease, so lease lifetime == scratch lifetime. The background field is
cfg-typed to SandboxScratch on macOS/Linux.
Tests: S1 synchronous hold, S2 lease-survives-transfer, S4 drop-reclaims,
S5 crash-orphan reclaim, S6 live-lease survival, S7 fail-closed-no-lock, plus
a real "sandbox init writes nothing into the workspace" execution test.
leveler-execution 217+ green; core/skills/tui green; clippy -D warnings clean.
Report: H-1/H-2 CLOSED; the accepted background-lease residual is withdrawn.
Record the post-closeout full run (2678 tests / 0 fail, clippy -D clean, fmt clean), the H-1/H-2 CLOSED status table, and the extra pre-canonical test harnesses updated (sandbox cache path + poisoned-LEVELER_HOME fail-closed).
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.
Home & Runtime Hardening
Establishes one canonical
~/.levelerlayout behind a single path authority(
LevelerHome), Zero Workspace Pollution, and a crash-safe sandbox runtime.What changed
LevelerHomeis the sole authority for every owned path; resolves theroot once (
$LEVELER_HOME→$HOME/.leveler→%USERPROFILE%\.leveler→process-local temp — never a cwd fallback) and hands out named accessors.
<repo>/.leveler/uploads→state/web/uploads; all machine-written state(
sessions.db, memory, ApproveAlwayspermissions.yaml, registry, remotepairing) lives under the home. User-authored
<repo>/.leveler/…+AGENTS.mdstay committable and read-only to the runtime.
state/{projects,remote,web},run/{sockets,locks,sandboxes},cache/tools,runtimes/,logs/{leveler.log,crash,daemon}.run/sandboxes/<id>guarded by an OSflockbound to the scratch's actuallifetime (incl. backgrounded commands); reaper reclaims only provably-dead
crash orphans.
sessions migrate-state.path by hand (cwd
.leveler, rawleveler_home_dir(,|home| home.join(,"tool-cache").Review closeout (H-1, H-2)
LevelerHome(
run/sandboxes,cache/tools; nocodeleveler-private, no baretool-cache); a poisonedLEVELER_HOMEfails closed.into_scratch()now transfersTempDir+ lease together, solease lifetime == scratch lifetime.Merge gate
cargo fmt --check✅cargo check --workspace --all-targets✅cargo clippy --workspace --all-targets --all-features -- -D warnings✅cargo test --workspace --all-features --locked --no-fail-fast✅ (2681 tests, 0 failures)Details:
docs/HOME_RUNTIME_HARDENING_REPORT.md; layout:docs/ARCHITECTURE.md.