From 27a960d84da9f1b6dedf1671b30594b857880e6f Mon Sep 17 00:00:00 2001 From: theyavuzarslan <80851990+theyavuzarslan@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:11:12 +0300 Subject: [PATCH 1/3] feat(cline): add native Cline plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Cline integration parallel to the existing Hermes and Claude Code adapters, so AgentGuard can sit in front of Cline's tool-execution loop and block risky shell, file, and network actions before they run. Surfaces: - ClineAdapter (src/adapters/cline.ts) maps Cline tool names (run_commands, execute_command, write_to_file/write_file/replace_in_file/editor, read_files/read_file, web_fetch, browser_action, web_search) to the shared HookAdapter contract used by the engine. - Runtime plugin (plugins/cline/) implements @cline/core's AgentPlugin shape with hooks.beforeTool/afterTool — install via `cline plugin install ~/.cline/plugins/agentguard`. - File-hook bridge (skills/agentguard/scripts/cline-hook.js) handles Cline's stdin/stdout PreToolUse/PostToolUse protocol, mapping deny -> cancel and ask -> review (Cline supports both natively, no lossy translation). - `agentguard init --agent cline` copies the skill, drops PreToolUse.js / PostToolUse.js shims into .cline/hooks/ (chmod +x), and stages the runtime plugin in .cline/plugins/agentguard/. Wiring: - 'cline' added to AgentInstaller, RuntimeAgentHost, AgentGuardAgentHost, SUPPORTED_AGENT_INSTALLERS, and AUTO_AGENT_DETECTION. - resolveCronAgentHost narrowed to the cron-capable subset so the new host doesn't leak into cron backends that don't target it. Fail policy mirrors Hermes: out-of-scope tools always pass through; for in-scope tools the file-hook fails closed by default (override: AGENTGUARD_CLINE_FAIL_OPEN=1) and the runtime plugin fails open by default (override: AGENTGUARD_CLINE_FAIL_CLOSED=1) since users may not always have the engine installed alongside Cline. Existing 415 tests pass. End-to-end smoke: - run_commands rm -rf / -> {"cancel":true,"errorMessage":"..."} - run_commands echo hello -> {} - write_to_file .env -> {"review":true,"context":"..."} Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/cline/README.md | 85 +++++ plugins/cline/cline.plugin.json | 12 + plugins/cline/index.ts | 165 ++++++++++ plugins/cline/package.json | 30 ++ skills/agentguard/scripts/cline-hook.js | 393 ++++++++++++++++++++++++ src/adapters/cline.ts | 205 ++++++++++++ src/adapters/index.ts | 1 + src/cli.ts | 20 +- src/config.ts | 4 +- src/installers.ts | 69 ++++- src/runtime/types.ts | 1 + 11 files changed, 975 insertions(+), 10 deletions(-) create mode 100644 plugins/cline/README.md create mode 100644 plugins/cline/cline.plugin.json create mode 100644 plugins/cline/index.ts create mode 100644 plugins/cline/package.json create mode 100644 skills/agentguard/scripts/cline-hook.js create mode 100644 src/adapters/cline.ts diff --git a/plugins/cline/README.md b/plugins/cline/README.md new file mode 100644 index 0000000..12762b7 --- /dev/null +++ b/plugins/cline/README.md @@ -0,0 +1,85 @@ +# GoPlus AgentGuard — Cline plugin + +A native [Cline](https://github.com/cline/cline) plugin that runs every tool +call through the [GoPlus AgentGuard](https://github.com/GoPlusSecurity/agentguard) +decision engine and **blocks risky shell, file, and network actions** before +they execute. + +Two install surfaces — pick one: + +| Surface | Lives at | Best for | +|---|---|---| +| **Runtime plugin** (this package) | `.cline/plugins/agentguard/` | TS plugin with typed `beforeTool` / `afterTool` hooks, runs in-process | +| **File hook** (`PreToolUse`) | `.cline/hooks/PreToolUse.js` | Stdin/stdout JSON contract, language-agnostic, also installs alongside this plugin | + +Both reuse the same `ClineAdapter` and AgentGuard engine — detection logic +stays in one place. + +## Requirements + +- Cline ≥ the version that ships the SDK plugin system (`@cline/core`'s + `AgentPlugin` with `hooks.beforeTool`). See + [Cline plugin examples](https://github.com/cline/cline/tree/main/sdk/examples/plugins). +- The AgentGuard engine reachable as the `agentguard` CLI on `PATH` + (`npm i -g @goplus/agentguard`), or installed locally where the plugin can + `import('@goplus/agentguard')`. + +## Install + +```bash +# Installs the plugin into ~/.cline/plugins/agentguard/ and a file-hook +# script into ~/.cline/hooks/PreToolUse.js (you can keep one or both). +agentguard init --agent cline + +# Then enable it inside Cline: +cline plugin install ~/.cline/plugins/agentguard +cline plugin list +``` + +Or install the runtime plugin directly from GitHub: + +```bash +cline plugin install https://github.com/GoPlusSecurity/agentguard/blob/main/plugins/cline/index.ts +``` + +## What it does + +| Cline hook | Behavior | +|-------------------|-----------------------------------------------------------------------| +| `beforeTool` | Evaluates the call; returns `{ skip: true, reason }` to veto a dangerous action, `{ review: true }` to pause for user confirmation. | +| `afterTool` | Audit-only; never blocks. | + +Tools evaluated (others pass through untouched): +`run_commands`, `execute_command`, `write_to_file`, `write_file`, +`replace_in_file`, `editor`, `read_files`, `read_file`, `web_fetch`, +`browser_action`, `web_search`. + +Cline's `beforeTool` natively supports both `skip` (cancel) and `review` (pause +for user approval), so AgentGuard's `deny` decisions map to `skip` and `ask` +decisions map to `review` — no lossy translation needed. + +## Configuration + +| Env var | Default | Effect | +|---|---|---| +| `AGENTGUARD_CLINE_FAIL_CLOSED` | `0` | `1` blocks tool calls when the engine cannot be loaded (default fails open — the security gate is opt-in). | +| `AGENTGUARD_LEVEL` | `balanced` | Override AgentGuard protection level (`strict` / `balanced` / `permissive`) when no on-disk config is present. | + +**Activation:** `agentguard init --agent cline` only writes files. The plugin is +inactive until you run `cline plugin install ~/.cline/plugins/agentguard` (Cline +plugins are opt-in). + +**Fail policy:** Out-of-scope tools always pass through without an engine call. +For the security-sensitive tools above, the default is **fail-open** to avoid +locking the user out of Cline when AgentGuard isn't installed; set +`AGENTGUARD_CLINE_FAIL_CLOSED=1` for production hardening (matches the Hermes +plugin's posture). + +## Development + +The runtime plugin is a single TypeScript file (`index.ts`) with no runtime +dependencies beyond `@goplus/agentguard` and the Cline SDK. There is no build +step — Cline transpiles plugin TypeScript on install. The shared `ClineAdapter` +that this plugin invokes lives in +[`src/adapters/cline.ts`](../../src/adapters/cline.ts) of the AgentGuard repo +and is exported from `@goplus/agentguard`. diff --git a/plugins/cline/cline.plugin.json b/plugins/cline/cline.plugin.json new file mode 100644 index 0000000..e9d853b --- /dev/null +++ b/plugins/cline/cline.plugin.json @@ -0,0 +1,12 @@ +{ + "name": "agentguard", + "displayName": "GoPlus AgentGuard", + "version": "1.1.28", + "description": "Security guardrails for Cline — blocks risky shell, file, and network actions before they execute.", + "author": "GoPlusSecurity", + "homepage": "https://github.com/GoPlusSecurity/agentguard", + "license": "MIT", + "entry": "./index.ts", + "capabilities": ["hooks"], + "hooks": ["beforeTool", "afterTool"] +} diff --git a/plugins/cline/index.ts b/plugins/cline/index.ts new file mode 100644 index 0000000..2a5c050 --- /dev/null +++ b/plugins/cline/index.ts @@ -0,0 +1,165 @@ +/** + * GoPlus AgentGuard — native Cline runtime plugin. + * + * Implements `@cline/core`'s `AgentPlugin` shape and hooks + * `beforeTool` / `afterTool` into the AgentGuard decision engine + * via the shared `ClineAdapter` + `evaluateHook`. + * + * Install (recommended): + * agentguard init --agent cline + * # Then in Cline: + * cline plugin install ~/.cline/plugins/agentguard + * + * Or directly: + * cline plugin install https://github.com/GoPlusSecurity/agentguard/blob/main/plugins/cline/index.ts + */ + +// `@cline/core` provides the AgentPlugin type. We declare a structural fallback +// so this file type-checks inside the AgentGuard repo (which does not depend on +// @cline/core) — at runtime inside Cline, the real type is used. +type BeforeToolResult = + | { skip: true; reason: string } + | { review: true; reason?: string } + | undefined; + +interface BeforeToolArgs { + toolCall: { id: string; toolName: string; input: Record }; + input: Record; + taskId?: string; + workspaceRoots?: string[]; +} + +interface AfterToolArgs { + toolCall: { id: string; toolName: string; input: Record }; + result?: unknown; + taskId?: string; +} + +export interface AgentPlugin { + name: string; + manifest?: { capabilities?: string[] }; + hooks?: { + beforeTool?: (args: BeforeToolArgs) => Promise | BeforeToolResult; + afterTool?: (args: AfterToolArgs) => Promise | void; + }; +} + +// Lazy-load the engine so the plugin works whether AgentGuard is installed +// alongside Cline (npm i -g @goplus/agentguard) or vendored elsewhere. +let engineCache: { + evaluateHook: ( + adapter: unknown, + raw: unknown, + opts: { config: { level?: string }; agentguard: unknown } + ) => Promise<{ decision: 'allow' | 'deny' | 'ask'; reason?: string }>; + ClineAdapter: new () => unknown; + loadConfig: () => { level?: string }; + createAgentGuard: () => unknown; +} | null = null; + +async function loadEngine() { + if (engineCache) return engineCache; + let mod: Record | undefined; + try { + mod = (await import('@goplus/agentguard')) as Record; + } catch { + return null; + } + const evaluateHook = mod.evaluateHook as typeof engineCache extends null + ? never + : NonNullable['evaluateHook']; + const ClineAdapter = mod.ClineAdapter as typeof engineCache extends null + ? never + : NonNullable['ClineAdapter']; + const loadConfig = + (mod.loadConfig as (() => { level?: string }) | undefined) || + (() => ({ level: process.env.AGENTGUARD_LEVEL || 'balanced' })); + const createAgentGuard = + (mod.createAgentGuard as (() => unknown) | undefined) || + (mod.default as (() => unknown) | undefined); + if (!evaluateHook || !ClineAdapter || !createAgentGuard) return null; + engineCache = { evaluateHook, ClineAdapter, loadConfig, createAgentGuard }; + return engineCache; +} + +function envBool(name: string, fallback: boolean): boolean { + const value = process.env[name]; + if (value === undefined || value === '') return fallback; + return value === '1' || value.toLowerCase() === 'true'; +} + +const plugin: AgentPlugin = { + name: '@goplus/agentguard', + manifest: { + capabilities: ['hooks'], + }, + hooks: { + async beforeTool({ toolCall, input, taskId, workspaceRoots }): Promise { + const engine = await loadEngine(); + if (!engine) { + // Fail-open by default — security gate is opt-in to fail-closed via env. + if (envBool('AGENTGUARD_CLINE_FAIL_CLOSED', false)) { + return { + skip: true, + reason: + 'GoPlus AgentGuard: engine not found (install @goplus/agentguard) — blocking fail-closed.', + }; + } + return undefined; + } + + const adapter = new engine.ClineAdapter(); + const envelope = { + hookName: 'tool_call', + taskId, + workspaceRoots, + tool_call: { id: toolCall.id, name: toolCall.toolName, input }, + }; + + try { + const decision = await engine.evaluateHook(adapter, envelope, { + config: engine.loadConfig(), + agentguard: engine.createAgentGuard(), + }); + if (decision.decision === 'deny') { + return { skip: true, reason: decision.reason || 'GoPlus AgentGuard blocked this tool call' }; + } + if (decision.decision === 'ask') { + // Cline supports `review` to pause for user confirmation. + return { review: true, reason: decision.reason } as BeforeToolResult; + } + return undefined; + } catch (err) { + if (envBool('AGENTGUARD_CLINE_FAIL_CLOSED', false)) { + return { + skip: true, + reason: `GoPlus AgentGuard engine error: ${err instanceof Error ? err.message : 'unknown'}`, + }; + } + return undefined; + } + }, + + async afterTool({ toolCall, taskId }) { + const engine = await loadEngine(); + if (!engine) return; + const adapter = new engine.ClineAdapter(); + const envelope = { + hookName: 'tool_result', + taskId, + tool_call: { id: toolCall.id, name: toolCall.toolName, input: toolCall.input }, + }; + try { + await engine.evaluateHook(adapter, envelope, { + config: engine.loadConfig(), + agentguard: engine.createAgentGuard(), + }); + } catch { + // Post-tool is audit-only; never affect Cline execution. + } + }, + }, +}; + +export { plugin }; +export default plugin; diff --git a/plugins/cline/package.json b/plugins/cline/package.json new file mode 100644 index 0000000..8d94b26 --- /dev/null +++ b/plugins/cline/package.json @@ -0,0 +1,30 @@ +{ + "name": "@goplus/agentguard-cline-plugin", + "version": "1.1.28", + "description": "GoPlus AgentGuard security guardrails for Cline. Hooks every tool call through the AgentGuard decision engine and blocks risky shell, file, and network actions before they execute.", + "type": "module", + "main": "./index.ts", + "exports": { + ".": "./index.ts" + }, + "keywords": [ + "cline", + "cline-plugin", + "agentguard", + "security", + "ai-safety", + "tool-gate" + ], + "author": "GoPlusSecurity", + "license": "MIT", + "homepage": "https://github.com/GoPlusSecurity/agentguard", + "repository": { + "type": "git", + "url": "https://github.com/GoPlusSecurity/agentguard.git", + "directory": "plugins/cline" + }, + "peerDependencies": { + "@cline/core": "*", + "@goplus/agentguard": ">=1.1.28" + } +} diff --git a/skills/agentguard/scripts/cline-hook.js b/skills/agentguard/scripts/cline-hook.js new file mode 100644 index 0000000..a7ad911 --- /dev/null +++ b/skills/agentguard/scripts/cline-hook.js @@ -0,0 +1,393 @@ +#!/usr/bin/env node + +/** + * GoPlus AgentGuard — Cline file-hook bridge. + * + * Cline file hooks (~/.cline/hooks/PreToolUse.js, PostToolUse.js) read a JSON + * event on stdin and influence behavior by writing a JSON control object on + * stdout. See https://github.com/cline/cline/tree/main/sdk/examples/hooks. + * + * PreToolUse payload (excerpt): + * { + * "hookName": "tool_call", + * "taskId": "...", + * "workspaceRoots": ["/repo"], + * "tool_call": { "id": "...", "name": "run_commands", "input": {...} } + * } + * + * Control output we emit: + * {} -> allow + * {"cancel": true, "errorMessage": "..."} -> block (AgentGuard deny) + * {"review": true, "context": "..."} -> require user confirm (AgentGuard ask) + * + * This script delegates to the unified `protectAction` runtime API with + * `agentHost: 'cline'`, falling back to `ClineAdapter` + `evaluateHook` when + * the host installed an older AgentGuard build. + */ + +import { join } from 'node:path'; + +function isPostHook(input) { + const event = typeof input?.hookName === 'string' ? input.hookName : ''; + return event === 'tool_result' || event.startsWith('after'); +} + +function isPreHook(input) { + return !isPostHook(input); +} + +function toolCallFrom(input) { + const tc = input?.tool_call ?? input?.toolCall; + return tc && typeof tc === 'object' && !Array.isArray(tc) ? tc : {}; +} + +function toolNameFrom(input) { + const tc = toolCallFrom(input); + return typeof tc.name === 'string' + ? tc.name + : typeof tc.toolName === 'string' + ? tc.toolName + : ''; +} + +function toolInputFrom(input) { + const tc = toolCallFrom(input); + const ti = tc?.input; + 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_CLINE_FAIL_OPEN', false); + +// --------------------------------------------------------------------------- +// Tool → runtime action type mapping (mirrors src/adapters/cline.ts) +// --------------------------------------------------------------------------- + +function runtimeActionTypeFrom(toolName) { + switch (toolName) { + case 'run_commands': + case 'execute_command': + return 'shell'; + case 'write_to_file': + case 'write_file': + case 'replace_in_file': + case 'editor': + return 'file_write'; + case 'read_files': + case 'read_file': + return 'file_read'; + case 'web_search': + return 'web_search'; + case 'web_fetch': + case 'browser_action': + return 'network'; + default: + return 'other'; + } +} + +function runtimeToolNameFrom(toolName) { + return toolName || 'ClineTool'; +} + +function shouldFailClosed(input) { + if (FAIL_OPEN) return false; + return !input || isPreHook(input); +} + +function validatePreToolPayload(input) { + const toolName = toolNameFrom(input); + const toolInput = toolInputFrom(input); + + switch (toolName) { + case 'run_commands': + case 'execute_command': { + const command = + firstString(toolInput.command, toolInput.cmd) || + (Array.isArray(toolInput.commands) + ? toolInput.commands.filter((c) => typeof c === 'string').join(' && ') + : ''); + if (!command) return `Cline ${toolName} hook payload is missing command`; + return null; + } + case 'write_to_file': + case 'write_file': + case 'replace_in_file': + case 'editor': + case 'read_files': + case 'read_file': + if (!firstString(toolInput.path, toolInput.file_path, toolInput.filePath, toolInput.target)) { + // read_files can pass `files: [...]` + if (toolName !== 'read_files' || !Array.isArray(toolInput.files)) { + return `Cline ${toolName} hook payload is missing path`; + } + } + return null; + case 'web_fetch': + case 'browser_action': + if (!firstString(toolInput.url, toolInput.href, toolInput.target)) { + return `Cline ${toolName} hook payload is missing URL`; + } + return null; + case 'web_search': + if (!firstString(toolInput.query, toolInput.q, toolInput.search)) { + return `Cline web_search hook payload is missing query`; + } + return null; + default: + // Out-of-scope tools pass through without engine evaluation. + return null; + } +} + +function isInScope(toolName) { + return runtimeActionTypeFrom(toolName) !== 'other'; +} + +// --------------------------------------------------------------------------- +// Load AgentGuard engine + Cline adapter +// --------------------------------------------------------------------------- + +const agentguardPath = join(import.meta.url.replace('file://', ''), '..', '..', '..', '..', 'dist', 'index.js'); + +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 = (await tryImport(agentguardPath)) || (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, + ClineAdapter: gs.ClineAdapter, + 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 outputBlock(reason) { + const message = reason || 'GoPlus AgentGuard blocked this action'; + process.stdout.write(JSON.stringify({ cancel: true, errorMessage: message }) + '\n'); + process.exit(0); +} + +function outputReview(reason) { + const message = reason || 'GoPlus AgentGuard requires confirmation for this action'; + process.stdout.write(JSON.stringify({ review: true, context: message }) + '\n'); + process.exit(0); +} + +function outputAllow() { + process.stdout.write('{}\n'); + process.exit(0); +} + +function debugLog(message, details) { + if (process.env.AGENTGUARD_CLINE_DEBUG !== '1') return; + const suffix = details === undefined ? '' : ` ${JSON.stringify(details)}`; + process.stderr.write(`[AgentGuard Cline] ${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 — Cline uses tool_call.{name,input}; runtime expects +// the standard tool_name/tool_input shape. Normalize before protectAction. +// --------------------------------------------------------------------------- + +function normalizeForRuntime(input) { + const toolName = toolNameFrom(input); + const toolInput = toolInputFrom(input); + return { + ...input, + tool_name: toolName, + tool_input: toolInput, + session_id: firstString(input?.taskId, input?.session_id, input?.sessionId), + cwd: + (Array.isArray(input?.workspaceRoots) && typeof input.workspaceRoots[0] === 'string' + ? input.workspaceRoots[0] + : input?.cwd) || undefined, + }; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const input = await readStdin(); + if (!input) { + if (FAIL_OPEN) outputAllow(); + outputBlock('GoPlus AgentGuard: invalid or missing Cline 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) { + if (FAIL_OPEN) outputAllow(); + outputBlock(`GoPlus AgentGuard: ${validationError}`); + } + + const engine = await loadEngine(); + if (!engine) { + debugLog('engine load failed'); + if (shouldFailClosed(input)) { + outputBlock('GoPlus AgentGuard: unable to load Cline 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: 'cline', + actionType: runtimeActionTypeFrom(toolName), + toolName: runtimeToolNameFrom(toolName), + sessionId: normalized.session_id || undefined, + phase: 'post', + }); + } else if (engine.ClineAdapter && engine.evaluateHook && engine.createAgentGuard) { + const adapter = new engine.ClineAdapter(); + 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 Cline. + } + outputAllow(); + } + + // Pre hooks — make a real decision. + try { + if (engine.protectAction) { + const config = engine.loadRuntimeConfig(); + const result = await engine.protectAction({ + config, + rawInput: normalized, + agentHost: 'cline', + 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 Cline tool call')); + } else if (result.decision.decision === 'require_approval') { + outputReview(formatDecisionReason(result, 'requires confirmation for this Cline tool call')); + } else { + outputAllow(); + } + } + + // Fallback: ClineAdapter + evaluateHook path. + if (engine.ClineAdapter && engine.evaluateHook && engine.createAgentGuard) { + const adapter = new engine.ClineAdapter(); + 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') outputReview(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/cline.ts b/src/adapters/cline.ts new file mode 100644 index 0000000..47ccd3a --- /dev/null +++ b/src/adapters/cline.ts @@ -0,0 +1,205 @@ +import type { ActionEnvelope } from '../types/action.js'; +import type { HookAdapter, HookInput } from './types.js'; + +/** + * Tool name -> action type mapping for Cline. + * + * Cline tool names come from two places: + * - File hooks (PreToolUse/PostToolUse): `tool_call.name` + * - Runtime plugins (@cline/core hooks.beforeTool): `toolCall.toolName` + * + * Both surfaces use the same identifiers, so a single map covers them. + * See https://github.com/cline/cline/tree/main/sdk/examples/hooks and + * https://github.com/cline/cline/tree/main/sdk/examples/plugins + */ +const TOOL_ACTION_MAP: Record = { + run_commands: 'exec_command', + execute_command: 'exec_command', + write_to_file: 'write_file', + write_file: 'write_file', + replace_in_file: 'write_file', + editor: 'write_file', + read_files: 'read_file', + read_file: 'read_file', + web_fetch: 'network_request', + browser_action: 'network_request', + web_search: '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' { + // Cline file-hook event names: tool_call (pre), tool_result (post). + // Runtime hook names: beforeTool / afterTool. + if (name === 'tool_result' || name.startsWith('after') || name.startsWith('Post')) return 'post'; + return 'pre'; +} + +/** + * Cline hook adapter. + * + * Bridges Cline file-hook stdin/stdout payloads and runtime-plugin + * `beforeTool` / `afterTool` events to the common AgentGuard engine. + * + * Cline file-hook payload (PreToolUse): + * { + * "hookName": "tool_call", + * "taskId": "...", + * "workspaceRoots": ["/repo"], + * "tool_call": { "id": "...", "name": "run_commands", "input": {...} } + * } + * + * Cline runtime-plugin payload (hooks.beforeTool): + * { toolCall: { id, toolName, input }, input } + * — wrap as { hookName: 'beforeTool', tool_call: {name: toolName, input} } + * before calling parseInput, or pass through as-is. + */ +export class ClineAdapter implements HookAdapter { + readonly name = 'cline'; + + parseInput(raw: unknown): HookInput { + const data = (raw as Record) || {}; + const hookEvent = firstString(data.hookName, data.hook_event_name, data.event); + + const toolCall = + (data.tool_call as Record) || + (data.toolCall as Record) || + {}; + + const toolName = + firstString(toolCall.name, (toolCall as Record).toolName, data.tool_name) || + ''; + + const toolInput = + ((toolCall.input as Record) || + (data.tool_input as Record) || + (data.preToolUse as Record | undefined)?.parameters || + {}) as Record; + + const workspaceRoots = data.workspaceRoots; + const cwd = Array.isArray(workspaceRoots) && typeof workspaceRoots[0] === 'string' + ? (workspaceRoots[0] as string) + : (data.cwd as string | undefined); + + return { + toolName, + toolInput, + eventType: eventTypeFromHookName(hookEvent), + sessionId: firstString(data.taskId, data.session_id, data.sessionId) || undefined, + cwd, + 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 || 'cline-session', + source: initiatingSkill || 'cline', + version_ref: '0.0.0', + artifact_hash: '', + }, + }; + + const context = { + session_id: input.sessionId || `cline-${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': { + // run_commands accepts string | string[] | { command | commands | cmd } + const ti = input.toolInput; + let command = firstString(ti.command, ti.cmd, ti.commands); + if (!command && Array.isArray(ti.commands)) { + command = (ti.commands as unknown[]) + .filter((c): c is string => typeof c === 'string') + .join(' && '); + } + actionData = { + command, + args: [], + cwd: firstString(ti.cwd, ti.workdir, input.cwd), + }; + break; + } + + case 'write_file': + actionData = { + path: firstString( + input.toolInput.path, + input.toolInput.file_path, + input.toolInput.filePath, + input.toolInput.target + ), + }; + break; + + case 'read_file': { + // read_files supports many shapes; pick the first path seen. + const ti = input.toolInput; + let path = firstString(ti.path, ti.file_path, ti.filePath); + if (!path) { + const files = (ti.files || ti.file_paths || ti.paths) as unknown; + if (Array.isArray(files)) { + const first = files.find((f) => typeof f === 'string' || (f && typeof (f as Record).path === 'string')); + if (typeof first === 'string') path = first; + else if (first && typeof first === 'object') path = String((first as Record).path || ''); + } + } + actionData = { path }; + break; + } + + case 'network_request': + actionData = { + method: firstString(input.toolInput.method) || 'GET', + url: firstString(input.toolInput.url, input.toolInput.href, input.toolInput.target), + body_preview: input.toolInput.body as string | undefined, + }; + break; + + case 'web_search': + actionData = { + query: firstString(input.toolInput.query, input.toolInput.q, input.toolInput.search), + }; + break; + + default: + return null; + } + + return { + actor, + action: { type: actionType, data: actionData }, + context, + } as unknown as ActionEnvelope; + } + + async inferInitiatingSkill(input: HookInput): Promise { + // Cline does not currently expose a skill stack the way Claude Code does. + // Plugins may set `initiating_skill` on the payload; honor it when present. + const raw = input.raw as Record; + return ( + firstString(raw.initiating_skill, raw.sourceSkill, (raw.metadata as Record | undefined)?.skill) || + null + ); + } +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index e0a3765..67f4f19 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 { ClineAdapter } from './cline.js'; export { evaluateHook } from './engine.js'; export { registerOpenClawPlugin, diff --git a/src/cli.ts b/src/cli.ts index 277609c..a6aff73 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', 'cline']; 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: 'cline', dir: '.cline' }, ]; 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 cline') .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 .cline.'); } 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 cline.'); } const agent = normalizedAgent as AgentInstaller; config.agentHost = agent; @@ -1062,8 +1064,14 @@ function printCronRemovalSummary(results: ThreatFeedCronRemovalResult[]): void { console.log('No AgentGuard subscribe cron job was found.'); } -function resolveCronAgentHost(config: AgentGuardConfig): AgentGuardAgentHost | undefined { - return config.agentHost ?? config.agentHosts?.[0]; +function resolveCronAgentHost(config: AgentGuardConfig): CronAgentHost | undefined { + const host = config.agentHost ?? config.agentHosts?.[0]; + // Cron infrastructure does not target every host (e.g. 'cline' has no cron + // backend). Narrow to the subset cron understands. + if (host === 'claude-code' || host === 'codex' || host === 'openclaw' || host === 'hermes' || host === 'qclaw') { + return host; + } + return undefined; } function readStdinIfAvailable(): string { diff --git a/src/config.ts b/src/config.ts index 2a6adfa..a04772c 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' | 'cline'; 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 === 'cline' ? value : undefined; } diff --git a/src/installers.ts b/src/installers.ts index 24f729b..1b0dc3a 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -2,7 +2,7 @@ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, wr 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' | 'cline'; 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 === 'cline') return installCline(options.cwd, Boolean(options.force)); throw new Error(`Unsupported agent installer: ${agent}`); } @@ -240,10 +241,74 @@ function installQClaw(root: string, force: boolean): InstallResult { return { agent: 'qclaw', files: pluginResult.files }; } +function installCline(cwd: string | undefined, force: boolean): InstallResult { + // Cline reads file hooks from .cline/hooks/ and runtime plugins from + // .cline/plugins/ (project-local) or ~/.cline/* (user-global). + const clineRoot = cwd + ? join(cwd, '.cline') + : process.env.CLINE_HOME?.trim() || join(homedir(), '.cline'); + + const skillDir = join(clineRoot, 'skills', 'agentguard'); + const hookScriptPath = join(skillDir, 'scripts', 'cline-hook.js'); + const preHookPath = join(clineRoot, 'hooks', 'PreToolUse.js'); + const postHookPath = join(clineRoot, 'hooks', 'PostToolUse.js'); + const pluginDir = join(clineRoot, 'plugins', 'agentguard'); + + // 1. Drop the shared skill (carries cline-hook.js and supporting scripts). + copyBundledSkill(skillDir, force); + + // 2. Write Cline file-hook shims that delegate to the bundled script. + const preShim = clineHookShim(hookScriptPath); + const postShim = clineHookShim(hookScriptPath); + writeIfAllowed(preHookPath, preShim, force); + writeIfAllowed(postHookPath, postShim, force); + + // 3. Copy the native runtime plugin (cline plugin install ~/.cline/plugins/agentguard). + copyBundledClinePlugin(pluginDir, force); + + return { + agent: 'cline', + files: [skillDir, preHookPath, postHookPath, pluginDir], + }; +} + +const CLINE_PLUGIN_REQUIRED_FILES = ['index.ts', 'package.json']; + +function copyBundledClinePlugin(targetDir: string, force: boolean): void { + const sourceDir = resolve(__dirname, '..', 'plugins', 'cline'); + if (!existsSync(sourceDir)) { + throw new Error(`Bundled Cline plugin not found at ${sourceDir}. Reinstall @goplus/agentguard.`); + } + if (!(existsSync(targetDir) && !force)) { + mkdirSync(dirname(targetDir), { recursive: true }); + cpSync(sourceDir, targetDir, { recursive: true, force }); + } + for (const required of CLINE_PLUGIN_REQUIRED_FILES) { + if (!existsSync(join(targetDir, required))) { + throw new Error(`Cline plugin install is incomplete: missing ${required} in ${targetDir}.`); + } + } +} + +function clineHookShim(hookScriptPath: string): string { + // Cline runs hook files matching the event name (PreToolUse / PostToolUse). + // The shim hands stdin straight through to the bundled engine bridge. + return `#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +const child = spawnSync(process.execPath, [${JSON.stringify(hookScriptPath)}], { + stdio: 'inherit', +}); +process.exit(child.status ?? 0); +`; +} + 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') || + /\/(\.cline|\.hermes)\/hooks\//.test(path); // Cline / Hermes file hooks must be +x. + 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..cc26cc6 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -23,6 +23,7 @@ export type RuntimeAgentHost = | 'cursor' | 'gemini' | 'copilot' + | 'cline' | 'other'; export interface PolicyReason { From 406ec718e31b8ad9e86b084803790bb06c8fc53e Mon Sep 17 00:00:00 2001 From: theyavuzarslan <80851990+theyavuzarslan@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:16:48 +0300 Subject: [PATCH 2/3] test(cline): ClineAdapter unit + cline-hook.js E2E coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 32 tests bringing the suite from 415 to 447, all passing. src/tests/adapter.test.ts — ClineAdapter unit tests: - parseInput covers both the file-hook shape (tool_call.{name,input}) and the runtime-plugin shape (toolCall.toolName), tool_result -> 'post', workspaceRoots[0] -> cwd, preToolUse.parameters fallback, and empty input - mapToolToActionType covers every entry in TOOL_ACTION_MAP plus null for out-of-scope tools (use_mcp_tool, ask_followup_question, '') - buildEnvelope covers run_commands string + commands[] array (joined), write_to_file, read_files with files: [{path}], web_fetch, web_search, and null return for unmapped tools - inferInitiatingSkill: null by default, honors initiating_skill metadata src/tests/smoke.test.ts — cline-hook.js subprocess E2E: - allow echo hello - block rm -rf / via {cancel, errorMessage} - map AgentGuard 'ask' to Cline {review, context} for .env writes - accept runtime-plugin toolCall shape - flatten run_commands.commands array before evaluating (catches the joined `echo ok && rm -rf /` case) - post-tool events audit-only -> {} - out-of-scope tools (use_mcp_tool) passthrough -> {} - missing required field (run_commands without command) -> block - AGENTGUARD_TEST_FORCE_ENGINE_LOAD_FAILURE=1 fails closed by default - AGENTGUARD_CLINE_FAIL_OPEN=1 inverts to allow on engine failure - invalid stdin exits promptly with cancel decision Bug fix surfaced by the commands-array test: cline-hook.js's normalizeForRuntime now flattens `tool_input.commands: string[]` into a single `command` field before handing off to protectAction. The runtime engine's input picker only looks at `command`/`cmd`, so without the flatten the array passed through unevaluated. Co-Authored-By: Claude Opus 4.7 (1M context) --- skills/agentguard/scripts/cline-hook.js | 17 +- src/tests/adapter.test.ts | 215 ++++++++++++++++++++++++ src/tests/smoke.test.ts | 159 ++++++++++++++++++ 3 files changed, 390 insertions(+), 1 deletion(-) diff --git a/skills/agentguard/scripts/cline-hook.js b/skills/agentguard/scripts/cline-hook.js index a7ad911..0fb939d 100644 --- a/skills/agentguard/scripts/cline-hook.js +++ b/skills/agentguard/scripts/cline-hook.js @@ -255,7 +255,22 @@ function formatDecisionReason(result, fallback) { function normalizeForRuntime(input) { const toolName = toolNameFrom(input); - const toolInput = toolInputFrom(input); + const toolInput = { ...toolInputFrom(input) }; + + // Cline's run_commands accepts a `commands: string[]` shape. The runtime + // engine's input-picker only looks at `command`/`cmd`, so flatten the + // array into a single shell expression before handing it off. + if ( + (toolName === 'run_commands' || toolName === 'execute_command') && + !firstString(toolInput.command, toolInput.cmd) && + Array.isArray(toolInput.commands) + ) { + const joined = toolInput.commands + .filter((c) => typeof c === 'string' && c.length > 0) + .join(' && '); + if (joined) toolInput.command = joined; + } + return { ...input, tool_name: toolName, diff --git a/src/tests/adapter.test.ts b/src/tests/adapter.test.ts index 7b7ed01..599a8ed 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 { ClineAdapter } from '../adapters/cline.js'; import { isSensitivePath, shouldDenyAtLevel, @@ -502,6 +503,220 @@ describe('HermesAdapter', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// ClineAdapter +// ───────────────────────────────────────────────────────────────────────────── + +describe('ClineAdapter', () => { + const adapter = new ClineAdapter(); + + it('should have name "cline"', () => { + assert.equal(adapter.name, 'cline'); + }); + + describe('parseInput', () => { + it('should parse Cline file-hook PreToolUse payload (tool_call shape)', () => { + const raw = { + hookName: 'tool_call', + taskId: 'conv-123', + workspaceRoots: ['/repo'], + tool_call: { id: 'c1', name: 'run_commands', input: { command: 'echo hello' } }, + }; + const input = adapter.parseInput(raw); + assert.equal(input.toolName, 'run_commands'); + assert.equal(input.eventType, 'pre'); + assert.deepEqual(input.toolInput, { command: 'echo hello' }); + assert.equal(input.sessionId, 'conv-123'); + assert.equal(input.cwd, '/repo'); + }); + + it('should parse Cline runtime-plugin payload (toolCall.toolName shape)', () => { + const raw = { + hookName: 'beforeTool', + toolCall: { id: 'c1', toolName: 'write_to_file', input: { path: '/tmp/x' } }, + }; + const input = adapter.parseInput(raw); + assert.equal(input.toolName, 'write_to_file'); + assert.equal(input.eventType, 'pre'); + assert.deepEqual(input.toolInput, { path: '/tmp/x' }); + }); + + it('should parse Cline file-hook PostToolUse (tool_result) payload', () => { + const input = adapter.parseInput({ + hookName: 'tool_result', + tool_call: { id: 'c1', name: 'write_to_file', input: { path: '/tmp/x' } }, + }); + assert.equal(input.eventType, 'post'); + assert.equal(input.toolName, 'write_to_file'); + }); + + it('should derive cwd from workspaceRoots[0] when cwd is absent', () => { + const input = adapter.parseInput({ + hookName: 'tool_call', + workspaceRoots: ['/repo', '/other'], + tool_call: { id: 'c1', name: 'run_commands', input: { command: 'pwd' } }, + }); + assert.equal(input.cwd, '/repo'); + }); + + it('should fall back to preToolUse.parameters when tool_call.input is empty', () => { + const input = adapter.parseInput({ + hookName: 'tool_call', + tool_call: { id: 'c1', name: 'run_commands' }, + preToolUse: { toolName: 'run_commands', parameters: { command: 'ls' } }, + }); + assert.deepEqual(input.toolInput, { command: 'ls' }); + }); + + 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 shell tools to exec_command', () => { + assert.equal(adapter.mapToolToActionType('run_commands'), 'exec_command'); + assert.equal(adapter.mapToolToActionType('execute_command'), 'exec_command'); + }); + + it('should map write tools to write_file', () => { + assert.equal(adapter.mapToolToActionType('write_to_file'), 'write_file'); + assert.equal(adapter.mapToolToActionType('write_file'), 'write_file'); + assert.equal(adapter.mapToolToActionType('replace_in_file'), 'write_file'); + assert.equal(adapter.mapToolToActionType('editor'), 'write_file'); + }); + + it('should map read tools to read_file', () => { + assert.equal(adapter.mapToolToActionType('read_files'), 'read_file'); + assert.equal(adapter.mapToolToActionType('read_file'), 'read_file'); + }); + + it('should split web_search from network tools', () => { + assert.equal(adapter.mapToolToActionType('web_search'), 'web_search'); + assert.equal(adapter.mapToolToActionType('web_fetch'), 'network_request'); + assert.equal(adapter.mapToolToActionType('browser_action'), 'network_request'); + }); + + it('should return null for unknown / out-of-scope tools', () => { + assert.equal(adapter.mapToolToActionType('use_mcp_tool'), null); + assert.equal(adapter.mapToolToActionType('ask_followup_question'), null); + assert.equal(adapter.mapToolToActionType(''), null); + assert.equal(adapter.mapToolToActionType('UnknownTool'), null); + }); + }); + + describe('buildEnvelope', () => { + it('should build exec_command envelope from run_commands', () => { + const input = adapter.parseInput({ + hookName: 'tool_call', + taskId: 'task-1', + workspaceRoots: ['/repo'], + tool_call: { id: 'c1', name: 'run_commands', input: { command: 'ls -la' } }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + assert.equal(envelope!.action.type, 'exec_command'); + assert.equal((envelope!.action.data as unknown as Record).command, 'ls -la'); + assert.equal((envelope!.action.data as unknown as Record).cwd, '/repo'); + assert.equal(envelope!.context.session_id, 'task-1'); + }); + + it('should join run_commands.commands array into a single command string', () => { + const input = adapter.parseInput({ + hookName: 'tool_call', + tool_call: { + id: 'c1', + name: 'run_commands', + input: { commands: ['pwd', 'ls'] }, + }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + assert.equal((envelope!.action.data as unknown as Record).command, 'pwd && ls'); + }); + + it('should build write_file envelope from write_to_file', () => { + const input = adapter.parseInput({ + hookName: 'tool_call', + tool_call: { id: 'c1', name: 'write_to_file', input: { path: '/project/.env' } }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + assert.equal(envelope!.action.type, 'write_file'); + assert.equal((envelope!.action.data as unknown as Record).path, '/project/.env'); + }); + + it('should build read_file envelope from read_files with files array', () => { + const input = adapter.parseInput({ + hookName: 'tool_call', + tool_call: { + id: 'c1', + name: 'read_files', + input: { files: [{ path: '/tmp/readme.md' }] }, + }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + assert.equal(envelope!.action.type, 'read_file'); + assert.equal((envelope!.action.data as unknown as Record).path, '/tmp/readme.md'); + }); + + it('should build network_request envelope from web_fetch URL', () => { + const input = adapter.parseInput({ + hookName: 'tool_call', + tool_call: { id: 'c1', name: 'web_fetch', 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 web_search query', () => { + const input = adapter.parseInput({ + hookName: 'tool_call', + tool_call: { id: 'c1', name: 'web_search', input: { query: 'cline 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, 'cline plugins'); + }); + + it('should return null for unmapped tools', () => { + const input = adapter.parseInput({ + hookName: 'tool_call', + tool_call: { id: 'c1', name: 'use_mcp_tool', input: {} }, + }); + assert.equal(adapter.buildEnvelope(input), null); + }); + }); + + describe('inferInitiatingSkill', () => { + it('should return null when Cline provides no skill metadata', async () => { + const input = adapter.parseInput({ + hookName: 'tool_call', + tool_call: { id: 'c1', name: 'run_commands', input: { command: 'echo' } }, + }); + const skill = await adapter.inferInitiatingSkill(input); + assert.equal(skill, null); + }); + + it('should honor initiating_skill metadata when present', async () => { + const input = adapter.parseInput({ + hookName: 'tool_call', + initiating_skill: 'my-cline-rule', + tool_call: { id: 'c1', name: 'run_commands', input: { command: 'echo' } }, + }); + const skill = await adapter.inferInitiatingSkill(input); + assert.equal(skill, 'my-cline-rule'); + }); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // Common utilities // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/tests/smoke.test.ts b/src/tests/smoke.test.ts index 7de6dee..cedfdbf 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 CLINE_HOOK_PATH = join(projectRoot, 'skills', 'agentguard', 'scripts', 'cline-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,28 @@ function runHermesHookRaw(input: string): Promise<{ return runNodeHookRaw(HERMES_HOOK_PATH, input); } +function runClineHook( + input: Record, + env: Record = {} +): Promise<{ + exitCode: number; + stdout: string; + stderr: string; +}> { + return runNodeHook(CLINE_HOOK_PATH, input, env); +} + +function runClineHookRaw( + input: string, + env: Record = {} +): Promise<{ + exitCode: number; + stdout: string; + stderr: string; +}> { + return runNodeHookRaw(CLINE_HOOK_PATH, input, env); +} + function runNodeHook( scriptPath: string, input: Record, @@ -354,6 +377,142 @@ describe('Smoke: hermes-hook.js E2E', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// E2: cline-hook.js subprocess E2E +// ───────────────────────────────────────────────────────────────────────────── + +describe('Smoke: cline-hook.js E2E', () => { + it('should allow echo hello with empty JSON output', async () => { + const { exitCode, stdout } = await runClineHook({ + hookName: 'tool_call', + taskId: 't1', + tool_call: { id: 'c1', name: 'run_commands', input: { command: 'echo hello' } }, + }); + assert.equal(exitCode, 0); + assert.deepEqual(JSON.parse(stdout), {}); + }); + + it('should block rm -rf / with Cline cancel protocol', async () => { + const { exitCode, stdout } = await runClineHook({ + hookName: 'tool_call', + taskId: 't1', + tool_call: { id: 'c1', name: 'run_commands', input: { command: 'rm -rf /' } }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { cancel?: boolean; errorMessage?: string }; + assert.equal(payload.cancel, true); + assert.ok(payload.errorMessage?.includes('AgentGuard')); + }); + + it('should map AgentGuard ask to Cline review (write to .env)', async () => { + const { exitCode, stdout } = await runClineHook({ + hookName: 'tool_call', + taskId: 't1', + tool_call: { id: 'c1', name: 'write_to_file', input: { path: '/project/.env' } }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { review?: boolean; cancel?: boolean; context?: string; errorMessage?: string }; + // A .env write should not silently allow; it must be either review or cancel. + assert.ok(payload.review === true || payload.cancel === true, + `expected review or cancel, got ${JSON.stringify(payload)}`); + const message = payload.context || payload.errorMessage || ''; + assert.ok(message.includes('AgentGuard'), 'should mention AgentGuard'); + }); + + it('should accept the runtime-plugin toolCall.toolName payload shape', async () => { + const { exitCode, stdout } = await runClineHook({ + hookName: 'beforeTool', + toolCall: { id: 'c1', toolName: 'run_commands', input: { command: 'rm -rf /' } }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { cancel?: boolean }; + assert.equal(payload.cancel, true); + }); + + it('should join run_commands.commands array before evaluating', async () => { + const { exitCode, stdout } = await runClineHook({ + hookName: 'tool_call', + tool_call: { + id: 'c1', + name: 'run_commands', + input: { commands: ['echo ok', 'rm -rf /'] }, + }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { cancel?: boolean }; + assert.equal(payload.cancel, true); + }); + + it('should allow post-tool events for audit-only handling', async () => { + const { exitCode, stdout } = await runClineHook({ + hookName: 'tool_result', + tool_call: { id: 'c1', name: 'run_commands', input: { command: 'rm -rf /' } }, + }); + assert.equal(exitCode, 0); + assert.deepEqual(JSON.parse(stdout), {}); + }); + + it('should pass through out-of-scope tools without engine evaluation', async () => { + const { exitCode, stdout } = await runClineHook({ + hookName: 'tool_call', + tool_call: { id: 'c1', name: 'use_mcp_tool', input: { server: 'x', tool: 'y' } }, + }); + assert.equal(exitCode, 0); + assert.deepEqual(JSON.parse(stdout), {}); + }); + + it('should block in-scope payloads missing required fields', async () => { + const { exitCode, stdout } = await runClineHook({ + hookName: 'tool_call', + tool_call: { id: 'c1', name: 'run_commands', input: {} }, + }); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { cancel?: boolean; errorMessage?: string }; + assert.equal(payload.cancel, true); + assert.ok(payload.errorMessage?.includes('missing command')); + }); + + it('should fail closed when the Cline engine cannot load for pre hooks', async () => { + const { exitCode, stdout } = await runClineHook( + { + hookName: 'tool_call', + tool_call: { id: 'c1', name: 'run_commands', input: { command: 'echo hello' } }, + }, + { AGENTGUARD_TEST_FORCE_ENGINE_LOAD_FAILURE: '1' } + ); + assert.equal(exitCode, 0); + const payload = JSON.parse(stdout) as { cancel?: boolean; errorMessage?: string }; + assert.equal(payload.cancel, true); + assert.ok(payload.errorMessage?.includes('unable to load Cline hook engine')); + }); + + it('should fail open with AGENTGUARD_CLINE_FAIL_OPEN=1 when the engine cannot load', async () => { + const { exitCode, stdout } = await runClineHook( + { + hookName: 'tool_call', + tool_call: { id: 'c1', name: 'run_commands', input: { command: 'echo hello' } }, + }, + { + AGENTGUARD_TEST_FORCE_ENGINE_LOAD_FAILURE: '1', + AGENTGUARD_CLINE_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 runClineHookRaw('{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 { cancel?: boolean; errorMessage?: string }; + assert.equal(payload.cancel, true); + assert.ok(payload.errorMessage?.includes('invalid or missing Cline hook payload')); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // F: skill CLI subprocess E2E // ───────────────────────────────────────────────────────────────────────────── From 750bc9dafc8279ce8ac6b750144005898bb1e4b7 Mon Sep 17 00:00:00 2001 From: theyavuzarslan <80851990+theyavuzarslan@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:25:25 +0300 Subject: [PATCH 3/3] fix(cline): address PR review (shim, path, multi-file, fail policy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five issues raised in review; this commit fixes four and verifies the fifth was a false positive. 1. HIGH — installer no longer writes a spawnSync shim. PreToolUse.js and PostToolUse.js are now cline-hook.js copied verbatim (same script, branches internally on hookName). One fewer process, no stdin-inheritance question. Installer test asserts byte-identity with the bundled source and that the file does not contain `spawnSync`. 2. HIGH — packaging was already correct. `plugins` is in package.json "files", so plugins/cline ships with the npm package. Added a comment in installers.ts noting this so the path contract is explicit. 3. MEDIUM — robust path resolution in cline-hook.js. Replaced `import.meta.url.replace('file://', '')` (broken on Windows, wrong join semantics) with `fileURLToPath() + dirname()`, and only attempt the bundled engine when the resolved path actually exists. Falls through to the `@goplus/agentguard` bare specifier otherwise. 4. MEDIUM — multi-file read_files prefers the worst-case path. When read_files lists multiple files, the adapter now scans them with isSensitivePath and picks any sensitive target as the envelope's primary path (with the full list in `paths` and the flagged target in `sensitive_path`). A two-file [benign, .env] read now blocks instead of slipping through on the first benign entry. Two adapter tests lock the contract in (sensitive-wins + paths preserved for benign lists). 5. MEDIUM — both Cline surfaces now default fail-closed under one env var. Runtime plugin previously defaulted fail-open via AGENTGUARD_CLINE_FAIL_CLOSED. Now both the file hook and the runtime plugin default to fail-closed and read AGENTGUARD_CLINE_FAIL_OPEN=1 to invert. A security gate that quietly turns off isn't a security gate; this matches the Hermes plugin's posture and removes the cross-surface divergence the reviewer flagged. Tests: 451/451 pass (was 447; +4 new — multi-file sensitive/benign, hook copied-not-shimmed, hook executable bit). End-to-end verified: `agentguard init --agent cline --force` against a fresh temp dir installs PreToolUse.js byte-identical to the source, and invoking it as Cline does correctly fails closed when the engine is unreachable. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/cline/README.md | 11 +++--- plugins/cline/index.ts | 30 +++++++++-------- skills/agentguard/scripts/cline-hook.js | 27 +++++++++++++-- src/adapters/cline.ts | 42 +++++++++++++++++------ src/installers.ts | 45 ++++++++++++++----------- src/tests/adapter.test.ts | 36 ++++++++++++++++++++ src/tests/installer.test.ts | 44 ++++++++++++++++++++++++ 7 files changed, 183 insertions(+), 52 deletions(-) diff --git a/plugins/cline/README.md b/plugins/cline/README.md index 12762b7..14c497b 100644 --- a/plugins/cline/README.md +++ b/plugins/cline/README.md @@ -62,7 +62,7 @@ decisions map to `review` — no lossy translation needed. | Env var | Default | Effect | |---|---|---| -| `AGENTGUARD_CLINE_FAIL_CLOSED` | `0` | `1` blocks tool calls when the engine cannot be loaded (default fails open — the security gate is opt-in). | +| `AGENTGUARD_CLINE_FAIL_OPEN` | `0` | `1` allows tool calls when the engine cannot be loaded or errors. Default is fail-closed — both the runtime plugin and the file-hook share this single env var so enforcement is consistent across surfaces. | | `AGENTGUARD_LEVEL` | `balanced` | Override AgentGuard protection level (`strict` / `balanced` / `permissive`) when no on-disk config is present. | **Activation:** `agentguard init --agent cline` only writes files. The plugin is @@ -70,10 +70,11 @@ inactive until you run `cline plugin install ~/.cline/plugins/agentguard` (Cline plugins are opt-in). **Fail policy:** Out-of-scope tools always pass through without an engine call. -For the security-sensitive tools above, the default is **fail-open** to avoid -locking the user out of Cline when AgentGuard isn't installed; set -`AGENTGUARD_CLINE_FAIL_CLOSED=1` for production hardening (matches the Hermes -plugin's posture). +For the security-sensitive tools above, both the runtime plugin and the file +hook default to **fail-closed** (a security gate that quietly turns off isn't a +security gate). Set `AGENTGUARD_CLINE_FAIL_OPEN=1` for local development. This +matches the Hermes plugin's posture and is a single env var across both Cline +surfaces. ## Development diff --git a/plugins/cline/index.ts b/plugins/cline/index.ts index 2a5c050..e8c715b 100644 --- a/plugins/cline/index.ts +++ b/plugins/cline/index.ts @@ -97,15 +97,17 @@ const plugin: AgentPlugin = { async beforeTool({ toolCall, input, taskId, workspaceRoots }): Promise { const engine = await loadEngine(); if (!engine) { - // Fail-open by default — security gate is opt-in to fail-closed via env. - if (envBool('AGENTGUARD_CLINE_FAIL_CLOSED', false)) { - return { - skip: true, - reason: - 'GoPlus AgentGuard: engine not found (install @goplus/agentguard) — blocking fail-closed.', - }; + // Default: fail-closed — match the file-hook surface and the hermes + // plugin so users get consistent enforcement across integrations. + // Override with AGENTGUARD_CLINE_FAIL_OPEN=1 for local dev. + if (envBool('AGENTGUARD_CLINE_FAIL_OPEN', false)) { + return undefined; } - return undefined; + return { + skip: true, + reason: + 'GoPlus AgentGuard: engine not found (install @goplus/agentguard) — blocking fail-closed. Set AGENTGUARD_CLINE_FAIL_OPEN=1 to allow.', + }; } const adapter = new engine.ClineAdapter(); @@ -130,13 +132,13 @@ const plugin: AgentPlugin = { } return undefined; } catch (err) { - if (envBool('AGENTGUARD_CLINE_FAIL_CLOSED', false)) { - return { - skip: true, - reason: `GoPlus AgentGuard engine error: ${err instanceof Error ? err.message : 'unknown'}`, - }; + if (envBool('AGENTGUARD_CLINE_FAIL_OPEN', false)) { + return undefined; } - return undefined; + return { + skip: true, + reason: `GoPlus AgentGuard engine error: ${err instanceof Error ? err.message : 'unknown'} — blocking fail-closed. Set AGENTGUARD_CLINE_FAIL_OPEN=1 to allow.`, + }; } }, diff --git a/skills/agentguard/scripts/cline-hook.js b/skills/agentguard/scripts/cline-hook.js index 0fb939d..b759619 100644 --- a/skills/agentguard/scripts/cline-hook.js +++ b/skills/agentguard/scripts/cline-hook.js @@ -25,7 +25,17 @@ * the host installed an older AgentGuard build. */ -import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function existsAtPath(p) { + try { + return existsSync(p); + } catch { + return false; + } +} function isPostHook(input) { const event = typeof input?.hookName === 'string' ? input.hookName : ''; @@ -160,7 +170,12 @@ function isInScope(toolName) { // Load AgentGuard engine + Cline adapter // --------------------------------------------------------------------------- -const agentguardPath = join(import.meta.url.replace('file://', ''), '..', '..', '..', '..', 'dist', 'index.js'); +// Resolve the bundled engine path safely across platforms. The script ships as +// `/skills/agentguard/scripts/cline-hook.js`; the engine entry is at +// `/dist/index.js`. fileURLToPath handles Windows `file:///C:/...` URLs +// correctly where a naive string replace would not. +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const bundledEnginePath = resolve(scriptDir, '..', '..', '..', 'dist', 'index.js'); async function loadEngine() { if (process.env.AGENTGUARD_TEST_FORCE_ENGINE_LOAD_FAILURE === '1') return null; @@ -173,7 +188,13 @@ async function loadEngine() { } }; - const gs = (await tryImport(agentguardPath)) || (await tryImport('@goplus/agentguard')); + // 1) Bundled engine (in-repo or installed via `npm i -g @goplus/agentguard` + // where the skill folder lives next to dist/). + // 2) Bare specifier (resolves when the host process has @goplus/agentguard + // in scope — e.g. invoked from a node_modules-aware cwd). + const gs = + (existsAtPath(bundledEnginePath) ? await tryImport(bundledEnginePath) : null) || + (await tryImport('@goplus/agentguard')); if (!gs) return null; return { diff --git a/src/adapters/cline.ts b/src/adapters/cline.ts index 47ccd3a..9266c9e 100644 --- a/src/adapters/cline.ts +++ b/src/adapters/cline.ts @@ -1,4 +1,5 @@ import type { ActionEnvelope } from '../types/action.js'; +import { isSensitivePath } from './common.js'; import type { HookAdapter, HookInput } from './types.js'; /** @@ -26,6 +27,25 @@ const TOOL_ACTION_MAP: Record = { web_search: 'web_search', }; +function collectReadFilePaths(toolInput: Record): string[] { + const out: string[] = []; + const single = firstString(toolInput.path, toolInput.file_path, toolInput.filePath); + if (single) out.push(single); + const lists = [toolInput.files, toolInput.file_paths, toolInput.paths]; + for (const list of lists) { + if (!Array.isArray(list)) continue; + for (const entry of list) { + if (typeof entry === 'string' && entry.length > 0) { + out.push(entry); + } else if (entry && typeof entry === 'object') { + const p = (entry as Record).path; + if (typeof p === 'string' && p.length > 0) out.push(p); + } + } + } + return out; +} + function firstString(...values: unknown[]): string { for (const value of values) { if (typeof value === 'string' && value.length > 0) return value; @@ -153,18 +173,18 @@ export class ClineAdapter implements HookAdapter { break; case 'read_file': { - // read_files supports many shapes; pick the first path seen. + // Cline's read_files accepts multiple files (string | string[] | + // { path }[] under .files/.file_paths/.paths). The single-envelope + // contract forces us to pick one representative path; we prefer a + // path that isSensitivePath flags so multi-file reads can't smuggle + // a sensitive target alongside benign ones. const ti = input.toolInput; - let path = firstString(ti.path, ti.file_path, ti.filePath); - if (!path) { - const files = (ti.files || ti.file_paths || ti.paths) as unknown; - if (Array.isArray(files)) { - const first = files.find((f) => typeof f === 'string' || (f && typeof (f as Record).path === 'string')); - if (typeof first === 'string') path = first; - else if (first && typeof first === 'object') path = String((first as Record).path || ''); - } - } - actionData = { path }; + const paths = collectReadFilePaths(ti); + const sensitive = paths.find((p) => isSensitivePath(p)); + const path = sensitive || paths[0] || ''; + actionData = sensitive + ? { path, paths, sensitive_path: sensitive } + : { path, ...(paths.length > 1 ? { paths } : {}) }; break; } diff --git a/src/installers.ts b/src/installers.ts index 1b0dc3a..7bbbe31 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -1,4 +1,4 @@ -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'; @@ -249,7 +249,6 @@ function installCline(cwd: string | undefined, force: boolean): InstallResult { : process.env.CLINE_HOME?.trim() || join(homedir(), '.cline'); const skillDir = join(clineRoot, 'skills', 'agentguard'); - const hookScriptPath = join(skillDir, 'scripts', 'cline-hook.js'); const preHookPath = join(clineRoot, 'hooks', 'PreToolUse.js'); const postHookPath = join(clineRoot, 'hooks', 'PostToolUse.js'); const pluginDir = join(clineRoot, 'plugins', 'agentguard'); @@ -257,11 +256,17 @@ function installCline(cwd: string | undefined, force: boolean): InstallResult { // 1. Drop the shared skill (carries cline-hook.js and supporting scripts). copyBundledSkill(skillDir, force); - // 2. Write Cline file-hook shims that delegate to the bundled script. - const preShim = clineHookShim(hookScriptPath); - const postShim = clineHookShim(hookScriptPath); - writeIfAllowed(preHookPath, preShim, force); - writeIfAllowed(postHookPath, postShim, force); + // 2. Install Cline file hooks by copying cline-hook.js directly. The hook + // branches on the incoming `hookName` (`tool_call` -> pre, `tool_result` + // -> post), so the same script handles both events. Copying avoids an + // extra process boundary and the stdin-inheritance footgun that comes + // with shim scripts. + const hookSource = resolve(__dirname, '..', 'skills', 'agentguard', 'scripts', 'cline-hook.js'); + if (!existsSync(hookSource)) { + throw new Error(`Bundled cline-hook.js not found at ${hookSource}. Reinstall @goplus/agentguard.`); + } + installClineHook(hookSource, preHookPath, force); + installClineHook(hookSource, postHookPath, force); // 3. Copy the native runtime plugin (cline plugin install ~/.cline/plugins/agentguard). copyBundledClinePlugin(pluginDir, force); @@ -272,9 +277,23 @@ function installCline(cwd: string | undefined, force: boolean): InstallResult { }; } +function installClineHook(source: string, target: string, force: boolean): void { + if (existsSync(target) && !force) return; + mkdirSync(dirname(target), { recursive: true }); + cpSync(source, target, { force }); + // Cline hook files must be executable for the shebang to fire. + try { + chmodSync(target, 0o755); + } catch { + // Best-effort; Windows ignores the mode bits. + } +} + const CLINE_PLUGIN_REQUIRED_FILES = ['index.ts', 'package.json']; function copyBundledClinePlugin(targetDir: string, force: boolean): void { + // The published package ships `plugins/` (see package.json "files"), so this + // path is the same in-repo and in installed @goplus/agentguard. const sourceDir = resolve(__dirname, '..', 'plugins', 'cline'); if (!existsSync(sourceDir)) { throw new Error(`Bundled Cline plugin not found at ${sourceDir}. Reinstall @goplus/agentguard.`); @@ -290,18 +309,6 @@ function copyBundledClinePlugin(targetDir: string, force: boolean): void { } } -function clineHookShim(hookScriptPath: string): string { - // Cline runs hook files matching the event name (PreToolUse / PostToolUse). - // The shim hands stdin straight through to the bundled engine bridge. - return `#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -const child = spawnSync(process.execPath, [${JSON.stringify(hookScriptPath)}], { - stdio: 'inherit', -}); -process.exit(child.status ?? 0); -`; -} - function writeIfAllowed(path: string, content: string, force: boolean): void { if (existsSync(path) && !force) return; mkdirSync(dirname(path), { recursive: true }); diff --git a/src/tests/adapter.test.ts b/src/tests/adapter.test.ts index 599a8ed..4b8f93a 100644 --- a/src/tests/adapter.test.ts +++ b/src/tests/adapter.test.ts @@ -664,6 +664,42 @@ describe('ClineAdapter', () => { assert.equal((envelope!.action.data as unknown as Record).path, '/tmp/readme.md'); }); + it('should prefer a sensitive path when read_files lists multiple files', () => { + // Locks in the medium-severity review fix: multi-file reads must not + // smuggle a sensitive target alongside benign ones into a single envelope. + const input = adapter.parseInput({ + hookName: 'tool_call', + tool_call: { + id: 'c1', + name: 'read_files', + input: { files: ['/tmp/readme.md', '/project/.env', '/tmp/other.md'] }, + }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + const data = envelope!.action.data as unknown as Record; + assert.equal(data.path, '/project/.env'); + assert.equal(data.sensitive_path, '/project/.env'); + assert.deepEqual(data.paths, ['/tmp/readme.md', '/project/.env', '/tmp/other.md']); + }); + + it('should include the paths list for non-sensitive multi-file reads', () => { + const input = adapter.parseInput({ + hookName: 'tool_call', + tool_call: { + id: 'c1', + name: 'read_files', + input: { paths: ['/tmp/a.md', '/tmp/b.md'] }, + }, + }); + const envelope = adapter.buildEnvelope(input); + assert.ok(envelope); + const data = envelope!.action.data as unknown as Record; + assert.equal(data.path, '/tmp/a.md'); + assert.deepEqual(data.paths, ['/tmp/a.md', '/tmp/b.md']); + assert.equal(data.sensitive_path, undefined); + }); + it('should build network_request envelope from web_fetch URL', () => { const input = adapter.parseInput({ hookName: 'tool_call', diff --git a/src/tests/installer.test.ts b/src/tests/installer.test.ts index d712316..1e63d9a 100644 --- a/src/tests/installer.test.ts +++ b/src/tests/installer.test.ts @@ -297,4 +297,48 @@ describe('Agent template installers', () => { assert.deepEqual(config.plugins.allow, ['existing', 'agentguard']); assert.equal(config.plugins.entries.agentguard.enabled, true); }); + + it('installs Cline skill, hooks (copied directly, no shim), and runtime plugin', () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-cline-')); + const result = installAgentTemplates('cline', { cwd: dir }); + + const skillDir = join(dir, '.cline', 'skills', 'agentguard'); + const preHookPath = join(dir, '.cline', 'hooks', 'PreToolUse.js'); + const postHookPath = join(dir, '.cline', 'hooks', 'PostToolUse.js'); + const pluginDir = join(dir, '.cline', 'plugins', 'agentguard'); + const sourceHookPath = join( + __dirname, + '..', + '..', + 'skills', + 'agentguard', + 'scripts', + 'cline-hook.js' + ); + + assert.equal(result.agent, 'cline'); + assert.ok(existsSync(skillDir)); + assert.ok(existsSync(preHookPath)); + assert.ok(existsSync(postHookPath)); + assert.ok(existsSync(join(pluginDir, 'index.ts'))); + assert.ok(existsSync(join(pluginDir, 'package.json'))); + + // Hooks are the engine bridge copied verbatim — no spawnSync shim. Verifies + // the high-severity review fix where the shim could lose stdin. + const preContents = readFileSync(preHookPath, 'utf8'); + const sourceContents = readFileSync(sourceHookPath, 'utf8'); + assert.equal(preContents, sourceContents); + assert.equal(readFileSync(postHookPath, 'utf8'), sourceContents); + assert.ok(!preContents.includes('spawnSync'), 'hook should not be a spawn shim'); + assert.ok(preContents.includes("#!/usr/bin/env node"), 'hook must have a node shebang'); + }); + + it('makes Cline hooks executable so Cline can run them via shebang', () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-cline-mode-')); + installAgentTemplates('cline', { cwd: dir }); + if (process.platform === 'win32') return; // mode bits are a no-op on Windows + const { statSync } = require('node:fs') as typeof import('node:fs'); + const mode = statSync(join(dir, '.cline', 'hooks', 'PreToolUse.js')).mode & 0o777; + assert.ok((mode & 0o111) !== 0, `expected executable bit on PreToolUse.js, got ${mode.toString(8)}`); + }); });