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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [3.7.3] - 2026-07-01

### Fixed

- **Repo work now requires the git operational OMI note plus a fresh-base check.**
The guard no longer treats an arbitrary OMI consult as enough before repo review,
edits, tests, commits, pushes, or releases. In a git repo, repo-sensitive actions
require reading `Operational Rules - Git Repos and Secrets` during the turn and
running a same-turn `git fetch --all --prune` or `git pull --ff-only` freshness
command first.
- **Global config/hook/bootstrap writes now require explicit current-turn user
authorization.** The guard blocks installed agent config/hook/bootstrap mutation
when the user asked a question rather than clearly authorizing the change.
- **Codex AGENTS bootstrap now spells out the repo freshness and global-config
authorization rules.** `omind setup --agent codex` updates the managed block so
fresh Codex sessions see the rule before acting.

## [3.7.2] - 2026-07-01

### Fixed
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.7.2"
version = "3.7.3"
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.7.2"
__version__ = "3.7.3"
10 changes: 10 additions & 0 deletions src/omind/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,23 @@ def normalize_action(event: dict[str, Any]) -> dict[str, Any]:
or _first_str(tool_input, ("command",))
or _first_str(event, ("args", "input"))
)
file_path = _first_str(tool_input, ("file_path", "path")) or _first_str(
event, ("file_path", "path")
)
session = _first_str(event, ("session", "session_id"))
is_consult = tool.startswith(_OMI_CONSULT_PREFIXES) or bool(event.get("is_omi_consult"))
consult_target = (
_first_str(tool_input, ("name", "query", "q", "file_path", "path", "pattern"))
or _first_str(event, ("consult_target",))
)
return {
"tool": tool,
"command": command,
"session": session,
"is_omi_consult": is_consult,
"file_path": file_path,
"consult_target": consult_target,
"consult_kind": "read" if "read" in tool.lower() else "search",
}


Expand Down
180 changes: 179 additions & 1 deletion src/omind/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@
"(an OMI search or read), then retry. One consult clears the rest of the "
"turn. This is NOT a prompt to open the credential/auth notes."
)
GIT_RULES_NOTE = "Operational Rules - Git Repos and Secrets"
GIT_RULES_MESSAGE = (
"repo work requires reading OMI note `Operational Rules - Git Repos and Secrets` "
"this turn; a generic project-memory consult is not enough."
)
GIT_FRESHNESS_MESSAGE = (
"repo work requires a same-turn freshness check before review/edit/test/commit/push: "
"run `git fetch --all --prune` or `git pull --ff-only`, then inspect branch status."
)
GLOBAL_MUTATION_MESSAGE = (
"global config/hook/bootstrap mutation requires explicit user authorization in the "
"current turn; answer questions first instead of inferring permission."
)


