fix(security): reconcile stale protect-* devices on persistent reuse (#610)#614
Conversation
Add integration tests that reproduce the #610 failure mode: on reuse/restart of a persistent container, COI reconciles only port devices (RemoveStalePortDevices) and never re-runs SetupSecurityMounts or strips stale protect-* disk devices (internal/session/setup.go reuse branches ~235/252). So if a protected path is removed from the workspace while the container is stopped, the persistent container keeps a protect-* device pointing at a now-missing source and the next start fails Incus start-validation with "Missing source path". A fresh `coi run` does not self-heal it. Tests (tests/git_hooks/test_issue_610_stale_protect_devices.py), each a distinct facet, all driving the real persistent `coi run` flow: - restart after removing a materialized default DIR (.husky) - restart after removing a materialized default FILE (.claude/settings.json) - protection must be RE-established (re-materialized + read-only) after restart - restart after removing a user additional_protected_paths entry (.idea) — proves the reconcile gap is not limited to the auto-materialized defaults These assert the post-fix contract and FAIL today on purpose (no xfail): red CI is the intended signal that the bug is still present. Remove nothing when fixing — they flip green once the reuse branches reconcile disk devices.
…610) On reuse/restart of a persistent container the reuse branches reconciled only port devices (RemoveStalePortDevices); SetupSecurityMounts runs only on a fresh launch. So a protect-* disk device attached on the first launch kept pointing at its host source forever, and if that source was removed from the workspace while the container was stopped, Incus rejected the container at start-validation with "Missing source path" — a wedge a fresh coi invocation never self-healed. Add ReconcileProtectedDevices, run on both reuse branches before mgr.Start(): for each protect-* device whose host source is gone, either - re-materialize it (COI default paths: .husky/.vscode/.git/hooks dirs, .claude/settings*.json + .git config-sink placeholders) so the device validates again and the Finding 2 planting protection is preserved, or - remove the device (user-added additional_protected_paths, where "gone" means "nothing to protect"). .git/* is never re-materialized when the workspace .git is not a real dir, so no .git tree is synthesized. Scoped to the protect- prefix; gitc-/mask-/coi-port- device families are untouched. New Manager.GetDeviceSource (incus config device get <c> <dev> source), added to the ContainerDevices interface. Go unit-test matrix for the reconcile decision; the four #610 integration repros (previously red) now assert this fix.
|
Pushed the fix ( |
… path Follow-up hardening of the #610 reconcile. The pass was wired into BOTH reuse branches. The already-running reuse branch (resume / post-reboot restore) has no Start(), so it never hits the "Missing source path" start-validation the fix targets — but calling the reconcile there means RemoveDevice could hot-unplug a read-only protect-* device from a LIVE container, dropping protection mid-session: a source that vanished while still mounted keeps writes blocked, and removing the device reopens it to a possibly-malicious agent. Reconcile now runs only on the stopped-container restart branch, before mgr.Start(). Doing nothing on the running branch is strictly safer and loses nothing (re-materialize is a no-op against a live mount, and the wedge only occurs at start). Re-materialize-vs-remove logic is unchanged.
Security hardening (
|
…trip + re-run (#610) Rework of the #610 fix after an in-depth review found the first attempt was on the WRONG seam and hand-rolled validity checks that diverged from the fresh-launch primitives. Wrong seam: the reconcile was wired only into session.Setup (coi shell). coi run restarts a persistent container through a separate launchOrReuseContainer -> StartWithIsolationFallback path that never reconciled — and the regression tests drive coi run, so they stayed red (CI confirmed: unit green, integration red). Divergent checks: a per-device os.Lstat + os.IsNotExist gate mis-handled dangling symlinks, ENOTDIR parents, and file<->dir type drift (wedge persisted), removed a DEFAULT device on any materialize error or filepath.Rel base drift (dropping protection = FLAWS Finding 2 planting gap), never re-established a removed device, and left the mask-* family uncovered. New approach — strip + re-run, before start, at both seams: - StripSecurityDevices removes the workspace-sourced families (protect-*/mask-*/ gitc-*); the read-write base mounts (workspace, git-worktree-common) are kept. - Re-run the SAME validated fresh-launch setup: coi run via a new preRestart hook in launchOrReuseContainer (before StartWithIsolationFallback); coi shell in the stopped-restart branch (before Start()). The shell block was extracted into a shared applySessionSecurity used by both fresh and reuse paths. - Materialization / symlink-rejection / type / missing-path handling now all come from one place, mask-* is covered, and protection is re-established to match the current workspace. Still never reconciles the already-running branch (no start validation there; hot-removing a device would drop live protection). Deletes the flawed ReconcileProtectedDevices/GetDeviceSource/interface addition. Tests: end-to-end regression for every reviewed case across coi run (tests/git_hooks: removed default dir/file, removed user path, protection re-established after restart and after recreate, dangling-symlink swap, ENOTDIR parent, dir->file type drift) and coi shell --resume (tests/shell/persistent), plus a Go unit test for StripSecurityDevices.
host_immutable is on by default and chattr +i's protected paths; a persistent shell keeps those flags across poweroff, so the host .husky was undeletable and the test failed with 'Operation not permitted' on rmtree (a one-shot coi run clears immutable on teardown, which is why the git-hooks lane passed). Immutable is orthogonal to the #610 device reconcile under test, so disable it via trusted COI_CONFIG scope (carrying [network] open + [container] persistent too).
…weroff The container came up fine on --resume (wait_for_container_ready passed → no #610 wedge), but the test sent 'sudo poweroff' while the dummy CLI was focused, so it was eaten as CLI input and the container never stopped; the next --resume then attached to a live tmux session and wait_for_prompt timed out. Exit the CLI to bash before poweroff (mirroring the passing persistent-resume tests), and drop the post-resume wait_for_prompt — container readiness alone proves the wedge is gone, and the tmux re-attach rendering is an unrelated harness flake surface.
What
Repro-first for #610. Adds 4 integration tests (
tests/git_hooks/test_issue_610_stale_protect_devices.py) that reproduce the persistent-container staleprotect-*device wedge. Draft: these are intentionally RED — they assert the post-fix contract and fail today, so CI going red is the signal the bug is still live. Noxfail; they flip green once the fix lands.The bug (confirmed by code-trace)
On reuse/restart of a persistent container, the reuse branches in
internal/session/setup.go(~L235 running-reuse, ~L252 stopped-restart) reconcile only port devices (RemoveStalePortDevices). They never re-runSetupSecurityMountsand never strip/reconcileprotect-*disk devices. So if a protected path is removed from the workspace while the container is stopped, the container keeps aprotect-*device whosesource=is now missing, and the next start fails Incus start-validation withMissing source path. A freshcoi rundoes not self-heal it — recovery needs a manualincus config device remove <container> protect-*.The fresh-launch case in #610's title is already handled (materialization creates the paths before attaching devices); the genuinely open defect is this reuse-path reconcile gap.
Tests (each a distinct facet)
test_restart_after_removing_materialized_dir— remove a materialized default dir (.husky) while stopped → restart must succeed.test_restart_after_removing_materialized_file— remove a materialized default file (.claude/settings.json) → restart must succeed.test_protection_reestablished_after_restart— after restart, the path must be re-materialized and re-protected (read-only in-container), not just booting. Pins against silent protection drift.test_restart_after_removing_user_additional_protected_path— same for a useradditional_protected_pathsentry (.idea), proving the gap is not limited to auto-materialized defaults.Next
Fix belongs on the reuse branches: a
RemoveStaleProtectDevicespass mirroring the port one, or re-runningSetupSecurityMountsbeforeStart()so disk devices reconcile against the current workspace. Fix comes in a follow-up once these repros are confirmed red in CI.Refs #610