Skip to content

fix(security): reconcile stale protect-* devices on persistent reuse (#610)#614

Merged
mensfeld merged 6 commits into
masterfrom
test/issue-610-stale-protect-devices
Jul 17, 2026
Merged

fix(security): reconcile stale protect-* devices on persistent reuse (#610)#614
mensfeld merged 6 commits into
masterfrom
test/issue-610-stale-protect-devices

Conversation

@mensfeld

Copy link
Copy Markdown
Owner

What

Repro-first for #610. Adds 4 integration tests (tests/git_hooks/test_issue_610_stale_protect_devices.py) that reproduce the persistent-container stale protect-* 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. No xfail; 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-run SetupSecurityMounts and never strip/reconcile protect-* disk devices. So if a protected path is removed from the workspace while the container is stopped, the container keeps a protect-* device whose source= is now missing, and the next start fails Incus start-validation with Missing source path. A fresh coi run does not self-heal it — recovery needs a manual incus 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 user additional_protected_paths entry (.idea), proving the gap is not limited to auto-materialized defaults.

Next

Fix belongs on the reuse branches: a RemoveStaleProtectDevices pass mirroring the port one, or re-running SetupSecurityMounts before Start() so disk devices reconcile against the current workspace. Fix comes in a follow-up once these repros are confirmed red in CI.

Refs #610

mensfeld added 2 commits July 17, 2026 10:30
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.
@mensfeld
mensfeld marked this pull request as ready for review July 17, 2026 09:18
@mensfeld mensfeld changed the title test(security): red repros for #610 stale protect-* device wedge on persistent restart fix(security): reconcile stale protect-* devices on persistent reuse (#610) Jul 17, 2026
@mensfeld

Copy link
Copy Markdown
Owner Author

Pushed the fix (3a9d210): ReconcileProtectedDevices on both reuse branches before Start() — re-materialize COI default paths (protection preserved), remove device for user-added paths. The four repro tests above now assert the fixed behavior and should flip green; added a Go unit-test matrix for the reconcile decision + Manager.GetDeviceSource. Local: build, go vet, gofmt, golangci-lint (0 issues), and the Go unit tests all pass. Marking ready.

… 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.
@mensfeld

Copy link
Copy Markdown
Owner Author

Security hardening (8edff07)

Self-review of the reconcile surfaced a real issue in where it was wired, so tightening it:

  • The reconcile was called on 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 this fixes — but calling RemoveDevice there would 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; removing the device reopens it to a possibly-malicious agent).
  • Reconcile now runs only on the stopped→start 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; the wedge only occurs at start).

Re-materialize-vs-remove logic is unchanged and remains safe: default paths are re-materialized as empty + re-protected (Finding 2 preserved); user paths are removed only when already gone host-side (= fresh-launch semantics). The container can't self-trigger either — protected paths are busy mount points it can't rm/rmdir from inside.

mensfeld added 3 commits July 17, 2026 12:02
…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.
@mensfeld
mensfeld merged commit 7fb872e into master Jul 17, 2026
74 of 78 checks passed
@mensfeld
mensfeld deleted the test/issue-610-stale-protect-devices branch July 17, 2026 11:30
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