Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 17 additions & 21 deletions src/omind/provision.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,22 +344,30 @@ 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
self._record(f"write {path}")
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:
Expand All @@ -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 --------------------------------------------------------------

Expand Down Expand Up @@ -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/."""
Expand All @@ -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/.
Expand All @@ -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/.
Expand All @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions tests/test_provision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading