Skip to content

feat: pre_tool_call hook should support REJECT semantics (10-line patch) #9388

@Ti0aceite

Description

@Ti0aceite

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:

  1. Structural: separate HERMES_HOME per agent (works in v0.8.0 as-is — thank you)
  2. Kernel: sandbox-exec on macOS denying syscalls to sibling agents' paths (works)
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions