Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,17 @@ jobs:

- name: Test
run: npm test

- name: Use Python for Hermes tests
if: matrix.node-version == 22
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install Hermes test dependencies
if: matrix.node-version == 22
run: python -m pip install pytest==8.4.2

- name: Test Hermes plugin
if: matrix.node-version == 22
run: python -m pytest plugins/hermes/tests -q
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Fixed
- Native Codex and Claude Code hooks no longer block standalone `agentguard` CLI results when output contains sensitive data. Codex replaces model-visible tool results with locally redacted feedback, Claude Code rewrites supported post-tool results, and the exact pending Agent JWT activation URL remains clickable. Compound commands and other tools remain protected.
- OpenClaw and QClaw integrations now rewrite correlated AgentGuard CLI text in supported OpenClaw-owned `tool_result_persist` transcripts; the native Hermes `transform_tool_result` hook and DSH `tools/post-execute` accept path provide equivalent non-blocking redaction where supported. An exact saved activation link stays clickable; other hooks and downstream policy decisions retain their existing boundaries.

## [1.2.2] - 2026-10-16

### Changed
Expand Down
2 changes: 2 additions & 0 deletions docs/dsh-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ The reason passed into DSH contains only a bounded risk score, normalized policy

## Response containment boundary

Single AgentGuard CLI shell commands are exempt from AgentGuard's own pre-execute policy evaluation (except `agentguard approve`). Simple shell wrappers are recognized, while explicit executable paths must resolve to the installed AgentGuard CLI. When their post-execute result exposes text or a structured value, the runtime can return an `accept` decision with locally redacted sensitive fields while preserving the exact saved pending Agent JWT activation URL and public key examples. It never weakens a downstream `block` decision.

Network results pass through `tools/post-execute`. AgentGuard extracts a bounded response preview plus available status, content type, headers, and byte count, then evaluates response and network-volume anomalies.

Post-execute remains audit-only in `observe` and in the default `protect` configuration. Set `postResponseMode: block-malicious` together with `mode: protect` to suppress only results whose final AgentGuard post-execute decision is `block`. The original result value and rendered content are not returned; DSH receives bounded AgentGuard feedback that excludes the untrusted preview and evidence. A stronger downstream block is preserved.
Expand Down
24 changes: 24 additions & 0 deletions docs/privacy-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,30 @@ Redacted request and response observations may be written to the local audit
file, but routine model-request records and all model-response records are not
uploaded to Cloud.

## AgentGuard-owned CLI results

A single `agentguard` shell invocation with a supported result shape is locally
redacted when its result contains recognized sensitive text. Unknown or malformed
result shapes are blocked. Simple `command`, `env`, and
shell wrappers are recognized; an explicit executable path must resolve to this
package's CLI. Supported native result hooks redact recognized secrets in
host-specific result or persistence surfaces. The documented
`TYPESAFE_API_KEY=<key>` example remains unchanged. The **exact**
pending Agent JWT activation URL saved in the local AgentGuard configuration
remains unchanged when it appears as an exact value, including a structured
`agentRegisterUrl` field or a link followed by sentence punctuation. Encoded
links and other tokenized URLs are not exempt. Compound commands, unverified
executable paths, and unrelated tools retain their ordinary protection;
`agentguard approve` is not exempt from agent-self-approval checks.

| Host | Own-command result handling | Limit |
|---|---|---|
| Codex | `PostToolUse` replaces sensitive results with redacted feedback. | Requires the native hook; no raw output replacement for unsupported lifecycle events. |
| Claude Code | `PostToolUse` rewrites supported result shapes; `PostToolBatch` excludes an own-command result only after confirming that its model-visible content is already safe. | `PostToolUseFailure` has no output rewrite channel; a sensitive failure result in the batch stops continuation. |
| OpenClaw / QClaw | `tool_result_persist` synchronously rewrites matched text in OpenClaw-owned session transcripts. | Requires host support and a matching `toolCallId`; cannot change results already displayed or Codex-native records. |
| Hermes | Native `transform_tool_result` rewrites results before model context; `/agentguard` uses the same redactor. | Legacy shell hooks expose only an audit-only post event and cannot rewrite tool output. |
| DSH | `tools/post-execute` can return redacted text or structured values with `kind: accept`. | Results blocked by another policy are not rewritten by this hook. |

## Public web-search results

