Files
hermes-agent/agent/delegation_context.py
Brooklyn Nicholson 387c78c8f7 fix(kanban): fence the explicit-board override to unfenced callers
The explicit-board-over-env-pin precedence landed for cross-board USER intent,
but it also applied to machine flows that enumerate boards and to
dispatched/delegated workers:

- On a dispatcher-pinned box (HERMES_KANBAN_DB in the gateway env) the
  notifier's per-slug connect(board=slug) resolved every enumerated slug to
  its own empty physical DB instead of the pinned one, so no wake
  notifications were delivered (10 red gateway tests, red on head / green on
  main). Same shape in the per-subscription cursor writes (_kanban_sub_op)
  and the embedded dispatcher's per-board ticks.
- A dispatched worker or delegated child passing board=<other> could resolve
  outside the dispatcher's pin-based fence (kanban_path_is_fenced checks the
  pinned path / fenced root) — weakening the 5ec6baa 'workers physically
  cannot see other boards' isolation.

Fix: explicit board (direct arg or scoped --board) outranks the env pin only
where no fence applies.

- kanban_db.pin_first_board_resolution(): ContextVar marking machine flows
  (notifier collect, per-sub cursor writes, dispatcher tick / readiness /
  auto-decompose) so an enumerated slug resolves through the pin; a no-op
  when no pin is set, so unpinned multi-board ticks are unchanged.
- delegation_context.explicit_board_intent_is_pinned(): dispatched workers
  (HERMES_KANBAN_TASK) and delegated children / descendants keep the pinned
  path, preserving both the isolation invariant and the path fence.
- _board_path: env pin wins for pinned callers, explicit slug otherwise;
  restored the env-pin invariant test (pin wins regardless of board= for
  fenced callers) while keeping the contributor's unfenced explicit-board
  tests, plus new fence and pin-first regression tests.
2026-10-02 22:21:27 -05:00

185 lines
8.1 KiB
Python

