# Conflicts: # AGENTS.md # acp_adapter/edit_approval.py # acp_adapter/server.py # agent/agent_init.py # agent/anthropic_adapter.py # agent/anthropic_credentials.py # agent/auxiliary_client.py # agent/azure_identity_adapter.py # agent/bedrock_adapter.py # agent/browser_registry.py # agent/chat_completion_helpers.py # agent/coding_context.py # agent/context_references.py # agent/conversation_loop.py # agent/copilot_acp_client.py # agent/credits_tracker.py # agent/curator.py # agent/curator_backup.py # agent/deadline.py # agent/display.py # agent/errors.py # agent/estop.py # agent/i18n.py # agent/image_gen_registry.py # agent/image_routing.py # agent/learning_graph.py # agent/learning_mutations.py # agent/lsp/servers.py # agent/model_metadata.py # agent/models_dev.py # agent/monitoring/gateway_health_export.py # agent/monitoring/otlp_exporter.py # agent/pet/store.py # agent/process_bootstrap.py # agent/prompt_builder.py # agent/proxy_sources/iron_proxy.py # agent/secret_sources/_cache.py # agent/secret_sources/bitwarden.py # agent/secret_sources/registry.py # agent/shell_hooks.py # agent/skill_bundles.py # agent/skill_commands.py # agent/skill_utils.py # agent/ssl_guard.py # agent/ssl_verify.py # agent/system_prompt.py # agent/terminal_env_registry.py # agent/trace_upload.py # agent/transcription_registry.py # agent/tts_registry.py # agent/verify/environment.py # agent/vertex_adapter.py # agent/video_gen_registry.py # agent/web_search_registry.py # cli.py # cron/jobs.py # cron/scheduler.py # gateway/agent_cache_pressure.py # gateway/cgroup_cleanup.py # gateway/channel_directory.py # gateway/config.py # gateway/control_socket.py # gateway/dead_targets.py # gateway/drain_control.py # gateway/hooks.py # gateway/kanban_watchers.py # gateway/lifecycle_ledger.py # gateway/mirror.py # gateway/pairing.py # gateway/platform_registry.py # gateway/platforms/helpers.py # gateway/platforms/weixin.py # gateway/readiness.py # gateway/restart_loop_guard.py # gateway/rich_sent_store.py # gateway/run.py # gateway/session.py # gateway/shutdown_flush.py # gateway/shutdown_forensics.py # gateway/slash_commands.py # gateway/status.py # gateway/sticker_cache.py # gateway/whatsapp_identity.py # hermes_bootstrap.py # hermes_cli/_early_recovery.py # hermes_cli/_install_repair.py # hermes_cli/_startup_fast.py # hermes_cli/_subprocess_compat.py # hermes_cli/agent_plugins.py # hermes_cli/auth.py # hermes_cli/backup.py # hermes_cli/banner.py # hermes_cli/browser_connect.py # hermes_cli/build_info.py # hermes_cli/cli_agent_setup_mixin.py # hermes_cli/cli_commands_mixin.py # hermes_cli/codex_models.py # hermes_cli/config.py # hermes_cli/config_defaults.py # hermes_cli/config_migrations.py # hermes_cli/container_boot.py # hermes_cli/dashboard_auth/registry.py # hermes_cli/debug.py # hermes_cli/dep_ensure.py # hermes_cli/doctor.py # hermes_cli/doctor_live.py # hermes_cli/dump.py # hermes_cli/env_loader.py # hermes_cli/foreign_sessions.py # hermes_cli/gateway.py # hermes_cli/gateway_windows.py # hermes_cli/gui_uninstall.py # hermes_cli/image_provenance.py # hermes_cli/install_identity.py # hermes_cli/kanban.py # hermes_cli/kanban_db.py # hermes_cli/linux_desktop_entry.py # hermes_cli/local_runtime/binaries.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/local_runtime/growth.py # hermes_cli/local_runtime/supervisor.py # hermes_cli/logs.py # hermes_cli/macos_tcc_anchor.py # hermes_cli/main.py # hermes_cli/memory_setup.py # hermes_cli/model_catalog.py # hermes_cli/models.py # hermes_cli/nous_subscription.py # hermes_cli/npm_engine.py # hermes_cli/plugin_index.py # hermes_cli/plugins.py # hermes_cli/plugins_cmd.py # hermes_cli/profile_distribution.py # hermes_cli/profiles.py # hermes_cli/prompt_size.py # hermes_cli/psutil_android.py # hermes_cli/runtime_repair.py # hermes_cli/security_advisories.py # hermes_cli/security_audit.py # hermes_cli/security_audit_startup.py # hermes_cli/service_manager.py # hermes_cli/session_export_md.py # hermes_cli/setup.py # hermes_cli/skills_hub.py # hermes_cli/slack_cli.py # hermes_cli/status.py # hermes_cli/subcommands/gateway.py # hermes_cli/subcommands/uninstall.py # hermes_cli/tools_config.py # hermes_cli/uninstall.py # hermes_cli/update_cmd.py # hermes_cli/update_contract.py # hermes_cli/update_inventory.py # hermes_cli/update_lock.py # hermes_cli/update_receipt.py # hermes_cli/urllib_security.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_routers/profiles.py # hermes_cli/web_routers/skills.py # hermes_cli/web_server.py # hermes_constants.py # hermes_state.py # plugins/disk-cleanup/__init__.py # plugins/disk-cleanup/disk_cleanup.py # plugins/google_meet/node/registry.py # plugins/google_meet/node/server.py # plugins/google_meet/process_manager.py # plugins/google_meet/realtime/openai_client.py # plugins/hermes-achievements/dashboard/plugin_api.py # plugins/memory/hindsight/__init__.py # plugins/memory/honcho/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/honcho/client.py # plugins/memory/honcho/oauth.py # plugins/memory/honcho/session.py # plugins/memory/mem0/__init__.py # plugins/memory/mem0/_setup.py # plugins/memory/openviking/__init__.py # plugins/memory/retaindb/__init__.py # plugins/memory/supermemory/__init__.py # plugins/platforms/a2a/protocol.py # plugins/platforms/dingtalk/adapter.py # plugins/platforms/discord/adapter.py # plugins/platforms/feishu/adapter.py # plugins/platforms/google_chat/adapter.py # plugins/platforms/matrix/adapter.py # plugins/platforms/photon/adapter.py # plugins/platforms/photon/auth.py # plugins/platforms/photon/cli.py # plugins/platforms/slack/adapter.py # plugins/platforms/teams/adapter.py # plugins/platforms/telegram/adapter.py # plugins/platforms/wecom/callback_adapter.py # plugins/platforms/whatsapp/adapter.py # plugins/teams_pipeline/store.py # plugins/video_gen/fal/__init__.py # plugins/web/ddgs/provider.py # plugins/web/exa/provider.py # plugins/web/firecrawl/provider.py # plugins/web/parallel/provider.py # tests/agent/test_ssl_ca_guard.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_cmd_update_apt.py # tests/hermes_cli/test_dashboard_unified_launch.py # tests/hermes_cli/test_dep_ensure.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_live.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_kanban_boards.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_memory_setup_provider_arg.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_pip_install_detection.py # tests/hermes_cli/test_profile_export_credentials.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_tui_npm_install.py # tests/hermes_cli/test_update_fleet_restart_pending.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_ui_build.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/tools/test_browser_chromium_autoinstall.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_lightpanda.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_open_timeout.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_suspect_recycle.py # tests/tools/test_find_shell.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_macos_protected_search.py # tests/tui_gateway/test_compute_host.py # tools/approval.py # tools/blueprints.py # tools/bot_mode_dm.py # tools/bot_mode_probe.py # tools/bot_relay.py # tools/browser_tool.py # tools/browser_use_cli.py # tools/checkpoint_manager.py # tools/code_execution_tool.py # tools/code_kernel.py # tools/computer_use/cua_backend.py # tools/cronjob_tools.py # tools/discord_tool.py # tools/environments/base.py # tools/environments/daytona.py # tools/environments/local.py # tools/environments/modal.py # tools/environments/vercel_sandbox.py # tools/fal_common.py # tools/file_operations.py # tools/lazy_deps.py # tools/mcp_tool.py # tools/neutts_synth.py # tools/process_registry.py # tools/read_extract.py # tools/registry.py # tools/skill_ledger.py # tools/skill_linter.py # tools/skill_manager_tool.py # tools/skill_usage.py # tools/skills_ast_audit.py # tools/skills_guard.py # tools/skills_hub.py # tools/skills_sync.py # tools/skills_sync_client.py # tools/skills_tool.py # tools/terminal_scope.py # tools/terminal_tool.py # tools/tirith_security.py # tools/transcription_tools.py # tools/tts_tool.py # tools/vision_tools.py # tools/voice_mode.py # tools/wake_word.py # tools/web_result_cache.py # tools/website_policy.py # tools/working_diff.py # tools/write_approval.py # tui_gateway/entry.py # tui_gateway/methods_tools.py # tui_gateway/server.py
336 lines
14 KiB
Python
336 lines
14 KiB
Python
"""hermes hooks — inspect and manage shell-script hooks."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List
|
||
|
||
|
||
def hooks_command(args) -> None:
|
||
"""Entry point for ``hermes hooks`` — dispatches to the requested action."""
|
||
sub = getattr(args, "hooks_action", None)
|
||
if not sub:
|
||
print("Usage: hermes hooks {list|test|revoke|doctor}")
|
||
print("Run 'hermes hooks --help' for details.")
|
||
return
|
||
handler = _ACTIONS.get(sub)
|
||
if handler is None:
|
||
print(f"Unknown hooks subcommand: {sub}")
|
||
return
|
||
handler(args)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# list
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _cmd_list(_args) -> None:
|
||
from hermes_cli.config import load_config
|
||
from agent import outbound_webhooks, shell_hooks
|
||
|
||
cfg = load_config()
|
||
specs = shell_hooks.iter_configured_hooks(cfg)
|
||
outbound = outbound_webhooks.iter_configured_targets(cfg)
|
||
|
||
if not specs and not outbound:
|
||
print("No shell hooks or outbound webhooks configured in ~/.hermes/config.yaml.")
|
||
print("See `hermes hooks --help` or")
|
||
print(" website/docs/user-guide/features/hooks.md")
|
||
print("for the config schema and worked examples.")
|
||
return
|
||
|
||
if not specs:
|
||
print("No shell hooks configured in ~/.hermes/config.yaml.")
|
||
else:
|
||
by_event: Dict[str, List] = {}
|
||
for spec in specs:
|
||
by_event.setdefault(spec.event, []).append(spec)
|
||
approved = {
|
||
(e.get("event"), e.get("command"))
|
||
for e in shell_hooks.load_allowlist().get("approvals", [])
|
||
if isinstance(e, dict)
|
||
}
|
||
print(f"Configured shell hooks ({len(specs)} total):\n")
|
||
for event in sorted(by_event):
|
||
print(f" [{event}]")
|
||
for spec in by_event[event]:
|
||
is_approved = (spec.event, spec.command) in approved
|
||
status = "✓ allowed" if is_approved else "✗ not allowlisted"
|
||
print(f" - {spec.command}{_matcher_part(spec)} (timeout={spec.timeout}s, {status})")
|
||
entry = shell_hooks.allowlist_entry_for(spec.event, spec.command) if is_approved else None
|
||
if entry and entry.get("approved_at"):
|
||
print(f" approved_at: {entry['approved_at']}")
|
||
drift, mtime_at, mtime_now = _mtime_drift(shell_hooks, spec, entry)
|
||
if drift is True:
|
||
print(
|
||
f" ⚠ script modified since approval "
|
||
f"(was {mtime_at}, now {mtime_now}) — "
|
||
f"run `hermes hooks doctor` to re-validate"
|
||
)
|
||
print()
|
||
|
||
if outbound:
|
||
print(f"Configured outbound webhooks ({len(outbound)} total):\n")
|
||
for target in outbound:
|
||
signed = "signed" if target.secret else "UNSIGNED"
|
||
print(f" - {target.label}")
|
||
print(f" url: {target.url}")
|
||
print(f" events: {', '.join(target.events)}{_matcher_part(target)} (timeout={target.timeout}s, {signed})")
|
||
print()
|
||
|
||
|
||
def _matcher_part(obj) -> str:
|
||
return f" matcher={obj.matcher!r}" if obj.matcher else ""
|
||
|
||
|
||
def _mtime_drift(shell_hooks, spec, entry) -> tuple[bool | None, str, str]:
|
||
"""``(drift, mtime_at, mtime_now)``: True if the script changed since approval, False if
|
||
unchanged, None when either mtime is unknown."""
|
||
mtime_now = shell_hooks.script_mtime_iso(spec.command)
|
||
mtime_at = entry.get("script_mtime_at_approval")
|
||
if not (mtime_now and mtime_at):
|
||
return None, mtime_at, mtime_now
|
||
if mtime_now > mtime_at:
|
||
return True, mtime_at, mtime_now
|
||
return (False if mtime_now == mtime_at else None), mtime_at, mtime_now
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# test
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Synthetic kwargs matching the real invoke_hook() call sites. They go verbatim to
|
||
# agent.shell_hooks.run_once() -> the same _serialize_payload() production uses, so the
|
||
# stdin a script sees under `hooks test` / `hooks doctor` has the runtime shape.
|
||
_DEFAULT_PAYLOADS = {
|
||
"pre_tool_call": {
|
||
"tool_name": "terminal", "args": {"command": "echo hello"},
|
||
"session_id": "test-session", "task_id": "test-task", "tool_call_id": "test-call",
|
||
},
|
||
"post_tool_call": {
|
||
"tool_name": "terminal", "args": {"command": "echo hello"},
|
||
"session_id": "test-session", "task_id": "test-task", "tool_call_id": "test-call",
|
||
"result": '{"output": "hello"}', "duration_ms": 42,
|
||
},
|
||
"pre_llm_call": {
|
||
"session_id": "test-session", "user_message": "What is the weather?",
|
||
"conversation_history": [], "is_first_turn": True, "model": "gpt-4", "platform": "cli",
|
||
},
|
||
"post_llm_call": {"session_id": "test-session", "model": "gpt-4", "platform": "cli"},
|
||
"pre_verify": {
|
||
"session_id": "test-session", "platform": "cli", "model": "gpt-4", "coding": True,
|
||
"attempt": 0, "final_response": "All done — the change is applied.",
|
||
"changed_paths": ["src/app.tsx"],
|
||
},
|
||
"on_session_start": {"session_id": "test-session"},
|
||
"on_session_end": {
|
||
"session_id": "test-session", "task_id": "test-task", "turn_id": "test-turn",
|
||
"completed": True, "failed": False, "interrupted": False,
|
||
"turn_exit_reason": "text_response(stop)", "model": "gpt-4", "platform": "cli",
|
||
},
|
||
"on_session_finalize": {"session_id": "test-session"},
|
||
"on_session_reset": {"session_id": "test-session"},
|
||
"pre_api_request": {
|
||
"session_id": "test-session", "task_id": "test-task", "platform": "cli",
|
||
"model": "claude-sonnet-4-6", "provider": "anthropic",
|
||
"base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages",
|
||
"api_call_count": 1, "message_count": 4, "tool_count": 12,
|
||
"approx_input_tokens": 2048, "request_char_count": 8192, "max_tokens": 4096,
|
||
},
|
||
"post_api_request": {
|
||
"session_id": "test-session", "task_id": "test-task", "platform": "cli",
|
||
"model": "claude-sonnet-4-6", "provider": "anthropic",
|
||
"base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages",
|
||
"api_call_count": 1, "api_duration": 1.234,
|
||
"started_at": 1756000000.0, "ended_at": 1756000001.234, "first_chunk_at": 1756000000.512,
|
||
"finish_reason": "stop", "message_count": 4, "response_model": "claude-sonnet-4-6",
|
||
"usage": {"input_tokens": 2048, "output_tokens": 512},
|
||
"assistant_content_chars": 1200, "assistant_tool_call_count": 0,
|
||
# Per-advisor metrics on a MoA turn, None otherwise: MoA returns only the aggregator's
|
||
# response, so without this an observer cannot see or price the fan-out.
|
||
"moa_references": None,
|
||
},
|
||
"subagent_stop": {
|
||
"parent_session_id": "parent-sess", "child_role": None,
|
||
"child_summary": "Synthetic summary for hooks test", "child_status": "completed",
|
||
"tool_call_history": [{
|
||
"tool_name": "write_file",
|
||
"tool_input": {"argument_keys": ["content", "path"], "targets": {"path": "/tmp/report.txt"}},
|
||
"input_bytes": 128, "output_bytes": 32, "status": "ok",
|
||
}],
|
||
"duration_ms": 1234,
|
||
},
|
||
}
|
||
|
||
|
||
def _cmd_test(args) -> None:
|
||
from hermes_cli.config import load_config
|
||
from hermes_cli.plugins import VALID_HOOKS
|
||
from agent import shell_hooks
|
||
|
||
event = args.event
|
||
if event not in VALID_HOOKS:
|
||
print(f"Unknown event: {event!r}")
|
||
print(f"Valid events: {', '.join(sorted(VALID_HOOKS))}")
|
||
return
|
||
|
||
# Synthetic kwargs merged with --for-tool (overrides tool_name) and --payload-file (extra kwargs).
|
||
payload = dict(_DEFAULT_PAYLOADS.get(event, {"session_id": "test-session"}))
|
||
for_tool = getattr(args, "for_tool", None)
|
||
if for_tool:
|
||
payload["tool_name"] = for_tool
|
||
if getattr(args, "payload_file", None):
|
||
try:
|
||
custom = json.loads(Path(args.payload_file).read_text(encoding="utf-8-sig"))
|
||
if isinstance(custom, dict):
|
||
payload.update(custom)
|
||
else:
|
||
print(f"Warning: {args.payload_file} is not a JSON object; ignoring")
|
||
except Exception as exc:
|
||
print(f"Error reading payload file: {exc}")
|
||
return
|
||
|
||
specs = [s for s in shell_hooks.iter_configured_hooks(load_config()) if s.event == event]
|
||
if for_tool:
|
||
specs = [s for s in specs if s.event not in {"pre_tool_call", "post_tool_call"} or s.matches_tool(for_tool)]
|
||
if not specs:
|
||
print(f"No shell hooks configured for event: {event}")
|
||
if for_tool:
|
||
print(f"(with matcher filter --for-tool={for_tool})")
|
||
return
|
||
|
||
print(f"Firing {len(specs)} hook(s) for event '{event}':\n")
|
||
for spec in specs:
|
||
print(f" → {spec.command}")
|
||
_print_run_result(shell_hooks.run_once(spec, payload))
|
||
print()
|
||
|
||
|
||
def _print_run_result(result: Dict[str, Any]) -> None:
|
||
if result.get("error"):
|
||
print(f" ✗ error: {result['error']}")
|
||
return
|
||
if result.get("timed_out"):
|
||
print(f" ✗ timed out after {result['elapsed_seconds']}s")
|
||
return
|
||
print(f" exit={result.get('returncode')} elapsed={result.get('elapsed_seconds', 0)}s")
|
||
for stream in ("stdout", "stderr"):
|
||
text = (result.get(stream) or "").strip()
|
||
if text:
|
||
print(f" {stream}: {_truncate(text, 400)}")
|
||
parsed = result.get("parsed")
|
||
if parsed:
|
||
print(f" parsed (Hermes wire shape): {json.dumps(parsed)}")
|
||
else:
|
||
print(" parsed: <none — hook contributed nothing to the dispatcher>")
|
||
|
||
|
||
def _truncate(s: str, n: int) -> str:
|
||
return s if len(s) <= n else s[: n - 3] + "..."
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# revoke / doctor
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _cmd_revoke(args) -> None:
|
||
from agent import shell_hooks
|
||
|
||
removed = shell_hooks.revoke(args.command)
|
||
if removed == 0:
|
||
print(f"No allowlist entry found for command: {args.command}")
|
||
return
|
||
print(f"Removed {removed} allowlist entry/entries for: {args.command}")
|
||
print(
|
||
"Note: currently running CLI / gateway processes keep their "
|
||
"already-registered callbacks until they restart."
|
||
)
|
||
|
||
|
||
def _cmd_doctor(_args) -> None:
|
||
from hermes_cli.config import load_config
|
||
from agent import shell_hooks
|
||
|
||
specs = shell_hooks.iter_configured_hooks(load_config())
|
||
if not specs:
|
||
print("No shell hooks configured — nothing to check.")
|
||
return
|
||
print(f"Checking {len(specs)} configured shell hook(s)...\n")
|
||
problems = 0
|
||
for spec in specs:
|
||
print(f" [{spec.event}] {spec.command}")
|
||
problems += _doctor_one(spec, shell_hooks)
|
||
print()
|
||
print(f"{problems} issue(s) found. Fix before relying on these hooks." if problems else "All shell hooks look healthy.")
|
||
|
||
|
||
def _doctor_one(spec, shell_hooks) -> int:
|
||
problems = 0
|
||
# 1. Script exists and is executable
|
||
if shell_hooks.script_is_executable(spec.command):
|
||
print(" ✓ script exists and is executable")
|
||
else:
|
||
problems += 1
|
||
print(" ✗ script missing or not executable "
|
||
"(chmod +x the file, or fix the path)")
|
||
# 2. Allowlist status
|
||
entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
|
||
if entry:
|
||
print(f" ✓ allowlisted (approved {entry.get('approved_at', '?')})")
|
||
else:
|
||
problems += 1
|
||
print(" ✗ not allowlisted — hook will NOT fire at runtime "
|
||
"(run with --accept-hooks once, or confirm at the TTY prompt)")
|
||
# 3. Mtime drift
|
||
if entry and entry.get("script_mtime_at_approval"):
|
||
drift, mtime_at, mtime_now = _mtime_drift(shell_hooks, spec, entry)
|
||
if drift is True:
|
||
problems += 1
|
||
print(f" ⚠ script modified since approval "
|
||
f"(was {mtime_at}, now {mtime_now}) — review changes, "
|
||
f"then `hermes hooks revoke` + re-approve to refresh")
|
||
elif drift is False:
|
||
print(" ✓ script unchanged since approval")
|
||
# 4. JSON smoke test on a synthetic payload — ONLY when already allowlisted. Otherwise doctor
|
||
# would execute every script in a freshly-pulled config before the user reviewed it, which
|
||
# contradicts the documented workflow ("spot newly-added hooks *before they register*").
|
||
if not entry:
|
||
print(" ℹ skipped JSON smoke test — not allowlisted yet. "
|
||
"Approve the hook first (via TTY prompt or --accept-hooks), "
|
||
"then re-run `hermes hooks doctor`.")
|
||
elif shell_hooks.script_is_executable(spec.command):
|
||
result = shell_hooks.run_once(spec, _DEFAULT_PAYLOADS.get(spec.event, {"extra": {}}))
|
||
if result.get("timed_out"):
|
||
problems += 1
|
||
print(f" ✗ timed out after {result['elapsed_seconds']}s "
|
||
f"on synthetic payload (timeout={spec.timeout}s)")
|
||
elif result.get("error"):
|
||
problems += 1
|
||
print(f" ✗ execution error: {result['error']}")
|
||
else:
|
||
rc = result.get("returncode")
|
||
elapsed = result.get("elapsed_seconds", 0)
|
||
stdout = (result.get("stdout") or "").strip()
|
||
if not stdout:
|
||
print(f" ✓ ran clean with empty stdout "
|
||
f"(exit={rc}, {elapsed}s) — hook is observer-only")
|
||
else:
|
||
try:
|
||
json.loads(stdout)
|
||
print(f" ✓ produced valid JSON on synthetic payload "
|
||
f"(exit={rc}, {elapsed}s)")
|
||
except json.JSONDecodeError:
|
||
problems += 1
|
||
print(f" ✗ stdout was not valid JSON (exit={rc}, "
|
||
f"{elapsed}s): {_truncate(stdout, 120)}")
|
||
return problems
|
||
|
||
|
||
_ACTIONS = {
|
||
"list": _cmd_list, "ls": _cmd_list,
|
||
"test": _cmd_test,
|
||
"revoke": _cmd_revoke, "remove": _cmd_revoke, "rm": _cmd_revoke,
|
||
"doctor": _cmd_doctor,
|
||
}
|