diff --git a/src/config/schema.ts b/src/config/schema.ts index 5b6e512..334d15b 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -60,6 +60,7 @@ export const HookNameSchema = z.enum([ "startup-toast", "keyword-detector", "agent-usage-reminder", + "interactive-bash-blocker", ]) export const AgentOverrideConfigSchema = z.object({ diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 251fe2d..a463533 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -17,3 +17,4 @@ export { createAutoUpdateCheckerHook } from "./auto-update-checker"; export { createAgentUsageReminderHook } from "./agent-usage-reminder"; export { createKeywordDetectorHook } from "./keyword-detector"; +export { createInteractiveBashBlockerHook } from "./interactive-bash-blocker"; diff --git a/src/hooks/interactive-bash-blocker/constants.ts b/src/hooks/interactive-bash-blocker/constants.ts new file mode 100644 index 0000000..157a0e9 --- /dev/null +++ b/src/hooks/interactive-bash-blocker/constants.ts @@ -0,0 +1,82 @@ +export const HOOK_NAME = "interactive-bash-blocker" + +export const INTERACTIVE_FLAG_PATTERNS = [ + /\bgit\s+(?:rebase|add|stash|reset|checkout|commit|merge|revert|cherry-pick)\s+.*-i\b/, + /\bgit\s+(?:rebase|add|stash|reset|checkout|commit|merge|revert|cherry-pick)\s+.*--interactive\b/, + /\bgit\s+.*-p\b/, + /\bgit\s+add\s+.*--patch\b/, + /\bgit\s+stash\s+.*--patch\b/, + + /\b(?:vim?|nvim|nano|emacs|pico|joe|micro|helix|hx)\b/, + + /^\s*(?:python|python3|ipython|node|bun|deno|irb|pry|ghci|erl|iex|lua|R)\s*$/, + + /\btop\b(?!\s+\|)/, + /\bhtop\b/, + /\bbtop\b/, + /\bless\b(?!\s+\|)/, + /\bmore\b(?!\s+\|)/, + /\bman\b/, + /\bwatch\b/, + /\bssh\b(?!.*-[oTNf])/, + /\btelnet\b/, + /\bftp\b/, + /\bsftp\b/, + /\bmysql\b(?!.*-e)/, + /\bpsql\b(?!.*-c)/, + /\bmongo\b(?!.*--eval)/, + /\bredis-cli\b(?!.*[^\s])/, + + /\bncurses\b/, + /\bdialog\b/, + /\bwhiptail\b/, + /\bmc\b/, + /\branger\b/, + /\bnnn\b/, + /\blf\b/, + /\bvifm\b/, + /\bgitui\b/, + /\blazygit\b/, + /\blazydocker\b/, + /\bk9s\b/, + + /\bapt\s+(?:install|remove|upgrade|dist-upgrade)\b(?!.*-y)/, + /\bapt-get\s+(?:install|remove|upgrade|dist-upgrade)\b(?!.*-y)/, + /\byum\s+(?:install|remove|update)\b(?!.*-y)/, + /\bdnf\s+(?:install|remove|update)\b(?!.*-y)/, + /\bpacman\s+-S\b(?!.*--noconfirm)/, + /\bbrew\s+(?:install|uninstall|upgrade)\b(?!.*--force)/, + + /\bread\b(?!\s+.*<)/, + + /\bselect\b.*\bin\b/, +] + +export const STDIN_REQUIRING_COMMANDS = [ + "passwd", + "su", + "sudo -S", + "gpg --gen-key", + "ssh-keygen", +] + +export const TMUX_SUGGESTION = ` +[interactive-bash-blocker] +This command requires interactive input which is not supported in this environment. + +**Recommendation**: Use tmux for interactive commands. + +Example with interactive-terminal skill: +\`\`\` +# Start a tmux session +tmux new-session -d -s interactive + +# Send your command +tmux send-keys -t interactive 'your-command-here' Enter + +# Capture output +tmux capture-pane -t interactive -p +\`\`\` + +Or use the 'interactive-terminal' skill for easier workflow. +` diff --git a/src/hooks/interactive-bash-blocker/index.ts b/src/hooks/interactive-bash-blocker/index.ts new file mode 100644 index 0000000..a2338ec --- /dev/null +++ b/src/hooks/interactive-bash-blocker/index.ts @@ -0,0 +1,88 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { + HOOK_NAME, + INTERACTIVE_FLAG_PATTERNS, + STDIN_REQUIRING_COMMANDS, + TMUX_SUGGESTION, +} from "./constants" +import type { BlockResult } from "./types" +import { log } from "../../shared" + +export * from "./constants" +export * from "./types" + +function checkInteractiveCommand(command: string): BlockResult { + const normalizedCmd = command.trim() + + for (const pattern of INTERACTIVE_FLAG_PATTERNS) { + if (pattern.test(normalizedCmd)) { + return { + blocked: true, + reason: `Command contains interactive pattern`, + command: normalizedCmd, + matchedPattern: pattern.source, + } + } + } + + for (const cmd of STDIN_REQUIRING_COMMANDS) { + if (normalizedCmd.includes(cmd)) { + return { + blocked: true, + reason: `Command requires stdin interaction: ${cmd}`, + command: normalizedCmd, + matchedPattern: cmd, + } + } + } + + return { blocked: false } +} + +export function createInteractiveBashBlockerHook(ctx: PluginInput) { + return { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record } + ): Promise => { + const toolLower = input.tool.toLowerCase() + + if (toolLower !== "bash") { + return + } + + const command = output.args.command as string | undefined + if (!command) { + return + } + + const result = checkInteractiveCommand(command) + + if (result.blocked) { + log(`[${HOOK_NAME}] Blocking interactive command`, { + sessionID: input.sessionID, + command: result.command, + pattern: result.matchedPattern, + }) + + ctx.client.tui + .showToast({ + body: { + title: "Interactive Command Blocked", + message: `${result.reason}\nUse tmux or interactive-terminal skill instead.`, + variant: "error", + duration: 5000, + }, + }) + .catch(() => {}) + + throw new Error( + `[${HOOK_NAME}] ${result.reason}\n` + + `Command: ${result.command}\n` + + `Pattern: ${result.matchedPattern}\n` + + TMUX_SUGGESTION + ) + } + }, + } +} diff --git a/src/hooks/interactive-bash-blocker/types.ts b/src/hooks/interactive-bash-blocker/types.ts new file mode 100644 index 0000000..565ad36 --- /dev/null +++ b/src/hooks/interactive-bash-blocker/types.ts @@ -0,0 +1,12 @@ +export interface InteractiveBashBlockerConfig { + additionalPatterns?: string[] + allowPatterns?: string[] + disabled?: boolean +} + +export interface BlockResult { + blocked: boolean + reason?: string + command?: string + matchedPattern?: string +} diff --git a/src/index.ts b/src/index.ts index be6997b..1766a8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { createAutoUpdateCheckerHook, createKeywordDetectorHook, createAgentUsageReminderHook, + createInteractiveBashBlockerHook, } from "./hooks"; import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity"; import { @@ -238,6 +239,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const agentUsageReminder = isHookEnabled("agent-usage-reminder") ? createAgentUsageReminderHook(ctx) : null; + const interactiveBashBlocker = isHookEnabled("interactive-bash-blocker") + ? createInteractiveBashBlockerHook(ctx) + : null; updateTerminalTitle({ sessionId: "main" }); @@ -479,6 +483,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { "tool.execute.before": async (input, output) => { await claudeCodeHooks["tool.execute.before"](input, output); + await interactiveBashBlocker?.["tool.execute.before"](input, output); await commentChecker?.["tool.execute.before"](input, output); if (input.sessionID === getMainSessionID()) {