Skip to content

feat: b00t guard interposition plugin — JSONL dry-run, stderr forwarding, 🥾 branding#2

Open
elasticdotventures wants to merge 6 commits into
b00tfrom
feat/pre-tool-rewrite-hook
Open

feat: b00t guard interposition plugin — JSONL dry-run, stderr forwarding, 🥾 branding#2
elasticdotventures wants to merge 6 commits into
b00tfrom
feat/pre-tool-rewrite-hook

Conversation

@elasticdotventures
Copy link
Copy Markdown
Member

Hermes plugin for b00t guard interposition.

  • Forward guard stderr to user terminal
  • Parse JSONL structured output from dry-run
  • Fall back to emoji scraping for compat
  • 🥾 branding on all interposition output

This PR targets the authoritative b00t branch.

…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.
Copilot AI review requested due to automatic review settings May 4, 2026 03:05
Copy link
Copy Markdown

Copilot AI left a comment

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 “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/b00t hook 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 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 +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 thread model_tools.py Outdated
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 thread hermes_cli/plugins.py Outdated
Comment thread hermes_cli/plugins.py
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 thread plugins/b00t/__init__.py Outdated
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 thread plugins/b00t/__init__.py
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"}
elasticdotventures and others added 2 commits May 8, 2026 09:57
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).
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.

3 participants