Skip to content

Adaptation for Hermes-Agent#9

Open
XXnormal wants to merge 23 commits into
mainfrom
hermes-adaptation
Open

Adaptation for Hermes-Agent#9
XXnormal wants to merge 23 commits into
mainfrom
hermes-adaptation

Conversation

@XXnormal

Copy link
Copy Markdown
Collaborator

feat: 双运行时支持(OpenClaw + Hermes)+ 改名 agent-aegis + WebUI 改版

把 L1(工具调用层)插件适配成一套代码同时跑在 OpenClaw 和 Hermes 上,完成claw-aegis → agent-aegis 改名,并重做 WebUI。

  • Hermes 运行时:新增 adapters/hermes/ 的 Python↔Node 桥接(install.sh、bridge.py、tool_wrappers.py、paths.py、web-server.py),以及 rpc-server/rpc-handlers——通过 JSON-RPC 暴露共享检测引擎;新增根入口(init.py、plugin.yaml)和
    start-web-hermes.sh 启动脚本。
  • 改名:claw-aegis → agent-aegis,覆盖 index/config/清单/文档(含遗漏的驼峰 clawAegis → agentAegis)。仅内部命名,无行为变化。
  • WebUI:响应式 Dashboard + 图表(事件分布 / Skill 信任 / 周趋势)、多列配置、全宽页面;后端按磁盘自动识别 config.yaml(Hermes)还是 openclaw.plugin.json(OpenClaw)。
  • Hermes 加固:工具加载晚于 register() 时也能武装工具调用拦截;install.sh 把备份移出 plugins/ 避免加载到旧副本;支持 hermes plugins enable 流程与可选 approvals.mode。
  • 文档:运维提示(改配置后需重启、observe vs enforce、Hermes 插件加载注意点)。

qingyuqi.qyq and others added 15 commits April 13, 2026 15:51
…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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread adapters/hermes/tool_wrappers.py Outdated
Comment on lines +75 to +84
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(),
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

engine.call 是一个阻塞式的同步 I/O 调用(内部向 stdin 写入并阻塞等待 stdout 的 readline)。在 async_handler 这个异步函数中直接调用它会阻塞整个 asyncio 事件循环,从而导致异步 Agent 的整体性能严重下降。建议使用 asyncio.to_thread 将该同步阻塞调用放到线程池中执行。

Suggested change
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(),
}
)

Comment thread adapters/hermes/web-server.py Outdated
Comment on lines +110 to +122
# 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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

在启动 Web 服务器进程后,立即启动了后台线程 _drain_stderr 来读取 stderr。随后如果检测到进程退出,又调用了 self._proc.communicate()。由于后台线程和 communicate() 会同时尝试读取同一个管道(stderr),这会引发竞争条件(Race Condition),可能导致 ValueError 或日志丢失。此外,Web 服务器启动失败的错误通常输出到 stderr,而原代码只打印了 stdout。建议先检查进程状态,如果启动成功再启动后台线程;如果启动失败,则通过 communicate() 同时获取 stdoutstderr 进行排查。

Suggested change
# 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()

Comment thread adapters/hermes/__init__.py Outdated
return {}

try:
with open(config_path) as f:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

在读取配置文件时,未指定 encoding='utf-8'。在非 UTF-8 默认编码的系统(如 Windows)上,如果配置文件包含非 ASCII 字符,可能会导致 UnicodeDecodeError。建议显式指定编码。

Suggested change
with open(config_path) as f:
with open(config_path, encoding="utf-8") as f:

Comment thread adapters/hermes/__init__.py Outdated
if hermes_config_path.is_file():
try:
import yaml
with open(hermes_config_path) as f:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

在读取 Hermes 配置文件时,未指定 encoding='utf-8'。建议显式指定编码以避免在不同操作系统上出现编码解析问题。

Suggested change
with open(hermes_config_path) as f:
with open(hermes_config_path, encoding="utf-8") as f:

Comment thread adapters/hermes/bridge.py
# Maximum restarts after unexpected process death
_MAX_RESTARTS = 3
# Timeout for a single RPC call (seconds)
_CALL_TIMEOUT = 5.0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

定义了 _CALL_TIMEOUT = 5.0 但在代码中并未实际使用。目前 proc.stdout.readline() 是一个完全阻塞的调用,如果 Node.js RPC 服务端挂起或未输出换行符,Python 进程将无限期阻塞。建议移除未使用的变量,或者实现超时机制以提高健壮性。

XXnormal and others added 8 commits June 10, 2026 19:05
…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>
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.

1 participant