From 49f35e33af56bd369b34f775e80372ac80663ee9 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Sat, 16 May 2026 00:14:59 +0800 Subject: [PATCH 1/2] Add Hermes hook support --- README.md | 7 +- docs/hermes.md | 70 ++++++++++ skills/agentguard/README.md | 12 ++ skills/agentguard/SKILL.md | 84 ++++++++++- skills/agentguard/hermes-hooks.yaml | 31 +++++ skills/agentguard/package.json | 2 +- skills/agentguard/scripts/auto-scan.js | 5 +- skills/agentguard/scripts/hermes-hook.js | 104 ++++++++++++++ src/adapters/hermes.ts | 170 +++++++++++++++++++++++ src/adapters/index.ts | 1 + src/index.ts | 1 + src/tests/adapter.test.ts | 163 ++++++++++++++++++++++ src/tests/smoke.test.ts | 70 +++++++++- 13 files changed, 712 insertions(+), 8 deletions(-) create mode 100644 docs/hermes.md create mode 100644 skills/agentguard/hermes-hooks.yaml create mode 100644 skills/agentguard/scripts/hermes-hook.js create mode 100644 src/adapters/hermes.ts diff --git a/README.md b/README.md index 84b11a3..cbcfa77 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ See also: - [Privacy and data boundary](docs/privacy-boundary.md) - [Claude Code setup](docs/claude-code.md) - [OpenClaw setup](docs/openclaw.md) +- [Hermes Agent setup](docs/hermes.md) - [Codex setup](docs/codex.md)
@@ -300,12 +301,13 @@ GoPlus AgentGuard follows the [Agent Skills](https://agentskills.io) open standa |----------|---------|----------| | **Claude Code** | Full | Skill + hooks auto-guard, transcript-based skill tracking | | **OpenClaw** | Full | Plugin hooks + **auto-scan on load** + tool→plugin mapping + **daily patrol** | +| **Hermes Agent** | Hooks | Shell hooks for `pre_tool_call` / `post_tool_call` runtime protection | | **OpenAI Codex CLI** | Skill | Scan/action/trust commands | | **Gemini CLI** | Skill | Scan/action/trust commands | | **Cursor** | Skill | Scan/action/trust commands | | **GitHub Copilot** | Skill | Scan/action/trust commands | -> **Hooks-based auto-guard (Layer 1)** works on Claude Code (PreToolUse/PostToolUse) and OpenClaw (before_tool_call/after_tool_call). Both platforms share the same decision engine via a unified adapter abstraction layer. +> **Hooks-based auto-guard (Layer 1)** works on Claude Code (PreToolUse/PostToolUse), OpenClaw (before_tool_call/after_tool_call), and Hermes Agent (pre_tool_call/post_tool_call shell hooks). These platforms share the same decision engine via a unified adapter abstraction layer. > > **OpenClaw exclusive**: Auto-scans all loaded plugins at registration time, automatically registers them to the trust registry, and supports automated daily security patrols via cron. @@ -313,11 +315,12 @@ GoPlus AgentGuard follows the [Agent Skills](https://agentskills.io) open standa The auto-guard hooks (Layer 1) have the following constraints: -- **Platform-specific**: Hooks rely on Claude Code's `PreToolUse` / `PostToolUse` events or OpenClaw's `before_tool_call` / `after_tool_call` plugin hooks. Both share the same decision engine via the adapter abstraction layer. +- **Platform-specific**: Hooks rely on Claude Code's `PreToolUse` / `PostToolUse` events, OpenClaw's `before_tool_call` / `after_tool_call` plugin hooks, or Hermes Agent's `pre_tool_call` / `post_tool_call` shell hooks. All share the same decision engine via the adapter abstraction layer. - **Default-deny policy**: First-time use may trigger confirmation prompts for certain commands. A built-in safe-command allowlist (`ls`, `echo`, `pwd`, `git status`, etc.) reduces false positives. - **Skill source tracking**: - *Claude Code*: Infers which skill initiated an action by analyzing the conversation transcript (heuristic, not 100% precise) - *OpenClaw*: Uses tool→plugin mapping built at registration time (more reliable) + - *Hermes Agent*: Uses session/tool metadata when available; most shell-hook payloads do not identify an initiating skill. - **Cannot intercept skill installation itself**: Hooks can only intercept tool calls (Bash, Write, WebFetch, etc.) that a skill makes *after* loading — they cannot block the Skill tool invocation itself. - **OpenClaw auto-scan timing**: Plugins are scanned asynchronously after AgentGuard registration completes. Very fast tool calls immediately after startup may execute before scan completes. diff --git a/docs/hermes.md b/docs/hermes.md new file mode 100644 index 0000000..6980d89 --- /dev/null +++ b/docs/hermes.md @@ -0,0 +1,70 @@ +# Hermes Agent + +Hermes Agent can use AgentGuard through Hermes shell hooks. AgentGuard evaluates +`pre_tool_call` events before risky tools execute and returns Hermes-compatible +block decisions on stdout. + +## Shell hook usage + +Build AgentGuard first so the hook script can import `dist/index.js`: + +```bash +npm run build +``` + +Copy the template from `skills/agentguard/hermes-hooks.yaml` into +`~/.hermes/config.yaml` and replace `AGENTGUARD_SKILL_DIR` with the absolute +path to the installed AgentGuard skill directory. + +```yaml +hooks: + on_session_start: + - command: "AGENTGUARD_AUTO_SCAN=1 node \"/path/to/agentguard/skills/agentguard/scripts/auto-scan.js\"" + timeout: 30 + + pre_tool_call: + - matcher: "terminal|execute_code" + command: "node \"/path/to/agentguard/skills/agentguard/scripts/hermes-hook.js\"" + timeout: 10 + - matcher: "write_file|patch|skill_manage" + command: "node \"/path/to/agentguard/skills/agentguard/scripts/hermes-hook.js\"" + timeout: 10 + - matcher: "web_search|web_extract|browser_.*" + command: "node \"/path/to/agentguard/skills/agentguard/scripts/hermes-hook.js\"" + timeout: 10 + + post_tool_call: + - matcher: "terminal|execute_code|write_file|patch|skill_manage|read_file|web_search|web_extract|browser_.*" + command: "node \"/path/to/agentguard/skills/agentguard/scripts/hermes-hook.js\"" + timeout: 5 +``` + +Hermes asks for first-use consent for shell hooks. Use one of: + +```bash +hermes --accept-hooks chat +HERMES_ACCEPT_HOOKS=1 hermes chat +``` + +or set `hooks_auto_accept: true` in `~/.hermes/config.yaml`. + +## Tool mapping + +| Hermes tool | AgentGuard action | +|-------------|-------------------| +| `terminal`, `execute_code` | `exec_command` | +| `write_file`, `patch`, `skill_manage` | `write_file` | +| `read_file` | `read_file` | +| `web_search`, `web_extract`, `browser_*` | `network_request` | + +## Decisions + +Hermes `pre_tool_call` supports allow or block. AgentGuard `deny` decisions are +returned as: + +```json +{"action":"block","message":"GoPlus AgentGuard: ..."} +``` + +AgentGuard `ask` decisions are also represented as blocks because Hermes shell +hooks do not have a native confirmation decision. diff --git a/skills/agentguard/README.md b/skills/agentguard/README.md index 429e951..a2c9890 100644 --- a/skills/agentguard/README.md +++ b/skills/agentguard/README.md @@ -21,8 +21,20 @@ AI Agent Security Guard — protect your AI agents from dangerous commands, data /agentguard report — View security event audit log /agentguard config — Set protection level (strict/balanced/permissive) /agentguard checkup — Run agent health checkup with visual HTML report +/agentguard hermes-hooks — Configure Hermes Agent shell hooks ``` +## Hermes Agent hooks + +When installed from SkillHub, Hermes sees the contents of this +`skills/agentguard` directory first. Runtime hooks are not loaded from +`SKILL.md` automatically; copy `hermes-hooks.yaml` into `~/.hermes/config.yaml` +and replace `AGENTGUARD_SKILL_DIR` with this skill's absolute path. + +The hook runner is `scripts/hermes-hook.js`. It uses the published +`@goplus/agentguard` package, so run `npm install` inside this skill directory +or install `@goplus/agentguard` globally if the package is not already present. + ## Agent Health Checkup 🦞 Run a full security health check on your AI agent and get a visual report in the browser: diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index 6ec1ed8..d50073a 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -20,6 +20,9 @@ filesystem-access: - path: "~/.openclaw/" access: read-only reason: "Discover installed skills and read OpenClaw config for patrol checks" + - path: "~/.hermes/" + access: read-write + reason: "Discover installed Hermes skills and help configure AgentGuard shell hooks" - path: "~/.qclaw/" access: read-only reason: "Discover installed skills in QClaw environments" @@ -60,9 +63,86 @@ Parse `$ARGUMENTS` to determine the subcommand: - **`report`** — View recent security events from the audit log - **`config `** — Set protection level - **`checkup`** — Run a comprehensive agent health checkup and generate a visual HTML report +- **`hermes-hooks`** — Show or install Hermes shell-hook configuration for runtime protection If no subcommand is given, or the first argument is a path, default to **scan**. +## Subcommand: hermes-hooks + +Help the user configure AgentGuard runtime protection for Hermes Agent. + +Hermes does **not** load hooks from `SKILL.md` automatically. Hermes shell hooks +must be present in `~/.hermes/config.yaml`. This skill ships the hook runner at +`scripts/hermes-hook.js` and a copyable template at `hermes-hooks.yaml`. + +### What the Hermes hook protects + +| Hermes hook | Tools | AgentGuard action | +|---|---|---| +| `pre_tool_call` | `terminal`, `execute_code` | `exec_command` | +| `pre_tool_call` | `write_file`, `patch`, `skill_manage` | `write_file` | +| `pre_tool_call` | `read_file` | `read_file` | +| `pre_tool_call` | `web_search`, `web_extract`, `browser_*` | `network_request` | +| `post_tool_call` | Same tools | Audit-only | + +Hermes `pre_tool_call` supports allow/block only. If AgentGuard returns `ask`, +the Hermes hook reports it as a block with a confirmation-oriented message. + +### Procedure + +1. Resolve the AgentGuard skill directory using the "Important: Resolving Script + Paths" rules above. +2. Confirm that dependencies are available. If `node scripts/hermes-hook.js` + cannot load `@goplus/agentguard`, tell the user to run: + ```bash + cd && npm install + ``` + or install the published package globally: + ```bash + npm install -g @goplus/agentguard + ``` +3. Read `hermes-hooks.yaml`, replace `AGENTGUARD_SKILL_DIR` with the absolute + skill directory, and show the resulting YAML to the user. +4. Ask for explicit confirmation before editing `~/.hermes/config.yaml`. +5. If confirmed, merge the `hooks:` entries into `~/.hermes/config.yaml`. + Preserve existing hooks and config values. Do not overwrite unrelated user + configuration. +6. Tell the user to restart Hermes or launch it with one of the first-use + consent options: + ```bash + hermes --accept-hooks chat + HERMES_ACCEPT_HOOKS=1 hermes chat + ``` + They may also set `hooks_auto_accept: true` in `~/.hermes/config.yaml`. + +### Verification + +After configuration, suggest a harmless test: + +```bash +printf '{"hook_event_name":"pre_tool_call","tool_name":"terminal","tool_input":{"command":"echo hello"}}' \ + | node /scripts/hermes-hook.js +``` + +Expected output: + +```json +{} +``` + +And a blocked-action test: + +```bash +printf '{"hook_event_name":"pre_tool_call","tool_name":"terminal","tool_input":{"command":"rm -rf /"}}' \ + | node /scripts/hermes-hook.js +``` + +Expected output contains: + +```json +{"action":"block"} +``` + ## Subcommand: subscribe Run the AgentGuard Cloud threat-feed subscription workflow through the installed CLI. @@ -666,6 +746,7 @@ Run these checks in parallel where possible. These are **universal agent securit 3. **[REQUIRED] Sensitive credential scan / DLP** (→ feeds Dimension 2: Credential Safety): Use Grep to scan **all** agent workspace directories for leaked secrets. This MUST cover the entire workspace root, not just the current agent's directory: - For OpenClaw / QClaw: scan `~/.openclaw/workspace/` and `~/.qclaw/workspace/` recursively — this includes **all** `workspace-agent-*/` subdirectories, not just the current agent's workspace - For Claude Code: scan `~/.claude/` recursively + - For Hermes Agent: scan `~/.hermes/` recursively - Patterns to detect: - Private keys: `0x[a-fA-F0-9]{64}`, `-----BEGIN.*PRIVATE KEY-----` - Mnemonics: sequences of 12+ BIP-39 words, `seed_phrase`, `mnemonic` @@ -674,7 +755,7 @@ Run these checks in parallel where possible. These are **universal agent securit 4. **[REQUIRED] Network exposure** (→ feeds Dimension 3: Network & System): Run `lsof -i -P -n 2>/dev/null | grep LISTEN` or `ss -tlnp 2>/dev/null` to check for dangerous open ports (Redis 6379, Docker API 2375, MySQL 3306, MongoDB 27017 on 0.0.0.0) 5. **[REQUIRED] Scheduled tasks audit** (→ feeds Dimension 3: Network & System): Check `crontab -l 2>/dev/null` for suspicious entries containing `curl|bash`, `wget|sh`, or accessing `~/.ssh/` 6. **[REQUIRED] Environment variable exposure** (→ feeds Dimension 3: Network & System): Run `env` and check for sensitive variable names (`PRIVATE_KEY`, `MNEMONIC`, `SECRET`, `PASSWORD`) — detect presence only, mask values -7. **[REQUIRED] Runtime protection check** (→ feeds Dimension 4: Runtime Protection): Check if security hooks exist in `~/.claude/settings.json` or `~/.openclaw/openclaw.json`, check for audit logs at `~/.agentguard/audit.jsonl` +7. **[REQUIRED] Runtime protection check** (→ feeds Dimension 4: Runtime Protection): Check if security hooks exist in `~/.claude/settings.json`, `~/.openclaw/openclaw.json`, or `~/.hermes/config.yaml`, check for audit logs at `~/.agentguard/audit.jsonl` ### Step 2: Score Calculation @@ -929,6 +1010,7 @@ AgentGuard can optionally scan installed skills at session startup. **This is di - **Claude Code**: Set environment variable `AGENTGUARD_AUTO_SCAN=1` - **OpenClaw**: Pass `{ skipAutoScan: false }` when registering the plugin +- **Hermes Agent**: Configure the `on_session_start` shell hook from `hermes-hooks.yaml`; the template sets `AGENTGUARD_AUTO_SCAN=1` for that hook. When enabled, auto-scan operates in **report-only mode**: diff --git a/skills/agentguard/hermes-hooks.yaml b/skills/agentguard/hermes-hooks.yaml new file mode 100644 index 0000000..261f1f8 --- /dev/null +++ b/skills/agentguard/hermes-hooks.yaml @@ -0,0 +1,31 @@ +# GoPlus AgentGuard hook template for Hermes Agent. +# +# Copy this block into ~/.hermes/config.yaml and replace AGENTGUARD_SKILL_DIR +# with the absolute path to the installed AgentGuard skill directory, e.g. +# ~/.hermes/skills/agentguard or ~/.openclaw/skills/agentguard. + +hooks: + on_session_start: + - command: "AGENTGUARD_AUTO_SCAN=1 node \"AGENTGUARD_SKILL_DIR/scripts/auto-scan.js\"" + timeout: 30 + + pre_tool_call: + - matcher: "terminal|execute_code" + command: "node \"AGENTGUARD_SKILL_DIR/scripts/hermes-hook.js\"" + timeout: 10 + - matcher: "write_file|patch|skill_manage" + command: "node \"AGENTGUARD_SKILL_DIR/scripts/hermes-hook.js\"" + timeout: 10 + - matcher: "read_file" + command: "node \"AGENTGUARD_SKILL_DIR/scripts/hermes-hook.js\"" + timeout: 10 + - matcher: "web_search|web_extract|browser_.*" + command: "node \"AGENTGUARD_SKILL_DIR/scripts/hermes-hook.js\"" + timeout: 10 + + post_tool_call: + - matcher: "terminal|execute_code|write_file|patch|skill_manage|read_file|web_search|web_extract|browser_.*" + command: "node \"AGENTGUARD_SKILL_DIR/scripts/hermes-hook.js\"" + timeout: 5 + +hooks_auto_accept: false diff --git a/skills/agentguard/package.json b/skills/agentguard/package.json index 1a5b35b..0366c40 100644 --- a/skills/agentguard/package.json +++ b/skills/agentguard/package.json @@ -2,7 +2,7 @@ "private": true, "type": "module", "dependencies": { - "@goplus/agentguard": "^1.0.6", + "@goplus/agentguard": "^1.1.4", "open": "11.0.0" } } diff --git a/skills/agentguard/scripts/auto-scan.js b/skills/agentguard/scripts/auto-scan.js index a6b4f1e..971658e 100644 --- a/skills/agentguard/scripts/auto-scan.js +++ b/skills/agentguard/scripts/auto-scan.js @@ -4,7 +4,7 @@ * GoPlus AgentGuard — SessionStart Auto-Scan Hook * * Runs on session startup to discover and scan newly installed skills. - * For each skill in ~/.claude/skills/: + * For each skill in supported agent skill directories: * 1. Calculate artifact hash * 2. Check trust registry — skip if already registered with same hash * 3. Run quickScan for new/updated skills @@ -59,6 +59,7 @@ try { const SKILLS_DIRS = [ join(homedir(), '.claude', 'skills'), + join(homedir(), '.hermes', 'skills'), join(homedir(), '.openclaw', 'skills'), ]; const AGENTGUARD_DIR = join(homedir(), '.agentguard'); @@ -84,7 +85,7 @@ function writeAuditLog(entry) { // --------------------------------------------------------------------------- /** - * Find all skill directories under ~/.claude/skills/ and ~/.openclaw/skills/ + * Find all skill directories under supported agent skill roots. * A skill directory is one that contains a SKILL.md file. */ function discoverSkills() { diff --git a/skills/agentguard/scripts/hermes-hook.js b/skills/agentguard/scripts/hermes-hook.js new file mode 100644 index 0000000..d442787 --- /dev/null +++ b/skills/agentguard/scripts/hermes-hook.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node + +/** + * GoPlus AgentGuard Hermes shell hook. + * + * Hermes shell hooks read JSON from stdin and use stdout JSON to influence + * behavior. For pre_tool_call, returning { action: "block", message: "..." } + * vetoes tool execution. There is no native "ask" decision in Hermes' + * pre_tool_call contract, so AgentGuard's ask decision is represented as a + * block with a confirmation-oriented message. + */ + +import { join } from 'node:path'; + +// --------------------------------------------------------------------------- +// Load AgentGuard engine + Hermes adapter +// --------------------------------------------------------------------------- + +const agentguardPath = join(import.meta.url.replace('file://', ''), '..', '..', '..', '..', 'dist', 'index.js'); + +let createAgentGuard, HermesAdapter, evaluateHook, loadConfig; +try { + const gs = await import(agentguardPath); + createAgentGuard = gs.createAgentGuard || gs.default; + HermesAdapter = gs.HermesAdapter; + evaluateHook = gs.evaluateHook; + loadConfig = gs.loadConfig; +} catch { + try { + const gs = await import('@goplus/agentguard'); + createAgentGuard = gs.createAgentGuard || gs.default; + HermesAdapter = gs.HermesAdapter; + evaluateHook = gs.evaluateHook; + loadConfig = gs.loadConfig; + } catch { + process.stderr.write('GoPlus AgentGuard: unable to load Hermes hook engine, allowing action\n'); + console.log('{}'); + process.exit(0); + } +} + +// --------------------------------------------------------------------------- +// Read stdin +// --------------------------------------------------------------------------- + +function readStdin() { + return new Promise((resolve) => { + let data = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => (data += chunk)); + process.stdin.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch { + resolve(null); + } + }); + setTimeout(() => resolve(null), 5000); + }); +} + +// --------------------------------------------------------------------------- +// Hermes output helpers +// --------------------------------------------------------------------------- + +function outputBlock(reason) { + console.log(JSON.stringify({ + action: 'block', + message: reason || 'GoPlus AgentGuard blocked this action', + })); + process.exit(0); +} + +function outputAllow() { + console.log('{}'); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const input = await readStdin(); + if (!input) { + outputAllow(); + } + + const adapter = new HermesAdapter(); + const config = loadConfig(); + const agentguard = createAgentGuard(); + + const result = await evaluateHook(adapter, input, { config, agentguard }); + + if (result.decision === 'deny') { + outputBlock(result.reason || 'GoPlus AgentGuard blocked this Hermes tool call'); + } else if (result.decision === 'ask') { + outputBlock(result.reason || 'GoPlus AgentGuard requires confirmation for this Hermes tool call'); + } else { + outputAllow(); + } +} + +main(); diff --git a/src/adapters/hermes.ts b/src/adapters/hermes.ts new file mode 100644 index 0000000..9cc60b8 --- /dev/null +++ b/src/adapters/hermes.ts @@ -0,0 +1,170 @@ +import type { ActionEnvelope } from '../types/action.js'; +import type { HookAdapter, HookInput } from './types.js'; + +/** + * Tool name -> action type mapping for Hermes Agent. + * + * Hermes shell hooks expose Python tool names such as "terminal", + * "write_file", and "web_extract" through the pre_tool_call/post_tool_call + * plugin-hook bridge. + */ +const TOOL_ACTION_MAP: Record = { + terminal: 'exec_command', + execute_code: 'exec_command', + write_file: 'write_file', + patch: 'write_file', + skill_manage: 'write_file', + read_file: 'read_file', + web_search: 'network_request', + web_extract: 'network_request', + browser_navigate: 'network_request', + browser_type: 'network_request', + browser_click: 'network_request', + browser_get_images: 'network_request', + browser_vision: 'network_request', +}; + +function firstString(...values: unknown[]): string { + for (const value of values) { + if (typeof value === 'string' && value.length > 0) return value; + } + return ''; +} + +function eventTypeFromName(name: string): 'pre' | 'post' { + return name.startsWith('post') ? 'post' : 'pre'; +} + +/** + * Hermes hook adapter. + * + * Bridges Hermes shell-hook JSON payloads to the common AgentGuard decision + * engine. Hermes passes hook input as: + * + * { + * "hook_event_name": "pre_tool_call", + * "tool_name": "terminal", + * "tool_input": {"command": "echo hello"}, + * "session_id": "sess_...", + * "cwd": "/workspace", + * "extra": {"task_id": "...", "tool_call_id": "..."} + * } + */ +export class HermesAdapter implements HookAdapter { + readonly name = 'hermes'; + + parseInput(raw: unknown): HookInput { + const data = raw as Record; + const hookEvent = (data.hook_event_name as string) || ''; + const toolInput = + (data.tool_input as Record) || + (data.args as Record) || + {}; + + return { + toolName: (data.tool_name as string) || '', + toolInput, + eventType: eventTypeFromName(hookEvent), + sessionId: data.session_id as string | undefined, + cwd: data.cwd as string | undefined, + raw: data, + }; + } + + mapToolToActionType(toolName: string): string | null { + if (TOOL_ACTION_MAP[toolName]) { + return TOOL_ACTION_MAP[toolName]; + } + + if (toolName.startsWith('browser_')) { + return 'network_request'; + } + + return null; + } + + buildEnvelope(input: HookInput, initiatingSkill?: string | null): ActionEnvelope | null { + const actionType = this.mapToolToActionType(input.toolName); + if (!actionType) return null; + + const actor = { + skill: { + id: initiatingSkill || 'hermes-session', + source: initiatingSkill || 'hermes', + version_ref: '0.0.0', + artifact_hash: '', + }, + }; + + const context = { + session_id: input.sessionId || `hermes-${Date.now()}`, + user_present: true, + env: 'prod' as const, + time: new Date().toISOString(), + initiating_skill: initiatingSkill || undefined, + }; + + let actionData: Record; + + switch (actionType) { + case 'exec_command': + actionData = { + command: firstString(input.toolInput.command, input.toolInput.code), + args: [], + cwd: firstString(input.toolInput.workdir, input.toolInput.cwd, input.cwd), + }; + break; + + case 'write_file': + actionData = { + path: firstString( + input.toolInput.path, + input.toolInput.file_path, + input.toolInput.target, + input.toolInput.skill_path + ), + }; + break; + + case 'read_file': + actionData = { + path: firstString(input.toolInput.path, input.toolInput.file_path), + }; + break; + + case 'network_request': + actionData = { + method: firstString(input.toolInput.method) || 'GET', + url: firstString( + input.toolInput.url, + input.toolInput.query, + input.toolInput.href, + input.toolInput.target + ), + body_preview: input.toolInput.body as string | undefined, + }; + break; + + default: + return null; + } + + return { + actor, + action: { type: actionType, data: actionData }, + context, + } as unknown as ActionEnvelope; + } + + async inferInitiatingSkill(input: HookInput): Promise { + const raw = input.raw as Record; + const extra = raw.extra as Record | undefined; + + return firstString( + raw.initiating_skill, + raw.skill, + extra?.initiating_skill, + extra?.skill + ) || null; + } +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 7380137..e0a3765 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -1,6 +1,7 @@ export type { HookAdapter, HookInput, HookOutput, EngineOptions, AgentGuardInstance } from './types.js'; export { ClaudeCodeAdapter } from './claude-code.js'; export { OpenClawAdapter } from './openclaw.js'; +export { HermesAdapter } from './hermes.js'; export { evaluateHook } from './engine.js'; export { registerOpenClawPlugin, diff --git a/src/index.ts b/src/index.ts index 5d5fdca..65d0e65 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,7 @@ export { export { ClaudeCodeAdapter, OpenClawAdapter, + HermesAdapter, evaluateHook, registerOpenClawPlugin, loadConfig, diff --git a/src/tests/adapter.test.ts b/src/tests/adapter.test.ts index f4d77cf..62e03f3 100644 --- a/src/tests/adapter.test.ts +++ b/src/tests/adapter.test.ts @@ -2,6 +2,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { ClaudeCodeAdapter } from '../adapters/claude-code.js'; import { OpenClawAdapter } from '../adapters/openclaw.js'; +import { HermesAdapter } from '../adapters/hermes.js'; import { isSensitivePath, shouldDenyAtLevel, @@ -317,6 +318,168 @@ describe('OpenClawAdapter', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// HermesAdapter +// ───────────────────────────────────────────────────────────────────────────── + +describe('HermesAdapter', () => { + const adapter = new HermesAdapter(); + + it('should have name "hermes"', () => { + assert.equal(adapter.name, 'hermes'); + }); + + describe('parseInput', () => { + it('should parse pre_tool_call payload', () => { + const raw = { + hook_event_name: 'pre_tool_call', + tool_name: 'terminal', + tool_input: { command: 'echo hello' }, + session_id: 'sess-1', + cwd: '/workspace', + }; + const input = adapter.parseInput(raw); + assert.equal(input.toolName, 'terminal'); + assert.equal(input.eventType, 'pre'); + assert.deepEqual(input.toolInput, { command: 'echo hello' }); + assert.equal(input.sessionId, 'sess-1'); + assert.equal(input.cwd, '/workspace'); + }); + + it('should parse post_tool_call payload', () => { + const input = adapter.parseInput({ + hook_event_name: 'post_tool_call', + tool_name: 'write_file', + tool_input: { path: '/tmp/test.txt' }, + }); + assert.equal(input.eventType, 'post'); + assert.equal(input.toolName, 'write_file'); + }); + + it('should fall back to args for direct plugin-style payloads', () => { + const input = adapter.parseInput({ + hook_event_name: 'pre_tool_call', + tool_name: 'terminal', + args: { command: 'pwd' }, + }); + assert.deepEqual(input.toolInput, { command: 'pwd' }); + }); + }); + + describe('mapToolToActionType', () => { + it('should map terminal to exec_command', () => { + assert.equal(adapter.mapToolToActionType('terminal'), 'exec_command'); + }); + + it('should map write tools to write_file', () => { + assert.equal(adapter.mapToolToActionType('write_file'), 'write_file'); + assert.equal(adapter.mapToolToActionType('patch'), 'write_file'); + assert.equal(adapter.mapToolToActionType('skill_manage'), 'write_file'); + }); + + it('should map read_file to read_file', () => { + assert.equal(adapter.mapToolToActionType('read_file'), 'read_file'); + }); + + it('should map web and browser tools to network_request', () => { + assert.equal(adapter.mapToolToActionType('web_search'), 'network_request'); + assert.equal(adapter.mapToolToActionType('web_extract'), 'network_request'); + assert.equal(adapter.mapToolToActionType('browser_navigate'), 'network_request'); + assert.equal(adapter.mapToolToActionType('browser_console'), 'network_request'); + }); + + it('should return null for unknown tools', () => { + assert.equal(adapter.mapToolToActionType('todo'), null); + assert.equal(adapter.mapToolToActionType('unknown'), null); + }); + }); + + describe('buildEnvelope', () => { + it('should build exec_command envelope', () => { + const input = adapter.parseInput({ + hook_event_name: 'pre_tool_call', + tool_name: 'terminal', + tool_input: { command: 'ls -la', workdir: '/repo' }, + session_id: 'sess-1', + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + assert.equal(envelope!.action.type, 'exec_command'); + assert.equal((envelope!.action.data as unknown as Record).command, 'ls -la'); + assert.equal((envelope!.action.data as unknown as Record).cwd, '/repo'); + assert.equal(envelope!.context.session_id, 'sess-1'); + }); + + it('should build write_file envelope from patch path', () => { + const input = adapter.parseInput({ + hook_event_name: 'pre_tool_call', + tool_name: 'patch', + tool_input: { path: '/project/.env' }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + assert.equal(envelope!.action.type, 'write_file'); + assert.equal((envelope!.action.data as unknown as Record).path, '/project/.env'); + }); + + it('should build read_file envelope', () => { + const input = adapter.parseInput({ + hook_event_name: 'pre_tool_call', + tool_name: 'read_file', + tool_input: { path: '/tmp/readme.md' }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + assert.equal(envelope!.action.type, 'read_file'); + assert.equal((envelope!.action.data as unknown as Record).path, '/tmp/readme.md'); + }); + + it('should build network_request envelope from web_extract URL', () => { + const input = adapter.parseInput({ + hook_event_name: 'pre_tool_call', + tool_name: 'web_extract', + tool_input: { url: 'https://example.com/page' }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + assert.equal(envelope!.action.type, 'network_request'); + assert.equal((envelope!.action.data as unknown as Record).url, 'https://example.com/page'); + }); + + it('should return null for unmapped tools', () => { + const input = adapter.parseInput({ + hook_event_name: 'pre_tool_call', + tool_name: 'todo', + tool_input: {}, + }); + assert.equal(adapter.buildEnvelope(input), null); + }); + }); + + describe('inferInitiatingSkill', () => { + it('should infer skill from extra metadata when present', async () => { + const input = adapter.parseInput({ + hook_event_name: 'pre_tool_call', + tool_name: 'terminal', + tool_input: { command: 'echo hi' }, + extra: { initiating_skill: 'my-hermes-skill' }, + }); + const skill = await adapter.inferInitiatingSkill(input); + assert.equal(skill, 'my-hermes-skill'); + }); + + it('should return null when Hermes provides no skill metadata', async () => { + const input = adapter.parseInput({ + hook_event_name: 'pre_tool_call', + tool_name: 'terminal', + tool_input: { command: 'echo hi' }, + }); + const skill = await adapter.inferInitiatingSkill(input); + assert.equal(skill, null); + }); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // Common utilities // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/tests/smoke.test.ts b/src/tests/smoke.test.ts index 618a362..cc4ecb5 100644 --- a/src/tests/smoke.test.ts +++ b/src/tests/smoke.test.ts @@ -13,16 +13,33 @@ import { SkillScanner } from '../scanner/index.js'; // __dirname points to dist/tests/ after compilation, project root is 2 levels up const projectRoot = resolve(__dirname, '..', '..'); const GUARD_HOOK_PATH = join(projectRoot, 'skills', 'agentguard', 'scripts', 'guard-hook.js'); +const HERMES_HOOK_PATH = join(projectRoot, 'skills', 'agentguard', 'scripts', 'hermes-hook.js'); function runGuardHook(input: Record): Promise<{ exitCode: number; stdout: string; stderr: string; +}> { + return runNodeHook(GUARD_HOOK_PATH, input); +} + +function runHermesHook(input: Record): Promise<{ + exitCode: number; + stdout: string; + stderr: string; +}> { + return runNodeHook(HERMES_HOOK_PATH, input); +} + +function runNodeHook(scriptPath: string, input: Record): Promise<{ + exitCode: number; + stdout: string; + stderr: string; }> { return new Promise((resolvePromise) => { // Isolate HOME to a temp dir so loadConfig/writeAuditLog don't touch real ~/.agentguard/ const tempHome = mkdtempSync(join(tmpdir(), 'agentguard-smoke-')); - const child = spawn('node', [GUARD_HOOK_PATH], { + const child = spawn('node', [scriptPath], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, HOME: tempHome }, }); @@ -87,7 +104,56 @@ describe('Smoke: guard-hook.js E2E', () => { }); // ───────────────────────────────────────────────────────────────────────────── -// E: Scanner integration +// E: hermes-hook.js subprocess E2E +// ───────────────────────────────────────────────────────────────────────────── + +describe('Smoke: hermes-hook.js E2E', () => { + it('should allow echo hello with empty JSON output', async () => { + const { exitCode, stdout } = await runHermesHook({ + hook_event_name: 'pre_tool_call', + tool_name: 'terminal', + tool_input: { command: 'echo hello' }, + }); + assert.equal(exitCode, 0); + assert.deepEqual(JSON.parse(stdout), {}); + }); + + it('should block rm -rf / using Hermes stdout protocol', async () => { + const { exitCode, stdout } = await runHermesHook({ + hook_event_name: 'pre_tool_call', + tool_name: 'terminal', + tool_input: { command: 'rm -rf /' }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { action?: string; message?: string }; + assert.equal(payload.action, 'block'); + assert.ok(payload.message?.includes('AgentGuard'), 'message should mention AgentGuard'); + }); + + it('should block write to .env using Hermes stdout protocol', async () => { + const { exitCode, stdout } = await runHermesHook({ + hook_event_name: 'pre_tool_call', + tool_name: 'write_file', + tool_input: { path: '/project/.env' }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { action?: string }; + assert.equal(payload.action, 'block'); + }); + + it('should allow post_tool_call event for audit-only handling', async () => { + const { exitCode, stdout } = await runHermesHook({ + hook_event_name: 'post_tool_call', + tool_name: 'terminal', + tool_input: { command: 'rm -rf /' }, + }); + assert.equal(exitCode, 0); + assert.deepEqual(JSON.parse(stdout), {}); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// F: Scanner integration // ───────────────────────────────────────────────────────────────────────────── describe('Smoke: SkillScanner on vulnerable-skill', () => { From 8c38023fae377c59a7dc62ef2763592c73881676 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Sat, 16 May 2026 00:31:28 +0800 Subject: [PATCH 2/2] Harden Hermes hook handling --- docs/hermes.md | 8 +- skills/agentguard/SKILL.md | 2 +- skills/agentguard/hermes-hooks.yaml | 6 +- skills/agentguard/scripts/hermes-hook.js | 133 ++++++++++++++++++++--- src/adapters/hermes.ts | 9 -- src/tests/adapter.test.ts | 5 +- src/tests/smoke.test.ts | 130 +++++++++++++++++++++- 7 files changed, 250 insertions(+), 43 deletions(-) diff --git a/docs/hermes.md b/docs/hermes.md index 6980d89..5f65f5d 100644 --- a/docs/hermes.md +++ b/docs/hermes.md @@ -19,7 +19,7 @@ path to the installed AgentGuard skill directory. ```yaml hooks: on_session_start: - - command: "AGENTGUARD_AUTO_SCAN=1 node \"/path/to/agentguard/skills/agentguard/scripts/auto-scan.js\"" + - command: "env AGENTGUARD_AUTO_SCAN=1 node \"/path/to/agentguard/skills/agentguard/scripts/auto-scan.js\"" timeout: 30 pre_tool_call: @@ -29,12 +29,12 @@ hooks: - matcher: "write_file|patch|skill_manage" command: "node \"/path/to/agentguard/skills/agentguard/scripts/hermes-hook.js\"" timeout: 10 - - matcher: "web_search|web_extract|browser_.*" + - matcher: "web_search|web_extract|browser_navigate" command: "node \"/path/to/agentguard/skills/agentguard/scripts/hermes-hook.js\"" timeout: 10 post_tool_call: - - matcher: "terminal|execute_code|write_file|patch|skill_manage|read_file|web_search|web_extract|browser_.*" + - matcher: "terminal|execute_code|write_file|patch|skill_manage|read_file|web_search|web_extract|browser_navigate" command: "node \"/path/to/agentguard/skills/agentguard/scripts/hermes-hook.js\"" timeout: 5 ``` @@ -55,7 +55,7 @@ or set `hooks_auto_accept: true` in `~/.hermes/config.yaml`. | `terminal`, `execute_code` | `exec_command` | | `write_file`, `patch`, `skill_manage` | `write_file` | | `read_file` | `read_file` | -| `web_search`, `web_extract`, `browser_*` | `network_request` | +| `web_search`, `web_extract`, `browser_navigate` | `network_request` | ## Decisions diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index d50073a..c706d58 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -82,7 +82,7 @@ must be present in `~/.hermes/config.yaml`. This skill ships the hook runner at | `pre_tool_call` | `terminal`, `execute_code` | `exec_command` | | `pre_tool_call` | `write_file`, `patch`, `skill_manage` | `write_file` | | `pre_tool_call` | `read_file` | `read_file` | -| `pre_tool_call` | `web_search`, `web_extract`, `browser_*` | `network_request` | +| `pre_tool_call` | `web_search`, `web_extract`, `browser_navigate` | `network_request` | | `post_tool_call` | Same tools | Audit-only | Hermes `pre_tool_call` supports allow/block only. If AgentGuard returns `ask`, diff --git a/skills/agentguard/hermes-hooks.yaml b/skills/agentguard/hermes-hooks.yaml index 261f1f8..8e1619b 100644 --- a/skills/agentguard/hermes-hooks.yaml +++ b/skills/agentguard/hermes-hooks.yaml @@ -6,7 +6,7 @@ hooks: on_session_start: - - command: "AGENTGUARD_AUTO_SCAN=1 node \"AGENTGUARD_SKILL_DIR/scripts/auto-scan.js\"" + - command: "env AGENTGUARD_AUTO_SCAN=1 node \"AGENTGUARD_SKILL_DIR/scripts/auto-scan.js\"" timeout: 30 pre_tool_call: @@ -19,12 +19,12 @@ hooks: - matcher: "read_file" command: "node \"AGENTGUARD_SKILL_DIR/scripts/hermes-hook.js\"" timeout: 10 - - matcher: "web_search|web_extract|browser_.*" + - matcher: "web_search|web_extract|browser_navigate" command: "node \"AGENTGUARD_SKILL_DIR/scripts/hermes-hook.js\"" timeout: 10 post_tool_call: - - matcher: "terminal|execute_code|write_file|patch|skill_manage|read_file|web_search|web_extract|browser_.*" + - matcher: "terminal|execute_code|write_file|patch|skill_manage|read_file|web_search|web_extract|browser_navigate" command: "node \"AGENTGUARD_SKILL_DIR/scripts/hermes-hook.js\"" timeout: 5 diff --git a/skills/agentguard/scripts/hermes-hook.js b/skills/agentguard/scripts/hermes-hook.js index d442787..e9361cd 100644 --- a/skills/agentguard/scripts/hermes-hook.js +++ b/skills/agentguard/scripts/hermes-hook.js @@ -12,6 +12,70 @@ import { join } from 'node:path'; +function isPostHook(input) { + const event = typeof input?.hook_event_name === 'string' ? input.hook_event_name : ''; + return event.startsWith('post'); +} + +function isPreHook(input) { + return !isPostHook(input); +} + +function toolNameFrom(input) { + return typeof input?.tool_name === 'string' ? input.tool_name : ''; +} + +function toolInputFrom(input) { + const toolInput = input?.tool_input ?? input?.args; + return toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput) + ? toolInput + : {}; +} + +function firstString(...values) { + for (const value of values) { + if (typeof value === 'string' && value.length > 0) return value; + } + return ''; +} + +function validatePreToolPayload(input) { + const toolName = toolNameFrom(input); + const toolInput = toolInputFrom(input); + + switch (toolName) { + case 'terminal': + if (!firstString(toolInput.command)) return 'Hermes terminal hook payload is missing command'; + return null; + case 'execute_code': + if (!firstString(toolInput.code, toolInput.command)) return 'Hermes execute_code hook payload is missing code'; + return null; + case 'write_file': + case 'patch': + case 'read_file': + if (!firstString(toolInput.path, toolInput.file_path)) return `Hermes ${toolName} hook payload is missing path`; + return null; + case 'skill_manage': + if (!firstString(toolInput.path, toolInput.file_path, toolInput.target, toolInput.skill_path)) { + return 'Hermes skill_manage hook payload is missing target path'; + } + return null; + case 'web_extract': + case 'browser_navigate': + if (!firstString(toolInput.url, toolInput.href, toolInput.target)) return `Hermes ${toolName} hook payload is missing URL`; + return null; + case 'web_search': + if (!firstString(toolInput.query, toolInput.url)) return 'Hermes web_search hook payload is missing query'; + return null; + default: + return `Hermes tool "${toolName || '(missing)'}" is not recognized by AgentGuard`; + } +} + +function shouldFailClosed(input) { + return !input || isPreHook(input); +} + // --------------------------------------------------------------------------- // Load AgentGuard engine + Hermes adapter // --------------------------------------------------------------------------- @@ -19,23 +83,32 @@ import { join } from 'node:path'; const agentguardPath = join(import.meta.url.replace('file://', ''), '..', '..', '..', '..', 'dist', 'index.js'); let createAgentGuard, HermesAdapter, evaluateHook, loadConfig; -try { - const gs = await import(agentguardPath); - createAgentGuard = gs.createAgentGuard || gs.default; - HermesAdapter = gs.HermesAdapter; - evaluateHook = gs.evaluateHook; - loadConfig = gs.loadConfig; -} catch { + +async function loadEngine() { + if (process.env.AGENTGUARD_TEST_FORCE_ENGINE_LOAD_FAILURE === '1') { + return null; + } + try { - const gs = await import('@goplus/agentguard'); - createAgentGuard = gs.createAgentGuard || gs.default; - HermesAdapter = gs.HermesAdapter; - evaluateHook = gs.evaluateHook; - loadConfig = gs.loadConfig; + const gs = await import(agentguardPath); + return { + createAgentGuard: gs.createAgentGuard || gs.default, + HermesAdapter: gs.HermesAdapter, + evaluateHook: gs.evaluateHook, + loadConfig: gs.loadConfig, + }; } catch { - process.stderr.write('GoPlus AgentGuard: unable to load Hermes hook engine, allowing action\n'); - console.log('{}'); - process.exit(0); + try { + const gs = await import('@goplus/agentguard'); + return { + createAgentGuard: gs.createAgentGuard || gs.default, + HermesAdapter: gs.HermesAdapter, + evaluateHook: gs.evaluateHook, + loadConfig: gs.loadConfig, + }; + } catch { + return null; + } } } @@ -46,16 +119,25 @@ try { function readStdin() { return new Promise((resolve) => { let data = ''; + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const timer = setTimeout(() => finish(null), 5000); + process.stdin.setEncoding('utf-8'); process.stdin.on('data', (chunk) => (data += chunk)); process.stdin.on('end', () => { try { - resolve(JSON.parse(data)); + finish(JSON.parse(data)); } catch { - resolve(null); + finish(null); } }); - setTimeout(() => resolve(null), 5000); + process.stdin.on('error', () => finish(null)); }); } @@ -83,9 +165,24 @@ function outputAllow() { async function main() { const input = await readStdin(); if (!input) { + outputBlock('GoPlus AgentGuard: invalid or missing Hermes hook payload'); + } + + const validationError = isPreHook(input) ? validatePreToolPayload(input) : null; + if (validationError) { + outputBlock(`GoPlus AgentGuard: ${validationError}`); + } + + const engine = await loadEngine(); + if (!engine) { + if (shouldFailClosed(input)) { + outputBlock('GoPlus AgentGuard: unable to load Hermes hook engine; blocking fail-closed'); + } outputAllow(); } + ({ createAgentGuard, HermesAdapter, evaluateHook, loadConfig } = engine); + const adapter = new HermesAdapter(); const config = loadConfig(); const agentguard = createAgentGuard(); diff --git a/src/adapters/hermes.ts b/src/adapters/hermes.ts index 9cc60b8..511cf64 100644 --- a/src/adapters/hermes.ts +++ b/src/adapters/hermes.ts @@ -18,10 +18,6 @@ const TOOL_ACTION_MAP: Record = { web_search: 'network_request', web_extract: 'network_request', browser_navigate: 'network_request', - browser_type: 'network_request', - browser_click: 'network_request', - browser_get_images: 'network_request', - browser_vision: 'network_request', }; function firstString(...values: unknown[]): string { @@ -75,11 +71,6 @@ export class HermesAdapter implements HookAdapter { if (TOOL_ACTION_MAP[toolName]) { return TOOL_ACTION_MAP[toolName]; } - - if (toolName.startsWith('browser_')) { - return 'network_request'; - } - return null; } diff --git a/src/tests/adapter.test.ts b/src/tests/adapter.test.ts index 62e03f3..72bbe95 100644 --- a/src/tests/adapter.test.ts +++ b/src/tests/adapter.test.ts @@ -381,14 +381,15 @@ describe('HermesAdapter', () => { assert.equal(adapter.mapToolToActionType('read_file'), 'read_file'); }); - it('should map web and browser tools to network_request', () => { + it('should map URL-bearing web and browser tools to network_request', () => { assert.equal(adapter.mapToolToActionType('web_search'), 'network_request'); assert.equal(adapter.mapToolToActionType('web_extract'), 'network_request'); assert.equal(adapter.mapToolToActionType('browser_navigate'), 'network_request'); - assert.equal(adapter.mapToolToActionType('browser_console'), 'network_request'); }); it('should return null for unknown tools', () => { + assert.equal(adapter.mapToolToActionType('browser_click'), null); + assert.equal(adapter.mapToolToActionType('browser_console'), null); assert.equal(adapter.mapToolToActionType('todo'), null); assert.equal(adapter.mapToolToActionType('unknown'), null); }); diff --git a/src/tests/smoke.test.ts b/src/tests/smoke.test.ts index cc4ecb5..e61deb7 100644 --- a/src/tests/smoke.test.ts +++ b/src/tests/smoke.test.ts @@ -4,6 +4,7 @@ import { spawn } from 'node:child_process'; import { mkdtempSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; +import { performance } from 'node:perf_hooks'; import { SkillScanner } from '../scanner/index.js'; // ───────────────────────────────────────────────────────────────────────────── @@ -31,7 +32,42 @@ function runHermesHook(input: Record): Promise<{ return runNodeHook(HERMES_HOOK_PATH, input); } -function runNodeHook(scriptPath: string, input: Record): Promise<{ +function runHermesHookWithEnv( + input: Record, + env: Record +): Promise<{ + exitCode: number; + stdout: string; + stderr: string; +}> { + return runNodeHook(HERMES_HOOK_PATH, input, env); +} + +function runHermesHookRaw(input: string): Promise<{ + exitCode: number; + stdout: string; + stderr: string; +}> { + return runNodeHookRaw(HERMES_HOOK_PATH, input); +} + +function runNodeHook( + scriptPath: string, + input: Record, + env: Record = {} +): Promise<{ + exitCode: number; + stdout: string; + stderr: string; +}> { + return runNodeHookRaw(scriptPath, JSON.stringify(input), env); +} + +function runNodeHookRaw( + scriptPath: string, + input: string, + env: Record = {} +): Promise<{ exitCode: number; stdout: string; stderr: string; @@ -41,25 +77,33 @@ function runNodeHook(scriptPath: string, input: Record): Promis const tempHome = mkdtempSync(join(tmpdir(), 'agentguard-smoke-')); const child = spawn('node', [scriptPath], { stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, HOME: tempHome }, + env: { ...process.env, HOME: tempHome, ...env }, }); let stdout = ''; let stderr = ''; + let settled = false; + const finish = (result: { exitCode: number; stdout: string; stderr: string }) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolvePromise(result); + }; + child.stdout.on('data', (d: Buffer) => (stdout += d.toString())); child.stderr.on('data', (d: Buffer) => (stderr += d.toString())); - child.stdin.write(JSON.stringify(input)); + child.stdin.write(input); child.stdin.end(); child.on('close', (code) => { - resolvePromise({ exitCode: code ?? 1, stdout, stderr }); + finish({ exitCode: code ?? 1, stdout, stderr }); }); // Timeout safety - setTimeout(() => { + const timeout = setTimeout(() => { child.kill(); - resolvePromise({ exitCode: -1, stdout, stderr: 'TIMEOUT' }); + finish({ exitCode: -1, stdout, stderr: 'TIMEOUT' }); }, 8000); }); } @@ -150,6 +194,80 @@ describe('Smoke: hermes-hook.js E2E', () => { assert.equal(exitCode, 0); assert.deepEqual(JSON.parse(stdout), {}); }); + + it('should fail closed when the Hermes engine cannot load for pre_tool_call', async () => { + const { exitCode, stdout } = await runHermesHookWithEnv( + { + hook_event_name: 'pre_tool_call', + tool_name: 'terminal', + tool_input: { command: 'echo hello' }, + }, + { AGENTGUARD_TEST_FORCE_ENGINE_LOAD_FAILURE: '1' } + ); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { action?: string; message?: string }; + assert.equal(payload.action, 'block'); + assert.ok(payload.message?.includes('unable to load Hermes hook engine')); + }); + + it('should block unknown pre_tool_call tools', async () => { + const { exitCode, stdout } = await runHermesHook({ + hook_event_name: 'pre_tool_call', + tool_name: 'browser_click', + tool_input: { selector: '#danger' }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { action?: string; message?: string }; + assert.equal(payload.action, 'block'); + assert.ok(payload.message?.includes('not recognized by AgentGuard')); + }); + + it('should block terminal payloads without a command', async () => { + const { exitCode, stdout } = await runHermesHook({ + hook_event_name: 'pre_tool_call', + tool_name: 'terminal', + tool_input: {}, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { action?: string; message?: string }; + assert.equal(payload.action, 'block'); + assert.ok(payload.message?.includes('missing command')); + }); + + it('should block file payloads without a path', async () => { + const { exitCode, stdout } = await runHermesHook({ + hook_event_name: 'pre_tool_call', + tool_name: 'write_file', + tool_input: { content: 'secret' }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { action?: string; message?: string }; + assert.equal(payload.action, 'block'); + assert.ok(payload.message?.includes('missing path')); + }); + + it('should block URL payloads without a URL', async () => { + const { exitCode, stdout } = await runHermesHook({ + hook_event_name: 'pre_tool_call', + tool_name: 'browser_navigate', + tool_input: { selector: '#login' }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { action?: string; message?: string }; + assert.equal(payload.action, 'block'); + assert.ok(payload.message?.includes('missing URL')); + }); + + it('should block invalid stdin without waiting for the stdin timeout', async () => { + const start = performance.now(); + const { exitCode, stdout } = await runHermesHookRaw('{not-json'); + const elapsedMs = performance.now() - start; + assert.equal(exitCode, 0); + assert.ok(elapsedMs < 2000, `hook should exit promptly, took ${elapsedMs}ms`); + const payload = JSON.parse(stdout) as { action?: string; message?: string }; + assert.equal(payload.action, 'block'); + assert.ok(payload.message?.includes('invalid or missing Hermes hook payload')); + }); }); // ─────────────────────────────────────────────────────────────────────────────