diff --git a/src/hooks/claude-code-hooks/config-loader.ts b/src/hooks/claude-code-hooks/config-loader.ts new file mode 100644 index 0000000..a9eda36 --- /dev/null +++ b/src/hooks/claude-code-hooks/config-loader.ts @@ -0,0 +1,105 @@ +import { existsSync } from "fs" +import { homedir } from "os" +import { join } from "path" +import type { ClaudeHookEvent } from "./types" +import { log } from "../../shared/logger" + +export interface DisabledHooksConfig { + Stop?: string[] + PreToolUse?: string[] + PostToolUse?: string[] + UserPromptSubmit?: string[] +} + +export interface PluginExtendedConfig { + disabledHooks?: DisabledHooksConfig +} + +const USER_CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode-cc-plugin.json") + +function getProjectConfigPath(): string { + return join(process.cwd(), ".opencode", "opencode-cc-plugin.json") +} + +async function loadConfigFromPath(path: string): Promise { + if (!existsSync(path)) { + return null + } + + try { + const content = await Bun.file(path).text() + return JSON.parse(content) as PluginExtendedConfig + } catch (error) { + log("Failed to load config", { path, error }) + return null + } +} + +function mergeDisabledHooks( + base: DisabledHooksConfig | undefined, + override: DisabledHooksConfig | undefined +): DisabledHooksConfig { + if (!override) return base ?? {} + if (!base) return override + + return { + Stop: override.Stop ?? base.Stop, + PreToolUse: override.PreToolUse ?? base.PreToolUse, + PostToolUse: override.PostToolUse ?? base.PostToolUse, + UserPromptSubmit: override.UserPromptSubmit ?? base.UserPromptSubmit, + } +} + +export async function loadPluginExtendedConfig(): Promise { + const userConfig = await loadConfigFromPath(USER_CONFIG_PATH) + const projectConfig = await loadConfigFromPath(getProjectConfigPath()) + + const merged: PluginExtendedConfig = { + disabledHooks: mergeDisabledHooks( + userConfig?.disabledHooks, + projectConfig?.disabledHooks + ), + } + + if (userConfig || projectConfig) { + log("Plugin extended config loaded", { + userConfigExists: userConfig !== null, + projectConfigExists: projectConfig !== null, + mergedDisabledHooks: merged.disabledHooks, + }) + } + + return merged +} + +const regexCache = new Map() + +function getRegex(pattern: string): RegExp { + let regex = regexCache.get(pattern) + if (!regex) { + try { + regex = new RegExp(pattern) + regexCache.set(pattern, regex) + } catch { + regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + regexCache.set(pattern, regex) + } + } + return regex +} + +export function isHookCommandDisabled( + eventType: ClaudeHookEvent, + command: string, + config: PluginExtendedConfig | null +): boolean { + if (!config?.disabledHooks) return false + + const patterns = config.disabledHooks[eventType] + if (!patterns || patterns.length === 0) return false + + return patterns.some((pattern) => { + const regex = getRegex(pattern) + return regex.test(command) + }) +} diff --git a/src/hooks/claude-code-hooks/plugin-config.ts b/src/hooks/claude-code-hooks/plugin-config.ts new file mode 100644 index 0000000..d0c4836 --- /dev/null +++ b/src/hooks/claude-code-hooks/plugin-config.ts @@ -0,0 +1,9 @@ +/** + * Plugin configuration for Claude Code hooks execution + * Contains settings for hook command execution (zsh, etc.) + */ + +export const DEFAULT_CONFIG = { + forceZsh: true, + zshPath: "/bin/zsh", +} diff --git a/src/hooks/claude-code-hooks/pre-tool-use.ts b/src/hooks/claude-code-hooks/pre-tool-use.ts new file mode 100644 index 0000000..51d8ca8 --- /dev/null +++ b/src/hooks/claude-code-hooks/pre-tool-use.ts @@ -0,0 +1,172 @@ +import type { + PreToolUseInput, + PreToolUseOutput, + PermissionDecision, + ClaudeHooksConfig, +} from "./types" +import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared" +import { DEFAULT_CONFIG } from "./plugin-config" +import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" + +export interface PreToolUseContext { + sessionId: string + toolName: string + toolInput: Record + cwd: string + transcriptPath?: string + toolUseId?: string + permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions" +} + +export interface PreToolUseResult { + decision: PermissionDecision + reason?: string + modifiedInput?: Record + elapsedMs?: number + hookName?: string + toolName?: string + inputLines?: string + // Common output fields (Claude Code spec) + continue?: boolean + stopReason?: string + suppressOutput?: boolean + systemMessage?: string +} + +function buildInputLines(toolInput: Record): string { + return Object.entries(toolInput) + .slice(0, 3) + .map(([key, val]) => { + const valStr = String(val).slice(0, 40) + return ` ${key}: ${valStr}${String(val).length > 40 ? "..." : ""}` + }) + .join("\n") +} + +export async function executePreToolUseHooks( + ctx: PreToolUseContext, + config: ClaudeHooksConfig | null, + extendedConfig?: PluginExtendedConfig | null +): Promise { + if (!config) { + return { decision: "allow" } + } + + const transformedToolName = transformToolName(ctx.toolName) + const matchers = findMatchingHooks(config, "PreToolUse", transformedToolName) + if (matchers.length === 0) { + return { decision: "allow" } + } + + const stdinData: PreToolUseInput = { + session_id: ctx.sessionId, + transcript_path: ctx.transcriptPath, + cwd: ctx.cwd, + permission_mode: ctx.permissionMode ?? "bypassPermissions", + hook_event_name: "PreToolUse", + tool_name: transformedToolName, + tool_input: objectToSnakeCase(ctx.toolInput), + tool_use_id: ctx.toolUseId, + hook_source: "opencode-plugin", + } + + const startTime = Date.now() + let firstHookName: string | undefined + const inputLines = buildInputLines(ctx.toolInput) + + for (const matcher of matchers) { + for (const hook of matcher.hooks) { + if (hook.type !== "command") continue + + if (isHookCommandDisabled("PreToolUse", hook.command, extendedConfig ?? null)) { + log("PreToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName }) + continue + } + + const hookName = hook.command.split("/").pop() || hook.command + if (!firstHookName) firstHookName = hookName + + const result = await executeHookCommand( + hook.command, + JSON.stringify(stdinData), + ctx.cwd, + { forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath } + ) + + if (result.exitCode === 2) { + return { + decision: "deny", + reason: result.stderr || result.stdout || "Hook blocked the operation", + elapsedMs: Date.now() - startTime, + hookName: firstHookName, + toolName: transformedToolName, + inputLines, + } + } + + if (result.exitCode === 1) { + return { + decision: "ask", + reason: result.stderr || result.stdout, + elapsedMs: Date.now() - startTime, + hookName: firstHookName, + toolName: transformedToolName, + inputLines, + } + } + + if (result.stdout) { + try { + const output = JSON.parse(result.stdout) as PreToolUseOutput + + // Handle deprecated decision/reason fields (Claude Code backward compat) + let decision: PermissionDecision | undefined + let reason: string | undefined + let modifiedInput: Record | undefined + + if (output.hookSpecificOutput?.permissionDecision) { + decision = output.hookSpecificOutput.permissionDecision + reason = output.hookSpecificOutput.permissionDecisionReason + modifiedInput = output.hookSpecificOutput.updatedInput + } else if (output.decision) { + // Map deprecated values: approve->allow, block->deny, ask->ask + const legacyDecision = output.decision + if (legacyDecision === "approve" || legacyDecision === "allow") { + decision = "allow" + } else if (legacyDecision === "block" || legacyDecision === "deny") { + decision = "deny" + } else if (legacyDecision === "ask") { + decision = "ask" + } + reason = output.reason + } + + // Return if decision is set OR if any common fields are set (fallback to allow) + const hasCommonFields = output.continue !== undefined || + output.stopReason !== undefined || + output.suppressOutput !== undefined || + output.systemMessage !== undefined + + if (decision || hasCommonFields) { + return { + decision: decision ?? "allow", + reason, + modifiedInput, + elapsedMs: Date.now() - startTime, + hookName: firstHookName, + toolName: transformedToolName, + inputLines, + continue: output.continue, + stopReason: output.stopReason, + suppressOutput: output.suppressOutput, + systemMessage: output.systemMessage, + } + } + } catch { + } + } + } + } + + return { decision: "allow" } +} diff --git a/src/hooks/claude-code-hooks/tool-input-cache.ts b/src/hooks/claude-code-hooks/tool-input-cache.ts new file mode 100644 index 0000000..7aa7d38 --- /dev/null +++ b/src/hooks/claude-code-hooks/tool-input-cache.ts @@ -0,0 +1,47 @@ +/** + * Caches tool_input from PreToolUse for PostToolUse + */ + +interface CacheEntry { + toolInput: Record + timestamp: number +} + +const cache = new Map() + +const CACHE_TTL = 60000 // 1 minute + +export function cacheToolInput( + sessionId: string, + toolName: string, + invocationId: string, + toolInput: Record +): void { + const key = `${sessionId}:${toolName}:${invocationId}` + cache.set(key, { toolInput, timestamp: Date.now() }) +} + +export function getToolInput( + sessionId: string, + toolName: string, + invocationId: string +): Record | null { + const key = `${sessionId}:${toolName}:${invocationId}` + const entry = cache.get(key) + if (!entry) return null + + cache.delete(key) + if (Date.now() - entry.timestamp > CACHE_TTL) return null + + return entry.toolInput +} + +// Periodic cleanup (every minute) +setInterval(() => { + const now = Date.now() + for (const [key, entry] of cache.entries()) { + if (now - entry.timestamp > CACHE_TTL) { + cache.delete(key) + } + } +}, CACHE_TTL)