Bare card- or national-ID-shaped digits in a **verified, host-owned web-search
Expand Down
1 change: 1 addition & 0 deletions plugins/hermes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Or copy this directory to `~/.hermes/plugins/agentguard/` manually.
| `post_api_request` | Observes and correlates main-loop responses; never blocks them. |
| `pre_tool_call` | Evaluates the call; returns `{"action":"block","message":...}` to veto a dangerous action. |
| `post_tool_call` | Audit-only; never blocks. |
| `transform_tool_result` | Redacts AgentGuard CLI results before model context; preserves the saved JWT activation link. |
| `on_session_start` | Best-effort background scan of installed skills (opt out: `AGENTGUARD_HERMES_AUTOSCAN=0`). |
| `/agentguard` | Slash command — `status`, `report` (default), or `checkup`. |

Expand Down
19 changes: 18 additions & 1 deletion plugins/hermes/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,19 @@ def _observe_llm(
request_id=request_id,
)

def redact_self_output(self, text: str) -> str:
"""Use AgentGuard's shared local redactor for one own-command result."""
bin_path = os.environ.get("AGENTGUARD_BIN") or shutil.which("agentguard")
if not bin_path:
raise RuntimeError("AgentGuard redactor is not installed")
proc = subprocess.run(
[bin_path, "self-output"], input=text, capture_output=True,
text=True, timeout=self.timeout, encoding="utf-8",
)
if proc.returncode != 0:
raise RuntimeError("AgentGuard redactor failed")
return proc.stdout

def run_cli(self, args: list) -> str:
"""Run an ``agentguard`` subcommand and return stdout (for /agentguard)."""
bin_path = os.environ.get("AGENTGUARD_BIN") or shutil.which("agentguard")
Expand All @@ -274,7 +287,11 @@ def run_cli(self, args: list) -> str:
)
except subprocess.SubprocessError as exc:
return "AgentGuard CLI failed: %s" % exc
return (proc.stdout or proc.stderr or "").strip()
output = (proc.stdout or proc.stderr or "").strip()
try:
return self.redact_self_output(output)
except Exception:
return "AgentGuard output could not be safely redacted; run the command directly in your terminal."

# -- internals ---------------------------------------------------------

Expand Down
69 changes: 69 additions & 0 deletions plugins/hermes/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import os
import shlex
import logging
import shutil
import subprocess
Expand All @@ -33,6 +34,7 @@ def register(ctx: Any, bridge: Optional[AgentGuardBridge] = None) -> None:
ctx.register_hook("post_api_request", _make_post_api_request(guard))
ctx.register_hook("pre_tool_call", _make_pre_tool_call(guard))
ctx.register_hook("post_tool_call", _make_post_tool_call(guard))
ctx.register_hook("transform_tool_result", _make_transform_tool_result(guard))
ctx.register_hook("on_session_start", _make_session_start(guard))

