diff --git a/CHANGELOG.md b/CHANGELOG.md index d43d6c5..cb157f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +### Added +- Added `agentguard init --agent auto` to detect installed agent directories and initialize each supported agent in order while continuing after per-agent failures. + +### Changed +- `agentguard init` now stores all initialized agent hosts in config while keeping the first detected host as the default for `--cron-target auto`. + +### Fixed +- Hermes hook runtime decisions now use the shared AgentGuard Cloud sync path and emit a more broadly compatible block response for `pre_tool_call`. +- `agentguard subscribe --cron` Gateway fallback/QClaw installation now uses OpenClaw-compatible WebSocket Gateway RPC instead of HTTP `POST /`, and sends `cron.add` the object payload expected by the Gateway schema. + ## [1.1.10] - 2026-05-21 ### Added diff --git a/README.md b/README.md index 6901ea4..7961c9a 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ agentguard checkup --against-advisory AGS-2026-0042 # Optional: write host-specific hook templates. # OpenClaw also installs and enables the AgentGuard plugin. +agentguard init --agent auto agentguard init --agent claude-code agentguard init --agent codex agentguard init --agent openclaw diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index 849af06..19a6e59 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -130,11 +130,13 @@ 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_navigate` | `network_request` | +| `pre_tool_call` | `web_search`, `web_extract`, `browser_navigate`, `browser_open`, `web_open`, `open_url`, `visit_url`, `open` | `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. +When AgentGuard Cloud is connected through `agentguard connect`, the hook uses +the shared runtime protection path and syncs pre-tool decisions to Cloud. ### Procedure @@ -162,6 +164,11 @@ the Hermes hook reports it as a block with a confirmation-oriented message. HERMES_ACCEPT_HOOKS=1 hermes chat ``` They may also set `hooks_auto_accept: true` in `~/.hermes/config.yaml`. +7. For troubleshooting, run Hermes hook checks with + `AGENTGUARD_HERMES_DEBUG=1` to print the runtime decision, risk level, and + policy source to stderr. Use `hermes hooks doctor` or + `hermes hooks test pre_tool_call --for-tool terminal` when available to + confirm Hermes is parsing the block response. ### Verification @@ -188,7 +195,7 @@ printf '{"hook_event_name":"pre_tool_call","tool_name":"terminal","tool_input":{ Expected output contains: ```json -{"action":"block"} +{"action":"block","decision":"block","block":true} ``` ## Subcommand: subscribe diff --git a/skills/agentguard/hermes-hooks.yaml b/skills/agentguard/hermes-hooks.yaml index 8e1619b..a97a418 100644 --- a/skills/agentguard/hermes-hooks.yaml +++ b/skills/agentguard/hermes-hooks.yaml @@ -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_navigate" + - matcher: "web_search|web_extract|browser_navigate|browser_open|web_open|open_url|visit_url|open" 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_navigate" + - matcher: "terminal|execute_code|write_file|patch|skill_manage|read_file|web_search|web_extract|browser_navigate|browser_open|web_open|open_url|visit_url|open" 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 e9361cd..99c17eb 100644 --- a/skills/agentguard/scripts/hermes-hook.js +++ b/skills/agentguard/scripts/hermes-hook.js @@ -4,10 +4,10 @@ * 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. + * behavior. For pre_tool_call, returning a block decision vetoes tool + * execution. There is no native "ask" decision in Hermes' pre_tool_call + * contract, so AgentGuard's confirmation decision is represented as a block + * with a confirmation-oriented message. */ import { join } from 'node:path'; @@ -62,6 +62,11 @@ function validatePreToolPayload(input) { return null; case 'web_extract': case 'browser_navigate': + case 'browser_open': + case 'web_open': + case 'open_url': + case 'visit_url': + case 'open': if (!firstString(toolInput.url, toolInput.href, toolInput.target)) return `Hermes ${toolName} hook payload is missing URL`; return null; case 'web_search': @@ -82,7 +87,7 @@ function shouldFailClosed(input) { const agentguardPath = join(import.meta.url.replace('file://', ''), '..', '..', '..', '..', 'dist', 'index.js'); -let createAgentGuard, HermesAdapter, evaluateHook, loadConfig; +let loadRuntimeConfig, loadHookConfig, protectAction, createAgentGuard, HermesAdapter, evaluateHook; async function loadEngine() { if (process.env.AGENTGUARD_TEST_FORCE_ENGINE_LOAD_FAILURE === '1') { @@ -92,19 +97,23 @@ async function loadEngine() { try { const gs = await import(agentguardPath); return { + loadRuntimeConfig: gs.loadAgentGuardConfig || gs.ensureConfig, + loadHookConfig: gs.loadConfig, + protectAction: gs.protectAction, createAgentGuard: gs.createAgentGuard || gs.default, HermesAdapter: gs.HermesAdapter, evaluateHook: gs.evaluateHook, - loadConfig: gs.loadConfig, }; } catch { try { const gs = await import('@goplus/agentguard'); return { + loadRuntimeConfig: gs.loadAgentGuardConfig || gs.ensureConfig, + loadHookConfig: gs.loadConfig, + protectAction: gs.protectAction, createAgentGuard: gs.createAgentGuard || gs.default, HermesAdapter: gs.HermesAdapter, evaluateHook: gs.evaluateHook, - loadConfig: gs.loadConfig, }; } catch { return null; @@ -148,7 +157,10 @@ function readStdin() { function outputBlock(reason) { console.log(JSON.stringify({ action: 'block', + decision: 'block', + block: true, message: reason || 'GoPlus AgentGuard blocked this action', + reason: reason || 'GoPlus AgentGuard blocked this action', })); process.exit(0); } @@ -158,6 +170,41 @@ function outputAllow() { process.exit(0); } +function runtimeActionTypeFrom(toolName) { + switch (toolName) { + case 'terminal': + case 'execute_code': + return 'shell'; + case 'write_file': + case 'patch': + case 'skill_manage': + return 'file_write'; + case 'read_file': + return 'file_read'; + case 'web_search': + case 'web_extract': + case 'browser_navigate': + case 'browser_open': + case 'web_open': + case 'open_url': + case 'visit_url': + case 'open': + return 'network'; + default: + return 'other'; + } +} + +function runtimeToolNameFrom(toolName) { + return toolName || 'HermesTool'; +} + +function debugLog(message, details) { + if (process.env.AGENTGUARD_HERMES_DEBUG !== '1') return; + const suffix = details === undefined ? '' : ` ${JSON.stringify(details)}`; + console.error(`[AgentGuard Hermes] ${message}${suffix}`); +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -181,21 +228,61 @@ async function main() { outputAllow(); } - ({ createAgentGuard, HermesAdapter, evaluateHook, loadConfig } = engine); + ({ loadRuntimeConfig, loadHookConfig, protectAction, createAgentGuard, HermesAdapter, evaluateHook } = engine); + + if (isPostHook(input)) { + try { + if (createAgentGuard && HermesAdapter && evaluateHook) { + const adapter = new HermesAdapter(); + const config = loadHookConfig ? loadHookConfig() : { level: loadRuntimeConfig().level }; + const agentguard = createAgentGuard(); + await evaluateHook(adapter, input, { config, agentguard }); + } + } catch { + // Post hooks are audit-only; never affect Hermes execution. + } + outputAllow(); + } + + const config = loadRuntimeConfig(); + const result = await protectAction({ + config, + rawInput: input, + agentHost: 'hermes', + actionType: runtimeActionTypeFrom(toolNameFrom(input)), + toolName: runtimeToolNameFrom(toolNameFrom(input)), + sessionId: typeof input.session_id === 'string' ? input.session_id : undefined, + }); - const adapter = new HermesAdapter(); - const config = loadConfig(); - const agentguard = createAgentGuard(); + if (!result) { + debugLog('allow: no runtime action was built'); + outputAllow(); + } - const result = await evaluateHook(adapter, input, { config, agentguard }); + debugLog('decision', { + decision: result.decision.decision, + riskLevel: result.decision.riskLevel, + riskScore: result.decision.riskScore, + policySource: result.policySource, + }); - 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'); + if (result.decision.decision === 'block') { + outputBlock(formatDecisionReason(result, 'blocked this Hermes tool call')); + } else if (result.decision.decision === 'require_approval') { + outputBlock(formatDecisionReason(result, 'requires confirmation for this Hermes tool call')); } else { outputAllow(); } } +function formatDecisionReason(result, fallback) { + const titles = result.decision.reasons + .map((item) => item.title) + .filter(Boolean) + .slice(0, 3) + .join(', '); + const suffix = titles ? ` Reasons: ${titles}.` : ''; + return `GoPlus AgentGuard ${fallback} (action: ${result.decision.actionId}, risk: ${result.decision.riskScore}/100, level: ${result.decision.riskLevel}).${suffix}`; +} + main(); diff --git a/src/cli.ts b/src/cli.ts index 1c0c6b8..88026a9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,12 +16,12 @@ import { normalizeCloudUrl, saveConfig, } from './config.js'; -import type { AgentGuardConfig } from './config.js'; +import type { AgentGuardAgentHost, AgentGuardConfig } from './config.js'; import { SkillScanner } from './scanner/index.js'; import { formatProtectResult, protectAction, exitCodeForDecision } from './runtime/protect.js'; import { getDefaultEffectiveRuntimePolicy, loadCachedPolicy, saveCachedPolicy } from './runtime/policy.js'; import type { RuntimeActionType, RuntimeAgentHost } from './runtime/types.js'; -import { installAgentTemplates, type AgentInstaller } from './installers.js'; +import { installAgentTemplates, type AgentInstaller, type InstallResult } from './installers.js'; import { packageVersion } from './version.js'; import { runSelfCheckForAdvisory } from './feed/selfcheck.js'; import { loadFeedState, markAdvisorySeen, saveFeedState } from './feed/state.js'; @@ -33,6 +33,15 @@ import { type CronBackend, } from './feed/cron.js'; +const SUPPORTED_AGENT_INSTALLERS: AgentInstaller[] = ['claude-code', 'codex', 'openclaw', 'hermes', 'qclaw']; +const AUTO_AGENT_DETECTION: Array<{ agent: AgentInstaller; dir: string }> = [ + { agent: 'claude-code', dir: '.claude' }, + { agent: 'openclaw', dir: '.openclaw' }, + { agent: 'hermes', dir: '.hermes' }, + { agent: 'qclaw', dir: '.qclaw' }, + { agent: 'codex', dir: '.codex' }, +]; + async function main() { const program = new Command(); @@ -66,11 +75,28 @@ async function main() { console.log(`Config: ${paths.configPath}`); if (options.agent) { const normalizedAgent = String(options.agent).trim().toLowerCase(); - if (!['claude-code', 'codex', 'openclaw', 'hermes', 'qclaw'].includes(normalizedAgent)) { - throw new Error('Invalid agent. Use claude-code, codex, openclaw, hermes, or qclaw.'); + if (normalizedAgent === 'auto') { + const results = initAutoAgents(config, Boolean(options.force)); + if (results.detected.length === 0) { + console.log('No supported agent directories found. Looked for .claude, .openclaw, .hermes, .qclaw, and .codex.'); + } else if (results.installed.length === 0) { + console.log('No agent templates were installed; all detected agent initializers failed.'); + } + for (const result of results.installed) { + console.log(`Installed ${result.agent} template:`); + for (const file of result.files) console.log(`- ${file}`); + } + for (const failure of results.failed) { + console.error(`! Failed to initialize ${failure.agent}: ${failure.error}`); + } + return; + } + if (!SUPPORTED_AGENT_INSTALLERS.includes(normalizedAgent as AgentInstaller)) { + throw new Error('Invalid agent. Use auto, claude-code, codex, openclaw, hermes, or qclaw.'); } const agent = normalizedAgent as AgentInstaller; config.agentHost = agent; + config.agentHosts = appendAgentHost(config.agentHosts, agent); saveConfig(config); const result = installAgentTemplates(agent, { force: options.force }); console.log(`Installed ${result.agent} template:`); @@ -124,6 +150,7 @@ async function main() { console.log(`Cloud URL: ${config.cloudUrl || 'not configured'}`); console.log(`API key: ${maskApiKey(config.apiKey)}`); console.log(`Agent host: ${config.agentHost || 'not configured'}`); + console.log(`Agent hosts: ${config.agentHosts?.join(', ') || 'not configured'}`); console.log(`Policy cache: ${config.policyCachePath}`); console.log(`Audit log: ${config.auditPath}`); }); @@ -405,7 +432,7 @@ async function main() { quiet, force: Boolean(options.force), backend: cronTarget, - agentHost: config.agentHost, + agentHost: resolveCronAgentHost(config), agentGuardHome: getAgentGuardPaths().home, }); summary.cron.installed = true; @@ -539,6 +566,50 @@ function validateCronTarget(value: unknown): CronBackend { throw new Error('Invalid cron target. Use auto, openclaw, qclaw, hermes, or system.'); } +function initAutoAgents(config: AgentGuardConfig, force: boolean): { + installed: InstallResult[]; + failed: Array<{ agent: AgentInstaller; error: string }>; + detected: AgentInstaller[]; +} { + const installed: InstallResult[] = []; + const failed: Array<{ agent: AgentInstaller; error: string }> = []; + const detectedAgents = AUTO_AGENT_DETECTION + .filter(({ dir }) => existsSync(join(process.cwd(), dir))) + .map(({ agent }) => agent); + + for (const agent of detectedAgents) { + try { + installed.push(installAgentTemplates(agent, { cwd: process.cwd(), force })); + } catch (err) { + failed.push({ + agent, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + if (installed.length > 0) { + config.agentHosts = installed.map((result) => result.agent); + config.agentHost = installed[0].agent; + saveConfig(config); + } + + return { installed, failed, detected: detectedAgents }; +} + +function appendAgentHost( + agentHosts: AgentGuardConfig['agentHosts'] | undefined, + agent: AgentGuardAgentHost +): AgentGuardAgentHost[] { + const next = agentHosts ? [...agentHosts] : []; + if (!next.includes(agent)) next.push(agent); + return next; +} + +function resolveCronAgentHost(config: AgentGuardConfig): AgentGuardAgentHost | undefined { + return config.agentHost ?? config.agentHosts?.[0]; +} + function readStdinIfAvailable(): string { if (process.stdin.isTTY) return ''; try { diff --git a/src/config.ts b/src/config.ts index 0186ebb..f9138f3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,10 +2,13 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; +export type AgentGuardAgentHost = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw'; + export interface AgentGuardConfig { version: 1; level: 'strict' | 'balanced' | 'permissive'; - agentHost?: 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw'; + agentHost?: AgentGuardAgentHost; + agentHosts?: AgentGuardAgentHost[]; cloudUrl?: string; apiKey?: string; connectedAt?: string; @@ -76,6 +79,7 @@ export function loadConfig(): AgentGuardConfig { version: 1, level: normalizeLevel(parsed.level) ?? fallback.level, agentHost: normalizeAgentHost(parsed.agentHost), + agentHosts: normalizeAgentHosts(parsed.agentHosts), cloudUrl: parsed.cloudUrl || fallback.cloudUrl, policyCachePath: parsed.policyCachePath || fallback.policyCachePath, auditPath: parsed.auditPath || fallback.auditPath, @@ -154,12 +158,22 @@ function normalizeLevel(value: unknown): AgentGuardConfig['level'] | null { : null; } -function normalizeAgentHost(value: unknown): AgentGuardConfig['agentHost'] | undefined { +function normalizeAgentHost(value: unknown): AgentGuardAgentHost | undefined { return value === 'claude-code' || value === 'codex' || value === 'openclaw' || value === 'hermes' || value === 'qclaw' ? value : undefined; } +function normalizeAgentHosts(value: unknown): AgentGuardAgentHost[] | undefined { + if (!Array.isArray(value)) return undefined; + const seen = new Set(); + for (const item of value) { + const host = normalizeAgentHost(item); + if (host) seen.add(host); + } + return seen.size > 0 ? [...seen] : undefined; +} + function chmodBestEffort(path: string, mode: number): void { try { chmodSync(path, mode); diff --git a/src/feed/cron.ts b/src/feed/cron.ts index 277a2ea..966732d 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -1,6 +1,8 @@ -import http from 'node:http'; import { spawn } from 'node:child_process'; +import { randomBytes, randomUUID, createHash } from 'node:crypto'; import { chmod, mkdir, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import net from 'node:net'; import { homedir } from 'node:os'; import { isAbsolute, join } from 'node:path'; @@ -21,6 +23,7 @@ export interface OpenClawCronInstallResult { export interface OpenClawGatewayOptions { host?: string; port?: number; + url?: string; label?: string; timeoutMs?: number; request?: (method: string, params: unknown) => Promise; @@ -38,6 +41,8 @@ interface OpenClawCronJob { name?: string; } +class GatewayHttpFallbackError extends Error {} + export function validateCronExpression(value: string): string { const expr = value.trim(); const fields = expr.split(/\s+/); @@ -156,31 +161,29 @@ export async function installOpenClawThreatFeedCron( } await openClawGatewayRequest( 'cron.add', - [ - { - name: options.name, - description, - enabled: true, - schedule: { - kind: 'cron', - expr: schedule, - tz: timezone, - }, - sessionTarget: 'isolated', - payload: { - kind: 'agentTurn', - message, - timeoutSeconds: 300, - agentguard: { - mode, - command, - }, - }, - delivery: { - mode: 'none', + { + name: options.name, + description, + enabled: true, + schedule: { + kind: 'cron', + expr: schedule, + tz: timezone, + }, + sessionTarget: 'isolated', + payload: { + kind: 'agentTurn', + message, + timeoutSeconds: 300, + agentguard: { + mode, + command, }, }, - ], + delivery: { + mode: 'none', + }, + }, gateway ); @@ -604,16 +607,36 @@ export function openClawGatewayRequest( return options.request(method, params); } - const payload = JSON.stringify({ - jsonrpc: '2.0', - method, - params, - id: 1, - }); const host = options.host ?? '127.0.0.1'; const port = options.port ?? 18789; const label = options.label ?? 'OpenClaw Gateway'; const timeoutMs = options.timeoutMs ?? 5000; + if (options.url) { + return openClawGatewayWebSocketRequest({ url: options.url, method, params, label, timeoutMs }); + } + + return openClawGatewayHttpRequest({ host, port, method, params, label, timeoutMs }).catch((err) => { + if (err instanceof GatewayHttpFallbackError) { + return openClawGatewayWebSocketRequest({ url: `ws://${host}:${port}`, method, params, label, timeoutMs }); + } + throw err; + }); +} + +function openClawGatewayHttpRequest(options: { + host: string; + port: number; + method: string; + params: unknown; + label: string; + timeoutMs: number; +}): Promise { + const payload = JSON.stringify({ + jsonrpc: '2.0', + method: options.method, + params: legacyGatewayParams(options.method, options.params), + id: 1, + }); return new Promise((resolve, reject) => { let settled = false; @@ -629,8 +652,8 @@ export function openClawGatewayRequest( }; const req = http.request( { - hostname: host, - port, + hostname: options.host, + port: options.port, path: '/', method: 'POST', headers: { @@ -642,7 +665,7 @@ export function openClawGatewayRequest( let data = ''; res.setEncoding('utf8'); res.on('error', (err) => { - fail(new Error(`${label} ${method} response failed: ${err.message}`)); + fail(new Error(`${options.label} ${options.method} response failed: ${err.message}`)); }); res.on('data', (chunk) => { data += chunk; @@ -652,15 +675,15 @@ export function openClawGatewayRequest( try { parsed = data ? JSON.parse(data) : null; } catch { - fail(new Error(`${label} returned non-JSON response: ${data}`)); + fail(new GatewayHttpFallbackError(`${options.label} returned non-JSON response: ${data}`)); return; } - if (parsed?.error) { - fail(new Error(`${label} ${method} failed: ${parsed.error.message ?? JSON.stringify(parsed.error)}`)); + if (res.statusCode && res.statusCode >= 400) { + fail(new GatewayHttpFallbackError(`${options.label} ${options.method} failed with HTTP ${res.statusCode}`)); return; } - if (res.statusCode && res.statusCode >= 400) { - fail(new Error(`${label} ${method} failed with HTTP ${res.statusCode}`)); + if (parsed?.error) { + fail(new Error(`${options.label} ${options.method} failed: ${parsed.error.message ?? JSON.stringify(parsed.error)}`)); return; } succeed(parsed?.result ?? parsed); @@ -668,10 +691,10 @@ export function openClawGatewayRequest( } ); req.on('error', (err) => { - fail(new Error(`Could not reach ${label} at ${host}:${port}: ${err.message}`)); + fail(new GatewayHttpFallbackError(`Could not reach ${options.label} at ${options.host}:${options.port}: ${err.message}`)); }); - req.setTimeout(timeoutMs, () => { - const err = new Error(`${label} ${method} request timed out after ${timeoutMs}ms`); + req.setTimeout(options.timeoutMs, () => { + const err = new GatewayHttpFallbackError(`${options.label} ${options.method} request timed out after ${options.timeoutMs}ms`); fail(err); req.destroy(err); }); @@ -679,3 +702,302 @@ export function openClawGatewayRequest( req.end(); }); } + +function legacyGatewayParams(method: string, params: unknown): unknown { + if (method === 'cron.add' && !Array.isArray(params)) return [params]; + return params; +} + +function openClawGatewayWebSocketRequest(options: { + url: string; + method: string; + params: unknown; + label: string; + timeoutMs: number; +}): Promise { + const endpoint = parseGatewayWebSocketUrl(options.url, options.label); + + return new Promise((resolve, reject) => { + const connectRequestId = randomUUID(); + const methodRequestId = randomUUID(); + const websocketKey = randomBytes(16).toString('base64'); + let handshakeComplete = false; + let connected = false; + let settled = false; + let buffer = Buffer.alloc(0); + let fragmentedText: Buffer[] | null = null; + + const fail = (err: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + socket.destroy(); + reject(err); + }; + const succeed = (value: unknown) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + socket.end(); + resolve(value); + }; + + const socket = net.createConnection({ host: endpoint.hostname, port: endpoint.port }, () => { + socket.write(buildWebSocketHandshake(endpoint, websocketKey)); + }); + + const timeout = setTimeout(() => { + fail(new Error(`${options.label} ${options.method} request timed out after ${options.timeoutMs}ms`)); + }, options.timeoutMs); + + socket.on('error', (err) => { + fail(new Error(`Could not reach ${options.label} at ${endpoint.hostname}:${endpoint.port}: ${err.message}`)); + }); + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + if (!handshakeComplete) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) return; + const header = buffer.subarray(0, headerEnd + 4).toString('utf8'); + buffer = buffer.subarray(headerEnd + 4); + try { + validateWebSocketHandshake(header, websocketKey, options.label); + } catch (err) { + fail(err as Error); + return; + } + handshakeComplete = true; + } + + while (true) { + let parsed: ReturnType; + try { + parsed = readWebSocketFrame(buffer); + } catch (err) { + fail(err as Error); + return; + } + if (!parsed) break; + buffer = parsed.rest; + if (parsed.opcode === 0x8) { + fail(new Error(`${options.label} closed the WebSocket before ${options.method} completed`)); + return; + } + if (parsed.opcode === 0x9) { + socket.write(encodeWebSocketFrame(parsed.payload.toString('utf8'), 0xA)); + continue; + } + if (parsed.opcode === 0xA) continue; + if (parsed.opcode === 0x1) { + if (fragmentedText) { + fail(new Error(`${options.label} started a new WebSocket text message before completing the previous one`)); + return; + } + if (parsed.fin) { + handleGatewayFrame(parsed.payload.toString('utf8')); + } else { + fragmentedText = [parsed.payload]; + } + continue; + } + if (parsed.opcode === 0x0) { + if (!fragmentedText) { + fail(new Error(`${options.label} returned an unexpected WebSocket continuation frame`)); + return; + } + fragmentedText.push(parsed.payload); + if (parsed.fin) { + const complete = Buffer.concat(fragmentedText); + fragmentedText = null; + handleGatewayFrame(complete.toString('utf8')); + } + } + } + }); + + function handleGatewayFrame(raw: string): void { + let frame: any; + try { + frame = JSON.parse(raw); + } catch { + fail(new Error(`${options.label} returned non-JSON WebSocket frame: ${raw}`)); + return; + } + if (frame?.type === 'event' && frame.event === 'connect.challenge') { + socket.write(encodeWebSocketFrame(JSON.stringify({ + type: 'req', + id: connectRequestId, + method: 'connect', + params: openClawConnectParams(), + }))); + return; + } + if (frame?.type !== 'res') return; + if (frame.id === connectRequestId) { + if (!frame.ok) { + fail(new Error(`${options.label} connect failed: ${gatewayFrameErrorMessage(frame)}`)); + return; + } + connected = true; + socket.write(encodeWebSocketFrame(JSON.stringify({ + type: 'req', + id: methodRequestId, + method: options.method, + params: options.params, + }))); + return; + } + if (connected && frame.id === methodRequestId) { + if (!frame.ok) { + fail(new Error(`${options.label} ${options.method} failed: ${gatewayFrameErrorMessage(frame)}`)); + return; + } + succeed(frame.payload); + } + } + }); +} + +function parseGatewayWebSocketUrl(raw: string, label: string): { hostname: string; port: number; path: string; hostHeader: string } { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error(`${label} URL is invalid: ${raw}`); + } + if (parsed.protocol !== 'ws:') { + throw new Error(`${label} URL must use ws:// for Gateway WebSocket RPC.`); + } + const port = parsed.port ? Number(parsed.port) : 80; + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`${label} URL has an invalid port: ${raw}`); + } + const path = `${parsed.pathname || '/'}${parsed.search}`; + const hostname = parsed.hostname; + const hostHeader = parsed.port ? parsed.host : `${parsed.hostname}:${port}`; + return { hostname, port, path, hostHeader }; +} + +function buildWebSocketHandshake(endpoint: { path: string; hostHeader: string }, key: string): string { + return [ + `GET ${endpoint.path || '/'} HTTP/1.1`, + `Host: ${endpoint.hostHeader}`, + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Key: ${key}`, + 'Sec-WebSocket-Version: 13', + '', + '', + ].join('\r\n'); +} + +function validateWebSocketHandshake(header: string, key: string, label: string): void { + const [statusLine, ...lines] = header.split(/\r\n/); + if (!/^HTTP\/1\.[01] 101\b/.test(statusLine ?? '')) { + throw new Error(`${label} WebSocket upgrade failed: ${statusLine || 'empty response'}`); + } + const headers = new Map(); + for (const line of lines) { + const index = line.indexOf(':'); + if (index === -1) continue; + headers.set(line.slice(0, index).trim().toLowerCase(), line.slice(index + 1).trim()); + } + const expected = createHash('sha1') + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest('base64'); + if (headers.get('sec-websocket-accept') !== expected) { + throw new Error(`${label} WebSocket upgrade returned an invalid accept key.`); + } +} + +function readWebSocketFrame(buffer: Buffer): { fin: boolean; opcode: number; payload: Buffer; rest: Buffer } | null { + if (buffer.length < 2) return null; + const first = buffer[0]!; + const second = buffer[1]!; + const fin = (first & 0x80) !== 0; + const opcode = first & 0x0f; + const masked = (second & 0x80) !== 0; + if (masked) { + throw new Error('WebSocket server frames must not be masked.'); + } + if (opcode >= 0x8 && !fin) { + throw new Error('WebSocket control frames must not be fragmented.'); + } + let length = second & 0x7f; + let offset = 2; + if (length === 126) { + if (buffer.length < offset + 2) return null; + length = buffer.readUInt16BE(offset); + offset += 2; + } else if (length === 127) { + if (buffer.length < offset + 8) return null; + const longLength = buffer.readBigUInt64BE(offset); + if (longLength > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error('WebSocket frame is too large.'); + } + length = Number(longLength); + offset += 8; + } + if (opcode >= 0x8 && length > 125) { + throw new Error('WebSocket control frames must not exceed 125 bytes.'); + } + const mask = masked ? buffer.subarray(offset, offset + 4) : null; + if (masked) offset += 4; + if (buffer.length < offset + length) return null; + const payload = Buffer.from(buffer.subarray(offset, offset + length)); + if (mask) { + for (let i = 0; i < payload.length; i += 1) { + payload[i] = payload[i]! ^ mask[i % 4]!; + } + } + return { fin, opcode, payload, rest: buffer.subarray(offset + length) }; +} + +function encodeWebSocketFrame(text: string, opcode = 0x1): Buffer { + const payload = Buffer.from(text, 'utf8'); + const mask = randomBytes(4); + const headerLength = payload.length < 126 ? 2 : payload.length <= 0xffff ? 4 : 10; + const header = Buffer.alloc(headerLength); + header[0] = 0x80 | opcode; + if (payload.length < 126) { + header[1] = 0x80 | payload.length; + } else if (payload.length <= 0xffff) { + header[1] = 0x80 | 126; + header.writeUInt16BE(payload.length, 2); + } else { + header[1] = 0x80 | 127; + header.writeBigUInt64BE(BigInt(payload.length), 2); + } + const masked = Buffer.from(payload); + for (let i = 0; i < masked.length; i += 1) { + masked[i] = masked[i]! ^ mask[i % 4]!; + } + return Buffer.concat([header, mask, masked]); +} + +function openClawConnectParams(): unknown { + return { + minProtocol: 3, + maxProtocol: 3, + client: { + id: 'cli', + version: 'agentguard', + platform: process.platform, + mode: 'cli', + }, + caps: [], + role: 'operator', + scopes: [ + 'operator.admin', + 'operator.read', + 'operator.write', + 'operator.approvals', + 'operator.pairing', + 'operator.talk.secrets', + ], + }; +} + +function gatewayFrameErrorMessage(frame: any): string { + return frame?.error?.message ?? JSON.stringify(frame?.error ?? frame); +} diff --git a/src/tests/cli-init.test.ts b/src/tests/cli-init.test.ts index c172817..458e112 100644 --- a/src/tests/cli-init.test.ts +++ b/src/tests/cli-init.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; -import { mkdtempSync, readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { promisify } from 'node:util'; @@ -53,4 +53,72 @@ describe('init CLI', () => { assert.equal(config.agentHost, 'hermes'); assert.match(stdout, /Installed hermes template:/); }); + + it('auto-initializes detected agents in detection order', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-init-auto-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-auto-cwd-')); + const cliPath = resolve('dist', 'cli.js'); + mkdirSync(join(cwd, '.codex'), { recursive: true }); + mkdirSync(join(cwd, '.openclaw'), { recursive: true }); + mkdirSync(join(cwd, '.hermes'), { recursive: true }); + + const { stdout } = await execFileAsync(process.execPath, [cliPath, 'init', '--agent', 'auto', '--force'], { + cwd, + env: { ...process.env, AGENTGUARD_HOME: home }, + }); + + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentHost?: string; + agentHosts?: string[]; + }; + assert.equal(config.agentHost, 'openclaw'); + assert.deepEqual(config.agentHosts, ['openclaw', 'hermes', 'codex']); + assert.ok(existsSync(join(cwd, '.openclaw', 'plugins', 'agentguard', 'openclaw.plugin.json'))); + assert.ok(existsSync(join(cwd, '.hermes', 'skills', 'agentguard'))); + assert.ok(existsSync(join(cwd, '.codex', 'skills', 'agentguard', 'SKILL.md'))); + assert.match(stdout, /Installed openclaw template:/); + assert.match(stdout, /Installed hermes template:/); + assert.match(stdout, /Installed codex template:/); + }); + + it('does not fail auto init when no supported agent directory exists', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-init-auto-empty-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-auto-empty-cwd-')); + const cliPath = resolve('dist', 'cli.js'); + + const { stdout } = await execFileAsync(process.execPath, [cliPath, 'init', '--agent', 'auto'], { + cwd, + env: { ...process.env, AGENTGUARD_HOME: home }, + }); + + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentHost?: string; + agentHosts?: string[]; + }; + assert.equal(config.agentHost, undefined); + assert.equal(config.agentHosts, undefined); + assert.match(stdout, /No supported agent directories found/); + }); + + it('continues auto init after one detected agent fails', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-init-auto-failure-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-auto-failure-cwd-')); + const cliPath = resolve('dist', 'cli.js'); + writeFileSync(join(cwd, '.openclaw'), 'not a directory'); + mkdirSync(join(cwd, '.hermes'), { recursive: true }); + + const { stdout, stderr } = await execFileAsync(process.execPath, [cliPath, 'init', '--agent', 'auto', '--force'], { + cwd, + env: { ...process.env, AGENTGUARD_HOME: home }, + }); + + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentHost?: string; + agentHosts?: string[]; + }; + assert.equal(config.agentHost, 'hermes'); + assert.deepEqual(config.agentHosts, ['hermes']); + assert.match(stdout, /Installed hermes template:/); + assert.match(stderr, /Failed to initialize openclaw/); + }); }); diff --git a/src/tests/feed-cron.test.ts b/src/tests/feed-cron.test.ts index e731a71..a62ce63 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -1,6 +1,9 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { mkdtempSync, readFileSync } from 'node:fs'; +import http from 'node:http'; +import net from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { @@ -13,6 +16,61 @@ import { type RpcCall = { method: string; params: any }; +async function closeServer(server: http.Server | net.Server): Promise { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +function serverPort(server: http.Server | net.Server): number { + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return address.port; +} + +function encodeServerWebSocketFrame(text: string, opcode = 0x1, fin = true): Buffer { + const payload = Buffer.from(text, 'utf8'); + const headerLength = payload.length < 126 ? 2 : payload.length <= 0xffff ? 4 : 10; + const header = Buffer.alloc(headerLength); + header[0] = (fin ? 0x80 : 0) | opcode; + if (payload.length < 126) { + header[1] = payload.length; + } else if (payload.length <= 0xffff) { + header[1] = 126; + header.writeUInt16BE(payload.length, 2); + } else { + header[1] = 127; + header.writeBigUInt64BE(BigInt(payload.length), 2); + } + return Buffer.concat([header, payload]); +} + +function readClientWebSocketFrame(buffer: Buffer): { payload: string; rest: Buffer } | null { + if (buffer.length < 2) return null; + let length = buffer[1]! & 0x7f; + let offset = 2; + if (length === 126) { + if (buffer.length < offset + 2) return null; + length = buffer.readUInt16BE(offset); + offset += 2; + } else if (length === 127) { + if (buffer.length < offset + 8) return null; + length = Number(buffer.readBigUInt64BE(offset)); + offset += 8; + } + if (buffer.length < offset + 4 + length) return null; + const mask = buffer.subarray(offset, offset + 4); + offset += 4; + const payload = Buffer.from(buffer.subarray(offset, offset + length)); + for (let i = 0; i < payload.length; i += 1) { + payload[i] = payload[i]! ^ mask[i % 4]!; + } + return { payload: payload.toString('utf8'), rest: buffer.subarray(offset + length) }; +} + function fakeGateway(jobs: Array<{ id: string; name: string }> = []): { calls: RpcCall[]; request: (method: string, params: unknown) => Promise; @@ -48,7 +106,7 @@ describe('feed/cron', () => { assert.equal(result.schedule, '0 * * * *'); assert.equal(result.timezone, 'Asia/Shanghai'); assert.deepEqual(gateway.calls.map((call) => call.method), ['cron.list', 'cron.add']); - const job = gateway.calls[1].params[0]; + const job = gateway.calls[1].params; assert.equal(job.name, 'agentguard-threat-feed'); assert.deepEqual(job.schedule, { kind: 'cron', expr: '0 * * * *', tz: 'Asia/Shanghai' }); assert.deepEqual(job.delivery, { mode: 'none' }); @@ -279,7 +337,7 @@ describe('feed/cron', () => { assert.equal(result.backend, 'qclaw-gateway'); assert.deepEqual(gateway.calls.map((call) => call.method), ['cron.list', 'cron.add']); - const job = gateway.calls[1].params[0]; + const job = gateway.calls[1].params; assert.equal(job.name, 'agentguard-threat-feed'); assert.deepEqual(job.schedule, { kind: 'cron', expr: '0 * * * *', tz: 'UTC' }); assert.equal(job.payload.agentguard.command, 'agentguard subscribe --json --cron-run'); @@ -428,14 +486,14 @@ describe('feed/cron', () => { assert.equal(result.created, true); assert.deepEqual(gateway.calls.map((call) => call.method), ['cron.list', 'cron.remove', 'cron.add']); assert.deepEqual(gateway.calls[1].params, { jobId: 'job-1' }); - assert.deepEqual(gateway.calls[2].params[0].schedule, { kind: 'cron', expr: '*/5 * * * *', tz: 'UTC' }); - assert.deepEqual(gateway.calls[2].params[0].payload.agentguard, { + assert.deepEqual(gateway.calls[2].params.schedule, { kind: 'cron', expr: '*/5 * * * *', tz: 'UTC' }); + assert.deepEqual(gateway.calls[2].params.payload.agentguard, { mode: 'quiet', command: 'agentguard subscribe --quiet --json --cron-run', }); - assert.match(gateway.calls[2].params[0].payload.message, /Mode: quiet/); - assert.match(gateway.calls[2].params[0].payload.message, /Command: `agentguard subscribe --quiet --json --cron-run`/); - assert.match(gateway.calls[2].params[0].payload.message, /agentguard subscribe --quiet --json --cron-run/); + assert.match(gateway.calls[2].params.payload.message, /Mode: quiet/); + assert.match(gateway.calls[2].params.payload.message, /Command: `agentguard subscribe --quiet --json --cron-run`/); + assert.match(gateway.calls[2].params.payload.message, /agentguard subscribe --quiet --json --cron-run/); }); it('does not add a replacement if force removal fails', async () => { @@ -469,4 +527,101 @@ describe('feed/cron', () => { /timed out/ ); }); + + it('keeps the default HTTP JSON-RPC Gateway path and legacy cron.add params', async () => { + let requestBody: any; + const server = http.createServer((req, res) => { + assert.equal(req.method, 'POST'); + assert.equal(req.url, '/'); + let raw = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + raw += chunk; + }); + req.on('end', () => { + requestBody = JSON.parse(raw); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ jsonrpc: '2.0', id: requestBody.id, result: { ok: true } })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const result = await openClawGatewayRequest('cron.add', { name: 'agentguard-threat-feed' }, { + host: '127.0.0.1', + port: serverPort(server), + timeoutMs: 100, + }); + + assert.deepEqual(result, { ok: true }); + assert.equal(requestBody.method, 'cron.add'); + assert.deepEqual(requestBody.params, [{ name: 'agentguard-threat-feed' }]); + } finally { + await closeServer(server); + } + }); + + it('handles fragmented WebSocket Gateway text responses', async () => { + const server = net.createServer((socket) => { + let handshakeComplete = false; + let buffer = Buffer.alloc(0); + let clientRequests = 0; + + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + if (!handshakeComplete) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) return; + const header = buffer.subarray(0, headerEnd + 4).toString('utf8'); + const key = /^Sec-WebSocket-Key:\s*(.+)$/im.exec(header)?.[1]?.trim(); + assert.ok(key); + const accept = createHash('sha1') + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest('base64'); + socket.write([ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${accept}`, + '', + '', + ].join('\r\n')); + handshakeComplete = true; + buffer = buffer.subarray(headerEnd + 4); + socket.write(encodeServerWebSocketFrame(JSON.stringify({ type: 'event', event: 'connect.challenge' }))); + } + + while (true) { + const parsed = readClientWebSocketFrame(buffer); + if (!parsed) break; + buffer = parsed.rest; + clientRequests += 1; + const frame = JSON.parse(parsed.payload); + if (clientRequests === 1) { + socket.write(encodeServerWebSocketFrame(JSON.stringify({ type: 'res', id: frame.id, ok: true, payload: {} }))); + } else { + const response = JSON.stringify({ + type: 'res', + id: frame.id, + ok: true, + payload: { jobs: [{ id: 'job-1', name: 'agentguard-threat-feed' }] }, + }); + const splitAt = Math.floor(response.length / 2); + socket.write(encodeServerWebSocketFrame(response.slice(0, splitAt), 0x1, false)); + socket.write(encodeServerWebSocketFrame(response.slice(splitAt), 0x0, true)); + } + } + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const result = await openClawGatewayRequest('cron.list', {}, { + url: `ws://127.0.0.1:${serverPort(server)}`, + timeoutMs: 500, + }); + + assert.deepEqual(result, { jobs: [{ id: 'job-1', name: 'agentguard-threat-feed' }] }); + } finally { + await closeServer(server); + } + }); }); diff --git a/src/tests/smoke.test.ts b/src/tests/smoke.test.ts index 347faf7..e3d28c1 100644 --- a/src/tests/smoke.test.ts +++ b/src/tests/smoke.test.ts @@ -237,6 +237,20 @@ describe('Smoke: hermes-hook.js E2E', () => { assert.equal(payload.action, 'block'); }); + it('should block confirm-only secret reads because Hermes has no ask protocol', async () => { + const { exitCode, stdout } = await runHermesHook({ + hook_event_name: 'pre_tool_call', + tool_name: 'terminal', + tool_input: { command: 'cat /home/hermes/.hermes/.env' }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { action?: string; decision?: string; block?: boolean; message?: string }; + assert.equal(payload.action, 'block'); + assert.equal(payload.decision, 'block'); + assert.equal(payload.block, true); + assert.ok(payload.message?.includes('requires confirmation')); + }); + it('should allow post_tool_call event for audit-only handling', async () => { const { exitCode, stdout } = await runHermesHook({ hook_event_name: 'post_tool_call', @@ -310,6 +324,18 @@ describe('Smoke: hermes-hook.js E2E', () => { assert.ok(payload.message?.includes('missing URL')); }); + it('should evaluate Hermes open-style URL tools', async () => { + const { exitCode, stdout } = await runHermesHook({ + hook_event_name: 'pre_tool_call', + tool_name: 'open', + tool_input: { url: 'https://www.tiktok.com' }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { action?: string; message?: string }; + assert.equal(payload.action, 'block'); + assert.ok(payload.message?.includes('requires confirmation')); + }); + it('should block invalid stdin without waiting for the stdin timeout', async () => { const start = performance.now(); const { exitCode, stdout } = await runHermesHookRaw('{not-json');