From fb9bf6e11debad4682b886b50b670b82bdc25c9d Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Wed, 1 Jul 2026 22:48:39 -0500 Subject: [PATCH] fix(provision): write managed hook scripts atomically at 0o755 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook writers wrote each script at mkstemp's 0600 default and set the exec bit in a SEPARATE chmod step — and omi-enforce.py never got a chmod at all. When a re-provision runs as root and the B2 guard-config hardening then chowns the hook to root, that 0600 window (or the never-widened enforce hook) is unreadable by the agent user, so `python3 omi-enforce.py` fails with EACCES mid-reprovision — the transient "the guard hook is dead" blip. Thread mode= through _write_managed/_write_if_absent into atomic_write_text (which already applies the mode to the temp file BEFORE the rename), and drop the separate post-rename dest.chmod(0o755) at all five hook sites. The destination is now never briefly at 0600. Adds a regression test asserting every provisioned hook is 0o755 (world-readable, so a chown-root can't render it unreadable). Full suite: 725 passed, 1 skipped; ruff clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JFuGddPx3UhUkcC74KBXZB --- CHANGELOG.md | 3 +++ src/omind/provision.py | 38 +++++++++++++++++--------------------- tests/test_provision.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 415391c..d94b51d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Provision every managed hook script atomically at `0o755`: the writers wrote the file at `mkstemp`'s 0600 default and set the exec bit in a *separate* `chmod` step (and `omi-enforce.py` never got the chmod at all). When a re-provision runs as root and the B2 guard-config hardening then `chown`s the hook to `root`, that 0600 window — or the never-widened enforce hook — is unreadable by the agent user, so `python3 omi-enforce.py` fails with EACCES mid-reprovision (the transient "the guard hook is dead" blip). `_write_managed`/`_write_if_absent` now thread `mode=` into the atomic write, so the mode is set on the temp file *before* the rename and the destination is never briefly at 0600. Adds a regression test asserting every provisioned hook is `0o755` (world-readable, so a `chown root` can't hide it). + ## [3.7.7] - 2026-07-01 The follow-up batch to 3.7.6: the deferred findings from the 2026-07-01 diff --git a/src/omind/provision.py b/src/omind/provision.py index a6d8f06..f6aa54b 100644 --- a/src/omind/provision.py +++ b/src/omind/provision.py @@ -344,7 +344,7 @@ def _record(self, message: str) -> None: self.actions.append(line) self.log(line) - def _write_if_absent(self, path: Path, content: str) -> None: + def _write_if_absent(self, path: Path, content: str, *, mode: int | None = None) -> None: if path.exists() and not self.config.force: self.log(f" exists, leaving as-is: {path}") return @@ -352,14 +352,22 @@ def _write_if_absent(self, path: Path, content: str) -> None: if not self.config.dry_run: _guard_test_isolation(path) path.parent.mkdir(parents=True, exist_ok=True) - paths.atomic_write_text(path, content) + paths.atomic_write_text(path, content, mode=mode) - def _write_managed(self, path: Path, content: str) -> None: + def _write_managed(self, path: Path, content: str, *, mode: int | None = None) -> None: """Write a Managed-by-omind file, refreshing it whenever its content drifts. Unlike user-owned seeds (``_write_if_absent``), managed files carry omind's own code; leaving stale copies in place means existing installs never receive fixes (issue #49 shipped a guard fix this way). + + ``mode`` (e.g. ``0o755`` for an executable hook) is applied to the temp + file BEFORE the atomic rename, so the destination is never briefly at + ``mkstemp``'s 0600 default. That matters when provision runs as root: a + 0600 dest that is then ``chown``'d to root is unreadable by the agent user + until a *separate* chmod runs — a transient window that made ``python3 + omi-enforce.py`` fail with EACCES mid-reprovision. Setting the mode inside + the atomic write closes it. """ if path.exists(): try: @@ -375,7 +383,7 @@ def _write_managed(self, path: Path, content: str) -> None: if not self.config.dry_run: _guard_test_isolation(path) path.parent.mkdir(parents=True, exist_ok=True) - paths.atomic_write_text(path, content) + paths.atomic_write_text(path, content, mode=mode) # -- steps -------------------------------------------------------------- @@ -582,7 +590,7 @@ def _write_enforce_hook_script(self) -> None: except Exception as exc: self.log(f" WARNING: could not read enforcement hook from package data: {exc}") return - self._write_managed(dest, content) + self._write_managed(dest, content, mode=0o755) def _write_guard_hook_script(self) -> None: """Write the fresh-base git guard hook from package data to ~/.claude/hooks/.""" @@ -596,10 +604,7 @@ def _write_guard_hook_script(self) -> None: except Exception as exc: self.log(f" WARNING: could not read git guard hook from package data: {exc}") return - self._write_managed(dest, content) - if not self.config.dry_run: - with contextlib.suppress(OSError): - dest.chmod(0o755) + self._write_managed(dest, content, mode=0o755) def _write_secret_output_guard_script(self) -> None: """Write the secret-output guard hook from package data to ~/.claude/hooks/. @@ -617,10 +622,7 @@ def _write_secret_output_guard_script(self) -> None: except Exception as exc: self.log(f" WARNING: could not read secret-output guard hook from package data: {exc}") return - self._write_managed(dest, content) - if not self.config.dry_run: - with contextlib.suppress(OSError): - dest.chmod(0o755) + self._write_managed(dest, content, mode=0o755) def _write_fleet_sudo_script(self) -> None: """Install the `fleet-sudo` wrapper from package data to ~/.local/bin/. @@ -643,10 +645,7 @@ def _write_fleet_sudo_script(self) -> None: if not self.config.dry_run: with contextlib.suppress(OSError): dest.parent.mkdir(parents=True, exist_ok=True) - self._write_managed(dest, content) - if not self.config.dry_run: - with contextlib.suppress(OSError): - dest.chmod(0o755) + self._write_managed(dest, content, mode=0o755) def install_claude_skill(self) -> None: """Install omind's Claude Code skill (OMI memory workflow + CLI ops). @@ -808,10 +807,7 @@ def _write_omi_guard_scripts(self) -> None: content = content.replace("__OMIND_BIN__", omind_exe).replace( "__OMI_DIR__", omi_dir ) - self._write_managed(dest, content) - if not self.config.dry_run: - with contextlib.suppress(OSError): - dest.chmod(0o755) + self._write_managed(dest, content, mode=0o755) # Stamp what we just installed so #87 self-heal / #86 doctor can detect a # later binary shipping a newer hook-set. Silent (not a recorded action), # so re-stamping after a no-op version bump doesn't look like a change. diff --git a/tests/test_provision.py b/tests/test_provision.py index 873512d..da500c5 100644 --- a/tests/test_provision.py +++ b/tests/test_provision.py @@ -126,6 +126,41 @@ def _install_guard(config: SetupConfig, monkeypatch: pytest.MonkeyPatch, home: P prov.ensure_omi_guard_installed() +def test_managed_hook_scripts_are_written_0755_atomically( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, fake_tools: None +) -> None: + """Regression: every managed hook script is written 0o755 in a SINGLE atomic + write (the mode is set on the temp file before the rename), never at mkstemp's + 0600 default followed by a separate chmod. A 0600 destination that a root-run + provision then chowns to root is unreadable by the agent user until the chmod + runs — the transient window that made `python3 omi-enforce.py` fail with EACCES + mid-reprovision. World-readable (o+r) is what keeps a chown-root from hiding it.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(provision.Path, "home", classmethod(lambda cls: home)) + prov = Provisioner(_config(tmp_path), log=_quiet) + prov._write_enforce_hook_script() + prov._write_guard_hook_script() + prov._write_secret_output_guard_script() + prov._write_omi_guard_scripts() + + hooks = home / ".claude" / "hooks" + for name in ( + "omi-enforce.py", + "git-fresh-base.sh", + "secret-output-guard.sh", + "omi-guard.sh", + "omi-gate-reset.sh", + ): + f = hooks / name + assert f.exists(), f"{name} was not provisioned" + perms = f.stat().st_mode & 0o777 + assert perms == 0o755, ( + f"{name} is {oct(perms)}; expected 0o755 — a hook must be o+r+x so a " + "chown-root can't render it unreadable to the agent user" + ) + + def test_default_vault_path_shape() -> None: path = default_vault_path() assert path.name == "Obsidian Vault"