feat: b00t guard interposition plugin — JSONL dry-run, stderr forwarding, 🥾 branding#2
Open
elasticdotventures wants to merge 6 commits into
Open
feat: b00t guard interposition plugin — JSONL dry-run, stderr forwarding, 🥾 branding#2elasticdotventures wants to merge 6 commits into
elasticdotventures wants to merge 6 commits into
Conversation
…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.
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.
There was a problem hiding this comment.
Pull request overview
Adds a new “b00t” pre-tool-call interposition plugin and updates Hermes’ tool-dispatch paths to support a single pre_tool_call evaluation that can either block or rewrite tool arguments (enabling command redirection like pip→uv).
Changes:
- Introduce
get_pre_tool_call_directives()to return(block_message, rewritten_args)and keep backward-compatible helpers. - Update tool execution paths (agent + model tool dispatcher) to use the new directives helper and apply rewrites.
- Add the
plugins/b00thook plugin (JSONL dry-run parsing + stderr forwarding + fallback parsing).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| run_agent.py | Uses get_pre_tool_call_directives() in multiple execution paths; adds rewrite handling in concurrent execution. |
| model_tools.py | Switches pre-tool-call handling to the directives helper and supports rewritten args. |
| hermes_cli/plugins.py | Implements get_pre_tool_call_directives() and keeps backward-compatible wrappers. |
| plugins/b00t/init.py | New b00t pre_tool_call hook that runs b00t-cli ... --dry-run and blocks/rewrites terminal commands. |
| plugins/b00t/plugin.yaml | Registers the b00t plugin manifest and pre_tool_call hook. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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 on lines
+9446
to
+9450
| function_args = _rewritten | ||
| # Re-check guardrails with rewritten args | ||
| guardrail_decision = self._tool_guardrails.before_call(function_name, function_args) | ||
| if not guardrail_decision.allows_execution: | ||
| block_result = self._guardrail_block_result(guardrail_decision) |
Comment on lines
681
to
685
| @@ -685,22 +685,22 @@ def handle_function_call( | |||
| # pass. When skip=True, the caller already fired it — do nothing | |||
Comment on lines
1172
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 | |||
Comment on lines
+19
to
+31
| import os | ||
| import shlex | ||
| import sys | ||
| import subprocess | ||
| from typing import Any | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # Emoji markers produced by b00t hive run guards | ||
| _BLOCK_EMOJIS = {"🚫", "💩"} | ||
| _WARN_EMOJI = "🦨" | ||
| _B00T_EMOJI = "🥾" # b00t identity marker for all interposition output | ||
|
|
Comment on lines
+33
to
+109
| def _run_b00t_guard(cmd: str) -> dict[str, Any]: | ||
| """Run ``b00t hive run --dry-run <cmd>`` and parse the result. | ||
|
|
||
| Returns: | ||
| {"action": "pass"} — no guard matched | ||
| {"action": "warn", "message": ..., | ||
| "redirect": "rewritten cmd"} — guard warned, may redirect | ||
| {"action": "block", "message": ...} — guard blocked | ||
| """ | ||
| try: | ||
| result = subprocess.run( | ||
| ["b00t-cli", "hive", "run", "--dry-run", "--", cmd], | ||
| stdout=subprocess.PIPE, | ||
| stderr=subprocess.PIPE, | ||
| text=True, | ||
| timeout=10, | ||
| ) | ||
| # Forward guard output to user's stderr + capture for parsing | ||
| if result.stderr: | ||
| sys.stderr.write(result.stderr) | ||
| sys.stderr.flush() | ||
| for line in result.stderr.splitlines(): | ||
| if line.strip(): | ||
| logger.info("%s", line.strip()) | ||
| except FileNotFoundError: | ||
| logger.debug("b00t-cli not found — guard interposition disabled") | ||
| return {"action": "pass"} | ||
| except subprocess.TimeoutExpired: | ||
| logger.warning("🥾 b00t guard timed out for cmd: %.60s", cmd) | ||
| return {"action": "pass"} | ||
|
|
||
| stdout = result.stdout or "" | ||
| stderr = result.stderr or "" | ||
|
|
||
| # Try JSONL parsing first (structured contract, v2) | ||
| for line in stdout.split("\n"): | ||
| stripped = line.strip() | ||
| if stripped.startswith("{"): | ||
| try: | ||
| j = json.loads(stripped) | ||
| action = j.get("action", "pass") | ||
| if action == "block": | ||
| msg = j.get("message", "") or j.get("error", "blocked by guard") | ||
| return {"action": "block", "message": msg} | ||
| if action == "warn": | ||
| return {"action": "warn", "message": j.get("message", ""), "redirect": j.get("redirect")} | ||
| if action == "pass": | ||
| return {"action": "pass"} | ||
| except (json.JSONDecodeError, KeyError): | ||
| continue # fall through to emoji scraping | ||
|
|
||
| # Fallback: emoji scraping from combined output (v1 compat) | ||
| combined = stdout + stderr | ||
|
|
||
| for emoji in _BLOCK_EMOJIS: | ||
| if emoji in combined: | ||
| msg = "" | ||
| for line in combined.split("\n"): | ||
| if emoji in line: | ||
| msg = line.strip() | ||
| break | ||
| return {"action": "block", "message": msg or "blocked by guard"} | ||
|
|
||
| if _WARN_EMOJI in combined: | ||
| redirect = None | ||
| msg = "" | ||
| for line in combined.split("\n"): | ||
| stripped = line.strip() | ||
| if _WARN_EMOJI in stripped: | ||
| msg = stripped | ||
| if "suggested:" in stripped.lower() or "redirect" in stripped.lower(): | ||
| parts = stripped.split(":", 1) | ||
| if len(parts) > 1: | ||
| redirect = parts[1].strip() | ||
| return {"action": "warn", "message": msg, "redirect": redirect} | ||
|
|
||
| return {"action": "pass"} |
Adds b00t Integration category to COMMAND_REGISTRY: /b00t routes any command to b00t-cli, /hive is an alias for /b00t hive. Adds h3rmes-capability plugin that checks subsystem health (b00t-cli, b00t-mcp, irontology-mcp, codebase-memory, guard-plugin) on session start and auto-remediates critical/high gaps.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Brian Horakh <35611074+elasticdotventures@users.noreply.github.com>
elasticdotventures
pushed a commit
that referenced
this pull request
May 30, 2026
… contract Three test classes lock in the NousResearch#30963 fix: 1. TestPartialStreamStubFinishReason — drives _interruptible_streaming_api_call through the two recovery branches and asserts: - text-only partial → finish_reason="length" (the new behaviour), - mid-tool-call partial → finish_reason="stop" (unchanged on purpose). 2. TestLengthContinuationPromptBranching — pure-Python check on the branch that picks the continuation prompt by response.id. Locks the network error wording for partial-stream-stub vs. the output-length wording for everything else. 3. TestConversationLoopPartialStreamContinuation — feeds a stub + continuation pair into run_conversation, verifies the loop makes a second API call (instead of exiting with text_response(stop)), confirms the network-error continuation prompt actually reaches the model on call #2, and that final_response stitches both halves. Refs: NousResearch#30963
elasticdotventures
pushed a commit
that referenced
this pull request
May 30, 2026
… OAuth gates
Two parallel public-path allowlists drifted: _PUBLIC_API_PATHS in
hermes_cli/web_server.py (legacy _SESSION_TOKEN middleware) and
_GATE_PUBLIC_PREFIXES in hermes_cli/dashboard_auth/middleware.py
(OAuth gate). The legacy list included /api/status (documented as a
non-sensitive read-only liveness target); the OAuth gate's list did not.
Effect: every wildcard-subdomain agent surfaced as STARTING/down to the
portal even though the dashboard was serving correctly. Nous account
service (src/server/agents/fly-provider.ts
getInstanceRuntimeStatus) fetches ``/api/status`` without a cookie
as its sole liveness probe; the OAuth gate's 401 looked identical to
'agent dead' on the portal side.
Fix: lift the allowlist into hermes_cli/dashboard_auth/public_paths.py
and have both middlewares import it. _path_is_public now consults
the shared frozenset first, then falls back to the gate's
auth-bootstrap/static prefix list. Future additions to the public list
hit both gates automatically.
Endpoint inventory (verified safe to remain public):
* /api/status — version, gateway state, active session count,
auth-gate shape. Portal liveness probe target.
* /api/config/defaults — config-defaults feed for the SPA's Config page
* /api/config/schema — config schema for the SPA's Config page
* /api/model/info — model catalogue metadata (context windows)
* /api/dashboard/themes — theme manifests for the skin engine
* /api/dashboard/plugins — plugin manifests for the dashboard
No user data, no session content, no secrets. Same shape an external
monitoring agent would hit on /healthz.
Tests:
* New: test_gated_status_is_public (regression guard with the NAS
fly-provider.ts liveness-probe rationale spelled out in the docstring)
* New: test_other_public_api_paths_are_public_under_gate (parametrised
over the rest of PUBLIC_API_PATHS — proves 401 / 302-to-login is
never the response)
* New: docker integration check NousResearch#3 in
test_dashboard_oauth_gate_engaged_by_default — /api/status
remains 200 under the gate AND reports auth_required=True so the
portal can distinguish modes
* Updated: test_full_login_round_trip_unlocks_gated_api now probes
/api/sessions instead of /api/status (status is public, so it
can no longer distinguish 'logged in' from 'gate accidentally
disabled')
* Updated: TestApi401Envelope (the no-cookie / invalid-cookie /
dead-cookie tests) probes /api/sessions for the same reason
* Updated: docker integration check #2 in
test_dashboard_oauth_gate_engaged_by_default probes
/api/sessions to prove the gate is intercepting
* Removed: dead _login() helper in
test_dashboard_auth_status_endpoint.py (no longer needed since
/api/status is reachable cold)
Companion to docs/handover/hermes-agent-dashboard-s6-insecure-fix.md
(the --insecure flag fix that shipped earlier).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hermes plugin for b00t guard interposition.
This PR targets the authoritative
b00tbranch.