Read the script
#!/usr/bin/env python3
"""
Pre-tool use hook - Thin Gate Architecture.
Entry point for every PreToolUse validation hooks.json routes here (Bash, the
Task/Agent dispatch, file tools and the rest). With Bash(*) in the settings.json
allow list, all commands reach this hook regardless of settings.json
permissions. All business logic lives in
ClaudeCodeAdapter.adapt_pre_tool_use(); this file is stdin/stdout glue only.
Two obligations belong to the glue itself, because nothing downstream can
discharge them. First, it WAITS for the payload rather than sampling for it:
the decision to run at all is made here, and a gate that skips itself because
the host's write was a few milliseconds late is the one failure no verdict can
describe. Second, EVERY exit records -- an exit that delivers no verdict is a
gate failure and leaves a fail-open trace, so no path out of this file is mute.
Tests drive adapters.claude_code.ClaudeCodeAdapter.adapt_pre_tool_use directly
(see tests/fixtures/pretool_adapter.py).
"""
from __future__ import annotations
import os
import select
import sys
import json
import logging
from pathlib import Path
from typing import Any, Mapping, NoReturn, Optional
def _deny_and_exit(message: str) -> NoReturn:
"""Deny the tool call with exit 2, using nothing outside the stdlib."""
print(message, file=sys.stderr)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": message,
}
}))
sys.exit(2)
try:
_hooks_dir = Path(__file__).resolve().parent
sys.path.insert(0, str(_hooks_dir))
_pkg_root = str(_hooks_dir.parent)
if _pkg_root not in sys.path:
sys.path.insert(0, _pkg_root)
from modules.core.hook_trace import record_hook_invocation
from modules.core.logging_setup import configure_hook_logging
# get_adapter() is the single construction point (registry), so this
# entry point never names the concrete host class.
from adapters.registry import get_adapter
from modules.core.stdin import has_stdin_data
from adapters.utils import warn_if_dual_channel
configure_hook_logging("pre_tool_use")
except BaseException as _startup_error:
if __name__ != "__main__":
raise
# The audit sink is part of what failed to load, so no event is recorded.
_deny_and_exit(
"[SECURITY GATE DEGRADED] The security gate failed to start and the "
f"operation was blocked unvalidated.\nDetail: "
f"{type(_startup_error).__name__}: {_startup_error}\n"
"Run `gaia doctor` to find the broken component, or reinstall Gaia, then retry."
)
logger = logging.getLogger(__name__)
USAGE = (
"Usage: echo '{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\","
"\"tool_input\":{\"command\":\"ls\"}}' | python pre_tool_use.py"
)
# How long to wait for the host's payload before concluding there is none.
# Nothing orders the host's write against this process's first read, and a
# zero-wait check lets the gate skip itself on a late write; the payload is
# usually already buffered, so the wait costs nothing there. The deadline keeps
# a pipe nobody writes to from stopping the session. GAIA_HOOK_STDIN_TIMEOUT
# overrides it.
_STDIN_WAIT_SECONDS_DEFAULT = 2.0
_STDIN_WAIT_ENV = "GAIA_HOOK_STDIN_TIMEOUT"
def _stdin_wait_seconds() -> float:
"""Deadline for the payload to arrive, in seconds."""
raw = os.environ.get(_STDIN_WAIT_ENV, "").strip()
if raw:
try:
parsed = float(raw)
if parsed >= 0:
return parsed
except ValueError:
pass
return _STDIN_WAIT_SECONDS_DEFAULT
def _await_stdin_data(timeout: float) -> bool:
"""Whether the host's payload is readable, waiting up to ``timeout`` for it.
A closed pipe counts as readable and resolves immediately: that is an EOF,
an answer rather than a wait. An interactive stdin is not a hook invocation
at all and never waits.
"""
if sys.stdin.isatty():
return False
try:
readable, _, _ = select.select([sys.stdin], [], [], max(timeout, 0.0))
return bool(readable)
except Exception:
return has_stdin_data()
def _trace(
exit_code: int,
*,
payload: Optional[Mapping[str, Any]] = None,
blocked: bool = False,
extra: Optional[Mapping[str, Any]] = None,
) -> None:
"""Record that this hook ran, whatever it decided or failed to decide.
Every exit records, including the ones that decide nothing: an invocation
that leaves no line is indistinguishable offline from a hook that was never
dispatched, which is precisely the confusion the silent exits caused.
"""
record_hook_invocation(
"pre_tool_use",
payload=payload,
exit_code=exit_code,
blocked=blocked,
extra=dict(extra) if extra else None,
)
def _fail_open_exit(
reason: str,
detail: str,
*,
payload: Optional[Mapping[str, Any]] = None,
cause: Optional[str] = None,
) -> NoReturn:
"""Record a gate failure and deny the tool call (modules.security.fail_open).
Every exit of this entry point that is not a delivered verdict is a gate
failure. Imported lazily so an ordinary invocation does not pay for it.
"""
from modules.security.fail_open import CAUSE_ERROR, decide_fail_open
outcome = decide_fail_open(reason, detail, cause or CAUSE_ERROR)
_trace(
outcome.exit_code,
payload=payload,
blocked=outcome.blocked,
extra={"fail_open": reason},
)
_deny_and_exit(outcome.message)
def _exit_without_input() -> NoReturn:
"""Handle the case where no payload arrived before the deadline.
Waiting narrows the race but cannot close it: the host may genuinely never
write. No verdict was reached, so it is handled as any other gate failure
and the call is denied.
An interactive stdin is the one