Adaptation for Hermes-Agent#9
Conversation
…olumn config, and full-width adaptive pages
Rebrand the plugin identity from `claw-aegis` to `agent-aegis` across both runtime adapters. Anchored strictly on the `*aegis*` compounds, so the runtime-detection tokens (CLAW_COMMAND, CLAW_CLI_PATTERNS, CLAW_LAUNCHERS, CLAW_CONTROL_PATTERNS) and the brand-neutral AEGIS_* env vars are left intact: - plugin id/name, npm packages (@openclaw/agent-aegis, @agent-aegis-web/*), brand AgentAegis, the [AgentAegis] refusal prefix, AGENT_AEGIS_* constants - install/state paths: ~/.hermes/plugins/agent-aegis, agent-aegis-state, and the .agentaegis-root marker Re-verified in a clean container: OpenClaw native plugin API 11/11, Hermes RPC bridge 13/13 — the plugin loads and blocks as agent-aegis on both surfaces. README (English + 中文): - add openclaw@latest and hermes-agent@latest install methods, plus each WebUI startup - rewrite Project Structure to reflect the real dual-runtime layout (Hermes adapter, RPC bridge, shared detection engine) Repo hygiene: - .gitignore: keep only the minimal runnable plugin; exclude tests, the local Docker verification harness, logs, build artifacts (dist/tsbuildinfo/pyc), and local/personal agent config - untrack personal .mcp.json (internal MCP config) and compiled __pycache__ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the agent-aegis rebrand. The original rename covered `ClawAegis` (capital C) but missed the camelCase `clawAegis` identifier (clawAegisPluginConfigDefinition / clawAegisPluginConfigSchema / clawAegisPluginUiHints) in index.ts + src/config.ts. Internal export/import names only — no behavior change. (eBPF branch already had this via its merge.) tsc build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uild fix - README (en + zh): the Hermes enable step now uses `hermes plugins enable agent-aegis` (+ `hermes plugins list`), with the manual config.yaml edit kept as an equivalent note. `approvals.mode: off` becomes an explicit OPTIONAL step explaining the effect — AgentAegis works regardless of Hermes's approval mode; `off` avoids double prompts and non-interactive hangs, while `manual` can be kept as an extra human-approval layer. - start-web-hermes.sh: build the FULL web workspace (shared -> api -> frontend), not just web/api. web/api/dist imports @agent-aegis-web/shared, whose web/shared/dist/index.js must exist at runtime; building only web/api left shared unbuilt -> ERR_MODULE_NOT_FOUND. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lugin.json by disk The WebUI config service only treated the runtime as Hermes when `AEGIS_APP === "hermes"`, but `start-web-hermes.sh` never exported that var, so the Hermes WebUI fell through to the OpenClaw branch and tried to open `openclaw.plugin.json` — which does not exist on a Hermes install: ENOENT: no such file or directory, open '~/.hermes/plugins/agent-aegis/openclaw.plugin.json' - config-service.ts: detect the config file by what is actually on disk — prefer `config.yaml` (Hermes) when present, else `openclaw.plugin.json` (OpenClaw / fresh dir). `AEGIS_APP` is kept as an explicit override. Robust regardless of how the WebUI is launched. - start-web-hermes.sh: export `AEGIS_APP="hermes"` (belt-and-suspenders; also fixes the `/status` app label which previously reported "openclaw"). Verified: a dir with only config.yaml (and no AEGIS_APP) resolves to config.yaml; an OpenClaw dir still resolves to openclaw.plugin.json; env override works both ways. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er() Hermes' pre_tool_call hook is observation-only and cannot block, so all before-tool blocking depends on wrapping the tool handlers (wrap_dangerous_tools). That wrap ran exactly once, during the plugin's register(). But Hermes may register its built-in tools (terminal, read_file, write_file, patch, execute_code) AFTER the plugin loads, so register() often found an EMPTY registry, wrapped 0 tools, and before-tool blocking silently never fired — e.g. `read_file` of a protected path was allowed, while the post_tool_call hook still scanned the result (tool_result_scan), which is exactly the "0 tools wrapped / not blocked but result observed" symptom. Fix: retry wrapping until it succeeds. _ensure_tools_wrapped wraps at register() AND on every on_session_start / pre_llm_call until at least one tool is wrapped, then stops (idempotent — already-wrapped handlers are skipped via a marker). By the first pre_llm_call the tools are guaranteed present (their schemas are sent to the model), so blocking reliably arms. Startup log now reports whether blocking is armed or deferred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d them
The installer backed up an existing install to ${HERMES_PLUGIN_DIR}.backup.<ts>,
i.e. a sibling INSIDE ~/.hermes/plugins/. Hermes discovers every directory under
plugins/ as a plugin, so the backup was loaded as a duplicate, stale plugin and
could shadow the fresh install — running old code (e.g. "0 tools wrapped",
rpc-server.js launched from the .backup dir) even after a correct reinstall.
- Back up to ~/.hermes/agent-aegis-backups/ (outside plugins/) instead.
- Self-heal: relocate any legacy ${HERMES_PLUGIN_DIR}.backup.* left in plugins/
by older installers on the next run.
- Drop the stray `mkdir -p "$HERMES_PLUGIN_DIR"` that ran before the backup check
(it made the "existing install?" test always true).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…enforce, hermes plugin loading) Surface the gotchas users hit in practice, between Quick Start and Features: - defense config is read only at startup → restart the agent after editing config / WebUI changes (a running session keeps the old config). - observe logs but does not block; only enforce blocks. - Hermes must load plugins (not `hermes -z`); the "N high-risk tools wrapped" startup line is what arms tool-call blocking. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This branch is the L1 (tool-call layer) Hermes adaptation only; the kernel-level sentinel subsystem lives on the eBPF branch. The sentinel/ files committed here were orphaned build outputs with no source — a 3.2MB compiled lsm/sentinel-runner binary, a generated 4.1MB vmlinux.h, lsm.bpf.o, a dist symlink, and go.sum — ~7.5MB of binary/generated bloat. Drop them and the dangling sentinel/ line from the README structure trees. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request rebrands the plugin from 'ClawAegis' to 'AgentAegis' and introduces a comprehensive adapter for the Hermes Agent, enabling the TypeScript-based security engine to run on both OpenClaw and Hermes runtimes via a Node.js JSON-RPC subprocess bridge. The Web UI has also been enhanced with new visualization charts. The review feedback highlights several critical improvements for the Hermes adapter, such as using asyncio.to_thread to prevent blocking the asyncio event loop in async tool wrappers, resolving a race condition on stderr reading during Web server startup, ensuring UTF-8 encoding is specified when reading configuration files, and addressing an unused timeout variable in the RPC bridge.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| async def async_handler(args: dict, **kwargs: Any) -> str: | ||
| # Same logic but for async handlers — the check itself is sync | ||
| # (short RPC call), only the original handler is awaited | ||
| try: | ||
| result = engine.call("check_before_tool", { | ||
| "tool": tool_name, | ||
| "args": args, | ||
| "sessionKey": session_key_fn(), | ||
| "runId": run_id_fn(), | ||
| }) |
There was a problem hiding this comment.
engine.call 是一个阻塞式的同步 I/O 调用(内部向 stdin 写入并阻塞等待 stdout 的 readline)。在 async_handler 这个异步函数中直接调用它会阻塞整个 asyncio 事件循环,从而导致异步 Agent 的整体性能严重下降。建议使用 asyncio.to_thread 将该同步阻塞调用放到线程池中执行。
| async def async_handler(args: dict, **kwargs: Any) -> str: | |
| # Same logic but for async handlers — the check itself is sync | |
| # (short RPC call), only the original handler is awaited | |
| try: | |
| result = engine.call("check_before_tool", { | |
| "tool": tool_name, | |
| "args": args, | |
| "sessionKey": session_key_fn(), | |
| "runId": run_id_fn(), | |
| }) | |
| async def async_handler(args: dict, **kwargs: Any) -> str: | |
| # Same logic but for async handlers — the check itself is sync | |
| # (short RPC call), only the original handler is awaited | |
| try: | |
| import asyncio | |
| result = await asyncio.to_thread( | |
| engine.call, | |
| "check_before_tool", | |
| { | |
| "tool": tool_name, | |
| "args": args, | |
| "sessionKey": session_key_fn(), | |
| "runId": run_id_fn(), | |
| } | |
| ) |
| # Background thread to drain stderr and forward to Python logging | ||
| self._stderr_thread = threading.Thread( | ||
| target=self._drain_stderr, daemon=True, | ||
| ) | ||
| self._stderr_thread.start() | ||
|
|
||
| # Wait a moment and check if process started successfully | ||
| time.sleep(0.5) | ||
| if self._proc.poll() is not None: | ||
| stdout, _ = self._proc.communicate(timeout=1) | ||
| logger.error("Web server failed to start: %s", stdout) | ||
| self._started = False | ||
| raise RuntimeError("Web server process exited immediately") |
There was a problem hiding this comment.
在启动 Web 服务器进程后,立即启动了后台线程 _drain_stderr 来读取 stderr。随后如果检测到进程退出,又调用了 self._proc.communicate()。由于后台线程和 communicate() 会同时尝试读取同一个管道(stderr),这会引发竞争条件(Race Condition),可能导致 ValueError 或日志丢失。此外,Web 服务器启动失败的错误通常输出到 stderr,而原代码只打印了 stdout。建议先检查进程状态,如果启动成功再启动后台线程;如果启动失败,则通过 communicate() 同时获取 stdout 和 stderr 进行排查。
| # Background thread to drain stderr and forward to Python logging | |
| self._stderr_thread = threading.Thread( | |
| target=self._drain_stderr, daemon=True, | |
| ) | |
| self._stderr_thread.start() | |
| # Wait a moment and check if process started successfully | |
| time.sleep(0.5) | |
| if self._proc.poll() is not None: | |
| stdout, _ = self._proc.communicate(timeout=1) | |
| logger.error("Web server failed to start: %s", stdout) | |
| self._started = False | |
| raise RuntimeError("Web server process exited immediately") | |
| # Wait a moment and check if process started successfully | |
| time.sleep(0.5) | |
| if self._proc.poll() is not None: | |
| stdout, stderr = self._proc.communicate(timeout=1) | |
| logger.error("Web server failed to start: stdout=%s, stderr=%s", stdout, stderr) | |
| self._started = False | |
| raise RuntimeError("Web server process exited immediately") | |
| # Background thread to drain stderr and forward to Python logging | |
| self._stderr_thread = threading.Thread( | |
| target=self._drain_stderr, daemon=True, | |
| ) | |
| self._stderr_thread.start() |
| return {} | ||
|
|
||
| try: | ||
| with open(config_path) as f: |
| if hermes_config_path.is_file(): | ||
| try: | ||
| import yaml | ||
| with open(hermes_config_path) as f: |
| # Maximum restarts after unexpected process death | ||
| _MAX_RESTARTS = 3 | ||
| # Timeout for a single RPC call (seconds) | ||
| _CALL_TIMEOUT = 5.0 |
…ce, utf-8, RPC timeout) Gemini Code Assist review on PR#9: - tool_wrappers.async_handler: engine.call is a blocking sync RPC; awaiting it directly froze the asyncio event loop. Run it via asyncio.to_thread. Safe because bridge.call serialises with self._lock. - web-server start: the stderr drain thread and communicate() raced on the same pipe and swallowed the real error. Poll + communicate(stdout+stderr) for diagnostics first, start the drain thread only once the process is alive. - __init__: open() config reads now pass encoding='utf-8'. - bridge: _CALL_TIMEOUT was dead and call()'s readline() had no timeout, so a hung (not dead) RPC subprocess froze every later tool call (lock held). Add _readline_timeout: read in a daemon thread, kill the subprocess on timeout so the next call restarts it, and raise (callers already fail-open). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lawHub publish metadata origin/main diverged with its own ClawAegis->AgentAegis rename (eebbf11) plus ClawHub publish prep (bdc6717); hermes-adaptation had already done the rebrand, so all 15 conflicts were rename-vs-rename. Resolution: - kept hermes-adaptation feature code (dual-runtime, WebUI, engine refactor), which already uses the agent-aegis identifiers; - adopted main's publish metadata: package.json (@antgroup/agent-aegis, Apache-2.0, repo/homepage/bugs, compiled-only files whitelist, openclaw.compat/build, prepublishOnly), openclaw.plugin.json (fuller description + activation + contracts), tsconfig.json (declaration:true + outDir for .d.ts output); - regenerated all .js/.d.ts via tsc; build clean (exit 0).
Backport the WebUI security fixes that landed on fix/webui-local-bind-auth
(not yet on main) to this branch. Adapted to the branch's agent-aegis-web
naming and { app } createServer signature; no behavioral change to existing
branch-specific structure (expandHome, dual openclaw/hermes state dirs,
AEGIS_APP, AEGIS_STATIC_DIR, existsSync static-layout probe).
Security changes (closing a confused-deputy / local-exposure vector):
- Bind the management API to 127.0.0.1 by default (AEGIS_HOST / --host to
override); warn on 0.0.0.0/:: binds.
- Lock CORS to derived loopback origins (api port + vite dev port) plus
AEGIS_ALLOWED_ORIGINS; no longer reflects Access-Control-Allow-Origin: *.
- Require a token on every API request (only /health stays open). Token
resolution: AEGIS_TOKEN (out-of-band, strongest) > persisted
.aegis-webui-token (0600) > auto-generated + printed to console. Compared
in constant time.
- Never embed the token in the served page (express.static index:false); the
frontend prompts the operator to enter it once (localStorage), single-flight
401 re-prompt. Vite dev proxy injects the header from the same sources.
Files: web/api/src/{server,index}.ts, web/frontend/src/api/client.ts,
web/frontend/vite.config.ts, README{,_zh}.md, web/README.md, .gitignore.
Co-Authored-By: Claude <noreply@anthropic.com>
feat: 双运行时支持(OpenClaw + Hermes)+ 改名 agent-aegis + WebUI 改版
把 L1(工具调用层)插件适配成一套代码同时跑在 OpenClaw 和 Hermes 上,完成claw-aegis → agent-aegis 改名,并重做 WebUI。
start-web-hermes.sh 启动脚本。
hermes plugins enable流程与可选 approvals.mode。