diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a9691f..2b668e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [3.8.4] - 2026-07-02 +## [3.8.5] - 2026-07-05 + +### Fixed +- **Guard: a brand-new `git init` repo with no remote no longer locks the agent out** (#149): the same-turn freshness gate demanded a `git fetch`/`git pull` before any review/edit/test/commit in a repo — but a freshly initialised repo has no remote, so `git fetch` errors (*No remote repository specified*) and `git pull --ff-only` errors (*no tracking information*); the freshness check was unsatisfiable and the agent could not work in its own new repo. A repo with **zero configured remotes has no upstream to be stale against**, so freshness is now waived for it (`repo-work-fresh-base` no longer fires) while the rules-note consult still applies. Detection (`_repo_has_remote`) is subprocess-free — it reads `/.git/config` for a `[remote "…"]` stanza — and deliberately conservative: a `.git` pointer file (linked worktree/submodule), an unreadable config, or any resolution error is treated as *has a remote*, so freshness is only ever waived when we positively confirm zero remotes. A repo that has a remote is completely unaffected. ### Fixed - **Verifier: reading the note a guard block demanded is no longer punished** (#148): the deny-log sampling under #147 found 16 cases where the guard blocked repo work demanding `Operational Rules - Git Repos and Secrets` be read, the agent obeyed, and the verifier scored that read off-topic and re-closed the gate. The guard now records the demanded note (per-turn marker, cleared at turn start by both `begin_turn` and the bash reset), and the verifier treats a consult of it as relevant by definition. A ritual read on a turn where nothing demanded it is still judged normally. diff --git a/pyproject.toml b/pyproject.toml index e8ec562..772b494 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "omind" -version = "3.8.4" +version = "3.8.5" description = "Reproduce the OMI/Obsidian memory integration for AI agents, plus a local web app to view, edit, and add memory entries." readme = "README.md" requires-python = ">=3.10" diff --git a/src/omind/__init__.py b/src/omind/__init__.py index da6fbe0..6bf3a1f 100644 --- a/src/omind/__init__.py +++ b/src/omind/__init__.py @@ -2,4 +2,4 @@ # Copyright 2026 Aaron K. Clark """omind — OMI/Obsidian memory tooling for AI agents.""" -__version__ = "3.8.4" +__version__ = "3.8.5" diff --git a/src/omind/guard.py b/src/omind/guard.py index f0e207d..051f558 100644 --- a/src/omind/guard.py +++ b/src/omind/guard.py @@ -783,6 +783,36 @@ def _repo_root_for_action(action: dict[str, Any]) -> Path | None: return None +def _repo_has_remote(repo: Path) -> bool: + """True when the repo has at least one configured remote — i.e. there is an + upstream its local base could be stale against, so a freshness check is + meaningful. A brand-new ``git init`` repo with no remote has nothing to + fetch: a bare ``git fetch`` errors (*No remote repository specified*) and + ``git pull --ff-only`` errors (*no tracking information*), so demanding a + same-turn freshness check there locks the agent out of its own new repo + (#149). Such a repo is treated as vacuously fresh (the caller waives the + freshness demand only — the rules-note consult still applies). + + Subprocess-free (this runs inside the PreToolUse hot path) and deliberately + CONSERVATIVE: it returns ``True`` on any doubt — a ``.git`` that is a + linked-worktree / submodule pointer *file* (whose remotes live in the shared + config, not here), an unreadable config, or a resolution error — so freshness + is waived ONLY when we positively read the repo's own config and find zero + ``[remote "…"]`` stanzas. This makes the change a pure correctness fix for + new local repos and never a loosening for a repo that has a remote. Never + raises.""" + try: + gitdir = repo / ".git" + if not gitdir.is_dir(): + # `.git` is a pointer file (worktree/submodule) or absent — don't + # guess the shared config; keep the freshness demand. + return True + text = (gitdir / "config").read_text(encoding="utf-8", errors="replace") + except OSError: + return True + return bool(re.search(r"(?m)^[ \t]*\[remote[ \t]", text)) + + def _has_consulted_git_rules(session: str) -> bool: needle = GIT_RULES_NOTE.lower() for consult in consults(session): @@ -986,7 +1016,10 @@ def decide(action: dict[str, Any]) -> Verdict: reason=f"omi-guard (hard): {GIT_RULES_MESSAGE}", rule_id="repo-work-read-git-rules", ) - if not _git_fresh_for_repo(session, repo): + # A repo with no configured remote has nothing to fetch and no upstream + # to be stale against, so the freshness check is vacuous — waive it + # rather than lock the agent out of a brand-new `git init` repo (#149). + if not _git_fresh_for_repo(session, repo) and _repo_has_remote(repo): record_pending(session, command or _action_path(action)) return Verdict( allow=False, diff --git a/tests/test_guard.py b/tests/test_guard.py index 5d4647d..7c60c9f 100644 --- a/tests/test_guard.py +++ b/tests/test_guard.py @@ -174,6 +174,69 @@ def test_repo_work_requires_git_rules_note_and_freshness_check() -> None: guard.clear_gate("repo") +def _git_init(path: Path) -> None: + subprocess.run(["git", "init", "-q", str(path)], check=True) + + +def test_repo_has_remote_detects_configured_remotes(tmp_path: Path) -> None: + repo = tmp_path / "r" + repo.mkdir() + _git_init(repo) + # A freshly initialised repo has no remote. + assert guard._repo_has_remote(repo) is False + subprocess.run( + ["git", "-C", str(repo), "remote", "add", "origin", "https://x.invalid/y.git"], + check=True, + ) + assert guard._repo_has_remote(repo) is True + # Conservative on any doubt: a path that isn't a resolvable repo dir (no + # readable `/.git/config`) is treated as HAVING a remote so freshness + # is never wrongly waived for a real repo. + assert guard._repo_has_remote(tmp_path / "does-not-exist") is True + + +def test_new_repo_without_a_remote_does_not_demand_freshness(tmp_path: Path) -> None: + # A brand-new `git init` repo has no remote — `git fetch`/`git pull` are + # impossible, so the freshness gate must not lock the agent out of its own + # new repo (#149). The rules-note consult is still required. + repo = tmp_path / "newrepo" + repo.mkdir() + _git_init(repo) + session = "newrepo-noremote" + guard.clear_gate(session) + + wfile = str(repo / "hello.py") + blocked = guard.decide({"tool": "Write", "file_path": wfile, "session": session}) + assert not blocked.allow + assert blocked.rule_id == "repo-work-read-git-rules" + + guard.record_consult(session, kind="read", target=guard.GIT_RULES_NOTE, relevant=True) + allowed = guard.decide({"tool": "Write", "file_path": wfile, "session": session}) + assert allowed.allow, allowed.rule_id # was repo-work-fresh-base before #149 + guard.clear_gate(session) + + +def test_new_repo_with_a_remote_still_demands_freshness(tmp_path: Path) -> None: + # A repo that HAS a remote has an upstream to be stale against, so the + # freshness demand is unchanged — the #149 waiver is scoped to no-remote. + repo = tmp_path / "hasremote" + repo.mkdir() + _git_init(repo) + subprocess.run( + ["git", "-C", str(repo), "remote", "add", "origin", "https://x.invalid/y.git"], + check=True, + ) + session = "hasremote-fresh" + guard.clear_gate(session) + guard.record_consult(session, kind="read", target=guard.GIT_RULES_NOTE, relevant=True) + + wfile = str(repo / "hello.py") + blocked = guard.decide({"tool": "Write", "file_path": wfile, "session": session}) + assert not blocked.allow + assert blocked.rule_id == "repo-work-fresh-base" + guard.clear_gate(session) + + def test_global_config_mutation_requires_explicit_turn_authorization() -> None: guard.begin_turn("global", "Can you fix both?") blocked = guard.decide( diff --git a/uv.lock b/uv.lock index fa30515..1089b52 100644 --- a/uv.lock +++ b/uv.lock @@ -2364,7 +2364,7 @@ wheels = [ [[package]] name = "omind" -version = "3.8.4" +version = "3.8.5" source = { editable = "." } dependencies = [ { name = "fastapi" },