Skip to content

Home & Runtime Hardening: canonical ~/.leveler layout + sandbox lease - #9

Merged
dengmengmian merged 11 commits into
mainfrom
refactor/home-runtime-hardening
Aug 13, 2026
Merged

Home & Runtime Hardening: canonical ~/.leveler layout + sandbox lease#9
dengmengmian merged 11 commits into
mainfrom
refactor/home-runtime-hardening

Conversation

@dengmengmian

Copy link
Copy Markdown
Owner

Home & Runtime Hardening

Establishes one canonical ~/.leveler layout behind a single path authority
(LevelerHome), Zero Workspace Pollution, and a crash-safe sandbox runtime.

What changed

  • LevelerHome is the sole authority for every owned path; resolves the
    root once ($LEVELER_HOME$HOME/.leveler%USERPROFILE%\.leveler
    process-local temp — never a cwd fallback) and hands out named accessors.
  • Zero Workspace Pollution: web attachment uploads moved out of
    <repo>/.leveler/uploadsstate/web/uploads; all machine-written state
    (sessions.db, memory, ApproveAlways permissions.yaml, registry, remote
    pairing) lives under the home. User-authored <repo>/.leveler/… + AGENTS.md
    stay committable and read-only to the runtime.
  • Canonical layout: state/{projects,remote,web}, run/{sockets,locks,sandboxes},
    cache/tools, runtimes/, logs/{leveler.log,crash,daemon}.
  • Sandbox lease + fail-closed reaper (D2): per-command scratch under
    run/sandboxes/<id> guarded by an OS flock bound to the scratch's actual
    lifetime (incl. backgrounded commands); reaper reclaims only provably-dead
    crash orphans.
  • Legacy removal: the whole state-migration surface + sessions migrate-state.
  • Architecture tripwire: a test fails the build if any crate rebuilds a home
    path by hand (cwd .leveler, raw leveler_home_dir(, |home| home.join(,
    "tool-cache").

Review closeout (H-1, H-2)

  • H-1: routed the sandbox scratch + tool cache through LevelerHome
    (run/sandboxes, cache/tools; no codeleveler-private, no bare
    tool-cache); a poisoned LEVELER_HOME fails closed.
  • H-2: into_scratch() now transfers TempDir + lease together, so
    lease 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.

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).
@dengmengmian
dengmengmian merged commit d3bb347 into main Aug 13, 2026
2 of 5 checks passed
@dengmengmian
dengmengmian deleted the refactor/home-runtime-hardening branch August 13, 2026 02:43
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