Skip to content

feat: add pre_tool_call rewrite support for plugin tool arg transformation#1

Closed
elasticdotventures wants to merge 3 commits into
mainfrom
feat/pre-tool-rewrite-hook
Closed

feat: add pre_tool_call rewrite support for plugin tool arg transformation#1
elasticdotventures wants to merge 3 commits into
mainfrom
feat/pre-tool-rewrite-hook

Conversation

@elasticdotventures

Copy link
Copy Markdown
Member

Internal PR for b00t integration. Upstream: NousResearch#19305

Required until upstream PR is merged. Adds get_pre_tool_call_directives() that fires pre_tool_call hook ONCE and returns both block_message and rewritten_args.

…ation

Adds get_pre_tool_call_directives() that fires pre_tool_call hook
ONCE and returns both block_message and rewritten_args.

Existing get_pre_tool_call_block_message() kept as backward-compat alias.
New get_pre_tool_call_rewrite() alias added.

Updates all 3 call sites (_invoke_tool, concurrent loop, sequential loop)
and handle_function_call in model_tools.py to use the combined function.
Copilot AI review requested due to automatic review settings May 3, 2026 17:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new plugin helper to fire the pre_tool_call hook once and return both blocking and argument-rewrite directives, then wires tool execution paths to use it (needed for b00t integration until upstream merges).

Changes:

  • Introduces get_pre_tool_call_directives() returning (block_message, rewritten_args) and keeps backward-compat wrappers.
  • Updates tool invocation paths (run_agent.py, model_tools.py) to use the new helper and apply rewritten tool args.
  • In the concurrent execution path, re-checks guardrails when args are rewritten.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.

File Description
run_agent.py Uses get_pre_tool_call_directives() across invocation paths; applies rewritten args and handles plugin blocks.
model_tools.py Uses get_pre_tool_call_directives() in the dispatcher to support block + rewrite without double-firing hooks.
hermes_cli/plugins.py Adds the new directives helper and backward-compatible wrappers for block-only / rewrite-only access.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread run_agent.py
Comment on lines 9452 to 9456
except Exception:
block_message = None
pass

