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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<repo>/.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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/omind/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
35 changes: 34 additions & 1 deletion src/omind/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions tests/test_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<repo>/.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(
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading