feat(hooks): Add interactive-bash-blocker hook
- Prevent interactive bash commands from being executed automatically - Block commands in tool.execute.before hook - Register in schema and main plugin initialization
This commit is contained in:
@@ -60,6 +60,7 @@ export const HookNameSchema = z.enum([
|
|||||||
"startup-toast",
|
"startup-toast",
|
||||||
"keyword-detector",
|
"keyword-detector",
|
||||||
"agent-usage-reminder",
|
"agent-usage-reminder",
|
||||||
|
"interactive-bash-blocker",
|
||||||
])
|
])
|
||||||
|
|
||||||
export const AgentOverrideConfigSchema = z.object({
|
export const AgentOverrideConfigSchema = z.object({
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ export { createAutoUpdateCheckerHook } from "./auto-update-checker";
|
|||||||
|
|
||||||
export { createAgentUsageReminderHook } from "./agent-usage-reminder";
|
export { createAgentUsageReminderHook } from "./agent-usage-reminder";
|
||||||
export { createKeywordDetectorHook } from "./keyword-detector";
|
export { createKeywordDetectorHook } from "./keyword-detector";
|
||||||
|
export { createInteractiveBashBlockerHook } from "./interactive-bash-blocker";
|
||||||
|
|||||||
82
src/hooks/interactive-bash-blocker/constants.ts
Normal file
82
src/hooks/interactive-bash-blocker/constants.ts
Normal file
@@ -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.
|
||||||
|
`
|
||||||
88
src/hooks/interactive-bash-blocker/index.ts
Normal file
88
src/hooks/interactive-bash-blocker/index.ts
Normal file
@@ -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<string, unknown> }
|
||||||
|
): Promise<void> => {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/hooks/interactive-bash-blocker/types.ts
Normal file
12
src/hooks/interactive-bash-blocker/types.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export interface InteractiveBashBlockerConfig {
|
||||||
|
additionalPatterns?: string[]
|
||||||
|
allowPatterns?: string[]
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockResult {
|
||||||
|
blocked: boolean
|
||||||
|
reason?: string
|
||||||
|
command?: string
|
||||||
|
matchedPattern?: string
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
createAutoUpdateCheckerHook,
|
createAutoUpdateCheckerHook,
|
||||||
createKeywordDetectorHook,
|
createKeywordDetectorHook,
|
||||||
createAgentUsageReminderHook,
|
createAgentUsageReminderHook,
|
||||||
|
createInteractiveBashBlockerHook,
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
||||||
import {
|
import {
|
||||||
@@ -238,6 +239,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
const agentUsageReminder = isHookEnabled("agent-usage-reminder")
|
const agentUsageReminder = isHookEnabled("agent-usage-reminder")
|
||||||
? createAgentUsageReminderHook(ctx)
|
? createAgentUsageReminderHook(ctx)
|
||||||
: null;
|
: null;
|
||||||
|
const interactiveBashBlocker = isHookEnabled("interactive-bash-blocker")
|
||||||
|
? createInteractiveBashBlockerHook(ctx)
|
||||||
|
: null;
|
||||||
|
|
||||||
updateTerminalTitle({ sessionId: "main" });
|
updateTerminalTitle({ sessionId: "main" });
|
||||||
|
|
||||||
@@ -479,6 +483,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
|
|
||||||
"tool.execute.before": async (input, output) => {
|
"tool.execute.before": async (input, output) => {
|
||||||
await claudeCodeHooks["tool.execute.before"](input, output);
|
await claudeCodeHooks["tool.execute.before"](input, output);
|
||||||
|
await interactiveBashBlocker?.["tool.execute.before"](input, output);
|
||||||
await commentChecker?.["tool.execute.before"](input, output);
|
await commentChecker?.["tool.execute.before"](input, output);
|
||||||
|
|
||||||
if (input.sessionID === getMainSessionID()) {
|
if (input.sessionID === getMainSessionID()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user