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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions skills/agentguard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions skills/agentguard/hermes-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
119 changes: 103 additions & 16 deletions skills/agentguard/scripts/hermes-hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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':
Expand All @@ -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') {
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -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();
81 changes: 76 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand Down Expand Up @@ -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:`);
Expand Down Expand Up @@ -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}`);
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading