diff --git a/skills/agentguard/scripts/continue-hook.js b/skills/agentguard/scripts/continue-hook.js new file mode 100644 index 0000000..05d8b8b --- /dev/null +++ b/skills/agentguard/scripts/continue-hook.js @@ -0,0 +1,455 @@ +#!/usr/bin/env node + +/** + * GoPlus AgentGuard — Continue file-hook bridge. + * + * Continue (the `cn` CLI) ships a Claude-Code-compatible hook system. Hooks are + * registered in `~/.continue/settings.json` (or `~/.claude/settings.json`, + * cwd variants, and `*.local.json`) and invoked with a JSON event on stdin. + * They return a JSON control object on stdout. See + * `continuedev/continue/extensions/cli/src/hooks/types.ts`. + * + * PreToolUse stdin (excerpt): + * { + * "hook_event_name": "PreToolUse", + * "tool_name": "run_terminal_command", + * "tool_input": {...}, + * "tool_use_id": "...", + * "session_id": "...", + * "cwd": "/repo" + * } + * + * Stdout we emit: + * {} -> allow + * { hookSpecificOutput: { hookEventName: "PreToolUse", + * permissionDecision: "deny", + * permissionDecisionReason: "..." } } -> block + * { hookSpecificOutput: { ..., permissionDecision: "ask", + * permissionDecisionReason: "..." } } -> review + * + * This script delegates to the unified `protectAction` runtime API with + * `agentHost: 'continue'`, falling back to `ContinueAdapter` + `evaluateHook` + * on older AgentGuard installs. + */ + +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function existsAtPath(p) { + try { + return existsSync(p); + } catch { + return false; + } +} + +// Resolve the bundled engine path safely across platforms. Mirrors the +// pattern used in cline-hook.js — fileURLToPath handles Windows correctly. +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const bundledEnginePath = resolve(scriptDir, '..', '..', '..', 'dist', 'index.js'); + +function isPostHook(input) { + const event = typeof input?.hook_event_name === 'string' ? input.hook_event_name : ''; + return event.startsWith('Post'); +} + +function isPreHook(input) { + return !isPostHook(input); +} + +function toolNameFrom(input) { + return typeof input?.tool_name === 'string' ? input.tool_name : ''; +} + +function toolInputFrom(input) { + const ti = input?.tool_input ?? input?.toolInput; + return ti && typeof ti === 'object' && !Array.isArray(ti) ? ti : {}; +} + +function firstString(...values) { + for (const value of values) { + if (typeof value === 'string' && value.length > 0) return value; + } + return ''; +} + +function envBool(name, fallback) { + const value = process.env[name]; + if (value === undefined || value === '') return fallback; + return value === '1' || value.toLowerCase() === 'true'; +} + +const FAIL_OPEN = envBool('AGENTGUARD_CONTINUE_FAIL_OPEN', false); + +// --------------------------------------------------------------------------- +// Tool → runtime action type mapping (mirrors src/adapters/continue.ts) +// --------------------------------------------------------------------------- + +function runtimeActionTypeFrom(toolName) { + switch (toolName) { + case 'run_terminal_command': + return 'shell'; + case 'create_new_file': + case 'edit_existing_file': + case 'single_find_and_replace': + case 'multi_edit': + return 'file_write'; + case 'read_file': + case 'read_file_range': + case 'read_currently_open_file': + return 'file_read'; + case 'search_web': + return 'web_search'; + case 'fetch_url_content': + return 'network'; + default: + return 'other'; + } +} + +function runtimeToolNameFrom(toolName) { + return toolName || 'ContinueTool'; +} + +function shouldFailClosed(input) { + if (FAIL_OPEN) return false; + return !input || isPreHook(input); +} + +function isInScope(toolName) { + return runtimeActionTypeFrom(toolName) !== 'other'; +} + +function validatePreToolPayload(input) { + const toolName = toolNameFrom(input); + const toolInput = toolInputFrom(input); + + switch (toolName) { + case 'run_terminal_command': { + const command = firstString(toolInput.command, toolInput.cmd); + if (!command) return `Continue ${toolName} hook payload is missing command`; + if (command.length > 1024 * 64) return `Continue ${toolName} command exceeds 64 KiB`; + return null; + } + case 'create_new_file': + case 'edit_existing_file': + case 'single_find_and_replace': + case 'read_file': + case 'read_file_range': + case 'read_currently_open_file': { + const path = firstString( + toolInput.filepath, + toolInput.file_path, + toolInput.filePath, + toolInput.path, + toolInput.target + ); + if (!path) return `Continue ${toolName} hook payload is missing filepath`; + if (path.includes('\0')) return `Continue ${toolName} filepath contains NUL byte`; + return null; + } + case 'multi_edit': { + const topPath = firstString(toolInput.filepath, toolInput.file_path, toolInput.filePath, toolInput.path); + const edits = Array.isArray(toolInput.edits) ? toolInput.edits : null; + if (!topPath && (!edits || edits.length === 0)) { + return `Continue multi_edit hook payload is missing edits / filepath`; + } + // Validate every edit entry — each must be an object with a non-empty + // filepath/path or contribute at least an old_string/new_string when + // a top-level filepath is provided. + if (edits) { + for (let i = 0; i < edits.length; i++) { + const entry = edits[i]; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return `Continue multi_edit edits[${i}] is not an object`; + } + const editPath = firstString(entry.filepath, entry.file_path, entry.filePath, entry.path); + if (!editPath && !topPath) { + return `Continue multi_edit edits[${i}] is missing filepath and no top-level filepath was provided`; + } + if (editPath && editPath.includes('\0')) { + return `Continue multi_edit edits[${i}].filepath contains NUL byte`; + } + } + } + return null; + } + case 'fetch_url_content': { + const url = firstString(toolInput.url, toolInput.uri, toolInput.href); + if (!url) return `Continue fetch_url_content hook payload is missing URL`; + // Cheap shape check — full URL validation lives in the engine. + try { + // eslint-disable-next-line no-new + new URL(url); + } catch { + return `Continue fetch_url_content URL is not parseable`; + } + return null; + } + case 'search_web': { + const query = firstString(toolInput.query, toolInput.q, toolInput.search); + if (!query) return `Continue search_web hook payload is missing query`; + return null; + } + default: + // Out-of-scope tools pass through without engine evaluation. + return null; + } +} + +// --------------------------------------------------------------------------- +// Engine loader +// --------------------------------------------------------------------------- + +async function loadEngine() { + if (process.env.AGENTGUARD_TEST_FORCE_ENGINE_LOAD_FAILURE === '1') return null; + + const tryImport = async (specifier) => { + try { + return await import(specifier); + } catch { + return null; + } + }; + + const gs = + (existsAtPath(bundledEnginePath) ? await tryImport(bundledEnginePath) : null) || + (await tryImport('@goplus/agentguard')); + if (!gs) return null; + + return { + loadRuntimeConfig: gs.loadAgentGuardConfig || gs.ensureConfig, + loadHookConfig: gs.loadConfig, + protectAction: gs.protectAction, + createAgentGuard: gs.createAgentGuard || gs.default, + ContinueAdapter: gs.ContinueAdapter, + evaluateHook: gs.evaluateHook, + }; +} + +// --------------------------------------------------------------------------- +// Stdin / stdout helpers +// --------------------------------------------------------------------------- + +function readStdin() { + return new Promise((resolve) => { + let data = ''; + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const timer = setTimeout(() => finish(null), 5000); + + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => (data += chunk)); + process.stdin.on('end', () => { + try { + finish(JSON.parse(data)); + } catch { + finish(null); + } + }); + process.stdin.on('error', () => finish(null)); + }); +} + +function continueDecisionPayload(permissionDecision, reason) { + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision, + permissionDecisionReason: reason, + }, + }; +} + +function outputBlock(reason) { + const message = reason || 'GoPlus AgentGuard blocked this action'; + process.stdout.write(JSON.stringify(continueDecisionPayload('deny', message)) + '\n'); + process.exit(0); +} + +function outputAsk(reason) { + const message = reason || 'GoPlus AgentGuard requires confirmation for this action'; + process.stdout.write(JSON.stringify(continueDecisionPayload('ask', message)) + '\n'); + process.exit(0); +} + +function outputAllow() { + process.stdout.write('{}\n'); + process.exit(0); +} + +function debugLog(message, details) { + if (process.env.AGENTGUARD_CONTINUE_DEBUG !== '1') return; + const suffix = details === undefined ? '' : ` ${JSON.stringify(details)}`; + process.stderr.write(`[AgentGuard Continue] ${message}${suffix}\n`); +} + +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}`; +} + +// --------------------------------------------------------------------------- +// Payload normalization — protectAction's input picker uses tool_input.path / +// file_path / url shapes. Continue is already mostly compatible (it uses +// `filepath` in some tools); normalize so the engine doesn't miss fields. +// --------------------------------------------------------------------------- + +function normalizeForRuntime(input) { + const toolName = toolNameFrom(input); + const toolInput = { ...toolInputFrom(input) }; + + // `filepath` -> `file_path` for tools that use Continue's spelling. + if (typeof toolInput.filepath === 'string' && !toolInput.file_path) { + toolInput.file_path = toolInput.filepath; + } + + return { + ...input, + tool_name: toolName, + tool_input: toolInput, + session_id: firstString(input?.session_id, input?.sessionId, input?.tool_use_id), + cwd: firstString(input?.cwd) || undefined, + }; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const input = await readStdin(); + if (!input) { + // Unparseable stdin — we have no way to know if it's an in-scope tool, so + // this is always fail-closed regardless of AGENTGUARD_CONTINUE_FAIL_OPEN. + // The override exists for engine-load failures, not for "we have no idea + // what we're being asked to allow". + outputBlock('GoPlus AgentGuard: invalid or missing Continue hook payload'); + } + + const toolName = toolNameFrom(input); + + // Out-of-scope tools always allow — no need to load the engine. + if (isPreHook(input) && !isInScope(toolName)) { + debugLog('out-of-scope tool, passthrough', { toolName }); + outputAllow(); + } + + const validationError = isPreHook(input) ? validatePreToolPayload(input) : null; + if (validationError) { + // Malformed payload for an in-scope (security-sensitive) tool. Block + // regardless of fail-open — the override is for cases where AgentGuard + // itself is unavailable, not for "we got a half-formed shell command + // and aren't sure what it is." + outputBlock(`GoPlus AgentGuard: ${validationError}`); + } + + const engine = await loadEngine(); + if (!engine) { + debugLog('engine load failed'); + if (shouldFailClosed(input)) { + outputBlock('GoPlus AgentGuard: unable to load Continue hook engine; blocking fail-closed'); + } + outputAllow(); + } + + const normalized = normalizeForRuntime(input); + + // Post hooks — audit only, never block. + if (isPostHook(input)) { + try { + if (engine.protectAction) { + const config = engine.loadRuntimeConfig(); + await engine.protectAction({ + config, + rawInput: normalized, + agentHost: 'continue', + actionType: runtimeActionTypeFrom(toolName), + toolName: runtimeToolNameFrom(toolName), + sessionId: normalized.session_id || undefined, + phase: 'post', + }); + } else if (engine.ContinueAdapter && engine.evaluateHook && engine.createAgentGuard) { + const adapter = new engine.ContinueAdapter(); + const config = engine.loadHookConfig + ? engine.loadHookConfig() + : { level: engine.loadRuntimeConfig?.()?.level }; + const agentguard = engine.createAgentGuard(); + await engine.evaluateHook(adapter, input, { config, agentguard }); + } + } catch { + // Post hooks must never affect Continue. + } + outputAllow(); + } + + // Pre hooks — make a real decision. + try { + if (engine.protectAction) { + const config = engine.loadRuntimeConfig(); + const result = await engine.protectAction({ + config, + rawInput: normalized, + agentHost: 'continue', + actionType: runtimeActionTypeFrom(toolName), + toolName: runtimeToolNameFrom(toolName), + sessionId: normalized.session_id || undefined, + }); + + if (!result) { + debugLog('allow: no runtime action was built'); + outputAllow(); + } + + debugLog('decision', { + decision: result.decision.decision, + riskLevel: result.decision.riskLevel, + riskScore: result.decision.riskScore, + policySource: result.policySource, + }); + + if (result.decision.decision === 'block') { + outputBlock(formatDecisionReason(result, 'blocked this Continue tool call')); + } else if (result.decision.decision === 'require_approval') { + outputAsk(formatDecisionReason(result, 'requires confirmation for this Continue tool call')); + } else { + outputAllow(); + } + } + + // Fallback: ContinueAdapter + evaluateHook path. + if (engine.ContinueAdapter && engine.evaluateHook && engine.createAgentGuard) { + const adapter = new engine.ContinueAdapter(); + const config = engine.loadHookConfig + ? engine.loadHookConfig() + : { level: engine.loadRuntimeConfig?.()?.level }; + const agentguard = engine.createAgentGuard(); + const decision = await engine.evaluateHook(adapter, input, { config, agentguard }); + + if (decision.decision === 'deny') outputBlock(decision.reason || 'GoPlus AgentGuard blocked this action'); + if (decision.decision === 'ask') outputAsk(decision.reason || 'GoPlus AgentGuard requires confirmation'); + outputAllow(); + } + + outputAllow(); + } catch (err) { + debugLog('engine error', { message: err instanceof Error ? err.message : String(err) }); + if (shouldFailClosed(input)) { + outputBlock(`GoPlus AgentGuard engine error: ${err instanceof Error ? err.message : 'unknown'}`); + } + outputAllow(); + } +} + +main(); diff --git a/src/adapters/continue.ts b/src/adapters/continue.ts new file mode 100644 index 0000000..fa7bc7d --- /dev/null +++ b/src/adapters/continue.ts @@ -0,0 +1,285 @@ +import type { ActionEnvelope } from '../types/action.js'; +import { isSensitivePath } from './common.js'; +import type { HookAdapter, HookInput } from './types.js'; + +/** + * Tool name → action type mapping for Continue. + * + * Continue's CLI (`cn`) exposes a Claude-Code-compatible PreToolUse/PostToolUse + * hook system at `~/.continue/settings.json` (and `~/.claude/settings.json`). + * Built-in tool names are listed in continuedev/continue's + * `core/tools/builtIn.ts` and `extensions/cli/src/tools/builtInToolNames.ts`. + * + * MCP tools come through with their bare server-defined names; we leave them + * unmapped (pass-through) so AgentGuard does not block third-party tools it + * cannot reason about. + */ +const TOOL_ACTION_MAP: Record = { + run_terminal_command: 'exec_command', + create_new_file: 'write_file', + edit_existing_file: 'write_file', + single_find_and_replace: 'write_file', + multi_edit: 'write_file', + read_file: 'read_file', + read_file_range: 'read_file', + read_currently_open_file: 'read_file', + fetch_url_content: 'network_request', + search_web: 'web_search', +}; + +function firstString(...values: unknown[]): string { + for (const value of values) { + if (typeof value === 'string' && value.length > 0) return value; + } + return ''; +} + +function eventTypeFromHookName(name: string): 'pre' | 'post' { + // Continue: PreToolUse / PostToolUse / PostToolUseFailure + if (name.startsWith('Post')) return 'post'; + return 'pre'; +} + +/** + * Continue hook adapter. + * + * Continue's PreToolUse payload (stdin JSON): + * { + * "hook_event_name": "PreToolUse", + * "tool_name": "run_terminal_command", + * "tool_input": {...}, + * "tool_use_id": "...", + * "session_id": "...", + * "cwd": "/repo", + * "transcript_path": "..." + * } + * + * Response shape lives in the engine bridge (continue-hook.js); this adapter + * is responsible only for normalizing the payload and building an envelope. + */ +export class ContinueAdapter implements HookAdapter { + readonly name = 'continue'; + + parseInput(raw: unknown): HookInput { + const data = (raw as Record) || {}; + const hookEvent = firstString(data.hook_event_name, data.hookEventName); + + return { + toolName: firstString(data.tool_name, data.toolName), + toolInput: + ((data.tool_input as Record) || + (data.toolInput as Record) || + {}) as Record, + eventType: eventTypeFromHookName(hookEvent), + sessionId: firstString(data.session_id, data.sessionId, data.tool_use_id) || undefined, + cwd: firstString(data.cwd) || undefined, + raw: data, + }; + } + + mapToolToActionType(toolName: string): string | null { + return TOOL_ACTION_MAP[toolName] || null; + } + + buildEnvelope(input: HookInput, initiatingSkill?: string | null): ActionEnvelope | null { + const actionType = this.mapToolToActionType(input.toolName); + if (!actionType) return null; + + const actor = { + skill: { + id: initiatingSkill || 'continue-session', + source: initiatingSkill || 'continue', + version_ref: '0.0.0', + artifact_hash: '', + }, + }; + + const context = { + session_id: input.sessionId || `continue-${Date.now()}`, + user_present: true, + env: 'prod' as const, + time: new Date().toISOString(), + initiating_skill: initiatingSkill || undefined, + }; + + let actionData: Record; + + switch (actionType) { + case 'exec_command': { + const ti = input.toolInput; + actionData = { + command: firstString(ti.command, ti.cmd), + args: [], + cwd: firstString(ti.cwd, input.cwd), + }; + break; + } + + case 'write_file': { + // Continue's multi_edit accepts an `edits: [{filePath, old_string, + // new_string, ...}]` list. Apply the same worst-case-path rule as the + // Cline adapter (sensitive target wins) AND surface the per-edit + // operations to the policy engine, so a multi_edit batch is + // distinguishable from a single-path write and the engine can reason + // about the actual change (e.g. count of operations, presence of + // delete-style edits with empty new_string). + const ti = input.toolInput; + const paths = collectWritePaths(ti); + const sensitive = paths.find((p) => isSensitivePath(p)); + const primary = + sensitive || + paths[0] || + firstString(ti.filepath, ti.file_path, ti.filePath, ti.path, ti.target); + + const operations = collectWriteOperations(ti, primary); + + actionData = { + path: primary, + ...(paths.length > 1 ? { paths } : {}), + ...(sensitive ? { sensitive_path: sensitive } : {}), + ...(operations.length > 0 ? { operations } : {}), + }; + break; + } + + case 'read_file': { + const ti = input.toolInput; + actionData = { + path: firstString(ti.filepath, ti.file_path, ti.filePath, ti.path, ti.target), + }; + break; + } + + case 'network_request': { + const ti = input.toolInput; + actionData = { + method: firstString(ti.method) || 'GET', + url: firstString(ti.url, ti.uri, ti.href), + body_preview: ti.body as string | undefined, + }; + break; + } + + case 'web_search': { + const ti = input.toolInput; + actionData = { + query: firstString(ti.query, ti.q, ti.search), + }; + break; + } + + default: + return null; + } + + return { + actor, + action: { type: actionType, data: actionData }, + context, + } as unknown as ActionEnvelope; + } + + async inferInitiatingSkill(input: HookInput): Promise { + // Continue passes `transcript_path` and `cwd` but no first-class "skill" + // attribution. Honor an explicit hint when present. + const raw = input.raw as Record; + return ( + firstString(raw.initiating_skill, raw.sourceSkill, raw.skill) || null + ); + } +} + +/** + * Normalize a Continue write-tool input into a list of `{ path, kind, + * old_preview, new_preview, is_delete }` operations the engine can reason + * about. Strings are previewed at ≤256 chars to keep the envelope bounded. + */ +function collectWriteOperations( + toolInput: Record, + fallbackPath: string +): Array> { + const preview = (v: unknown): string => { + if (typeof v !== 'string') return ''; + return v.length <= 256 ? v : `${v.slice(0, 256)}…`; + }; + + const out: Array> = []; + const edits = toolInput.edits; + if (Array.isArray(edits)) { + for (const entry of edits) { + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + const editPath = + firstString(e.filepath, e.file_path, e.filePath, e.path) || fallbackPath; + const oldStr = typeof e.old_string === 'string' ? e.old_string : ''; + const newStr = typeof e.new_string === 'string' ? e.new_string : ''; + out.push({ + path: editPath, + kind: 'edit', + old_preview: preview(oldStr), + new_preview: preview(newStr), + is_delete: oldStr.length > 0 && newStr.length === 0, + }); + } + } + + // single_find_and_replace exposes one op as flat fields on toolInput. + if ( + out.length === 0 && + (typeof toolInput.old_string === 'string' || typeof toolInput.new_string === 'string') + ) { + const oldStr = typeof toolInput.old_string === 'string' ? toolInput.old_string : ''; + const newStr = typeof toolInput.new_string === 'string' ? toolInput.new_string : ''; + out.push({ + path: fallbackPath, + kind: 'edit', + old_preview: preview(oldStr), + new_preview: preview(newStr), + is_delete: oldStr.length > 0 && newStr.length === 0, + }); + } + + // create_new_file exposes the new content as `contents` or `content`. + const newContent = firstString(toolInput.contents, toolInput.content); + if (out.length === 0 && newContent) { + out.push({ + path: fallbackPath, + kind: 'create', + new_preview: preview(newContent), + }); + } + + return out; +} + +function collectWritePaths(toolInput: Record): string[] { + const out: string[] = []; + const single = firstString( + toolInput.filepath, + toolInput.file_path, + toolInput.filePath, + toolInput.path, + toolInput.target + ); + if (single) out.push(single); + const edits = toolInput.edits; + if (Array.isArray(edits)) { + for (const entry of edits) { + if (!entry || typeof entry !== 'object') continue; + const p = firstString( + (entry as Record).filepath, + (entry as Record).file_path, + (entry as Record).filePath, + (entry as Record).path + ); + if (p) out.push(p); + } + } + const files = toolInput.files || toolInput.file_paths || toolInput.paths; + if (Array.isArray(files)) { + for (const entry of files) { + if (typeof entry === 'string' && entry.length > 0) out.push(entry); + } + } + return out; +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index e0a3765..811ace7 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -2,6 +2,7 @@ export type { HookAdapter, HookInput, HookOutput, EngineOptions, AgentGuardInsta export { ClaudeCodeAdapter } from './claude-code.js'; export { OpenClawAdapter } from './openclaw.js'; export { HermesAdapter } from './hermes.js'; +export { ContinueAdapter } from './continue.js'; export { evaluateHook } from './engine.js'; export { registerOpenClawPlugin, diff --git a/src/cli.ts b/src/cli.ts index 277609c..4d1dca5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -40,15 +40,17 @@ import { type CronBackend, type ThreatFeedCronRemovalResult, type OpenClawGatewayOptions, + type CronAgentHost, } from './feed/cron.js'; -const SUPPORTED_AGENT_INSTALLERS: AgentInstaller[] = ['claude-code', 'codex', 'openclaw', 'hermes', 'qclaw']; +const SUPPORTED_AGENT_INSTALLERS: AgentInstaller[] = ['claude-code', 'codex', 'openclaw', 'hermes', 'qclaw', 'continue']; 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' }, + { agent: 'continue', dir: '.continue' }, ]; const REQUIRED_INIT_COMMAND = 'agentguard init --agent auto'; @@ -64,7 +66,7 @@ async function main() { .command('init') .description('Create ~/.agentguard/config.json and local runtime paths') .option('--level ', 'Protection level: strict | balanced | permissive') - .option('--agent ', 'Install hook/template for claude-code, codex, openclaw, hermes, or qclaw') + .option('--agent ', 'Install hook/template for claude-code, codex, openclaw, hermes, qclaw, or continue') .option('--cloud ', 'AgentGuard Cloud URL to store in local config') .option('--shell-hooks', 'For Hermes: install legacy shell hooks instead of the native plugin') .option('--force', 'Overwrite existing hook/template files') @@ -91,7 +93,7 @@ async function main() { if (normalizedAgent === 'auto') { const results = initAutoAgents(config, forceTemplates); if (results.detected.length === 0) { - console.log('No supported agent directories found. Looked for .claude, .openclaw, .hermes, .qclaw, and .codex.'); + console.log('No supported agent directories found. Looked for .claude, .openclaw, .hermes, .qclaw, .codex, and .continue.'); } else if (results.installed.length === 0) { console.log('No agent templates were installed; all detected agent initializers failed.'); } @@ -106,7 +108,7 @@ async function main() { return; } if (!SUPPORTED_AGENT_INSTALLERS.includes(normalizedAgent as AgentInstaller)) { - throw new Error('Invalid agent. Use auto, claude-code, codex, openclaw, hermes, or qclaw.'); + throw new Error('Invalid agent. Use auto, claude-code, codex, openclaw, hermes, qclaw, or continue.'); } const agent = normalizedAgent as AgentInstaller; config.agentHost = agent; @@ -202,10 +204,11 @@ async function main() { .description('Disconnect local AgentGuard from AgentGuard Cloud') .action(async () => { const currentConfig = ensureConfig(); + noteCronBackendFallbackIfNeeded(currentConfig); const cronRemoval = await removeThreatFeedCron({ name: currentConfig.threatFeedCronName || 'agentguard-threat-feed', backend: 'auto', - agentHost: resolveCronAgentHost(currentConfig), + agentHost: resolveCronBackendHost(currentConfig), agentGuardHome: getAgentGuardPaths().home, }); const config = disconnectCloud(); @@ -703,13 +706,14 @@ async function main() { if (options.cron && !options.cronRun) { summary.cron.requested = true; try { + noteCronBackendFallbackIfNeeded(config); summary.cron.result = await installThreatFeedCron({ name: options.cronName as string, cronExpression: cronExpression!, quiet, force: Boolean(options.force), backend: cronTarget, - agentHost: resolveCronAgentHost(config), + agentHost: resolveCronBackendHost(config), agentGuardHome: getAgentGuardPaths().home, }, { gateway: resolveOpenClawGatewayOptionsFromEnv(), @@ -1062,10 +1066,62 @@ function printCronRemovalSummary(results: ThreatFeedCronRemovalResult[]): void { console.log('No AgentGuard subscribe cron job was found.'); } -function resolveCronAgentHost(config: AgentGuardConfig): AgentGuardAgentHost | undefined { +/** + * Hosts that have a cron-targeted backend (OpenClaw / Hermes use agent-managed + * cron; the rest fall through to system cron). Hosts not in this set still + * accept cron commands — they just default to the system backend. + */ +const CRON_CAPABLE_HOSTS = new Set([ + 'claude-code', + 'codex', + 'openclaw', + 'hermes', + 'qclaw', +]); + +/** + * Return the host configured for this AgentGuard install. Always returns the + * raw host (never silently strips) so messaging code can name it correctly. + */ +function resolveConfiguredAgentHost(config: AgentGuardConfig): AgentGuardAgentHost | undefined { return config.agentHost ?? config.agentHosts?.[0]; } +/** + * Narrow the configured host to one of the cron-capable backends used by + * `installThreatFeedCron` / `removeThreatFeedCron`. Returns undefined when + * the host has no specific cron backend — callers should treat that as + * "fall back to system cron" rather than as "no host configured". + */ +function resolveCronBackendHost(config: AgentGuardConfig): CronAgentHost | undefined { + const host = resolveConfiguredAgentHost(config); + return host && CRON_CAPABLE_HOSTS.has(host) ? (host as CronAgentHost) : undefined; +} + +/** + * If the user has a host configured that has no agent-specific cron backend + * (e.g. `continue`, `goose`), print a one-line stderr note so they know cron + * is falling back to the system scheduler rather than silently doing nothing + * surprising. Idempotent per-process — fires once. + */ +let cronFallbackNoteShown = false; +function noteCronBackendFallbackIfNeeded(config: AgentGuardConfig): void { + if (cronFallbackNoteShown) return; + const host = resolveConfiguredAgentHost(config); + if (!host || CRON_CAPABLE_HOSTS.has(host)) return; + cronFallbackNoteShown = true; + console.error( + `Note: agent host "${host}" has no agent-managed cron backend. Cron commands will use the system scheduler. ` + + `Run \`agentguard init --agent openclaw\` or \`agentguard init --agent hermes\` if you'd like an agent-managed schedule instead.` + ); +} + +// Back-compat name retained so existing call sites compile without churn. +// New code should use `resolveCronBackendHost` for clarity. +function resolveCronAgentHost(config: AgentGuardConfig): CronAgentHost | undefined { + return resolveCronBackendHost(config); +} + function readStdinIfAvailable(): string { if (process.stdin.isTTY) return ''; try { diff --git a/src/config.ts b/src/config.ts index 2a6adfa..38f24e0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,7 @@ 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 type AgentGuardAgentHost = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw' | 'continue'; export interface AgentGuardConfig { version: 1; @@ -226,7 +226,7 @@ function normalizeLevel(value: unknown): AgentGuardConfig['level'] | null { } function normalizeAgentHost(value: unknown): AgentGuardAgentHost | undefined { - return value === 'claude-code' || value === 'codex' || value === 'openclaw' || value === 'hermes' || value === 'qclaw' + return value === 'claude-code' || value === 'codex' || value === 'openclaw' || value === 'hermes' || value === 'qclaw' || value === 'continue' ? value : undefined; } diff --git a/src/installers.ts b/src/installers.ts index 24f729b..ab6ad89 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -1,8 +1,8 @@ -import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; -export type AgentInstaller = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw'; +export type AgentInstaller = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw' | 'continue'; export interface InstallResult { agent: AgentInstaller; @@ -21,6 +21,7 @@ export function installAgentTemplates(agent: AgentInstaller, options: { cwd?: st if (agent === 'openclaw') return installOpenClaw(options.cwd, Boolean(options.force)); if (agent === 'hermes') return installHermes(options.cwd, Boolean(options.force), { shellHooks: Boolean(options.shellHooks) }); if (agent === 'qclaw') return installQClaw(root, Boolean(options.force)); + if (agent === 'continue') return installContinue(options.cwd, Boolean(options.force)); throw new Error(`Unsupported agent installer: ${agent}`); } @@ -240,10 +241,158 @@ function installQClaw(root: string, force: boolean): InstallResult { return { agent: 'qclaw', files: pluginResult.files }; } +function installContinue(cwd: string | undefined, force: boolean): InstallResult { + // Continue's CLI (`cn`) reads hooks from `~/.continue/settings.json` and + // `~/.claude/settings.json` (plus cwd variants). We install into + // .continue/settings.local.json so Continue users on the same machine as a + // Claude Code install don't double-register. + // + // Hook contract is documented in continuedev/continue at + // extensions/cli/src/hooks/types.ts. + const continueRoot = cwd + ? join(cwd, '.continue') + : process.env.CONTINUE_GLOBAL_DIR?.trim() || join(homedir(), '.continue'); + + const skillDir = join(continueRoot, 'skills', 'agentguard'); + const settingsPath = join(continueRoot, 'settings.local.json'); + + // 1. Drop the shared skill (carries continue-hook.js + supporting scripts). + copyBundledSkill(skillDir, force); + + // 2. Resolve the absolute path of the bundled engine bridge. Continue + // spawns the hook command directly, so a `node ` value + // in settings.json is enough — no spawnSync shim, no stdin-passthrough + // questions, and no extra process boundary. + const hookScriptPath = join(skillDir, 'scripts', 'continue-hook.js'); + + // 3. Merge AgentGuard into the user's existing Continue settings (preserve + // any prior hooks under PreToolUse / PostToolUse). + mergeContinueSettings(settingsPath, hookScriptPath, force); + + return { + agent: 'continue', + files: [skillDir, settingsPath], + }; +} + +function mergeContinueSettings( + settingsPath: string, + hookScriptPath: string, + force: boolean +): void { + const existing: Record = existsSync(settingsPath) + ? safeJsonParseObject(readFileSync(settingsPath, 'utf8')) + : {}; + const hooks = (existing.hooks && typeof existing.hooks === 'object' && !Array.isArray(existing.hooks) + ? (existing.hooks as Record) + : {}); + + const command = continueHookCommand(hookScriptPath); + const pre = ensureContinueHookEvent(hooks.PreToolUse, command, force); + const post = ensureContinueHookEvent(hooks.PostToolUse, command, force); + + const merged = { + ...existing, + hooks: { + ...hooks, + PreToolUse: pre, + PostToolUse: post, + }, + }; + + mkdirSync(dirname(settingsPath), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 }); +} + +/** + * Build a Continue hook command string that survives shell parsing of paths + * with spaces, single quotes, or backslashes. + * + * Continue's hook runner (`extensions/cli/src/hooks/hookRunner.ts`) executes + * `command` as a shell command, so JSON-quoting is wrong — JSON escapes are + * not shell escapes. POSIX-style single-quoting works on macOS/Linux; for + * Windows we wrap in `"..."` because cmd.exe does not honor single quotes. + * + * Embedded single quotes are handled by the POSIX trick `'\''` (close the + * single-quoted string, emit an escaped quote, re-open). Embedded double + * quotes on Windows are not allowed in paths by NTFS so we don't escape + * them — but we do guard with a clear error. + */ +function continueHookCommand(hookScriptPath: string): string { + if (process.platform === 'win32') { + if (hookScriptPath.includes('"')) { + throw new Error( + `Continue hook script path may not contain a double quote on Windows: ${hookScriptPath}` + ); + } + return `node "${hookScriptPath}"`; + } + // POSIX-safe single-quote escaping: any ' in the path becomes '\'' + const escaped = hookScriptPath.replace(/'/g, "'\\''"); + return `node '${escaped}'`; +} + +function ensureContinueHookEvent(existing: unknown, command: string, force: boolean): unknown[] { + const list = Array.isArray(existing) ? [...existing] : []; + + // Dedup by substring ("continue-hook.js"), not exact equality, so a prior + // entry written by an older AgentGuard version (e.g. with JSON quoting + // instead of shell quoting) is recognized as ours and replaced, not + // appended-alongside. + const isAgentGuardEntry = (entry: unknown): boolean => { + if (!entry || typeof entry !== 'object') return false; + const hooks = (entry as Record).hooks; + if (!Array.isArray(hooks)) return false; + return hooks.some((h) => { + if (!h || typeof h !== 'object') return false; + const cmd = (h as Record).command; + return typeof cmd === 'string' && cmd.includes('continue-hook.js'); + }); + }; + + const hasExactMatch = list.some((entry) => { + if (!entry || typeof entry !== 'object') return false; + const hooks = (entry as Record).hooks; + if (!Array.isArray(hooks)) return false; + return hooks.some((h) => { + if (!h || typeof h !== 'object') return false; + return (h as Record).command === command; + }); + }); + + // Idempotent fast path: exact-match entry already present and we're not + // being asked to rewrite — leave the list untouched. + if (hasExactMatch && !force) return list; + + // Otherwise (forced, or stale-format AgentGuard entry present, or no entry + // at all): strip any prior AgentGuard entries and re-add canonically. + const filtered = list.filter((entry) => !isAgentGuardEntry(entry)); + filtered.push({ + matcher: '.*', + hooks: [{ type: 'command', command }], + }); + return filtered; +} + +function safeJsonParseObject(text: string): Record { + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // fall through + } + return {}; +} + function writeIfAllowed(path: string, content: string, force: boolean): void { if (existsSync(path) && !force) return; mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, content, { mode: path.endsWith('.sh') ? 0o755 : undefined }); + const isExecutable = + path.endsWith('.sh') || + /\/(\.continue|\.hermes)\/hooks\//.test(path); + writeFileSync(path, content, { mode: isExecutable ? 0o755 : undefined }); } function copyBundledSkill(targetDir: string, force: boolean): void { diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 60cc09e..65e08f7 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -23,6 +23,7 @@ export type RuntimeAgentHost = | 'cursor' | 'gemini' | 'copilot' + | 'continue' | 'other'; export interface PolicyReason { diff --git a/src/tests/adapter.test.ts b/src/tests/adapter.test.ts index 7b7ed01..0a242ca 100644 --- a/src/tests/adapter.test.ts +++ b/src/tests/adapter.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { ClaudeCodeAdapter } from '../adapters/claude-code.js'; import { OpenClawAdapter } from '../adapters/openclaw.js'; import { HermesAdapter } from '../adapters/hermes.js'; +import { ContinueAdapter } from '../adapters/continue.js'; import { isSensitivePath, shouldDenyAtLevel, @@ -502,6 +503,261 @@ describe('HermesAdapter', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// ContinueAdapter +// ───────────────────────────────────────────────────────────────────────────── + +describe('ContinueAdapter', () => { + const adapter = new ContinueAdapter(); + + it('should have name "continue"', () => { + assert.equal(adapter.name, 'continue'); + }); + + describe('parseInput', () => { + it('should parse a Continue PreToolUse payload', () => { + const raw = { + hook_event_name: 'PreToolUse', + tool_name: 'run_terminal_command', + tool_input: { command: 'echo hello' }, + tool_use_id: 'tu-1', + session_id: 'sess-1', + cwd: '/repo', + }; + const input = adapter.parseInput(raw); + assert.equal(input.toolName, 'run_terminal_command'); + assert.equal(input.eventType, 'pre'); + assert.deepEqual(input.toolInput, { command: 'echo hello' }); + assert.equal(input.sessionId, 'sess-1'); + assert.equal(input.cwd, '/repo'); + }); + + it('should parse PostToolUse / PostToolUseFailure as post events', () => { + assert.equal(adapter.parseInput({ hook_event_name: 'PostToolUse' }).eventType, 'post'); + assert.equal(adapter.parseInput({ hook_event_name: 'PostToolUseFailure' }).eventType, 'post'); + }); + + it('should fall back to tool_use_id when session_id is absent', () => { + const input = adapter.parseInput({ + hook_event_name: 'PreToolUse', + tool_name: 'run_terminal_command', + tool_input: { command: 'echo hi' }, + tool_use_id: 'tu-only', + }); + assert.equal(input.sessionId, 'tu-only'); + }); + + it('should handle missing fields gracefully', () => { + const input = adapter.parseInput({}); + assert.equal(input.toolName, ''); + assert.deepEqual(input.toolInput, {}); + assert.equal(input.eventType, 'pre'); + }); + }); + + describe('mapToolToActionType', () => { + it('should map run_terminal_command to exec_command', () => { + assert.equal(adapter.mapToolToActionType('run_terminal_command'), 'exec_command'); + }); + + it('should map write tools to write_file', () => { + assert.equal(adapter.mapToolToActionType('create_new_file'), 'write_file'); + assert.equal(adapter.mapToolToActionType('edit_existing_file'), 'write_file'); + assert.equal(adapter.mapToolToActionType('single_find_and_replace'), 'write_file'); + assert.equal(adapter.mapToolToActionType('multi_edit'), 'write_file'); + }); + + it('should map read tools to read_file', () => { + assert.equal(adapter.mapToolToActionType('read_file'), 'read_file'); + assert.equal(adapter.mapToolToActionType('read_file_range'), 'read_file'); + assert.equal(adapter.mapToolToActionType('read_currently_open_file'), 'read_file'); + }); + + it('should split search_web from fetch_url_content', () => { + assert.equal(adapter.mapToolToActionType('search_web'), 'web_search'); + assert.equal(adapter.mapToolToActionType('fetch_url_content'), 'network_request'); + }); + + it('should return null for MCP / unknown / out-of-scope tools', () => { + assert.equal(adapter.mapToolToActionType('grep_search'), null); + assert.equal(adapter.mapToolToActionType('file_glob_search'), null); + assert.equal(adapter.mapToolToActionType('ls'), null); + assert.equal(adapter.mapToolToActionType('codebase'), null); + assert.equal(adapter.mapToolToActionType('UnknownTool'), null); + assert.equal(adapter.mapToolToActionType(''), null); + }); + }); + + describe('buildEnvelope', () => { + it('should build exec_command envelope from run_terminal_command', () => { + const input = adapter.parseInput({ + hook_event_name: 'PreToolUse', + tool_name: 'run_terminal_command', + tool_input: { command: 'ls -la' }, + cwd: '/repo', + session_id: 'sess-1', + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + assert.equal(envelope!.action.type, 'exec_command'); + assert.equal((envelope!.action.data as unknown as Record).command, 'ls -la'); + assert.equal((envelope!.action.data as unknown as Record).cwd, '/repo'); + }); + + it('should build write_file envelope from create_new_file (filepath spelling)', () => { + const input = adapter.parseInput({ + hook_event_name: 'PreToolUse', + tool_name: 'create_new_file', + tool_input: { filepath: '/project/.env', contents: 'SECRET=1' }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + assert.equal(envelope!.action.type, 'write_file'); + assert.equal((envelope!.action.data as unknown as Record).path, '/project/.env'); + }); + + it('should prefer a sensitive path when multi_edit batches multiple files', () => { + const input = adapter.parseInput({ + hook_event_name: 'PreToolUse', + tool_name: 'multi_edit', + tool_input: { + edits: [ + { filepath: '/repo/src/foo.ts' }, + { filepath: '/repo/.env.production' }, + { filepath: '/repo/src/bar.ts' }, + ], + }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + const data = envelope!.action.data as unknown as Record; + assert.equal(data.path, '/repo/.env.production'); + assert.equal(data.sensitive_path, '/repo/.env.production'); + assert.deepEqual(data.paths, [ + '/repo/src/foo.ts', + '/repo/.env.production', + '/repo/src/bar.ts', + ]); + }); + + it('should surface multi_edit operations (path/kind/previews/is_delete) for policy', () => { + // Locks in the medium-severity review fix: the envelope must include + // the actual edits so policy can distinguish a benign path list from + // a destructive batch (e.g. empty new_string deletes). + const input = adapter.parseInput({ + hook_event_name: 'PreToolUse', + tool_name: 'multi_edit', + tool_input: { + edits: [ + { filepath: '/repo/src/foo.ts', old_string: 'a', new_string: 'b' }, + { filepath: '/repo/src/secret.ts', old_string: 'EVERYTHING', new_string: '' }, + ], + }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + const data = envelope!.action.data as unknown as Record; + const ops = data.operations as Array>; + assert.equal(ops.length, 2); + assert.equal(ops[0].path, '/repo/src/foo.ts'); + assert.equal(ops[0].kind, 'edit'); + assert.equal(ops[0].is_delete, false); + assert.equal(ops[1].path, '/repo/src/secret.ts'); + assert.equal(ops[1].is_delete, true, 'old_string non-empty + new_string empty == delete'); + }); + + it('should preview large multi_edit strings without ballooning the envelope', () => { + const huge = 'X'.repeat(10_000); + const input = adapter.parseInput({ + hook_event_name: 'PreToolUse', + tool_name: 'multi_edit', + tool_input: { + edits: [{ filepath: '/repo/a.txt', old_string: huge, new_string: huge }], + }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + const ops = (envelope!.action.data as unknown as Record).operations as Array< + Record + >; + const old = ops[0].old_preview as string; + assert.ok(old.length <= 257, `preview must be ≤256 chars + ellipsis, got ${old.length}`); + assert.ok(old.endsWith('…')); + }); + + it('should surface create_new_file content as a create operation', () => { + const input = adapter.parseInput({ + hook_event_name: 'PreToolUse', + tool_name: 'create_new_file', + tool_input: { filepath: '/repo/.env', contents: 'SECRET=1' }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + const ops = (envelope!.action.data as unknown as Record).operations as Array< + Record + >; + assert.equal(ops[0].kind, 'create'); + assert.equal(ops[0].new_preview, 'SECRET=1'); + }); + + it('should build read_file envelope', () => { + const input = adapter.parseInput({ + hook_event_name: 'PreToolUse', + tool_name: 'read_file', + tool_input: { filepath: '/tmp/readme.md' }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + assert.equal(envelope!.action.type, 'read_file'); + assert.equal((envelope!.action.data as unknown as Record).path, '/tmp/readme.md'); + }); + + it('should build network_request envelope from fetch_url_content', () => { + const input = adapter.parseInput({ + hook_event_name: 'PreToolUse', + tool_name: 'fetch_url_content', + tool_input: { url: 'https://example.com/page' }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + assert.equal(envelope!.action.type, 'network_request'); + assert.equal((envelope!.action.data as unknown as Record).url, 'https://example.com/page'); + }); + + it('should build web_search envelope from search_web query', () => { + const input = adapter.parseInput({ + hook_event_name: 'PreToolUse', + tool_name: 'search_web', + tool_input: { query: 'continue plugins' }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + assert.equal(envelope!.action.type, 'web_search'); + assert.equal((envelope!.action.data as unknown as Record).query, 'continue plugins'); + }); + + it('should return null for unmapped tools (e.g. grep_search)', () => { + const input = adapter.parseInput({ + hook_event_name: 'PreToolUse', + tool_name: 'grep_search', + tool_input: { query: 'foo' }, + }); + assert.equal(adapter.buildEnvelope(input), null); + }); + }); + + describe('inferInitiatingSkill', () => { + it('should return null when Continue provides no skill metadata', async () => { + const input = adapter.parseInput({ + hook_event_name: 'PreToolUse', + tool_name: 'run_terminal_command', + tool_input: { command: 'echo' }, + }); + assert.equal(await adapter.inferInitiatingSkill(input), null); + }); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // Common utilities // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/tests/installer.test.ts b/src/tests/installer.test.ts index d712316..cac5725 100644 --- a/src/tests/installer.test.ts +++ b/src/tests/installer.test.ts @@ -297,4 +297,134 @@ describe('Agent template installers', () => { assert.deepEqual(config.plugins.allow, ['existing', 'agentguard']); assert.equal(config.plugins.entries.agentguard.enabled, true); }); + + it('installs Continue skill and merges PreToolUse / PostToolUse hooks into settings.local.json', () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-continue-')); + const result = installAgentTemplates('continue', { cwd: dir }); + + const skillDir = join(dir, '.continue', 'skills', 'agentguard'); + const settingsPath = join(dir, '.continue', 'settings.local.json'); + + assert.equal(result.agent, 'continue'); + assert.ok(existsSync(skillDir)); + assert.ok(existsSync(join(skillDir, 'scripts', 'continue-hook.js'))); + assert.ok(existsSync(settingsPath)); + + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')); + const pre = settings.hooks.PreToolUse; + const post = settings.hooks.PostToolUse; + assert.ok(Array.isArray(pre) && pre.length >= 1); + assert.ok(Array.isArray(post) && post.length >= 1); + assert.equal(pre[0].matcher, '.*'); + const preCmd = pre[0].hooks[0].command; + assert.equal(pre[0].hooks[0].type, 'command'); + assert.ok(preCmd.includes('continue-hook.js')); + assert.ok(/^node /.test(preCmd)); + }); + + it("preserves a user's existing Continue hooks when merging AgentGuard", () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-continue-existing-')); + const settingsPath = join(dir, '.continue', 'settings.local.json'); + mkdirSync(join(dir, '.continue'), { recursive: true }); + writeFileSync( + settingsPath, + JSON.stringify( + { + theme: 'dark', + hooks: { + PreToolUse: [ + { matcher: 'run_terminal_command', hooks: [{ type: 'command', command: 'echo keep' }] }, + ], + }, + }, + null, + 2 + ) + ); + + installAgentTemplates('continue', { cwd: dir }); + + const merged = JSON.parse(readFileSync(settingsPath, 'utf8')); + assert.equal(merged.theme, 'dark'); + const pre = merged.hooks.PreToolUse as Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>; + assert.ok(Array.isArray(pre) && pre.length >= 2); + const userEntry = pre.find((e) => e.matcher === 'run_terminal_command'); + assert.ok(userEntry); + const guardEntry = pre.find((e) => + e.hooks?.some((h) => typeof h.command === 'string' && h.command.includes('continue-hook.js')) + ); + assert.ok(guardEntry); + }); + + it('is idempotent: re-running continue install does not duplicate AgentGuard entries', () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-continue-idempotent-')); + installAgentTemplates('continue', { cwd: dir }); + installAgentTemplates('continue', { cwd: dir }); + + const settings = JSON.parse( + readFileSync(join(dir, '.continue', 'settings.local.json'), 'utf8') + ); + const pre = settings.hooks.PreToolUse as Array<{ hooks: Array<{ command: string }> }>; + const guardEntries = pre.filter((e) => + e.hooks.some((h) => h.command.includes('continue-hook.js')) + ); + assert.equal(guardEntries.length, 1); + }); + + it('shell-quotes the Continue hook command so paths with spaces survive', () => { + // Lock in the medium-severity review fix: replacing JSON.stringify with + // POSIX single-quote escaping. Paths containing spaces and apostrophes + // must produce a command Continue's shell-based hook runner can parse. + const dir = mkdtempSync(join(tmpdir(), "agentguard-continue space's-")); + installAgentTemplates('continue', { cwd: dir }); + const settings = JSON.parse( + readFileSync(join(dir, '.continue', 'settings.local.json'), 'utf8') + ); + const cmd = settings.hooks.PreToolUse[0].hooks[0].command as string; + // On POSIX the command must wrap the path in single quotes (no JSON + // double-quoting). The Windows branch is exercised in CI on win32. + if (process.platform !== 'win32') { + assert.match(cmd, /^node '.*continue-hook\.js'$/, `got ${cmd}`); + // Apostrophes in the path must use the POSIX '\'' + // close-escape-reopen trick. The tmp dir name contains one. + if (dir.includes("'")) { + assert.ok(cmd.includes("'\\''"), `expected '\\'' escape, got ${cmd}`); + } + } + }); + + it("recognizes a prior AgentGuard entry written with old JSON-quoted command (idempotent)", () => { + // Lock in the dedup-by-substring behavior: a Continue install written by + // an older AgentGuard version (which used JSON.stringify(...)) must be + // recognized as ours and replaced, not duplicated. + const dir = mkdtempSync(join(tmpdir(), 'agentguard-continue-stale-')); + const settingsPath = join(dir, '.continue', 'settings.local.json'); + mkdirSync(join(dir, '.continue'), { recursive: true }); + writeFileSync( + settingsPath, + JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: '.*', + hooks: [ + { + type: 'command', + command: 'node "/old/path/continue-hook.js"', // JSON-quoted, stale + }, + ], + }, + ], + }, + }) + ); + + installAgentTemplates('continue', { cwd: dir }); + + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')); + const guardEntries = (settings.hooks.PreToolUse as Array<{ + hooks: Array<{ command: string }>; + }>).filter((e) => e.hooks.some((h) => h.command.includes('continue-hook.js'))); + assert.equal(guardEntries.length, 1, 'stale entry must be replaced, not appended-alongside'); + }); }); diff --git a/src/tests/smoke.test.ts b/src/tests/smoke.test.ts index 7de6dee..38a68e1 100644 --- a/src/tests/smoke.test.ts +++ b/src/tests/smoke.test.ts @@ -15,6 +15,7 @@ import { SkillScanner } from '../scanner/index.js'; const projectRoot = resolve(__dirname, '..', '..'); const GUARD_HOOK_PATH = join(projectRoot, 'skills', 'agentguard', 'scripts', 'guard-hook.js'); const HERMES_HOOK_PATH = join(projectRoot, 'skills', 'agentguard', 'scripts', 'hermes-hook.js'); +const CONTINUE_HOOK_PATH = join(projectRoot, 'skills', 'agentguard', 'scripts', 'continue-hook.js'); const TRUST_CLI_PATH = join(projectRoot, 'skills', 'agentguard', 'scripts', 'trust-cli.js'); const ACTION_CLI_PATH = join(projectRoot, 'skills', 'agentguard', 'scripts', 'action-cli.js'); @@ -53,6 +54,17 @@ function runHermesHookRaw(input: string): Promise<{ return runNodeHookRaw(HERMES_HOOK_PATH, input); } +function runContinueHook( + input: Record, + env: Record = {} +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + return runNodeHook(CONTINUE_HOOK_PATH, input, env); +} + +function runContinueHookRaw(input: string): Promise<{ exitCode: number; stdout: string; stderr: string }> { + return runNodeHookRaw(CONTINUE_HOOK_PATH, input); +} + function runNodeHook( scriptPath: string, input: Record, @@ -354,6 +366,219 @@ describe('Smoke: hermes-hook.js E2E', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// E2: continue-hook.js subprocess E2E +// ───────────────────────────────────────────────────────────────────────────── + +describe('Smoke: continue-hook.js E2E', () => { + it('should allow echo hello with empty JSON output', async () => { + const { exitCode, stdout } = await runContinueHook({ + hook_event_name: 'PreToolUse', + tool_name: 'run_terminal_command', + tool_input: { command: 'echo hello' }, + }); + assert.equal(exitCode, 0); + assert.deepEqual(JSON.parse(stdout), {}); + }); + + it('should block rm -rf / with Continue deny protocol', async () => { + const { exitCode, stdout } = await runContinueHook({ + hook_event_name: 'PreToolUse', + tool_name: 'run_terminal_command', + tool_input: { command: 'rm -rf /' }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { + hookSpecificOutput?: { + hookEventName?: string; + permissionDecision?: string; + permissionDecisionReason?: string; + }; + }; + assert.equal(payload.hookSpecificOutput?.hookEventName, 'PreToolUse'); + assert.equal(payload.hookSpecificOutput?.permissionDecision, 'deny'); + assert.ok(payload.hookSpecificOutput?.permissionDecisionReason?.includes('AgentGuard')); + }); + + it('should map AgentGuard require_approval to Continue "ask" for .env writes', async () => { + const { exitCode, stdout } = await runContinueHook({ + hook_event_name: 'PreToolUse', + tool_name: 'create_new_file', + tool_input: { filepath: '/project/.env', contents: 'SECRET=1' }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { + hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; + }; + const decision = payload.hookSpecificOutput?.permissionDecision; + assert.ok(decision === 'ask' || decision === 'deny', `expected ask or deny, got ${decision}`); + assert.ok(payload.hookSpecificOutput?.permissionDecisionReason?.includes('AgentGuard')); + }); + + it('should pick a sensitive path from multi_edit even if listed later', async () => { + const { exitCode, stdout } = await runContinueHook({ + hook_event_name: 'PreToolUse', + tool_name: 'multi_edit', + tool_input: { + edits: [ + { filepath: '/repo/src/foo.ts' }, + { filepath: '/repo/.env.production' }, + ], + }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { + hookSpecificOutput?: { permissionDecision?: string }; + }; + assert.ok( + payload.hookSpecificOutput?.permissionDecision === 'ask' || + payload.hookSpecificOutput?.permissionDecision === 'deny', + `multi_edit batch including .env.production must not be silently allowed` + ); + }); + + it('should pass through out-of-scope tools (grep_search) without engine evaluation', async () => { + const { exitCode, stdout } = await runContinueHook({ + hook_event_name: 'PreToolUse', + tool_name: 'grep_search', + tool_input: { query: 'foo' }, + }); + assert.equal(exitCode, 0); + assert.deepEqual(JSON.parse(stdout), {}); + }); + + it('should allow PostToolUse events for audit-only handling', async () => { + const { exitCode, stdout } = await runContinueHook({ + hook_event_name: 'PostToolUse', + tool_name: 'run_terminal_command', + tool_input: { command: 'rm -rf /' }, + }); + assert.equal(exitCode, 0); + assert.deepEqual(JSON.parse(stdout), {}); + }); + + it('should block in-scope payloads missing required fields', async () => { + const { exitCode, stdout } = await runContinueHook({ + hook_event_name: 'PreToolUse', + tool_name: 'run_terminal_command', + tool_input: {}, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { + hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; + }; + assert.equal(payload.hookSpecificOutput?.permissionDecision, 'deny'); + assert.ok(payload.hookSpecificOutput?.permissionDecisionReason?.includes('missing command')); + }); + + it('should fail closed when the Continue engine cannot load for pre hooks', async () => { + const { exitCode, stdout } = await runContinueHook( + { + hook_event_name: 'PreToolUse', + tool_name: 'run_terminal_command', + tool_input: { command: 'echo hello' }, + }, + { AGENTGUARD_TEST_FORCE_ENGINE_LOAD_FAILURE: '1' } + ); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { + hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; + }; + assert.equal(payload.hookSpecificOutput?.permissionDecision, 'deny'); + assert.ok(payload.hookSpecificOutput?.permissionDecisionReason?.includes('unable to load Continue hook engine')); + }); + + it('should fail open with AGENTGUARD_CONTINUE_FAIL_OPEN=1 when the engine cannot load', async () => { + const { exitCode, stdout } = await runContinueHook( + { + hook_event_name: 'PreToolUse', + tool_name: 'run_terminal_command', + tool_input: { command: 'echo hello' }, + }, + { + AGENTGUARD_TEST_FORCE_ENGINE_LOAD_FAILURE: '1', + AGENTGUARD_CONTINUE_FAIL_OPEN: '1', + } + ); + assert.equal(exitCode, 0); + assert.deepEqual(JSON.parse(stdout), {}); + }); + + it('should block invalid stdin without waiting for the stdin timeout', async () => { + const start = performance.now(); + const { exitCode, stdout } = await runContinueHookRaw('{not-json'); + const elapsedMs = performance.now() - start; + assert.equal(exitCode, 0); + assert.ok(elapsedMs < 2000, `hook should exit promptly, took ${elapsedMs}ms`); + const payload = JSON.parse(stdout) as { + hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; + }; + assert.equal(payload.hookSpecificOutput?.permissionDecision, 'deny'); + assert.ok(payload.hookSpecificOutput?.permissionDecisionReason?.includes('invalid or missing Continue hook payload')); + }); + + it('should FAIL CLOSED on malformed in-scope payload even with FAIL_OPEN=1', async () => { + // Lock in the high-severity review fix: the fail-open override exists + // for engine-unavailable cases, NOT for "we got a half-formed shell + // command and aren't sure what it is". + const { exitCode, stdout } = await runContinueHook( + { + hook_event_name: 'PreToolUse', + tool_name: 'run_terminal_command', + tool_input: {}, // missing command + }, + { AGENTGUARD_CONTINUE_FAIL_OPEN: '1' } + ); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { + hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; + }; + assert.equal(payload.hookSpecificOutput?.permissionDecision, 'deny', + `FAIL_OPEN must not bypass validation for in-scope tools, got ${JSON.stringify(payload)}`); + assert.ok(payload.hookSpecificOutput?.permissionDecisionReason?.includes('missing command')); + }); + + it('should FAIL CLOSED on invalid stdin even with FAIL_OPEN=1', async () => { + const { exitCode, stdout } = await runContinueHookRaw('{not-json'); + // Even with FAIL_OPEN=1 we don't know what tool this would have been, + // so blocking is the only safe choice. Set the env via spawn. + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { hookSpecificOutput?: { permissionDecision?: string } }; + assert.equal(payload.hookSpecificOutput?.permissionDecision, 'deny'); + }); + + it('should reject multi_edit with a non-object edits[] entry', async () => { + const { exitCode, stdout } = await runContinueHook({ + hook_event_name: 'PreToolUse', + tool_name: 'multi_edit', + tool_input: { + filepath: '/repo/src/foo.ts', + edits: ['oops, not an object'], + }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { + hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; + }; + assert.equal(payload.hookSpecificOutput?.permissionDecision, 'deny'); + assert.ok(payload.hookSpecificOutput?.permissionDecisionReason?.includes('edits[0] is not an object')); + }); + + it('should reject fetch_url_content with an unparseable URL', async () => { + const { exitCode, stdout } = await runContinueHook({ + hook_event_name: 'PreToolUse', + tool_name: 'fetch_url_content', + tool_input: { url: 'not a url at all' }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { + hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; + }; + assert.equal(payload.hookSpecificOutput?.permissionDecision, 'deny'); + assert.ok(payload.hookSpecificOutput?.permissionDecisionReason?.includes('not parseable')); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // F: skill CLI subprocess E2E // ─────────────────────────────────────────────────────────────────────────────