"""Context-local state for delegate_task child execution.
A Hermes process may itself be a Kanban dispatcher worker with HERMES_KANBAN_* in
os.environ. In-process delegate_task children and cron jobs fired via
``cronjob(action="run")`` are NOT dispatcher-owned, so identity gates must fail
closed for them without mutating the process-global environment.
"""
from __future__ import annotations
import os
from contextlib import contextmanager
from contextvars import ContextVar, Token
from typing import Iterator, Mapping, MutableMapping, overload
_DELEGATED_CHILD_CONTEXT: ContextVar[bool] = ContextVar("hermes_delegated_child_context", default=False)
# Any in-process execution that is NOT the dispatcher-owned worker (cron jobs). Kept separate
# so delegate_task-specific behaviour (subprocess env scrubbing, its error strings) is unchanged.
_NON_DISPATCHER_OWNED_CONTEXT: ContextVar[bool] = ContextVar("hermes_non_dispatcher_owned_context", default=False)
DELEGATED_CHILD_ENV_MARKER = "HERMES_DELEGATED_CHILD_CONTEXT"
KANBAN_ENV_KEYS: tuple[str, ...] = (
"HERMES_KANBAN_TASK", "HERMES_KANBAN_RUN_ID", "HERMES_KANBAN_CLAIM_LOCK",
"HERMES_KANBAN_GOAL_MODE", "HERMES_KANBAN_GOAL_MAX_TURNS",
)
@contextmanager
def delegated_child_context(session_id: str | None = None) -> Iterator[None]:
"""Mark child execution and isolate its task-local session identity. Even a context
entered without an id must restore the parent's session ContextVar (child
construction calls ``set_current_session_id``)."""
token = _DELEGATED_CHILD_CONTEXT.set(True)
try:
from gateway.session_context import scoped_current_session_id # lazy: it calls is_delegated_child_context()
with scoped_current_session_id(session_id):
yield
finally:
_DELEGATED_CHILD_CONTEXT.reset(token)
def is_delegated_child_context() -> bool:
"""Return True while code is running for a delegate_task child."""
return bool(_DELEGATED_CHILD_CONTEXT.get())
def enter_non_dispatcher_owned_context() -> Token[bool]:
"""Token form of :func:`non_dispatcher_owned_context` for long try/finally scopes."""
return _NON_DISPATCHER_OWNED_CONTEXT.set(True)
def exit_non_dispatcher_owned_context(token: Token[bool]) -> None:
"""Restore the flag saved by :func:`enter_non_dispatcher_owned_context`."""
_NON_DISPATCHER_OWNED_CONTEXT.reset(token)
@contextmanager
def non_dispatcher_owned_context() -> Iterator[None]:
"""Mark in-process execution that does NOT own the dispatcher's Kanban task; without it
a cron agent run inside a worker is misread as that worker (kanban toolset force-added,
``kanban_complete`` defaulting to its task). ContextVar-scoped rather than clearing
os.environ, which the worker's claim heartbeat and concurrent readers share."""
token = enter_non_dispatcher_owned_context()
try:
yield
finally:
exit_non_dispatcher_owned_context(token)
def is_dispatcher_owned_worker_context() -> bool:
"""The single predicate every ``HERMES_KANBAN_*`` identity gate should use."""
return not (is_delegated_child_process_context() or _NON_DISPATCHER_OWNED_CONTEXT.get())
def explicit_board_intent_is_pinned() -> bool:
"""Whether an explicit kanban ``board=`` argument must still resolve through
the dispatcher-injected env pins (``HERMES_KANBAN_DB`` & friends) rather than
its own board directory.
True for in-process delegate children, descendants carrying
:data:`DELEGATED_CHILD_ENV_MARKER`, and dispatched workers
(``HERMES_KANBAN_TASK`` set). The pins are the "workers physically cannot
see other boards" isolation, and :func:`kanban_path_is_fenced` checks the
pinned path / fenced root — an explicit board that resolved elsewhere would
also escape that fence. Outside these fences an explicit board is the
caller's own intent and wins.
"""
if _DELEGATED_CHILD_CONTEXT.get():
return True
if os.environ.get(DELEGATED_CHILD_ENV_MARKER):
return True
return bool((os.environ.get("HERMES_KANBAN_TASK") or "").strip())
def owned_kanban_task() -> str:
"""The board task this execution OWNS: ``HERMES_KANBAN_TASK`` for the dispatcher-owned
worker, ``""`` otherwise. Tool access is not worker identity — a profile can expose the
kanban toolset interactively, and children/cron runs inherit the env var — so every
reader that turns the task id into worker behaviour (guidance, stop nudge, terminal
outcomes) goes through this one helper."""
if not is_dispatcher_owned_worker_context():
return ""
return (os.environ.get("HERMES_KANBAN_TASK") or "").strip()
def is_delegated_child_process_context() -> bool:
"""Return True in this process or a subprocess spawned by a child."""
return bool(_DELEGATED_CHILD_CONTEXT.get()) or bool(os.environ.get(DELEGATED_CHILD_ENV_MARKER))
def _fenced_kanban_root() -> str:
"""The board root this process's Kanban lineage lives under (``kanban_home()``); ``"1"`` when it
cannot be resolved, which readers treat as "fence every board" (the pre-path marker)."""
try:
from hermes_cli.kanban_db import kanban_home
return str(kanban_home())
except Exception:
return "1"
def scrub_kanban_env(env: Mapping[str, str] | MutableMapping[str, str]) -> dict[str, str]:
"""Remove worker identity, retaining board/location and an inherited write fence.
TASK absence alone would promote a descendant to an orchestrator. The marker
survives later execs, including scripts that remove TASK themselves. This is
cooperative runtime scoping, not confinement of code with direct SQLite access.
The marker's value is the fenced board ROOT, so the fence applies to the lineage's
board and not to every Kanban DB the descendant touches: a child running a repro
against a temp ``HERMES_HOME`` got a silently read-only board there. An inherited
path-valued marker is kept (a grandchild that moved HERMES_HOME must not re-fence
onto its scratch root and unfence the real one).
"""
cleaned = {k: v for k, v in env.items() if k not in KANBAN_ENV_KEYS}
inherited = str(env.get(DELEGATED_CHILD_ENV_MARKER) or "")
cleaned[DELEGATED_CHILD_ENV_MARKER] = inherited if inherited and inherited != "1" else _fenced_kanban_root()
return cleaned
def kanban_path_is_fenced(path: "os.PathLike[str] | str") -> bool:
"""Whether Kanban mutations at *path* (a board DB or board-metadata root) are denied for this
process: always for an in-process delegate child (the parent's own board); for a spawned
descendant only when *path* is the dispatcher-pinned ``HERMES_KANBAN_DB`` or lies under the
fenced root the marker carries. A legacy ``"1"`` marker fences everything."""
if _DELEGATED_CHILD_CONTEXT.get():
return True
marker = os.environ.get(DELEGATED_CHILD_ENV_MARKER, "")
if not marker:
return False
if marker == "1":
return True
from pathlib import Path
target = Path(path).expanduser().resolve()
pinned = os.environ.get("HERMES_KANBAN_DB", "").strip()
if pinned and target == Path(pinned).expanduser().resolve():
return True
try:
target.relative_to(Path(marker).expanduser().resolve())
except ValueError:
return False
return True
@overload
def delegated_child_subprocess_env(env: Mapping[str, str]) -> dict[str, str]: ...
@overload
def delegated_child_subprocess_env(env: None = None) -> dict[str, str] | None: ...
def delegated_child_subprocess_env(
env: Mapping[str, str] | MutableMapping[str, str] | None = None,
) -> dict[str, str] | None:
"""Carry worker/delegate descendant denial across a real process spawn.
Location and credentials are untouched; callers retain their existing secret policy.
Dispatcher workers and supervised tool transports grant their own explicit scope.
"""
if not (is_delegated_child_process_context() or os.environ.get("HERMES_KANBAN_TASK")
or (env and (env.get("HERMES_KANBAN_TASK") or env.get(DELEGATED_CHILD_ENV_MARKER)))):
return None if env is None else dict(env)
return scrub_kanban_env(os.environ if env is None else env)