register_command = getattr(ctx, "register_command", None)
Expand Down Expand Up @@ -136,6 +138,73 @@ def post_tool_call(tool_name: str, args: Optional[Dict[str, Any]] = None, **kwar
return post_tool_call


def _is_standalone_agentguard_command(command: Any, depth: int = 0) -> bool:
if depth > 2:
return False
if not isinstance(command, str) or any(c in command for c in ";&|<>`\n\r\t") or "$(" in command:
return False
try:
tokens = shlex.split(command)
except ValueError:
return False
if not tokens:
return False
index = 0
while index < len(tokens) and _is_shell_assignment(tokens[index]):
index += 1
if index < len(tokens) and os.path.basename(tokens[index]) == "env":
index += 1
while index < len(tokens) and tokens[index].startswith("-"):
index += 1
while index < len(tokens) and _is_shell_assignment(tokens[index]):
index += 1
while index < len(tokens) and tokens[index] in ("command", "builtin"):
index += 1
if index >= len(tokens):
return False
executable = tokens[index]
if executable == "agentguard":
return True
installed = os.environ.get("AGENTGUARD_BIN") or shutil.which("agentguard")
if (os.path.isabs(executable) and os.path.basename(executable) == "agentguard"
and installed and os.path.realpath(executable) == os.path.realpath(installed)):
return True
if os.path.basename(executable) in ("sh", "bash", "zsh", "dash", "fish"):
for flag_index in range(index + 1, len(tokens) - 1):
flag = tokens[flag_index]
if not flag.startswith("-"):
break
if "c" in flag:
return _is_standalone_agentguard_command(tokens[flag_index + 1], depth + 1)
return False


def _is_shell_assignment(token: str) -> bool:
name, separator, _value = token.partition("=")
return bool(separator) and name.isidentifier()


def _make_transform_tool_result(guard: AgentGuardBridge):
def transform_tool_result(tool_name: str, args: Optional[Dict[str, Any]] = None, **kwargs: Any):
if tool_name not in ("terminal", "execute_code") or not isinstance(args, dict):
return None
command = args.get("command") or args.get("code")
if not _is_standalone_agentguard_command(command):
return None
result = kwargs.get("result")
if not isinstance(result, str):
return None
try:
redacted = guard.redact_self_output(result)
return redacted if redacted != result else None
except Exception:
# This hook runs before Hermes puts the result in model context.
# Never hand raw credentials to the model if the local redactor fails.
return "AgentGuard output could not be safely redacted; run the command directly in your terminal."

return transform_tool_result


def _make_session_start(guard: AgentGuardBridge):
def on_session_start(*_args: Any, **_kwargs: Any):
# Best-effort background scan of installed skills, mirroring the shell
Expand Down
1 change: 1 addition & 0 deletions plugins/hermes/tests/test_allow.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def test_register_wires_all_hooks_and_command():
"post_api_request",
"pre_tool_call",
"post_tool_call",
"transform_tool_result",
"on_session_start",
}
assert "agentguard" in ctx.commands
Expand Down
57 changes: 57 additions & 0 deletions plugins/hermes/tests/test_self_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Native Hermes own-command results are rewritten before model context."""

import json
import shlex
import shutil
from pathlib import Path

from helpers import make_protect_runner, register_with


def test_transform_hook_scopes_standalone_commands_and_fails_safe(monkeypatch):
ctx, guard = register_with(make_protect_runner())
guard.redact_self_output = lambda text: text.replace("sk-test-secret-1234567890", "[REDACTED]")
transform = ctx.hooks["transform_tool_result"]
own = '{"output":"api_key=sk-test-secret-1234567890"}'
assert transform("terminal", {"command": "agentguard scan ./skill"}, result=own) == '{"output":"api_key=[REDACTED]"}'
assert transform("terminal", {"command": "command agentguard status"}, result=own) == '{"output":"api_key=[REDACTED]"}'
assert transform("execute_code", {"code": "agentguard status"}, result=own) == '{"output":"api_key=[REDACTED]"}'
assert transform("terminal", {"command": "agentguard status; cat .env"}, result=own) is None
assert transform("terminal", {"command": "cat .env"}, result=own) is None
assert transform("read_file", {"path": "x"}, result=own) is None
assert transform("terminal", {"command": "agentguard status"}, result="public") is None
guard.redact_self_output = lambda _text: (_ for _ in ()).throw(RuntimeError("sk-private-value"))
failed = transform("terminal", {"command": "agentguard status"}, result=own)
assert "safely redacted" in failed
assert "sk-test-secret" not in failed


def test_bridge_uses_shared_redactor_and_preserves_only_the_saved_link(monkeypatch, tmp_path):
node = shutil.which("node")
assert node
cli = Path(__file__).resolve().parents[3] / "dist" / "cli.js"
shim = tmp_path / "agentguard"
shim.write_text("#!/bin/sh\nexec " + shlex.quote(node) + " " + shlex.quote(str(cli)) + ' "$@"\n')
shim.chmod(0o755)
monkeypatch.setenv("AGENTGUARD_BIN", str(shim))
monkeypatch.setenv("AGENTGUARD_HOME", str(tmp_path))
link = "https://agentguard.example/activate?token=hermes-self-link-123456"
(tmp_path / "config.json").write_text(json.dumps({
"version": 1, "level": "balanced", "agentJwt": "agent.jwt.test", "agentRegisterUrl": link,
"cloudUrl": "http://127.0.0.1:9",
}))
ctx, guard = register_with(make_protect_runner())
output = f"{link}\napi_key=sk-hermes-secret-1234567890"
rewritten = ctx.hooks["transform_tool_result"]("terminal", {"command": "agentguard connect"}, result=output)
assert link in rewritten
assert "hermes-secret" not in rewritten
assert "api_key=[REDACTED]" in rewritten
absolute = ctx.hooks["transform_tool_result"]("terminal", {"command": f"{shim} status"}, result=output)
assert link in absolute and "hermes-secret" not in absolute
assert ctx.hooks["transform_tool_result"]("terminal", {"command": "agentguard status"}, result=link) is None
forged = guard.redact_self_output(output.replace(link, link + "-forged"))
assert "hermes-self-link-123456-forged" not in forged
assert "sk-hermes-secret" not in forged
slash_status = ctx.commands["agentguard"][0]("status")
assert link in slash_status
assert "TYPESAFE_API_KEY=<key>" in slash_status
Loading
Loading