Summary
Currently the pre_tool_call hook is invoked fire-and-forget — the runtime calls invoke_hook("pre_tool_call", ...) in model_tools.py but discards the return value. This makes it impossible for extensions to block a tool call based on policy, which is exactly the feature needed for defense-in-depth sandboxing, multi-tenant isolation, and RBAC.
This is a small, self-contained change (~10 lines) that gives the hook system teeth without requiring the larger refactor proposed in #359. It is forward-compatible with that issue's vision — #359's richer event API would subsume this, but in the meantime this unlocks real use cases today.
Motivation
Related security/sandbox work in the tracker:
- #4281 — Enforce sandboxed execution for messaging platform sessions
- #8943 — Docker sandbox for non-main terminal sessions
- #8028 — Security: PYTHONPATH injection in code_execution_tool sandbox
- #3897 — Per-user tool restrictions in gateway (RBAC)
All of these either need a hook that can block a tool call, or currently have to work around the fact that pre_tool_call can observe but not intervene. Reddit discussions of v0.8/v0.9 messaging deployments repeatedly ask "how do I stop the LLM from reading ~/.ssh/id_rsa via the terminal tool" — there is no clean answer today without forking the runtime.
Real use case: multi-tenant agent isolation
I maintain several Hermes profiles on the same machine (one productive clinical agent in ~/.hermes/, plus a commercial co-founder agent in ~/.hermes-forge-runtime/ with its own HERMES_HOME at ~/.forge-zero-runtime/). I need to guarantee that the commercial agent's LLM — exposed via Telegram to potentially adversarial input — cannot invoke cat ~/.hermes/.env through the terminal tool, even under prompt injection.
My current stack:
- Structural: separate
HERMES_HOME per agent (works in v0.8.0 as-is — thank you)
- Kernel:
sandbox-exec on macOS denying syscalls to sibling agents' paths (works)
- Runtime hook: a
pre_tool_call plugin that inspects args["command"], parses with shlex, rejects denylisted paths with a structured reason — this requires a local patch to the runtime
Layer 3 gives much better UX than Layer 2: instead of the LLM getting Operation not permitted from the kernel with no context, it gets {"error": "Tool call rejected", "reason": "path ~/.hermes is under denylist; this belongs to another agent on this host"} and can adapt its plan (ask the user for a different path, etc).
Full design write-up here for context: forge/specs/spec-005-profile-isolation-3-layers.md. The plugin that consumes this hook is here.
Proposed API
When a pre_tool_call hook returns a dict with {"reject": True, "reason": "..."}, invoke_hook collects it and model_tools.py short-circuits the tool call, returning a structured error that the LLM consumes as a tool result:
{
"error": "Tool call rejected by pre_tool_call hook",
"tool_name": "terminal",
"reason": "<hook-provided reason>",
"hint": "<optional additional context>"
}
Hooks that return None or anything not matching the reject shape → tool proceeds normally (backward compatible).
If multiple hooks register, any one returning reject: True blocks the call (defense-in-depth: more hooks = more coverage, not more bypass surface).
Reference patch
The patch I'm running locally on v0.8.0 is ~15 lines and lives in model_tools.py right where invoke_hook("pre_tool_call", ...) is called today:
hook_results = invoke_hook(
"pre_tool_call",
tool_name=function_name,
args=function_args,
task_id=task_id or "",
session_id=session_id or "",
tool_call_id=tool_call_id or "",
)
for hook_result in (hook_results or []):
if isinstance(hook_result, dict) and hook_result.get("reject"):
reason = hook_result.get("reason", "policy violation")
return json.dumps({
"error": "Tool call rejected by pre_tool_call hook",
"tool_name": function_name,
"reason": reason,
"hint": hook_result.get("hint", ""),
}, ensure_ascii=False)
Full diff: model_tools-pre_tool_call-reject.patch.
Tested in production with 23 unit cases (read/write/terminal/MCP/alias coverage) + live smoke against a Telegram-facing agent for 24h. No regressions — tool calls that don't match a reject rule pass through with zero overhead beyond the existing invoke_hook cost.
Why not wait for #359
I'm 100% supportive of #359's broader vision (Pi-inspired 30+ lifecycle events with cancellation). But that is a large refactor touching many subsystems, and in the meantime security-critical deployments are either:
- Running forks of
model_tools.py (my situation — increases maintenance cost per upstream release)
- Relying on kernel-level sandboxing alone (works on macOS/Linux but hurts UX and doesn't give the LLM actionable feedback)
- Not deploying Hermes for multi-tenant use cases at all (blocks commercial adoption)
This small patch unblocks those use cases today and is a natural stepping stone toward #359's event cancellation pattern. Happy to submit a PR with the patch + tests if a maintainer signals the approach is acceptable.
Alternatives considered
- Tool registry allowlist (related: see sibling issue for
tools.*.allowed_paths). Declarative config scoping is cleaner for simple cases but insufficient for policies that depend on parsed shell tokens or tool-call context (e.g., "block cat /path only if path is outside HERMES_HOME of this profile").
- Wrapper shim around the runtime: brittle, version-coupled, and doesn't compose with other extensions.
- Post-hoc audit logging: detects violations after the fact; too late when credentials already leaked.
Checklist
Summary
Currently the
pre_tool_callhook is invoked fire-and-forget — the runtime callsinvoke_hook("pre_tool_call", ...)inmodel_tools.pybut discards the return value. This makes it impossible for extensions to block a tool call based on policy, which is exactly the feature needed for defense-in-depth sandboxing, multi-tenant isolation, and RBAC.This is a small, self-contained change (~10 lines) that gives the hook system teeth without requiring the larger refactor proposed in #359. It is forward-compatible with that issue's vision —
#359's richer event API would subsume this, but in the meantime this unlocks real use cases today.Motivation
Related security/sandbox work in the tracker:
All of these either need a hook that can block a tool call, or currently have to work around the fact that
pre_tool_callcan observe but not intervene. Reddit discussions of v0.8/v0.9 messaging deployments repeatedly ask "how do I stop the LLM from reading~/.ssh/id_rsavia theterminaltool" — there is no clean answer today without forking the runtime.Real use case: multi-tenant agent isolation
I maintain several Hermes profiles on the same machine (one productive clinical agent in
~/.hermes/, plus a commercial co-founder agent in~/.hermes-forge-runtime/with its ownHERMES_HOMEat~/.forge-zero-runtime/). I need to guarantee that the commercial agent's LLM — exposed via Telegram to potentially adversarial input — cannot invokecat ~/.hermes/.envthrough theterminaltool, even under prompt injection.My current stack:
HERMES_HOMEper agent (works in v0.8.0 as-is — thank you)sandbox-execon macOS denying syscalls to sibling agents' paths (works)pre_tool_callplugin that inspectsargs["command"], parses withshlex, rejects denylisted paths with a structured reason — this requires a local patch to the runtimeLayer 3 gives much better UX than Layer 2: instead of the LLM getting
Operation not permittedfrom the kernel with no context, it gets{"error": "Tool call rejected", "reason": "path ~/.hermes is under denylist; this belongs to another agent on this host"}and can adapt its plan (ask the user for a different path, etc).Full design write-up here for context: forge/specs/spec-005-profile-isolation-3-layers.md. The plugin that consumes this hook is here.
Proposed API
When a
pre_tool_callhook returns a dict with{"reject": True, "reason": "..."},invoke_hookcollects it andmodel_tools.pyshort-circuits the tool call, returning a structured error that the LLM consumes as a tool result:{ "error": "Tool call rejected by pre_tool_call hook", "tool_name": "terminal", "reason": "<hook-provided reason>", "hint": "<optional additional context>" }Hooks that return
Noneor anything not matching the reject shape → tool proceeds normally (backward compatible).If multiple hooks register, any one returning
reject: Trueblocks the call (defense-in-depth: more hooks = more coverage, not more bypass surface).Reference patch
The patch I'm running locally on v0.8.0 is ~15 lines and lives in
model_tools.pyright whereinvoke_hook("pre_tool_call", ...)is called today:Full diff: model_tools-pre_tool_call-reject.patch.
Tested in production with 23 unit cases (read/write/terminal/MCP/alias coverage) + live smoke against a Telegram-facing agent for 24h. No regressions — tool calls that don't match a reject rule pass through with zero overhead beyond the existing
invoke_hookcost.Why not wait for #359
I'm 100% supportive of #359's broader vision (Pi-inspired 30+ lifecycle events with cancellation). But that is a large refactor touching many subsystems, and in the meantime security-critical deployments are either:
model_tools.py(my situation — increases maintenance cost per upstream release)This small patch unblocks those use cases today and is a natural stepping stone toward #359's event cancellation pattern. Happy to submit a PR with the patch + tests if a maintainer signals the approach is acceptable.
Alternatives considered
tools.*.allowed_paths). Declarative config scoping is cleaner for simple cases but insufficient for policies that depend on parsed shell tokens or tool-call context (e.g., "blockcat /pathonly if path is outside HERMES_HOME of this profile").Checklist
model_tools.pyonly, no API breakage)Nonebehave exactly as today)