From 2f801f6c28b9f9a111219fe73b5e4b78fbd72c1d Mon Sep 17 00:00:00 2001 From: MoerAI Date: Mon, 23 Mar 2026 20:15:20 +0900 Subject: [PATCH] fix(hooks): add bash-file-read-guard to warn agents against cat/head/tail (fixes #2096) --- src/config/schema/hooks.ts | 1 + src/hooks/bash-file-read-guard.ts | 44 +++++++++++++++++++++ src/hooks/index.ts | 1 + src/plugin/hooks/create-tool-guard-hooks.ts | 7 ++++ src/plugin/tool-execute-before.ts | 1 + 5 files changed, 54 insertions(+) create mode 100644 src/hooks/bash-file-read-guard.ts diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index 00e04404e..5fc73954a 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -47,6 +47,7 @@ export const HookNameSchema = z.enum([ "tasks-todowrite-disabler", "runtime-fallback", "write-existing-file-guard", + "bash-file-read-guard", "anthropic-effort", "hashline-read-enhancer", "read-image-resizer", diff --git a/src/hooks/bash-file-read-guard.ts b/src/hooks/bash-file-read-guard.ts new file mode 100644 index 000000000..e93af893b --- /dev/null +++ b/src/hooks/bash-file-read-guard.ts @@ -0,0 +1,44 @@ +import type { Hooks } from "@opencode-ai/plugin" + +import { log } from "../shared" + +const WARNING_MESSAGE = "Prefer the Read tool over `cat`/`head`/`tail` for reading file contents. The Read tool provides line numbers and hash anchors for precise editing." + +const FILE_READ_PATTERNS = [ + /^\s*cat\s+(?!-)[^\s|&;]+\s*$/, + /^\s*head\s+(-n\s+\d+\s+)?(?!-)[^\s|&;]+\s*$/, + /^\s*tail\s+(-n\s+\d+\s+)?(?!-)[^\s|&;]+\s*$/, +] + +function isSimpleFileReadCommand(command: string): boolean { + return FILE_READ_PATTERNS.some((pattern) => pattern.test(command)) +} + +export function createBashFileReadGuardHook(): Hooks { + return { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record; message?: string }, + ): Promise => { + if (input.tool.toLowerCase() !== "bash") { + return + } + + const command = output.args.command + if (typeof command !== "string") { + return + } + + if (!isSimpleFileReadCommand(command)) { + return + } + + output.message = WARNING_MESSAGE + + log("[bash-file-read-guard] warned on bash file read command", { + sessionID: input.sessionID, + command, + }) + }, + } +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index abbf79bb7..f9f525cd9 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -48,6 +48,7 @@ export { createPreemptiveCompactionHook } from "./preemptive-compaction"; export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler"; export { createRuntimeFallbackHook, type RuntimeFallbackHook, type RuntimeFallbackOptions } from "./runtime-fallback"; export { createWriteExistingFileGuardHook } from "./write-existing-file-guard"; +export { createBashFileReadGuardHook } from "./bash-file-read-guard"; export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer"; export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery"; export { createReadImageResizerHook } from "./read-image-resizer" diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts index 1c79f6949..4081ab41c 100644 --- a/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -11,6 +11,7 @@ import { createRulesInjectorHook, createTasksTodowriteDisablerHook, createWriteExistingFileGuardHook, + createBashFileReadGuardHook, createHashlineReadEnhancerHook, createReadImageResizerHook, createJsonErrorRecoveryHook, @@ -33,6 +34,7 @@ export type ToolGuardHooks = { rulesInjector: ReturnType | null tasksTodowriteDisabler: ReturnType | null writeExistingFileGuard: ReturnType | null + bashFileReadGuard: ReturnType | null hashlineReadEnhancer: ReturnType | null jsonErrorRecovery: ReturnType | null readImageResizer: ReturnType | null @@ -101,6 +103,10 @@ export function createToolGuardHooks(args: { ? safeHook("write-existing-file-guard", () => createWriteExistingFileGuardHook(ctx)) : null + const bashFileReadGuard = isHookEnabled("bash-file-read-guard") + ? safeHook("bash-file-read-guard", () => createBashFileReadGuardHook()) + : null + const hashlineReadEnhancer = isHookEnabled("hashline-read-enhancer") ? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.hashline_edit ?? false } })) : null @@ -126,6 +132,7 @@ export function createToolGuardHooks(args: { rulesInjector, tasksTodowriteDisabler, writeExistingFileGuard, + bashFileReadGuard, hashlineReadEnhancer, jsonErrorRecovery, readImageResizer, diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index 5d4f2c86e..08139d4b0 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -45,6 +45,7 @@ export function createToolExecuteBeforeHandler(args: { await hooks.questionLabelTruncator?.["tool.execute.before"]?.(input, output) await hooks.claudeCodeHooks?.["tool.execute.before"]?.(input, output) await hooks.nonInteractiveEnv?.["tool.execute.before"]?.(input, output) + await hooks.bashFileReadGuard?.["tool.execute.before"]?.(input, output) await hooks.commentChecker?.["tool.execute.before"]?.(input, output) await hooks.directoryAgentsInjector?.["tool.execute.before"]?.(input, output) await hooks.directoryReadmeInjector?.["tool.execute.before"]?.(input, output)