@dataclass(frozen=True)
Expand Down Expand Up @@ -99,6 +112,7 @@ def begin_turn(session: str, task: str) -> None:
(the bash turn-start hook clears the same counter file)."""
_clear_reclose(session)
_clear_pending(session)
_clear_git_freshness(session)
with contextlib.suppress(OSError):
path = _turn_path(session)
path.parent.mkdir(parents=True, exist_ok=True)
Expand All @@ -122,6 +136,10 @@ def _pending_path(session: str) -> Path:
return paths.state_dir() / f"pending-{_safe_sid(session)}.txt"


def _git_fresh_path(session: str) -> Path:
return paths.state_dir() / f"git-fresh-{_safe_sid(session)}.json"


def record_pending(session: str, text: str) -> None:
"""Stash the gate-blocked action's text as this turn's pending intent
(best-effort, never raises). Empty/blank text is a no-op."""
Expand All @@ -147,6 +165,29 @@ def _clear_pending(session: str) -> None:
_pending_path(session).unlink()


def _record_git_freshness(session: str, repo: Path, command: str) -> None:
if not session:
return
with contextlib.suppress(OSError):
path = _git_fresh_path(session)
path.parent.mkdir(parents=True, exist_ok=True)
payload = {"repo": str(repo), "command": command, "ts": int(time.time())}
path.write_text(json.dumps(payload), encoding="utf-8")


def _git_fresh_for_repo(session: str, repo: Path) -> bool:
try:
data = json.loads(_git_fresh_path(session).read_text(encoding="utf-8"))
except (OSError, ValueError):
return False
return isinstance(data, dict) and data.get("repo") == str(repo)


def _clear_git_freshness(session: str) -> None:
with contextlib.suppress(OSError):
_git_fresh_path(session).unlink()


def _read_sentinel(session: str) -> dict[str, Any]:
"""The gate sentinel's JSON body ({} when empty/absent/garbage). The bash
adapter creates the file empty (``touch``); Python enriches it with the
Expand Down Expand Up @@ -371,7 +412,7 @@ def clear_all_gates() -> None:
cannot know the live session id, so a single-session clear would miss it).
Also reaps the legacy ``/tmp`` sentinels. Never raises."""
state = paths.state_dir()
for pattern in ("gate-*", "reclose-*", "pending-*", "offtopic-*"):
for pattern in ("gate-*", "reclose-*", "pending-*", "offtopic-*", "git-fresh-*"):
try:
stale = list(state.glob(pattern))
except OSError:
Expand All @@ -387,6 +428,40 @@ def clear_all_gates() -> None:
#: to clear the gate is to consult OMI, but where the OMI tools are deferred the
#: consult needs the very schema this tool loads.
_GATE_EXEMPT_TOOLS = frozenset({"ToolSearch"})
_WRITE_TOOLS = frozenset(
{
"Edit",
"MultiEdit",
"Write",
"NotebookEdit",
"apply_patch",
"functions.apply_patch",
}
)
_READ_REVIEW_TOOLS = frozenset({"Read", "Grep", "Glob", "LS", "find", "rg"})
_REPO_TEST_RE = re.compile(
r"(?:^|[;&|\n(]\s*)(?:uv|pytest|python|tox|nox|hatch|npm|pnpm|yarn|cargo|go|make)\b"
)
_GIT_FRESH_ONLY_RE = re.compile(
r"^\s*git\s+(?:fetch\b[^;&|\n]*|pull\b[^;&|\n]*(?:--ff-only|--rebase)[^;&|\n]*)\s*$"
)
_GIT_STATUS_ONLY_RE = re.compile(r"^\s*git\s+(?:status|rev-parse|branch|remote)\b[^;&|\n]*$")
_GLOBAL_CONFIG_RE = re.compile(
r"(?:^|[\s'\"=:/])(?:~?/)?(?:"
r"\.codex/(?:AGENTS\.md|hooks\.json|config\.toml)|"
r"\.claude/(?:settings\.json|hooks/[^ \t\n'\";]+)|"
r"\.hermes/(?:config\.yaml|hooks/[^ \t\n'\";]+|AGENTS\.md)|"
r"\.config/opencode/(?:opencode\.json|plugin/omi-guard\.js)|"
r"\.gemini/settings\.json|"
r"\.openclaw/(?:openclaw\.json|omind/MEMORY\.md)"
r")"
)
_GLOBAL_AUTH_RE = re.compile(
r"\b(?:please\s+)?(?:"
r"make|modify|edit|write|install|update|change|patch|apply|do it|go ahead|proceed"
r")\b",
re.IGNORECASE,
)


def _opt_in_satisfied(opt_in: str, command: str) -> bool:
Expand All @@ -408,10 +483,86 @@ def _opt_in_satisfied(opt_in: str, command: str) -> bool:
return re.search(pattern, command) is not None


def _action_path(action: dict[str, Any]) -> str:
for key in ("file_path", "path"):
value = action.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""


def _repo_root_for_action(action: dict[str, Any]) -> Path | None:
candidates: list[Path] = []
raw_path = _action_path(action)
if raw_path:
p = Path(raw_path).expanduser()
candidates.append(p if p.is_dir() else p.parent)
else:
candidates.append(Path.cwd())
for candidate in candidates:
try:
cur = candidate.resolve()
except OSError:
cur = candidate.absolute()
for parent in (cur, *cur.parents):
if (parent / ".git").exists():
return parent
return None


def _has_consulted_git_rules(session: str) -> bool:
needle = GIT_RULES_NOTE.lower()
for consult in consults(session):
target = str(consult.get("target") or "").lower()
if needle in target:
return True
return False


def _is_repo_sensitive_action(action: dict[str, Any]) -> bool:
tool = str(action.get("tool") or "")
command = str(action.get("command") or "")
path = _action_path(action)
if tool in _WRITE_TOOLS or tool in _READ_REVIEW_TOOLS:
return True
if tool == "Bash":
if _GIT_FRESH_ONLY_RE.match(command) or _GIT_STATUS_ONLY_RE.match(command):
return False
if re.search(
r"(?:^|[;&|\n(]\s*)git\s+(?:add|commit|push|merge|rebase|checkout|switch)\b",
command,
):
return True
if re.search(r"(?:^|[;&|\n(]\s*)gh\s+(?:pr|release|repo)\b", command):
return True
if _REPO_TEST_RE.search(command):
return True
if re.search(r"(?:^|[;&|\n(]\s*)(?:sed|perl|python|python3|node|ruby)\b", command) and (
" -i" in command or "write_text" in command or "Path(" in command
):
return True
return bool(path)


def _is_global_config_mutation(action: dict[str, Any]) -> bool:
tool = str(action.get("tool") or "")
if tool not in _WRITE_TOOLS and tool != "Bash":
return False
haystack = " ".join(
part for part in (str(action.get("command") or ""), _action_path(action)) if part
).replace("\\", "/")
return bool(_GLOBAL_CONFIG_RE.search(haystack))


def _turn_has_explicit_global_auth(session: str) -> bool:
return bool(_GLOBAL_AUTH_RE.search(turn_task(session)))


def decide(action: dict[str, Any]) -> Verdict:
"""The harness-agnostic policy. See the module docstring for the schema."""
session = str(action.get("session") or "")
command = str(action.get("command") or "")
repo = _repo_root_for_action(action)

# 1) Consulting OMI sets the per-turn sentinel and is always allowed. When
# the adapter knows what was consulted, record it (with target) so the
Expand Down Expand Up @@ -444,6 +595,10 @@ def decide(action: dict[str, Any]) -> Verdict:
rule_id=rule.id,
)

if repo is not None and _GIT_FRESH_ONLY_RE.match(command):
_record_git_freshness(session, repo, command)
return Verdict(allow=True)

# 2.5) Tool-schema loading (e.g. ToolSearch) is never gated. It already
# passed the hard blocks above; skip the gate WITHOUT satisfying it (loading
# a schema is not a consult), so a deferred OMI tool can be loaded and then
Expand All @@ -459,6 +614,29 @@ def decide(action: dict[str, Any]) -> Verdict:
if gate_paused():
return Verdict(allow=True)

if _is_global_config_mutation(action) and not _turn_has_explicit_global_auth(session):
return Verdict(
allow=False,
reason=f"omi-guard (hard): {GLOBAL_MUTATION_MESSAGE}",
rule_id="global-config-explicit-auth",
)

if repo is not None and _is_repo_sensitive_action(action):
if not _has_consulted_git_rules(session):
record_pending(session, command or _action_path(action))
return Verdict(
allow=False,
reason=f"omi-guard (hard): {GIT_RULES_MESSAGE}",
rule_id="repo-work-read-git-rules",
)
if not _git_fresh_for_repo(session, repo):
record_pending(session, command or _action_path(action))
return Verdict(
allow=False,
reason=f"omi-guard (hard): {GIT_FRESHNESS_MESSAGE}",
rule_id="repo-work-fresh-base",
)

# 3) The gate — block until OMI was consulted this turn.
if consulted_this_turn(session):
return Verdict(allow=True)
Expand Down
15 changes: 13 additions & 2 deletions src/omind/omi-guard-hermes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ SENT="$STATE/gate-$sid"
# Consulting OMI clears the per-turn gate (always allowed — the clear-path).
# `touch` (not truncate) so the PostToolUse verifier's JSON survives the turn.
case "$tool" in
mcp__omi__*) mkdir -p "$STATE" 2>/dev/null; touch "$SENT" 2>/dev/null; exit 0 ;;
mcp__omi__*)
target="$(printf '%s' "$input" | jq -r '.tool_input.name // .tool_input.query // .tool_input.q // .tool_input.file_path // .tool_input.path // empty' 2>/dev/null)"
jq -nc --arg t "$tool" --arg s "$sid" --arg target "$target" \
'{tool:$t, command:"", session:$s, is_omi_consult:true, consult_target:$target}' 2>/dev/null \
| "$OMIND" guard adapter --harness hermes --omi-dir "$OMI_DIR" >/dev/null 2>&1
exit 0
;;
# Tool-schema loading is never gated: deferred OMI MCP tools become callable
# only via ToolSearch, so gating it deadlocks the turn (no consult possible).
# Allow it through WITHOUT clearing the gate — loading a schema is not a consult.
Expand All @@ -48,7 +54,12 @@ if [ "$tool" = "Read" ] || [ "$tool" = "read_file" ]; then
# with paths.NON_CONSULT_FILENAMES.)
case "${fp##*/}" in
index.md|MEMORY.md|"Memory Template.md") exit 0 ;;
*) mkdir -p "$STATE" 2>/dev/null; touch "$SENT" 2>/dev/null; exit 0 ;;
*)
jq -nc --arg s "$sid" --arg target "$fp" \
'{tool:"Read", command:"", session:$s, is_omi_consult:true, consult_target:$target, consult_kind:"read", file_path:$target}' 2>/dev/null \
| "$OMIND" guard adapter --harness hermes --omi-dir "$OMI_DIR" >/dev/null 2>&1
exit 0
;;
esac
;;
esac
Expand Down
31 changes: 24 additions & 7 deletions src/omind/omi-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ SENT="$STATE/gate-$sid"
# turn's consults + relevance verdicts as JSON in this same file, and a second
# consult in the turn must not wipe the first.
case "$tool" in
mcp__omi__*) mkdir -p "$STATE" 2>/dev/null; touch "$SENT" 2>/dev/null; exit 0 ;;
mcp__omi__*)
target="$(printf '%s' "$input" | jq -r '.tool_input.name // .tool_input.query // .tool_input.q // .tool_input.file_path // .tool_input.path // empty' 2>/dev/null)"
jq -nc --arg t "$tool" --arg s "$sid" --arg target "$target" \
'{tool:$t, command:"", session:$s, is_omi_consult:true, consult_target:$target}' 2>/dev/null \
| "$OMIND" guard check >/dev/null 2>&1
exit 0
;;
# Tool-schema loading is never gated: deferred OMI MCP tools become callable
# only via ToolSearch, so gating it deadlocks the turn (no consult possible).
# Allow it through WITHOUT clearing the gate — loading a schema is not a consult.
Expand All @@ -80,7 +86,12 @@ if [ "$tool" = "Read" ]; then
# with paths.NON_CONSULT_FILENAMES.)
case "${fp##*/}" in
index.md|MEMORY.md|"Memory Template.md") exit 0 ;;
*) mkdir -p "$STATE" 2>/dev/null; touch "$SENT" 2>/dev/null; exit 0 ;;
*)
jq -nc --arg s "$sid" --arg target "$fp" \
'{tool:"Read", command:"", session:$s, is_omi_consult:true, consult_target:$target, consult_kind:"read", file_path:$target}' 2>/dev/null \
| "$OMIND" guard check >/dev/null 2>&1
exit 0
;;
esac
;;
esac
Expand Down Expand Up @@ -121,11 +132,17 @@ if [ -f "$PAUSE" ]; then
if [ -n "$exp" ] && [ -n "$now" ] && [ "$exp" -gt "$now" ] 2>/dev/null; then exit 0; fi
fi

# All other tools: the per-turn gate. The common post-consult case is a pure
# bash existence check (no subprocess). Only the first BLOCKED action of a turn
# pays one subprocess to name the notes relevant to this turn's task (Phase 3.2),
# falling back to the static message if omind can't be reached.
[ -e "$SENT" ] && exit 0
# All other tools: delegate to the core so repo/global-config preconditions can
# inspect file paths. This is slower than the old sentinel-only fast path, but the
# policy now needs more context than "has OMI been consulted".
fp="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.path // .file_path // .path // empty' 2>/dev/null)"
jq -nc --arg t "$tool" --arg s "$sid" --arg fp "$fp" \
'{tool:$t, command:"", session:$s, is_omi_consult:false, file_path:$fp}' 2>/dev/null \
| "$OMIND" guard check
rc=$?
case "$rc" in
0 | 2) exit "$rc" ;;
esac
if msg="$(printf '%s' "$input" | "$OMIND" guard suggest --omi-dir "$OMI_DIR" 2>/dev/null)" \
&& [ -n "$msg" ]; then
printf '%s\n' "$msg" >&2
Expand Down
Loading