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
8 changes: 8 additions & 0 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ _Closed as not planned or rejected. Kept for the record; nothing here is pending

## Done

### Shipped — 2026-10-02 (v10.2.28)

- [x] **guard: `decide()` raises OSError (ENAMETOOLONG) on long cd chains; add an overall fail-closed hook deadline** ([#460](https://github.com/CryptoJones/omind/issues/460)) — **v10.2.28** — _bug_ —
unlookable paths (too long, embedded NUL) degrade to an unknown repo; a `cd` chain is walked in
linear time; judging runs under an 8 s cooperative deadline past which the action is denied with
the #445 reason and a `deadline-exceeded` event; each `gh`/`git` fact lookup has its own timeout,
so a slow lookup leaves only that fact unknown.

### Shipped — 2026-10-02 (v10.2.27)

- [x] **guard hard rules: quoted wrapper switch values and unique-prefix long options hide sudo** ([#464](https://github.com/CryptoJones/omind/issues/464)) — **v10.2.27** — _bug_ —
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [10.2.28] - 2026-10-02

### Fixed
- **The guard no longer raises on paths the filesystem cannot look up, and
judges each action under a deadline
([#460](https://github.com/CryptoJones/omind/issues/460)).** `decide()` raised
`OSError` (`ENAMETOOLONG`) on `cd x; ` repeated about 2,000 times and on a long
`git -C` or Write path, and `ValueError` on a Write path holding a NUL: the walked
directory or target reached `stat`/`resolve` uncaught (invariant 2). Such a path
now degrades to an unknown repo.
- A `cd` chain stops growing past 4,096 characters (`PATH_MAX`; the directory is
unknowable there) and is joined as text, so `cd x; ` × 100,000 is walked in
linear time instead of quadratic. The note-rule view resolves and walks each
directory of a chain once per command, so `cd x; ` × 2,000 through
`check_action` with note rules takes about 1 s instead of 8.
- Judging one action runs under a cooperative wall-clock deadline
(`omind.deadline`, 8 s, inside the 15 s OMI guard hook timeout), checked at the
walk, stage, hard-rule and note-rule loop points. Past it, wherever it fires,
the action is denied with the #445 reason (`command too large/complex to judge
safely — split it or shorten it`, rule `omi-guard-deadline`) and a
`deadline-exceeded` event. Several gates after the hard rules are hard denies
too (global config, capability questions, repo work, operator note-rule
denies), so padding a command past the budget must not skip them. A hook
timeout used to return no verdict at all, which skipped every gate.
- Ordinary commands never get there: every `gh`/`git` fact lookup runs under its
own timeout (at most 3 s, and never into the 3 s left for judging), so a slow
lookup makes only that fact unknown. The hard rules always get at least 3 s
of their own. The steps after a verdict (the budget re-arm, the note excerpt,
the retrieval suggestion) run outside the deadline.

## [10.2.27] - 2026-10-02

### 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 = "10.2.27"
version = "10.2.28"
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__ = "10.2.27"
__version__ = "10.2.28"
3 changes: 3 additions & 0 deletions src/omind/compliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@
KIND_GATE_RESET = "gate-reset"
#: A hard rule denied a command too costly to judge, not a real match (#445).
KIND_BUDGET_EXCEEDED = "budget-exceeded"
#: Judging one action ran past the guard's wall-clock deadline (#460): the
#: hard rules denied it (``deny``) or the soft gates waved it on (``fail-open``).
KIND_DEADLINE_EXCEEDED = "deadline-exceeded"


def compliance_log_path() -> Path:
Expand Down
145 changes: 145 additions & 0 deletions src/omind/deadline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""A wall-clock budget for judging one action in the guard (#460).

A hook the harness kills on its timeout returns no verdict at all, and a
missing verdict skips every gate. So judging one action runs under a deadline
well inside the hook timeout, and an exhausted budget fails CLOSED: the guard
denies the action with an explicit "too large/complex to judge" reason. A
padded command must not slip past a hard gate by outrunning it.

Ordinary commands never get there. Each fact lookup that waits on another
process or the network (``gh repo view``, ``git ls-remote``, ``git config``)
runs under its own timeout from :func:`lookup_timeout`, cut so that
:data:`LOOKUP_RESERVE_SECONDS` of the budget always remain for judging. A
lookup that times out makes that one fact unknown; it never spends the budget.

The deadline is cooperative: the walk, the stage loops and the rule loops call
:func:`check` at their loop points, and :func:`check` raises
:class:`DeadlineExceededError` once the budget is spent. ``signal.SIGALRM``
would interrupt anything, but it is POSIX-only, fires only on the main
thread, and lands at an arbitrary bytecode; a watchdog thread cannot stop the
judging thread in Python at all. A check at each loop point works on every
platform and every thread, and always leaves the guard at a known point.

The active deadline lives in a :class:`contextvars.ContextVar`, so concurrent
judges (threads, the web app) never share one. With no active deadline,
:func:`check` does nothing, so code that runs outside a judging scope (tests,
``omind rules`` tooling) behaves exactly as before.
"""

from __future__ import annotations

import contextlib
import contextvars
import time
from collections.abc import Iterator

#: Seconds one action may spend being judged before the guard returns a
#: verdict anyway. The shortest hook timeout omind provisions for the OMI
#: guard is ``provision.OMI_GUARD_TIMEOUT`` (15 s); the budget leaves the
#: rest for the interpreter start (seconds under antivirus), the adapter's
#: rendering and the gate's retrieval suggestion.
JUDGE_BUDGET_SECONDS = 8.0

#: Seconds the hard rules may always take, however much of the budget an
#: earlier step spent (see :func:`at_least`).
HARD_RULE_FLOOR_SECONDS = 3.0

#: Longest any one fact lookup (a ``gh``/``git`` subprocess) may take while
#: judging: well inside the budget.
LOOKUP_TIMEOUT_SECONDS = 3.0

#: Seconds of the budget fact lookups leave for judging itself, which takes
#: milliseconds on an ordinary command: lookups stop short of them.
LOOKUP_RESERVE_SECONDS = 3.0


class DeadlineExceededError(Exception):
"""The active judging budget is spent (#460)."""


class Deadline:
"""An absolute :func:`time.monotonic` instant past which judging stops."""

__slots__ = ("end",)

def __init__(self, end: float) -> None:
self.end = end


_ACTIVE: contextvars.ContextVar[Deadline | None] = contextvars.ContextVar(
"omind_judge_deadline", default=None
)


def check() -> None:
"""Raise :class:`DeadlineExceededError` when the active deadline is spent.
A no-op outside a judging scope."""
current = _ACTIVE.get()
if current is not None and time.monotonic() >= current.end:
raise DeadlineExceededError


def lookup_timeout(cap: float) -> float:
"""The timeout for one fact lookup whose own limit is ``cap`` seconds.

Outside a judging scope, ``cap``. Inside one, at most
:data:`LOOKUP_TIMEOUT_SECONDS`, and never past the point where
:data:`LOOKUP_RESERVE_SECONDS` of the budget remain: a slow lookup then
times out, and only that fact is unknown. ``0.0`` when the reserve is
already reached; the caller treats the fact as unknown without asking."""
current = _ACTIVE.get()
if current is None:
return cap
left = current.end - time.monotonic() - LOOKUP_RESERVE_SECONDS
return max(0.0, min(cap, LOOKUP_TIMEOUT_SECONDS, left))


@contextlib.contextmanager
def suspended() -> Iterator[None]:
"""Run the block with no deadline at all (:func:`check` a no-op). For the
steps after a verdict is reached (the gate's budget re-arm, a deny's
note excerpt, the retrieval suggestion): they have their own bounds, and
a deadline hit there is not a judging failure."""
token = _ACTIVE.set(None)
try:
yield
finally:
_ACTIVE.reset(token)


@contextlib.contextmanager
def scope(seconds: float | None = None) -> Iterator[Deadline]:
"""Judge under a deadline ``seconds`` from now (default
:data:`JUDGE_BUDGET_SECONDS`, read at call time so tests can shrink it).
An enclosing scope wins: a nested call keeps the outer deadline, so the
budget covers the whole action, not each step of it."""
current = _ACTIVE.get()
if current is not None:
yield current
return
budget = JUDGE_BUDGET_SECONDS if seconds is None else seconds
created = Deadline(time.monotonic() + budget)
token = _ACTIVE.set(created)
try:
yield created
finally:
_ACTIVE.reset(token)


@contextlib.contextmanager
def at_least(seconds: float) -> Iterator[None]:
"""Extend the active deadline so that at least ``seconds`` remain, for
the duration of the block. The hard rules run under this: they are
normally judged in milliseconds, so time an earlier, slower step spent
(a note rule's network lookup) must not turn a plain command into a
deadline deny. Outside a judging scope it opens one of ``seconds``."""
current = _ACTIVE.get()
floor = time.monotonic() + seconds
if current is not None and current.end >= floor:
yield
return
token = _ACTIVE.set(Deadline(floor))
try:
yield
finally:
_ACTIVE.reset(token)
Loading
Loading