Skip to content
Open
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
167 changes: 167 additions & 0 deletions hermes_cli/web_routers/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Shell-hook dashboard routes (extracted verbatim from web_server.py).

Handler bodies are byte-identical to their previous in-web_server form; the
config helpers (``load_config``/``save_config``) still live in web_server and
are reached via the late-binding seam in :mod:`hermes_cli.web_deps`, so
``monkeypatch.setattr(web_server, ...)`` keeps working.
"""

import logging
from typing import Any, Dict

from fastapi import APIRouter, HTTPException

from hermes_cli.web_deps import late
from hermes_cli.web_models import HookCreate, HookDelete

# Same logger the handlers used before extraction (identical logger object).
_log = logging.getLogger("hermes_cli.web_server")

router = APIRouter()

# Late-bound web_server helpers (resolved at call time; cycle-safe,
# monkeypatch-transparent).
load_config = late("load_config")
save_config = late("save_config")


@router.get("/api/ops/hooks")
async def list_hooks():
"""List configured shell hooks from config.yaml with consent + health.

Reports each hook's allowlist (consent) status and whether the script is
currently executable, plus the set of valid hook events so the create
form can offer them.
"""
from hermes_cli.config import load_config as _load_config
from agent import shell_hooks

try:
from hermes_cli.plugins import VALID_HOOKS
valid_events = sorted(VALID_HOOKS)
except Exception:
valid_events = []

specs = []
try:
specs = shell_hooks.iter_configured_hooks(_load_config())
except Exception:
_log.exception("iter_configured_hooks failed")

out = []
for spec in specs:
entry = None
try:
entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
except Exception:
pass
executable = False
try:
executable = shell_hooks.script_is_executable(spec.command)
except Exception:
pass
out.append({
"event": spec.event,
"matcher": spec.matcher,
"command": spec.command,
"timeout": spec.timeout,
"allowed": entry is not None,
"approved_at": (entry or {}).get("approved_at"),
"executable": executable,
})

return {"hooks": out, "valid_events": valid_events}


@router.post("/api/ops/hooks")
async def create_hook(body: HookCreate):
"""Add a shell hook to config.yaml (and optionally approve it).

Shell hooks run arbitrary commands, so this is a privileged action: it
writes to the ``hooks:`` config block and, when ``approve`` is set, records
consent in the allowlist so the hook actually fires. Takes effect on the
next session / gateway restart.
"""
from agent import shell_hooks

event = (body.event or "").strip()
command = (body.command or "").strip()
if not event or not command:
raise HTTPException(status_code=400, detail="event and command are required")

try:
from hermes_cli.plugins import VALID_HOOKS
if event not in VALID_HOOKS:
raise HTTPException(
status_code=400,
detail=f"Unknown event '{event}'. Valid: {', '.join(sorted(VALID_HOOKS))}",
)
except HTTPException:
raise
except Exception:
pass

cfg = load_config()
hooks_cfg = cfg.get("hooks")
if not isinstance(hooks_cfg, dict):
hooks_cfg = {}
cfg["hooks"] = hooks_cfg
entries = hooks_cfg.get(event)
if not isinstance(entries, list):
entries = []
hooks_cfg[event] = entries

new_entry: Dict[str, Any] = {"command": command}
if body.matcher:
new_entry["matcher"] = body.matcher
if body.timeout is not None:
new_entry["timeout"] = int(body.timeout)
entries.append(new_entry)
save_config(cfg)

approved = False
if body.approve:
try:
shell_hooks._record_approval(event, command)
approved = True
except Exception:
_log.exception("hook consent record failed")

return {"ok": True, "event": event, "command": command, "approved": approved}


@router.delete("/api/ops/hooks")
async def delete_hook(body: HookDelete):
"""Remove a hook from config.yaml and revoke its consent allowlist entry."""
from agent import shell_hooks

event = (body.event or "").strip()
command = (body.command or "").strip()
if not event or not command:
raise HTTPException(status_code=400, detail="event and command are required")

cfg = load_config()
hooks_cfg = cfg.get("hooks")
removed = False
if isinstance(hooks_cfg, dict) and isinstance(hooks_cfg.get(event), list):
before = len(hooks_cfg[event])
hooks_cfg[event] = [
e for e in hooks_cfg[event]
if not (isinstance(e, dict) and e.get("command") == command)
]
removed = len(hooks_cfg[event]) < before
if not hooks_cfg[event]:
del hooks_cfg[event]
if not hooks_cfg:
cfg.pop("hooks", None)
save_config(cfg)

# Revoke consent regardless so a re-add re-prompts.
try:
shell_hooks.revoke(command)
except Exception:
pass

if not removed:
raise HTTPException(status_code=404, detail="No matching hook found")
return {"ok": True}
Loading
Loading