diff --git a/CHANGELOG.md b/CHANGELOG.md index d2677cd..88769a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,12 @@ ### Added - Added `agentguard init --agent auto` to detect installed agent directories and initialize each supported agent in order while continuing after per-agent failures. +- Added automatic Hermes hook configuration and QClaw plugin enablement during `agentguard init --agent` and `setup.sh` installs. ### Changed - `agentguard init` now stores all initialized agent hosts in config while keeping the first detected host as the default for `--cron-target auto`. +- `agentguard init --agent codex` now writes `.codex/agentguard-hook.json` as the concrete AgentGuard hook configuration file instead of an example filename. +- Install guidance now treats `agentguard init --agent auto` as the only required next step; Cloud connect and checkup remain optional commands. - Postinstall now writes persistent next-step guidance to `~/.agentguard/next-steps.txt` and the package directory so agent installers can discover it even when npm hides lifecycle output. ### Fixed diff --git a/README.md b/README.md index 7961c9a..7deeb92 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,11 @@ AI coding agents can execute any command, read any file, and install any skill ```bash npm install -g @goplus/agentguard -agentguard init +agentguard init --agent auto agentguard status ``` -The npm install runs a best-effort local bootstrap; `agentguard init` ensures `~/.agentguard/config.json` exists and protects locally by default. +The npm install runs a best-effort local bootstrap; `agentguard init --agent auto` is the required next step that detects installed agent directories and configures supported hooks/plugins. No Cloud account or network connection is required for the local runtime guard. ## 3 minutes: protect your agent @@ -107,8 +107,7 @@ agentguard subscribe --json # Or run a one-off self-check against a single advisory id agentguard checkup --against-advisory AGS-2026-0042 -# Optional: write host-specific hook templates. -# OpenClaw also installs and enables the AgentGuard plugin. +# Re-run host setup manually when needed. `auto` detects installed agents. agentguard init --agent auto agentguard init --agent claude-code agentguard init --agent codex diff --git a/docs/codex.md b/docs/codex.md index fd679a5..81f6469 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -18,7 +18,7 @@ To write Codex templates in the current project: agentguard init --agent codex ``` -This creates `.codex/skills/agentguard/SKILL.md` and `.codex/agentguard-hook.example.json`. +This creates `.codex/skills/agentguard/SKILL.md` and `.codex/agentguard-hook.json`. Pipe a tool event to `agentguard protect`: diff --git a/docs/hermes.md b/docs/hermes.md index 5f65f5d..5ca786d 100644 --- a/docs/hermes.md +++ b/docs/hermes.md @@ -12,9 +12,9 @@ Build AgentGuard first so the hook script can import `dist/index.js`: 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. +Run `agentguard init --agent hermes` to install the skill and merge the +AgentGuard hook entries into `~/.hermes/config.yaml`. The bundled template at +`skills/agentguard/hermes-hooks.yaml` is still available for manual setups. ```yaml hooks: diff --git a/setup.sh b/setup.sh index 38b6028..16d0a71 100755 --- a/setup.sh +++ b/setup.sh @@ -20,6 +20,9 @@ MIN_NODE_VERSION=18 OPENCLAW_ROOT="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}" OPENCLAW_PLUGIN_DIR="$OPENCLAW_ROOT/plugins/agentguard" OPENCLAW_CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-$OPENCLAW_ROOT/openclaw.json}" +QCLAW_ROOT="${QCLAW_STATE_DIR:-$HOME/.qclaw}" +QCLAW_PLUGIN_DIR="$QCLAW_ROOT/plugins/agentguard" +QCLAW_CONFIG_PATH="${QCLAW_CONFIG_PATH:-$QCLAW_ROOT/qclaw.json}" # ---- Parse arguments ---- TARGET_DIR="" @@ -172,6 +175,24 @@ detect_platform() { return fi + if [ -d "$HOME/.hermes" ]; then + SKILLS_DIR="$HOME/.hermes/skills/agentguard" + PLATFORM="hermes" + return + fi + + if [ -d "$HOME/.qclaw" ]; then + SKILLS_DIR="$HOME/.qclaw/skills/agentguard" + PLATFORM="qclaw" + return + fi + + if [ -d "$HOME/.codex" ]; then + SKILLS_DIR="$HOME/.codex/skills/agentguard" + PLATFORM="codex" + return + fi + # Fallback: create Claude Code dir (most common legacy install path). # Use --target for custom layouts when this default is not desired. SKILLS_DIR="$HOME/.claude/skills/agentguard" @@ -224,7 +245,7 @@ echo "[3/5] Installing scripts..." mkdir -p "$SKILLS_DIR/scripts" # Copy script files -for f in checkup-report.js checkup-score.js scan-to-sarif.js guard-hook.js auto-scan.js trust-cli.js action-cli.js; do +for f in checkup-report.js checkup-score.js scan-to-sarif.js guard-hook.js hermes-hook.js auto-scan.js trust-cli.js action-cli.js; do [ -f "$SKILL_SRC/scripts/$f" ] && cp "$SKILL_SRC/scripts/$f" "$SKILLS_DIR/scripts/" 2>/dev/null || true done @@ -324,6 +345,166 @@ NODE echo " OK: OpenClaw plugin enabled in $OPENCLAW_CONFIG_PATH" fi +if [ "$PLATFORM" = "qclaw" ]; then + echo " Enabling QClaw plugin..." + mkdir -p "$QCLAW_PLUGIN_DIR" + AGENTGUARD_DIST_INDEX="$SCRIPT_DIR/dist/index.js" node - "$QCLAW_PLUGIN_DIR/index.js" <<'NODE' +const { writeFileSync } = require('node:fs'); +const pluginPath = process.argv[2]; +const distIndex = process.env.AGENTGUARD_DIST_INDEX; +writeFileSync(pluginPath, `const { registerOpenClawPlugin } = require(${JSON.stringify(distIndex)}); + +module.exports = function setup(api) { + registerOpenClawPlugin(api, { + skipAutoScan: false, + }); +}; +module.exports.default = module.exports; +`); +NODE + cat > "$QCLAW_PLUGIN_DIR/package.json" <<'JSON' +{ + "name": "agentguard-qclaw-local", + "private": true, + "type": "commonjs", + "openclaw": { + "extensions": ["./index.js"], + "runtimeExtensions": ["./index.js"] + }, + "qclaw": { + "extensions": ["./index.js"], + "runtimeExtensions": ["./index.js"] + } +} +JSON + cat > "$QCLAW_PLUGIN_DIR/openclaw.plugin.json" <<'JSON' +{ + "id": "agentguard", + "name": "GoPlus AgentGuard", + "description": "AI agent security framework — blocks dangerous commands, prevents data leaks, and protects secrets" +} +JSON + node - "$QCLAW_CONFIG_PATH" "$QCLAW_PLUGIN_DIR" <<'NODE' +const { existsSync, mkdirSync, readFileSync, writeFileSync } = require('node:fs'); +const { dirname } = require('node:path'); +const [configPath, pluginDir] = process.argv.slice(2); +const ensureRecord = (parent, key) => { + const existing = parent[key]; + if (existing && typeof existing === 'object' && !Array.isArray(existing)) return existing; + const next = {}; + parent[key] = next; + return next; +}; +let config = {}; +if (existsSync(configPath)) { + const raw = readFileSync(configPath, 'utf8').trim(); + config = raw ? JSON.parse(raw) : {}; +} +const plugins = ensureRecord(config, 'plugins'); +const load = ensureRecord(plugins, 'load'); +const entries = ensureRecord(plugins, 'entries'); +const agentguard = ensureRecord(entries, 'agentguard'); +agentguard.enabled = true; +const paths = Array.isArray(load.paths) ? load.paths.filter((p) => typeof p === 'string') : []; +if (!paths.includes(pluginDir)) paths.push(pluginDir); +load.paths = paths; +if (Array.isArray(plugins.allow)) { + const allow = plugins.allow.filter((id) => typeof id === 'string'); + if (!allow.includes('agentguard')) allow.push('agentguard'); + plugins.allow = allow; +} +mkdirSync(dirname(configPath), { recursive: true }); +writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); +NODE + echo " OK: QClaw plugin enabled in $QCLAW_CONFIG_PATH" +fi + +if [ "$PLATFORM" = "hermes" ]; then + echo " Configuring Hermes hooks..." + HERMES_CONFIG_PATH="$HOME/.hermes/config.yaml" AGENTGUARD_SKILL_DIR="$SKILLS_DIR" node <<'NODE' +const { existsSync, mkdirSync, readFileSync, writeFileSync } = require('node:fs'); +const { dirname } = require('node:path'); +const configPath = process.env.HERMES_CONFIG_PATH; +const skillDir = process.env.AGENTGUARD_SKILL_DIR; +const block = ` on_session_start: + - command: "env AGENTGUARD_AUTO_SCAN=1 node \\"${skillDir}/scripts/auto-scan.js\\"" + timeout: 30 + + pre_tool_call: + - matcher: "terminal|execute_code" + command: "node \\"${skillDir}/scripts/hermes-hook.js\\"" + timeout: 10 + - matcher: "write_file|patch|skill_manage" + command: "node \\"${skillDir}/scripts/hermes-hook.js\\"" + timeout: 10 + - matcher: "read_file" + command: "node \\"${skillDir}/scripts/hermes-hook.js\\"" + timeout: 10 + - matcher: "web_search|web_extract|browser_navigate" + command: "node \\"${skillDir}/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" + command: "node \\"${skillDir}/scripts/hermes-hook.js\\"" + timeout: 5`; +const findNextTopLevel = (lines, start) => { + for (let i = start; i < lines.length; i += 1) { + if (/^\S/.test(lines[i]) && !/^#/.test(lines[i])) return i; + } + return lines.length; +}; +const removeManaged = (lines) => { + const events = new Set(['on_session_start', 'pre_tool_call', 'post_tool_call']); + const kept = []; + for (let i = 0; i < lines.length;) { + const match = /^ ([A-Za-z0-9_-]+):\s*(?:#.*)?$/.exec(lines[i]); + if (match && events.has(match[1])) { + i += 1; + while (i < lines.length && !/^ [A-Za-z0-9_-]+:\s*(?:#.*)?$/.test(lines[i]) && !/^\S/.test(lines[i])) i += 1; + continue; + } + kept.push(lines[i]); + i += 1; + } + return kept; +}; +const existing = existsSync(configPath) ? readFileSync(configPath, 'utf8') : ''; +const lines = existing.replace(/\s+$/g, '').split(/\r?\n/).filter((line, index, arr) => !(arr.length === 1 && index === 0 && line === '')); +const hooksIndex = lines.findIndex((line) => /^hooks:\s*(?:#.*)?$/.test(line)); +let merged; +if (hooksIndex === -1) { + merged = `${lines.length ? `${lines.join('\n')}\n\n` : ''}hooks:\n${block}\n`; +} else { + const hooksEnd = findNextTopLevel(lines, hooksIndex + 1); + merged = [...lines.slice(0, hooksIndex + 1), ...removeManaged(lines.slice(hooksIndex + 1, hooksEnd)), ...block.split('\n'), ...lines.slice(hooksEnd)].join('\n'); +} +if (!/^hooks_auto_accept:\s*/m.test(merged)) merged += '\n\nhooks_auto_accept: false'; +mkdirSync(dirname(configPath), { recursive: true }); +writeFileSync(configPath, `${merged.replace(/\s+$/g, '')}\n`); +NODE + echo " OK: Hermes hooks configured in $HOME/.hermes/config.yaml" +fi + +if [ "$PLATFORM" = "codex" ]; then + echo " Writing Codex AgentGuard hook config..." + mkdir -p "$HOME/.codex" + cat > "$HOME/.codex/agentguard-hook.json" <<'JSON' +{ + "agentHost": "codex", + "command": "AGENTGUARD_AGENT_HOST=codex agentguard protect", + "actionTypes": { + "shell": "shell", + "fileRead": "file_read", + "fileWrite": "file_write", + "network": "network", + "mcpTool": "mcp_tool" + } +} +JSON + echo " OK: Codex AgentGuard config written to $HOME/.codex/agentguard-hook.json" +fi + # ---- Done ---- echo "" echo " ✅ GoPlus AgentGuard is installed!" diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index 19a6e59..95d1894 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -56,17 +56,17 @@ cd ~/.openclaw/skills/agentguard && node scripts/checkup-report.js ### Post-Install Guidance When AgentGuard has just been installed, or the user asks what to do next after -installing AgentGuard, recommend these steps in this order instead of deriving a -longer list from every supported command: +installing AgentGuard, recommend the required setup step below instead of +deriving a longer list from every supported command: ```bash -agentguard init --agent -agentguard connect -agentguard checkup +agentguard init --agent auto ``` -Use the current agent host for `` when it is known; otherwise leave -`` as a placeholder. +Use a specific agent such as `hermes`, `qclaw`, `openclaw`, `claude-code`, or +`codex` only when the current host is known and the user wants targeted setup. +Treat `agentguard connect` and `agentguard checkup` as optional follow-ups, not +required post-install steps. Parse `$ARGUMENTS` to determine the subcommand: @@ -120,8 +120,10 @@ If the user writes `/agentguard checkup --against-advisory `, use the CLI co 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`. +must be present in `~/.hermes/config.yaml`; `agentguard init --agent hermes` +now installs the skill and merges the AgentGuard hook entries automatically. +This skill ships the hook runner at `scripts/hermes-hook.js` and a copyable +template at `hermes-hooks.yaml`. ### What the Hermes hook protects @@ -151,12 +153,13 @@ the shared runtime protection path and syncs pre-tool decisions to Cloud. ```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. +3. Prefer `agentguard init --agent hermes --force` to install and merge the + hook entries automatically. +4. For manual setup, read `hermes-hooks.yaml`, replace + `AGENTGUARD_SKILL_DIR` with the absolute skill directory, and show the + resulting YAML to the user. +5. Ask for explicit confirmation before manually editing + `~/.hermes/config.yaml`. 6. Tell the user to restart Hermes or launch it with one of the first-use consent options: ```bash diff --git a/src/adapters/openclaw-plugin.ts b/src/adapters/openclaw-plugin.ts index a085590..673589b 100644 --- a/src/adapters/openclaw-plugin.ts +++ b/src/adapters/openclaw-plugin.ts @@ -640,7 +640,15 @@ function readOpenClawConfigLevel( type OpenClawBeforeToolCallResult = | { block: true; blockReason: string } - | { ask: true; askReason: string }; + | { + requireApproval: { + title: string; + description: string; + severity?: 'info' | 'warning' | 'critical'; + timeoutMs?: number; + timeoutBehavior?: 'allow' | 'deny'; + }; + }; function runtimeResultToBeforeToolCallResult( result: ProtectResult | null @@ -668,8 +676,13 @@ function runtimeResultToBeforeToolCallResult( if (decision === 'require_approval' && result.approvalChannel === 'agent') { return { - ask: true, - askReason: reason, + requireApproval: { + title: 'AgentGuard approval required', + description: reason, + severity: openClawApprovalSeverity(result.decision.riskLevel), + timeoutMs: 60_000, + timeoutBehavior: 'deny', + }, }; } return { @@ -678,6 +691,12 @@ function runtimeResultToBeforeToolCallResult( }; } +function openClawApprovalSeverity(riskLevel: ProtectResult['decision']['riskLevel']): 'info' | 'warning' | 'critical' { + if (riskLevel === 'critical' || riskLevel === 'high') return 'critical'; + if (riskLevel === 'medium') return 'warning'; + return 'info'; +} + function shouldSurfaceRuntimeApproval(result: ProtectResult): boolean { return ( result.policySource === 'cloud-decision' || diff --git a/src/cli.ts b/src/cli.ts index 88026a9..5e67f83 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -41,6 +41,7 @@ const AUTO_AGENT_DETECTION: Array<{ agent: AgentInstaller; dir: string }> = [ { agent: 'qclaw', dir: '.qclaw' }, { agent: 'codex', dir: '.codex' }, ]; +const REQUIRED_INIT_COMMAND = 'agentguard init --agent auto'; async function main() { const program = new Command(); @@ -153,6 +154,7 @@ async function main() { console.log(`Agent hosts: ${config.agentHosts?.join(', ') || 'not configured'}`); console.log(`Policy cache: ${config.policyCachePath}`); console.log(`Audit log: ${config.auditPath}`); + printInitGuidanceIfNeeded(config); }); const policy = program @@ -261,6 +263,7 @@ async function main() { } else { console.log('! Cloud: not connected'); } + printInitGuidanceIfNeeded(config); }); program @@ -510,6 +513,7 @@ async function main() { return null; }); printHealthCheckupSummary(report, htmlPath); + printInitGuidanceIfNeeded(config); } appendCheckupAudit(config.auditPath, report); process.exitCode = 0; @@ -558,6 +562,11 @@ async function main() { process.exitCode = result.matchedArtifacts.length > 0 ? 2 : 0; }); + if (process.argv.length <= 2) { + printInstalledGuidance(); + return; + } + await program.parseAsync(process.argv); } @@ -606,6 +615,27 @@ function appendAgentHost( return next; } +function hasSavedAgentHost(config: AgentGuardConfig): boolean { + return Boolean(config.agentHost || config.agentHosts?.length); +} + +function printInstalledGuidance(): void { + console.log('AgentGuard is installed.'); + console.log(''); + console.log('Required next step:'); + console.log(` ${REQUIRED_INIT_COMMAND}`); + console.log(''); + console.log('This detects installed agent directories and configures supported hooks/plugins.'); + console.log('Run `agentguard --help` to see all commands.'); +} + +function printInitGuidanceIfNeeded(config: AgentGuardConfig): void { + if (hasSavedAgentHost(config)) return; + console.log(''); + console.log('Required next step:'); + console.log(` ${REQUIRED_INIT_COMMAND}`); +} + function resolveCronAgentHost(config: AgentGuardConfig): AgentGuardAgentHost | undefined { return config.agentHost ?? config.agentHosts?.[0]; } diff --git a/src/installers.ts b/src/installers.ts index 144fec7..6047bd0 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -32,7 +32,7 @@ function installClaudeCode(root: string, force: boolean): InstallResult { function installCodex(root: string, force: boolean): InstallResult { const skillDir = join(root, '.codex', 'skills', 'agentguard'); const skillPath = join(skillDir, 'SKILL.md'); - const hookPath = join(root, '.codex', 'agentguard-hook.example.json'); + const hookPath = join(root, '.codex', 'agentguard-hook.json'); mkdirSync(skillDir, { recursive: true }); writeIfAllowed(skillPath, codexSkillTemplate(), force); writeIfAllowed(hookPath, JSON.stringify(codexHookTemplate(), null, 2) + '\n', force); @@ -43,34 +43,30 @@ function installOpenClaw(cwd: string | undefined, force: boolean): InstallResult const openClawRoot = cwd ? join(cwd, '.openclaw') : process.env.OPENCLAW_STATE_DIR || join(homedir(), '.openclaw'); - const pluginDir = join(openClawRoot, 'plugins', 'agentguard'); - const packagePath = join(pluginDir, 'package.json'); - const pluginPath = join(pluginDir, 'index.js'); - const manifestPath = join(pluginDir, 'openclaw.plugin.json'); const configPath = cwd ? join(openClawRoot, 'openclaw.json') : process.env.OPENCLAW_CONFIG_PATH || join(openClawRoot, 'openclaw.json'); - writeIfAllowed(packagePath, JSON.stringify(openClawPackageManifest(), null, 2) + '\n', force); - writeIfAllowed(pluginPath, openClawPluginTemplate(), force); - writeIfAllowed(manifestPath, JSON.stringify(openClawPluginManifest(), null, 2) + '\n', force); - enableOpenClawPlugin(configPath, pluginDir); - - return { agent: 'openclaw', files: [packagePath, pluginPath, manifestPath, configPath] }; + return installClawPlugin('openclaw', openClawRoot, configPath, force); } function installHermes(root: string, force: boolean): InstallResult { const skillDir = join(root, '.hermes', 'skills', 'agentguard'); const configExamplePath = join(root, '.hermes', 'agentguard-hooks.example.yaml'); + const configPath = join(root, '.hermes', 'config.yaml'); copyBundledSkill(skillDir, force); writeIfAllowed(configExamplePath, hermesHooksTemplate(skillDir), force); - return { agent: 'hermes', files: [skillDir, configExamplePath] }; + enableHermesHooks(configPath, skillDir, force); + return { agent: 'hermes', files: [skillDir, configExamplePath, configPath] }; } function installQClaw(root: string, force: boolean): InstallResult { - const skillDir = join(root, '.qclaw', 'skills', 'agentguard'); + const qclawRoot = join(root, '.qclaw'); + const skillDir = join(qclawRoot, 'skills', 'agentguard'); + const configPath = join(qclawRoot, 'qclaw.json'); copyBundledSkill(skillDir, force); - return { agent: 'qclaw', files: [skillDir] }; + const pluginResult = installClawPlugin('qclaw', qclawRoot, configPath, force); + return { agent: 'qclaw', files: [skillDir, ...pluginResult.files] }; } function writeIfAllowed(path: string, content: string, force: boolean): void { @@ -210,6 +206,20 @@ hooks_auto_accept: false `; } +function installClawPlugin(agent: 'openclaw' | 'qclaw', root: string, configPath: string, force: boolean): InstallResult { + const pluginDir = join(root, 'plugins', 'agentguard'); + const packagePath = join(pluginDir, 'package.json'); + const pluginPath = join(pluginDir, 'index.js'); + const manifestPath = join(pluginDir, 'openclaw.plugin.json'); + + writeIfAllowed(packagePath, JSON.stringify(openClawPackageManifest(agent), null, 2) + '\n', force); + writeIfAllowed(pluginPath, openClawPluginTemplate(), force); + writeIfAllowed(manifestPath, JSON.stringify(openClawPluginManifest(), null, 2) + '\n', force); + enableClawPlugin(configPath, pluginDir); + + return { agent, files: [packagePath, pluginPath, manifestPath, configPath] }; +} + function openClawPluginTemplate(): string { return `const { registerOpenClawPlugin } = require('@goplus/agentguard'); @@ -231,8 +241,8 @@ module.exports = Object.defineProperties(register, { `; } -function openClawPackageManifest(): unknown { - return { +function openClawPackageManifest(agent: 'openclaw' | 'qclaw' = 'openclaw'): unknown { + const manifest: Record = { name: 'agentguard-openclaw-local', private: true, type: 'commonjs', @@ -241,6 +251,14 @@ function openClawPackageManifest(): unknown { runtimeExtensions: ['./index.js'], }, }; + if (agent === 'qclaw') { + manifest.name = 'agentguard-qclaw-local'; + manifest.qclaw = { + extensions: ['./index.js'], + runtimeExtensions: ['./index.js'], + }; + } + return manifest; } function openClawPluginManifest(): unknown { @@ -262,7 +280,7 @@ function openClawPluginManifest(): unknown { }; } -function enableOpenClawPlugin(configPath: string, pluginDir: string): void { +function enableClawPlugin(configPath: string, pluginDir: string): void { let config: Record = {}; if (existsSync(configPath)) { const raw = readFileSync(configPath, 'utf8').trim(); @@ -293,6 +311,94 @@ function enableOpenClawPlugin(configPath: string, pluginDir: string): void { writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); } +function enableHermesHooks(configPath: string, skillDir: string, force: boolean): void { + if (existsSync(configPath) && !force && readFileSync(configPath, 'utf8').includes(`${skillDir}/scripts/hermes-hook.js`)) { + return; + } + + const existing = existsSync(configPath) ? readFileSync(configPath, 'utf8') : ''; + const next = mergeHermesHooks(existing, skillDir); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, next); +} + +function mergeHermesHooks(existing: string, skillDir: string): string { + const lines = existing.replace(/\s+$/g, '').split(/\r?\n/).filter((line, index, arr) => !(arr.length === 1 && index === 0 && line === '')); + const hooksBlock = hermesHookEventBlock(skillDir).split('\n').filter(Boolean); + const hooksIndex = lines.findIndex((line) => /^hooks:\s*(?:#.*)?$/.test(line)); + + if (hooksIndex === -1) { + const prefix = lines.length ? `${lines.join('\n')}\n\n` : ''; + return `${prefix}hooks:\n${hooksBlock.join('\n')}\n\n${hermesAutoAcceptLine(lines)}\n`; + } + + const hooksEnd = findNextTopLevelIndex(lines, hooksIndex + 1); + const before = lines.slice(0, hooksIndex + 1); + const body = removeHermesManagedEvents(lines.slice(hooksIndex + 1, hooksEnd)); + const after = lines.slice(hooksEnd); + const merged = [...before, ...body, ...hooksBlock, ...after]; + + if (!merged.some((line) => /^hooks_auto_accept:\s*/.test(line))) { + merged.push('', 'hooks_auto_accept: false'); + } + + return `${merged.join('\n').replace(/\s+$/g, '')}\n`; +} + +function hermesHookEventBlock(skillDir: string): string { + return ` on_session_start: + - command: "env AGENTGUARD_AUTO_SCAN=1 node \\"${skillDir}/scripts/auto-scan.js\\"" + timeout: 30 + + pre_tool_call: + - matcher: "terminal|execute_code" + command: "node \\"${skillDir}/scripts/hermes-hook.js\\"" + timeout: 10 + - matcher: "write_file|patch|skill_manage" + command: "node \\"${skillDir}/scripts/hermes-hook.js\\"" + timeout: 10 + - matcher: "read_file" + command: "node \\"${skillDir}/scripts/hermes-hook.js\\"" + timeout: 10 + - matcher: "web_search|web_extract|browser_navigate" + command: "node \\"${skillDir}/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" + command: "node \\"${skillDir}/scripts/hermes-hook.js\\"" + timeout: 5`; +} + +function removeHermesManagedEvents(lines: string[]): string[] { + const events = new Set(['on_session_start', 'pre_tool_call', 'post_tool_call']); + const kept: string[] = []; + for (let index = 0; index < lines.length;) { + const match = /^ ([A-Za-z0-9_-]+):\s*(?:#.*)?$/.exec(lines[index]); + if (match && events.has(match[1])) { + index += 1; + while (index < lines.length && !/^ [A-Za-z0-9_-]+:\s*(?:#.*)?$/.test(lines[index]) && !/^\S/.test(lines[index])) { + index += 1; + } + continue; + } + kept.push(lines[index]); + index += 1; + } + return kept; +} + +function findNextTopLevelIndex(lines: string[], start: number): number { + for (let index = start; index < lines.length; index += 1) { + if (/^\S/.test(lines[index]) && !/^#/.test(lines[index])) return index; + } + return lines.length; +} + +function hermesAutoAcceptLine(lines: string[]): string { + return lines.some((line) => /^hooks_auto_accept:\s*/.test(line)) ? '' : 'hooks_auto_accept: false'; +} + function ensureRecord(parent: Record, key: string): Record { const existing = parent[key]; if (existing && typeof existing === 'object' && !Array.isArray(existing)) { diff --git a/src/postinstall.ts b/src/postinstall.ts index 715aec4..21ff575 100644 --- a/src/postinstall.ts +++ b/src/postinstall.ts @@ -5,10 +5,8 @@ import { resolve } from 'node:path'; import { ensureConfig, getAgentGuardPaths } from './config.js'; const NEXT_STEPS = [ - 'Next steps:', - ' agentguard init --agent ', - ' agentguard connect', - ' agentguard checkup', + 'Next step:', + ' agentguard init --agent auto', '', ].join('\n'); diff --git a/src/tests/cli-init.test.ts b/src/tests/cli-init.test.ts index 458e112..710d86f 100644 --- a/src/tests/cli-init.test.ts +++ b/src/tests/cli-init.test.ts @@ -9,6 +9,38 @@ import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); describe('init CLI', () => { + it('prints required init guidance when run without a command', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-init-guidance-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-guidance-cwd-')); + const cliPath = resolve('dist', 'cli.js'); + + const { stdout } = await execFileAsync(process.execPath, [cliPath], { + cwd, + env: { ...process.env, AGENTGUARD_HOME: home }, + }); + + assert.match(stdout, /Required next step:/); + assert.match(stdout, /agentguard init --agent auto/); + assert.doesNotMatch(stdout, /agentguard connect/); + assert.doesNotMatch(stdout, /agentguard checkup/); + }); + + it('prints required init guidance from status when no agent host is saved', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-status-guidance-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-status-guidance-cwd-')); + const cliPath = resolve('dist', 'cli.js'); + + const { stdout } = await execFileAsync(process.execPath, [cliPath, 'status'], { + cwd, + env: { ...process.env, AGENTGUARD_HOME: home }, + }); + + assert.match(stdout, /Agent host: not configured/); + assert.match(stdout, /agentguard init --agent auto/); + assert.doesNotMatch(stdout, /agentguard connect/); + assert.doesNotMatch(stdout, /agentguard checkup/); + }); + it('persists the selected agent host in AgentGuard config', async () => { const home = mkdtempSync(join(tmpdir(), 'agentguard-init-home-')); const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-cwd-')); @@ -75,7 +107,9 @@ describe('init CLI', () => { 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(readFileSync(join(cwd, '.hermes', 'config.yaml'), 'utf8').includes('hermes-hook.js')); assert.ok(existsSync(join(cwd, '.codex', 'skills', 'agentguard', 'SKILL.md'))); + assert.ok(existsSync(join(cwd, '.codex', 'agentguard-hook.json'))); assert.match(stdout, /Installed openclaw template:/); assert.match(stdout, /Installed hermes template:/); assert.match(stdout, /Installed codex template:/); diff --git a/src/tests/installer.test.ts b/src/tests/installer.test.ts index 3985891..4ec223b 100644 --- a/src/tests/installer.test.ts +++ b/src/tests/installer.test.ts @@ -15,29 +15,54 @@ describe('Agent template installers', () => { assert.ok(readFileSync(join(dir, '.claude', 'settings.local.json'), 'utf8').includes('agentguard-protect.sh')); }); - it('writes Codex skill and hook templates', () => { + it('writes Codex skill and AgentGuard hook config', () => { const dir = mkdtempSync(join(tmpdir(), 'agentguard-codex-')); installAgentTemplates('codex', { cwd: dir }); assert.ok(existsSync(join(dir, '.codex', 'skills', 'agentguard', 'SKILL.md'))); - assert.ok(readFileSync(join(dir, '.codex', 'agentguard-hook.example.json'), 'utf8').includes('AGENTGUARD_AGENT_HOST=codex')); + assert.ok(readFileSync(join(dir, '.codex', 'agentguard-hook.json'), 'utf8').includes('AGENTGUARD_AGENT_HOST=codex')); }); - it('writes Hermes skill and hook config example', () => { + it('writes Hermes skill and enables hook config', () => { const dir = mkdtempSync(join(tmpdir(), 'agentguard-hermes-')); const result = installAgentTemplates('hermes', { cwd: dir }); + const config = readFileSync(join(dir, '.hermes', 'config.yaml'), 'utf8'); assert.equal(result.agent, 'hermes'); assert.ok(existsSync(join(dir, '.hermes', 'skills', 'agentguard', 'SKILL.md'))); assert.ok(readFileSync(join(dir, '.hermes', 'agentguard-hooks.example.yaml'), 'utf8').includes('hermes-hook.js')); + assert.ok(config.includes('pre_tool_call:')); + assert.ok(config.includes('hermes-hook.js')); + assert.ok(config.includes('hooks_auto_accept: false')); }); - it('writes QClaw skill template', () => { + it('merges Hermes hooks into an existing config', () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-hermes-existing-')); + const configPath = join(dir, '.hermes', 'config.yaml'); + mkdirSync(join(dir, '.hermes'), { recursive: true }); + writeFileSync(configPath, 'theme: dark\nhooks:\n custom_event:\n - command: "echo keep"\n'); + + installAgentTemplates('hermes', { cwd: dir }); + + const config = readFileSync(configPath, 'utf8'); + assert.ok(config.includes('theme: dark')); + assert.ok(config.includes('custom_event:')); + assert.ok(config.includes('pre_tool_call:')); + assert.ok(config.includes('hermes-hook.js')); + }); + + it('writes QClaw skill template and enables plugin', () => { const dir = mkdtempSync(join(tmpdir(), 'agentguard-qclaw-')); const result = installAgentTemplates('qclaw', { cwd: dir }); + const pluginDir = join(dir, '.qclaw', 'plugins', 'agentguard'); + const packageJson = JSON.parse(readFileSync(join(pluginDir, 'package.json'), 'utf8')); + const config = JSON.parse(readFileSync(join(dir, '.qclaw', 'qclaw.json'), 'utf8')); assert.equal(result.agent, 'qclaw'); assert.ok(existsSync(join(dir, '.qclaw', 'skills', 'agentguard', 'SKILL.md'))); + assert.deepEqual(packageJson.qclaw.extensions, ['./index.js']); + assert.equal(config.plugins.entries.agentguard.enabled, true); + assert.deepEqual(config.plugins.load.paths, [pluginDir]); }); it('writes and enables OpenClaw plugin template', () => { diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index b65a2db..e1bba83 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -431,11 +431,19 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { const result = await handlers['before_tool_call']({ toolName: 'Read', params: { path: '/workspace/.env' }, - }) as { ask?: boolean; askReason?: string } | undefined; - - assert.equal(result?.ask, true); - assert.ok(result?.askReason?.includes('requires approval')); - assert.ok(result?.askReason?.includes('Protected path')); + }) as { + ask?: boolean; + askReason?: string; + requireApproval?: { title?: string; description?: string; severity?: string; timeoutBehavior?: string }; + } | undefined; + + assert.equal(result?.ask, undefined); + assert.equal(result?.askReason, undefined); + assert.equal(result?.requireApproval?.title, 'AgentGuard approval required'); + assert.equal(result?.requireApproval?.severity, 'critical'); + assert.equal(result?.requireApproval?.timeoutBehavior, 'deny'); + assert.ok(result?.requireApproval?.description?.includes('requires approval')); + assert.ok(result?.requireApproval?.description?.includes('Protected path')); }); it('should return { block: true } for rm -rf /', async () => { @@ -467,10 +475,10 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { const result = await handlers['before_tool_call']({ toolName: 'write', params: { path: '/project/.env' }, - }) as { ask?: boolean; askReason?: string } | undefined; + }) as { requireApproval?: { description?: string } } | undefined; - assert.ok(result?.ask, 'Should ask before writing .env'); - assert.ok(result?.askReason?.includes('requires approval')); + assert.ok(result?.requireApproval, 'Should ask before writing .env'); + assert.ok(result?.requireApproval?.description?.includes('requires approval')); }); it('should handle after_tool_call without error', async () => { diff --git a/src/tests/postinstall.test.ts b/src/tests/postinstall.test.ts index 3b9e3da..e6ef82c 100644 --- a/src/tests/postinstall.test.ts +++ b/src/tests/postinstall.test.ts @@ -18,13 +18,13 @@ describe('postinstall', () => { }); assert.match(stdout, /AgentGuard local config ready:/); - assert.match(stdout, /agentguard init --agent /); - assert.match(stdout, /agentguard connect/); - assert.match(stdout, /agentguard checkup/); + assert.match(stdout, /agentguard init --agent auto/); + assert.doesNotMatch(stdout, /agentguard connect/); + assert.doesNotMatch(stdout, /agentguard checkup/); const nextSteps = readFileSync(join(home, 'next-steps.txt'), 'utf8'); - assert.match(nextSteps, /agentguard init --agent /); - assert.match(nextSteps, /agentguard connect/); - assert.match(nextSteps, /agentguard checkup/); + assert.match(nextSteps, /agentguard init --agent auto/); + assert.doesNotMatch(nextSteps, /agentguard connect/); + assert.doesNotMatch(nextSteps, /agentguard checkup/); }); });