if block_message is not None:
block_result = json.dumps({"error": block_message}, ensure_ascii=False)
else:
if block_result is None and _block_msg is None:
guardrail_decision = self._tool_guardrails.before_call(function_name, function_args)
Comment thread run_agent.py
Comment on lines +9439 to +9447
from hermes_cli.plugins import get_pre_tool_call_directives
_block_msg, _rewritten = get_pre_tool_call_directives(
function_name, function_args, task_id=effective_task_id or "",
)
if _block_msg is not None:
block_result = json.dumps({"error": _block_msg}, ensure_ascii=False)
elif _rewritten is not None:
function_args = _rewritten
# Re-check guardrails with rewritten args
Comment thread run_agent.py
Comment on lines +9277 to +9284
from hermes_cli.plugins import get_pre_tool_call_directives
_block_msg, _rewritten = get_pre_tool_call_directives(
function_name, function_args, task_id=effective_task_id or "",
)
if _block_msg is not None:
return json.dumps({"error": _block_msg}, ensure_ascii=False)
if _rewritten is not None:
function_args = _rewritten
Comment thread model_tools.py
Comment on lines 687 to +690
if not skip_pre_tool_call_hook:
block_message: Optional[str] = None
try:
from hermes_cli.plugins import get_pre_tool_call_block_message
block_message = get_pre_tool_call_block_message(
from hermes_cli.plugins import get_pre_tool_call_directives
_block_msg, _rewritten = get_pre_tool_call_directives(
Comment thread hermes_cli/plugins.py
tool_call_id: str = "",
) -> Optional[str]:
"""Check ``pre_tool_call`` hooks for a blocking directive.
) -> tuple:
Comment thread hermes_cli/plugins.py
Comment on lines 1186 to +1216
@@ -1196,16 +1195,56 @@ def get_pre_tool_call_block_message(
tool_call_id=tool_call_id,
)

block_message: Optional[str] = None
rewritten_args: Optional[Dict[str, Any]] = None

for result in hook_results:
if not isinstance(result, dict):
continue
if result.get("action") != "block":
continue
message = result.get("message")
if isinstance(message, str) and message:
return message
action = result.get("action")

if action == "block" and block_message is None:
message = result.get("message")
if isinstance(message, str) and message:
block_message = message

elif action == "rewrite" and rewritten_args is None:
new_args = result.get("args")
if isinstance(new_args, dict):
rewritten_args = new_args

return block_message, rewritten_args
Routes terminal commands through b00t hive run --dry-run guards.
Intercepts pre_tool_call hook to block, warn, or rewrite commands.
Handles pip->uv, docker->podman, main-branch protection, and more.
elasticdotventures added a commit to elasticdotventures/_b00t_ that referenced this pull request May 4, 2026
…ite patch

Vendor submodule pointing to PromptExecution/hermes-agent-b00t on
feat/pre-tool-rewrite-hook branch. Contains the get_pre_tool_call_directives()
patch required for b00t guard interposition via Hermes plugin hooks.

Upstream PR: NousResearch/hermes-agent#19305
Internal PR: PromptExecution/hermes-agent-b00t#1
elasticdotventures added a commit to elasticdotventures/_b00t_ that referenced this pull request May 4, 2026
…ite patch

Vendor submodule pointing to PromptExecution/hermes-agent-b00t on
feat/pre-tool-rewrite-hook branch. Contains the get_pre_tool_call_directives()
patch required for b00t guard interposition via Hermes plugin hooks.

Upstream PR: NousResearch/hermes-agent#19305
Internal PR: PromptExecution/hermes-agent-b00t#1
elasticdotventures added a commit to elasticdotventures/_b00t_ that referenced this pull request May 4, 2026
…tocol (#369)

* chore: remove plantuml-server embedded repo from git index

* feat: guard escalation, parser stages, b00t-ast CLI, b00t-py bindings, violation persistence

- Guard violation counter with JSONL persistence (~/.b00t/guard-violations.jsonl)
- 🦨→💩 escalation: Warn→Block when violation_count >= repeat_threshold
- check_guards() auto-persists violations on every match
- K0mmand3rStage guards: pattern = { stage = "pre_parse" } in hive-guards.hive.toml
- parser_stages wired into KmdLine::parse() at 7 phases
- b00t-ast CLI binary: b00t-ast dir <path> [--format json|mcp|counts]
- b00t-py: guard_check, emoji_lookup, register_stage_guard bindings
- KmdLine fields made pub for serde serialization
- Schema datums moved to _b00t_/schema/ (uppercase convention)
- k0mmand3r crate edition 2024, clean lints
- Rust 2024: #![allow]→removed, set_var unsafe wrappers
- b00t_env_backend.py promoted from DESIGN to working Python backend
- Hermes backend symlinked: just hermes-backend-enable

* chore: add hermes-agent-b00t vendor submodule with pre_tool_call rewrite patch

Vendor submodule pointing to PromptExecution/hermes-agent-b00t on
feat/pre-tool-rewrite-hook branch. Contains the get_pre_tool_call_directives()
patch required for b00t guard interposition via Hermes plugin hooks.

Upstream PR: NousResearch/hermes-agent#19305
Internal PR: PromptExecution/hermes-agent-b00t#1

* feat: add SCM convention guards — branch naming, main protection, conventional commits

New hive guards block or warn before git commands reach the shell:
- BLOCK: git checkout main/master — use feature branches
- BLOCK: git push origin main — use PRs instead
- BLOCK: git merge main — use gh pr merge
- WARN: git checkout -b without type/ — use feat/fix/chore/ prefix
- WARN: git commit -m without colon — use Conventional Commits format

* feat: add regex_match() to Rhai engine + SCM convention guards

- Registered regex_match(cmd, pattern) on Rhai engine in hive.rs
  for future guard pattern matching
- Added 5 SCM guards to hive-guards.hive.toml:
  BLOCK: git checkout main/master, git push origin main, git merge main
  WARN: branch without type/ prefix, commit without conventional format
- All guards use simple cmd.contains() — readable, no escaping hell

* feat: add b00t guard interposition Hermes plugin

Bumps vendor/hermes-agent-b00t to include the new plugins/b00t/
directory with pre_tool_call hook that routes terminal commands
through b00t hive run --dry-run guard evaluation.

* crypto-sign: ed25519 signing for peer_facts in IrontologyPeerStore

* hive-peers: gossip, mDNS discover, list --health, peer GC

* docs: initial review plan

Agent-Logs-Url: https://github.com/elasticdotventures/_b00t_/sessions/b13588c4-07c5-4b06-8575-5be55c579fb1

Co-authored-by: elasticdotventures <35611074+elasticdotventures@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
elasticdotventures added a commit to elasticdotventures/_b00t_ that referenced this pull request May 4, 2026
#373)

* feat: guard escalation, parser stages, b00t-ast CLI, b00t-py bindings, violation persistence

- Guard violation counter with JSONL persistence (~/.b00t/guard-violations.jsonl)
- 🦨→💩 escalation: Warn→Block when violation_count >= repeat_threshold
- check_guards() auto-persists violations on every match
- K0mmand3rStage guards: pattern = { stage = "pre_parse" } in hive-guards.hive.toml
- parser_stages wired into KmdLine::parse() at 7 phases
- b00t-ast CLI binary: b00t-ast dir <path> [--format json|mcp|counts]
- b00t-py: guard_check, emoji_lookup, register_stage_guard bindings
- KmdLine fields made pub for serde serialization
- Schema datums moved to _b00t_/schema/ (uppercase convention)
- k0mmand3r crate edition 2024, clean lints
- Rust 2024: #![allow]→removed, set_var unsafe wrappers
- b00t_env_backend.py promoted from DESIGN to working Python backend
- Hermes backend symlinked: just hermes-backend-enable

* chore: add hermes-agent-b00t vendor submodule with pre_tool_call rewrite patch

Vendor submodule pointing to PromptExecution/hermes-agent-b00t on
feat/pre-tool-rewrite-hook branch. Contains the get_pre_tool_call_directives()
patch required for b00t guard interposition via Hermes plugin hooks.

Upstream PR: NousResearch/hermes-agent#19305
Internal PR: PromptExecution/hermes-agent-b00t#1

* feat: add SCM convention guards — branch naming, main protection, conventional commits

New hive guards block or warn before git commands reach the shell:
- BLOCK: git checkout main/master — use feature branches
- BLOCK: git push origin main — use PRs instead
- BLOCK: git merge main — use gh pr merge
- WARN: git checkout -b without type/ — use feat/fix/chore/ prefix
- WARN: git commit -m without colon — use Conventional Commits format

* feat: add regex_match() to Rhai engine + SCM convention guards

- Registered regex_match(cmd, pattern) on Rhai engine in hive.rs
  for future guard pattern matching
- Added 5 SCM guards to hive-guards.hive.toml:
  BLOCK: git checkout main/master, git push origin main, git merge main
  WARN: branch without type/ prefix, commit without conventional format
- All guards use simple cmd.contains() — readable, no escaping hell

* crypto-sign: ed25519 signing for peer_facts in IrontologyPeerStore

* chore: update vendor/l3dg3rr submodule to ledgrrr (rebranded upstream)

- Submodule URL: https://github.com/PromptExecution/l3dg3rr → git@github.com:PromptExecution/ledgrrr
- Submodule pointer: 1ed3b3d → 2168595 (includes PR #80, dashboard-generated-panels-51-rebased)
- Remote changed from HTTPS to SSH for consistent auth
elasticdotventures pushed a commit that referenced this pull request May 30, 2026
Four findings from Copilot's review on PR NousResearch#22891, all in the AX
elements-array cap added by 22fa1ed:

1. The truncation note ("response truncated to N of M elements") was
   appended unconditionally — including in the som/vision multimodal
   path, whose response carries a screenshot rather than an `elements`
   array. The note described a payload field that wasn't present.
   Moved the note into the AX-text branch where the array actually
   appears.

2. `_format_elements(cap.elements)` ran on the full untrimmed list with
   its own `max_lines=40` cap, so a caller passing `max_elements=10`
   would see summary lines referencing `NousResearch#11..NousResearch#40` even though the JSON
   `elements` array only held #1..NousResearch#10. Format on `visible_elements`
   instead so the summary indices always exist in the response.

3. `_coerce_max_elements` enforced a lower bound but no upper bound,
   so `max_elements=10_000_000` silently disabled the safeguard and
   reintroduced the original context-blow-up. Added a hard cap
   (`_MAX_ALLOWED_MAX_ELEMENTS = 1000`) that clamps oversized values.

4. The schema string said "Default 100" but the property carried no
   `default` field, and claimed `max_elements` had no effect on som/
   vision while the image-missing fallback path can still return an
   elements array. Added `"default": 100`, `"maximum": 1000`, and
   clarified the fallback-path wording.

Each finding gets a regression test:

- test_capture_ax_clamps_oversized_max_elements_to_hard_cap
- test_capture_ax_summary_indices_match_returned_elements
- test_capture_multimodal_summary_omits_truncation_note
- test_schema_max_elements_documents_default_and_upper_bound

Verified with `pytest tests/tools/test_computer_use.py` (53 passed,
including the 5 new cases). Confirmed each new test fails on the
pre-fix code path before applying the production change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
elasticdotventures pushed a commit that referenced this pull request May 30, 2026
…NousResearch#31416)

PR NousResearch#31416 (avoid persisting borrowed credential secrets) added
sanitize_borrowed_credential_payload, which strips access_token from
any auth.json pool entry whose (provider, source) isn't in the
_PERSISTABLE_PROVIDER_SOURCES allowlist.

(copilot, gh_cli) is borrowed (not in the allowlist), so the test
fixture's pre-seeded access_token now gets stripped at load_pool()
time, leaving the pool empty. resolve_target('1') then fails with
'No credential #1. Provider: copilot.'

Fix: align the test with the new contract. At runtime, copilot tokens
are hydrated by resolve_copilot_token() — mock that path so the pool
gets an entry the test can remove. The behavior under test
(suppression of gh_cli + env variants on remove) is unchanged.

CI repro on origin/main HEAD; reproduced locally with stock checkout.
elasticdotventures pushed a commit that referenced this pull request May 30, 2026
…s reached

After key #1 is marked exhausted the retry still called the API with key #1
due to env-var bias in _get_cached_client / resolve_api_key_provider_credentials.
Fix: peek the pool and pass the active entry's key as explicit_api_key.
Secondary: api_key_hint in mark_exhausted_and_rotate pins the correct entry
under concurrent CLI+gateway calls; _is_payment_error matches GoUsageLimitError;
extract_api_error_context parses "Resets in Xhr Ymin".
elasticdotventures pushed a commit that referenced this pull request May 30, 2026
…ookies

Mission-control style deploys reverse-proxy the dashboard at a path
prefix (e.g. mission-control.tilos.com/hermes/* -> :9119) and inject
X-Forwarded-Prefix: /hermes on every request. The SPA mount already
honoured this for asset URLs and the bootstrap __HERMES_BASE_PATH__,
but the OAuth gate didn't:

  1. The gate's Location: header to /login and the 401 envelope's
     login_url were built bare ("/login?next=..."). Under a /hermes
     prefix the browser follows that to mission-control.tilos.com/login
     which the proxy doesn't route to the dashboard.
  2. _redirect_uri (the OAuth callback URL handed to the IDP) used
     request.url_for() which doesn't honour X-Forwarded-Prefix
     (Starlette/uvicorn only proxy_headers Host + Proto + For). The
     IDP redirects back to /auth/callback instead of /hermes/auth/
     callback → 404 in the user's browser.
  3. Cookies were set with Path=/ which leaks them to other apps on
     the same origin and won't be sent back on requests under the
     prefix in the first place.

Fix threads the normalised prefix through every boundary:

  * New hermes_cli/dashboard_auth/prefix.py — single source of truth
    for X-Forwarded-Prefix parsing. web_server._normalise_prefix
    becomes a re-export so the SPA mount, the gate, and the cookies
    helper all agree.
  * middleware._unauth_response builds login_url = f"{prefix}/login".
  * routes._redirect_uri splices the prefix into the path component
    of the IDP-bound URL (with full validation of the header).
  * cookies.{set,clear}_{session,pkce}_cookie now take prefix="".
    Path attribute switches to /hermes when set; cookie name switches
    name variant (see below). Every caller passes the request's
    normalised prefix.

Cookie hardening (Teknium's lesser-note #1 in the PR review): adopt
the __Host- / __Secure- cookie name prefixes per draft-west-cookie-
prefixes. The variant is selected from (use_https, prefix):

  * Loopback HTTP → bare "hermes_session_at" (both prefixes require
    Secure, incompatible with HTTP).
  * HTTPS, direct deploy (Path=/) → "__Host-hermes_session_at".
    Strongest spec: bound to exact origin, no Domain attribute, Secure
    required.
  * HTTPS, behind a proxy prefix (Path=/hermes) →
    "__Secure-hermes_session_at". __Host- forbids Path != "/"; the
    explicit Path=/hermes covers same-origin app isolation.

Setter and reader BOTH consult the prefix because the cookie *name*
changes — a reader that looked up the bare name when the setter wrote
__Secure- would never find the value. The reader falls back across
all three variants so a request whose shape changed mid-session (e.g.
post-deploy from no-prefix to /hermes) still picks up the existing
cookie until it expires.

Test coverage:

  - tests/hermes_cli/test_dashboard_auth_prefix.py — new file. 11 tests
    pinning:
      • Location: /hermes/login on the gate's HTML redirect
      • 401 envelope login_url carries the prefix
      • Malformed X-Forwarded-Prefix is ignored (header-injection
        defence; the script-tag value is normalised to empty string)
      • _redirect_uri splices /hermes into the path (the property
        that prevents the IDP-returns-to-404 failure)
      • PKCE cookie uses Path=/hermes + __Secure- when proxied
      • Session cookies use __Host- when direct, __Secure- when
        proxied, bare on loopback HTTP
      • End-to-end round trip with hand-managed PKCE cookie carriage
        (TestClient can't simulate a Path=/hermes cookie automatically)
  - tests/hermes_cli/test_dashboard_auth_cookies.py — rewritten to pin
    each (use_https, prefix) shape produces its expected cookie name,
    plus reader-side coverage that __Host- and __Secure- variants are
    both recognised.
  - Existing tests across middleware / 401-reauth / etc. updated to
    match the new cookie names (substring contains instead of
    startswith).

Mutation-tested: reverting _unauth_response to build the bare
"/login" URL trips exactly the two tests that pin the prefix
carriage, confirming the suite discriminates the regression.
elasticdotventures pushed a commit that referenced this pull request May 30, 2026
Two CI flakes surfaced on PR NousResearch#34572 (both in files this PR doesn't touch;
pre-existing host-dependent flakes):

1. test_process_registry::TestPopenLeakOnSetupFailure — the failure-cleanup
   tests use a fake proc.pid (8888/9999) and assert proc.kill() runs. But
   spawn_local's primary cleanup is os.killpg(os.getpgid(pid), SIGKILL),
   falling back to proc.kill() only on ProcessLookupError/PermissionError/
   OSError. When the fake PID happens to exist on a busy host, os.getpgid
   succeeds, os.killpg fires against an UNRELATED real process group, and
   proc.kill() is never reached -> flaky AssertionError (and a real risk of
   SIGKILLing an innocent process group from a unit test). Patch os.getpgid
   to raise ProcessLookupError so the fallback path runs deterministically
   and no real killpg is ever issued.

2. test_web_server::test_resize_escape_is_forwarded — the receive loop calls
   the blocking conn.receive_bytes() with no exception guard. Once the child
   prints its winsize and exits, the PTY closes; on a missed-marker run the
   next recv blocks until the 30s pytest-timeout instead of failing fast.
   Add a try/except break (matching the working sibling tests) and bump the
   child's pre-read sleep 0.15s -> 0.5s so the resize reliably lands first.

Verified: 4/4 pass across 3 consecutive runs; root cause for #1 reproduced
(os.getpgid(1) succeeds -> old code skips proc.kill).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants