How Configurable `hooks.json` Scripts Observe and Gate Agent Behavior in OpenHuman
OpenHuman uses a layered hooks.json system that spawns external scripts at critical lifecycle points to inspect tool calls and enforce allow, ask, or deny decisions, merging verdicts across system, user, workspace, and project layers with deny taking precedence over ask over allow.
The hooks.json configuration file in OpenHuman provides a declarative, user-authored mechanism to govern autonomous agent behavior. By implementing a four-layer trust model and a process-isolated bridge architecture, the system allows administrators and developers to intercept every tool execution and turn completion without modifying the core Rust codebase. This article examines how the hooks.json contract is parsed, executed, and enforced according to the source implementation in the tinyhumansai/openhuman repository.
The Three-Stage Hook Architecture
OpenHuman’s hook system operates through three distinct phases: configuration discovery, bridge registration, and runtime enforcement. Each phase is implemented in dedicated modules under src/openhuman/hooks/.
Discovery and Layering
At startup, the core runtime reads hooks.json from four potential locations—system, user, workspace, and project directories—in order of increasing trust. The logic in src/openhuman/hooks/config.rs loads each file into a HooksFile struct and merges them into a single effective configuration.
The merging algorithm follows a deterministic precedence rule: deny > ask > allow. This means a restrictive policy defined in a higher-trust layer (e.g., system-wide) cannot be overridden by a more permissive rule in a lower-trust layer (e.g., a specific project). The resulting HookOutput collapses all layered decisions into a single verdict that governs the agent’s next action.
Bridge Installation
Once configurations are loaded, the ConfiguredHookBridge (defined in src/openhuman/hooks/bridge.rs) registers each declared hook with the tinyagents harness via tinyagents::harness::tool_hook. The bridge supports two primary hook types:
- Script hooks: Spawn external executables as child processes
- Prompt hooks: Render templated strings in the user interface
For script hooks, the bridge feeds a JSON envelope describing the current turn context to the script’s STDIN and expects a JSON decision object from STDOUT. This design ensures complete language agnosticism—hooks can be written in Bash, Python, JavaScript, or any other runtime.
Enforcement and Decision Merging
When a turn completes or a tool is about to execute, the harness consults the aggregated HookDecision enum variants (Allow, Ask, Deny) from src/openhuman/hooks/types.rs. The strictest verdict across all active layers determines the outcome:
- Deny: Immediately aborts the turn or tool execution
- Ask: Pauses execution to prompt the user for confirmation
- Allow: Permits the operation to proceed
Because the bridge is wired directly into the tinyagents dispatcher, this policy enforcement applies uniformly to all agent-tool interactions, including built-in tools like file_edit, shell, and prompt.
The JSON Envelope and Hook Contract
Each hook script receives a standardized input envelope matching Cursor’s hooks.json contract. The envelope structure, defined in src/openhuman/hooks/types.rs, contains:
event: The lifecycle trigger (e.g.,beforeToolUse,afterToolResult,sessionStart)payload: A typed context object such asToolPayload(tool name and arguments),ShellPayload(command string),PromptPayload(LLM prompt text), orFileEdit(file path and diff)
Scripts inspect these fields and emit a JSON response with a decision field and optional reason string. Any non-zero exit code, timeout, or malformed JSON response is automatically interpreted as a denial, ensuring fail-closed behavior.
Practical Implementation Examples
The following examples demonstrate common governance patterns using the hooks.json specification.
Denying Dangerous Tools
Create a hooks.json that invokes a security script before any tool execution:
{
"version": 1,
"enabled": true,
"hooks": [
{
"event": "beforeToolUse",
"type": "script",
"command": "/usr/local/bin/deny-dangerous-tool.sh"
}
]
}
The corresponding script inspects the payload and blocks destructive operations:
#!/usr/bin/env bash
read -r envelope
tool=$(echo "$envelope" | jq -r '.payload.tool')
if [[ "$tool" == "file_delete" ]]; then
echo '{"decision":"deny","reason":"File deletion is blocked by policy"}'
else
echo '{"decision":"allow"}'
fi
When the agent attempts to execute file_delete, the bridge receives the denial verdict from STDOUT and aborts the turn before any file system modification occurs.
Interactive User Prompts
For operations requiring human oversight, use a prompt-type hook to render dynamic confirmation dialogs:
{
"version": 1,
"enabled": true,
"hooks": [
{
"event": "beforeToolUse",
"type": "prompt",
"command": "Execute {{tool}} with arguments {{args}}? (yes/no)"
}
]
}
The bridge template-replaces {{tool}} and {{args}} with the actual invocation details, displays the prompt in the UI, and maps "yes" responses to allow while treating all other input as deny.
Embedded Rust Hooks
For performance-critical or sandboxed environments, src/openhuman/hooks/ops.rs exposes a programmatic API to register native Rust closures as hooks without spawning external processes:
use openhuman::hooks::{self, HookDecision, HookInput};
fn deny_network(_: HookInput) -> HookDecision {
HookDecision::Deny("Network tools disabled in sandbox".into())
}
// During core bootstrap:
hooks::ops::register_hook("beforeToolUse", deny_network);
This approach eliminates process overhead while maintaining the same HookInput and HookDecision contract used by external scripts.
Security Model and Layered Trust
The OpenHuman hook architecture implements several defense-in-depth mechanisms to prevent policy bypass:
- Working Directory Isolation: Each script executes with its containing
hooks.jsondirectory as the working directory, preventing unintended access outside the policy scope. - Deterministic Precedence: The
HookOutput::mergelogic insrc/openhuman/hooks/config.rsstrictly applies the deny-ask-allow hierarchy, ensuring local configurations cannot weaken global security policies. - Fail-Closed Execution: Any script failure—whether timeout, crash, or invalid JSON—results in an implicit denial, guaranteeing that infrastructure failures cannot accidentally grant unauthorized access.
Summary
- Four-layer discovery: OpenHuman reads
hooks.jsonfrom system, user, workspace, and project directories, merging them with deny-precedence logic implemented insrc/openhuman/hooks/config.rs. - Bridge architecture: The
ConfiguredHookBridgeinsrc/openhuman/hooks/bridge.rsspawns scripts as isolated child processes, passing context via STDIN and reading decisions from STDOUT. - Uniform enforcement: All tool interactions route through the tinyagents harness, which consults aggregated
HookDecisionenums fromsrc/openhuman/hooks/types.rsto determine whether to proceed, prompt, or abort. - Fail-closed design: Malformed responses or execution failures default to denial, ensuring policy integrity even during system degradation.
- Dual API: External scripts and embedded Rust hooks share the same contract, allowing flexibility between administrative policy scripts and compile-time security controls.
Frequently Asked Questions
What file paths does OpenHuman search for hooks.json?
OpenHuman searches four specific locations in order of increasing priority: the system-wide configuration directory, the user’s home directory, the current workspace directory, and the specific project root. The merging logic in src/openhuman/hooks/config.rs processes these layers sequentially, ensuring that higher-trust configurations override lower-trust ones according to the deny-ask-allow precedence rules.
Can hooks.json scripts be written in languages other than Bash?
Yes. The ConfiguredHookBridge executes the command specified in hooks.json as a generic child process, feeding the JSON envelope to STDIN and expecting a JSON response on STDOUT. This architecture supports Python, JavaScript, compiled binaries, or any executable capable of parsing JSON and emitting the required decision format.
What happens if a hook script crashes or times out?
If a script exits with a non-zero status code, exceeds the execution timeout, or returns malformed JSON, OpenHuman treats the result as an implicit deny decision. This fail-closed behavior, implemented in src/openhuman/hooks/bridge.rs, ensures that infrastructure failures or script bugs cannot inadvertently permit unauthorized tool executions.
How do I block specific tools like shell or file_edit for certain projects?
Create a project-level hooks.json with a beforeToolUse script hook that inspects the .payload.tool field in the JSON envelope. If the tool name matches your restricted list (e.g., shell, file_edit), emit {"decision":"deny"}. Because project-level configurations layer above workspace and user configurations, these restrictions apply only to that specific project without affecting global settings.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →