Skip to content

Commit 4b6b725

Browse files
committed
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
1 parent 1aaa6e6 commit 4b6b725

File tree

6 files changed

+189
-0
lines changed

6 files changed

+189
-0
lines changed

src/config/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export const HookNameSchema = z.enum([
6060
"startup-toast",
6161
"keyword-detector",
6262
"agent-usage-reminder",
63+
"interactive-bash-blocker",
6364
])
6465

6566
export const AgentOverrideConfigSchema = z.object({

src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ export { createAutoUpdateCheckerHook } from "./auto-update-checker";
1717

1818
export { createAgentUsageReminderHook } from "./agent-usage-reminder";
1919
export { createKeywordDetectorHook } from "./keyword-detector";
20+
export { createInteractiveBashBlockerHook } from "./interactive-bash-blocker";
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
export const HOOK_NAME = "interactive-bash-blocker"
2+
3+
export const INTERACTIVE_FLAG_PATTERNS = [
4+
/\bgit\s+(?:rebase|add|stash|reset|checkout|commit|merge|revert|cherry-pick)\s+.*-i\b/,
5+
/\bgit\s+(?:rebase|add|stash|reset|checkout|commit|merge|revert|cherry-pick)\s+.*--interactive\b/,
6+
/\bgit\s+.*-p\b/,
7+
/\bgit\s+add\s+.*--patch\b/,
8+
/\bgit\s+stash\s+.*--patch\b/,
9+
10+
/\b(?:vim?|nvim|nano|emacs|pico|joe|micro|helix|hx)\b/,
11+
12+
/^\s*(?:python|python3|ipython|node|bun|deno|irb|pry|ghci|erl|iex|lua|R)\s*$/,
13+
14+
/\btop\b(?!\s+\|)/,
15+
/\bhtop\b/,
16+
/\bbtop\b/,
17+
/\bless\b(?!\s+\|)/,
18+
/\bmore\b(?!\s+\|)/,
19+
/\bman\b/,
20+
/\bwatch\b/,
21+
/\bssh\b(?!.*-[oTNf])/,
22+
/\btelnet\b/,
23+
/\bftp\b/,
24+
/\bsftp\b/,
25+
/\bmysql\b(?!.*-e)/,
26+
/\bpsql\b(?!.*-c)/,
27+
/\bmongo\b(?!.*--eval)/,
28+
/\bredis-cli\b(?!.*[^\s])/,
29+
30+
/\bncurses\b/,
31+
/\bdialog\b/,
32+
/\bwhiptail\b/,
33+
/\bmc\b/,
34+
/\branger\b/,
35+
/\bnnn\b/,
36+
/\blf\b/,
37+
/\bvifm\b/,
38+
/\bgitui\b/,
39+
/\blazygit\b/,
40+
/\blazydocker\b/,
41+
/\bk9s\b/,
42+
43+
/\bapt\s+(?:install|remove|upgrade|dist-upgrade)\b(?!.*-y)/,
44+
/\bapt-get\s+(?:install|remove|upgrade|dist-upgrade)\b(?!.*-y)/,
45+
/\byum\s+(?:install|remove|update)\b(?!.*-y)/,
46+
/\bdnf\s+(?:install|remove|update)\b(?!.*-y)/,
47+
/\bpacman\s+-S\b(?!.*--noconfirm)/,
48+
/\bbrew\s+(?:install|uninstall|upgrade)\b(?!.*--force)/,
49+
50+
/\bread\b(?!\s+.*<)/,
51+
52+
/\bselect\b.*\bin\b/,
53+
]
54+
55+
export const STDIN_REQUIRING_COMMANDS = [
56+
"passwd",
57+
"su",
58+
"sudo -S",
59+
"gpg --gen-key",
60+
"ssh-keygen",
61+
]
62+
63+
export const TMUX_SUGGESTION = `
64+
[interactive-bash-blocker]
65+
This command requires interactive input which is not supported in this environment.
66+
67+
**Recommendation**: Use tmux for interactive commands.
68+
69+
Example with interactive-terminal skill:
70+
\`\`\`
71+
# Start a tmux session
72+
tmux new-session -d -s interactive
73+
74+
# Send your command
75+
tmux send-keys -t interactive 'your-command-here' Enter
76+
77+
# Capture output
78+
tmux capture-pane -t interactive -p
79+
\`\`\`
80+
81+
Or use the 'interactive-terminal' skill for easier workflow.
82+
`
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { PluginInput } from "@opencode-ai/plugin"
2+
import {
3+
HOOK_NAME,
4+
INTERACTIVE_FLAG_PATTERNS,
5+
STDIN_REQUIRING_COMMANDS,
6+
TMUX_SUGGESTION,
7+
} from "./constants"
8+
import type { BlockResult } from "./types"
9+
import { log } from "../../shared"
10+
11+
export * from "./constants"
12+
export * from "./types"
13+
14+
function checkInteractiveCommand(command: string): BlockResult {
15+
const normalizedCmd = command.trim()
16+
17+
for (const pattern of INTERACTIVE_FLAG_PATTERNS) {
18+
if (pattern.test(normalizedCmd)) {
19+
return {
20+
blocked: true,
21+
reason: `Command contains interactive pattern`,
22+
command: normalizedCmd,
23+
matchedPattern: pattern.source,
24+
}
25+
}
26+
}
27+
28+
for (const cmd of STDIN_REQUIRING_COMMANDS) {
29+
if (normalizedCmd.includes(cmd)) {
30+
return {
31+
blocked: true,
32+
reason: `Command requires stdin interaction: ${cmd}`,
33+
command: normalizedCmd,
34+
matchedPattern: cmd,
35+
}
36+
}
37+
}
38+
39+
return { blocked: false }
40+
}
41+
42+
export function createInteractiveBashBlockerHook(ctx: PluginInput) {
43+
return {
44+
"tool.execute.before": async (
45+
input: { tool: string; sessionID: string; callID: string },
46+
output: { args: Record<string, unknown> }
47+
): Promise<void> => {
48+
const toolLower = input.tool.toLowerCase()
49+
50+
if (toolLower !== "bash") {
51+
return
52+
}
53+
54+
const command = output.args.command as string | undefined
55+
if (!command) {
56+
return
57+
}
58+
59+
const result = checkInteractiveCommand(command)
60+
61+
if (result.blocked) {
62+
log(`[${HOOK_NAME}] Blocking interactive command`, {
63+
sessionID: input.sessionID,
64+
command: result.command,
65+
pattern: result.matchedPattern,
66+
})
67+
68+
ctx.client.tui
69+
.showToast({
70+
body: {
71+
title: "Interactive Command Blocked",
72+
message: `${result.reason}\nUse tmux or interactive-terminal skill instead.`,
73+
variant: "error",
74+
duration: 5000,
75+
},
76+
})
77+
.catch(() => {})
78+
79+
throw new Error(
80+
`[${HOOK_NAME}] ${result.reason}\n` +
81+
`Command: ${result.command}\n` +
82+
`Pattern: ${result.matchedPattern}\n` +
83+
TMUX_SUGGESTION
84+
)
85+
}
86+
},
87+
}
88+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export interface InteractiveBashBlockerConfig {
2+
additionalPatterns?: string[]
3+
allowPatterns?: string[]
4+
disabled?: boolean
5+
}
6+
7+
export interface BlockResult {
8+
blocked: boolean
9+
reason?: string
10+
command?: string
11+
matchedPattern?: string
12+
}

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
createAutoUpdateCheckerHook,
1919
createKeywordDetectorHook,
2020
createAgentUsageReminderHook,
21+
createInteractiveBashBlockerHook,
2122
} from "./hooks";
2223
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
2324
import {
@@ -238,6 +239,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
238239
const agentUsageReminder = isHookEnabled("agent-usage-reminder")
239240
? createAgentUsageReminderHook(ctx)
240241
: null;
242+
const interactiveBashBlocker = isHookEnabled("interactive-bash-blocker")
243+
? createInteractiveBashBlockerHook(ctx)
244+
: null;
241245

242246
updateTerminalTitle({ sessionId: "main" });
243247

@@ -479,6 +483,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
479483

480484
"tool.execute.before": async (input, output) => {
481485
await claudeCodeHooks["tool.execute.before"](input, output);
486+
await interactiveBashBlocker?.["tool.execute.before"](input, output);
482487
await commentChecker?.["tool.execute.before"](input, output);
483488

484489
if (input.sessionID === getMainSessionID()) {

0 commit comments

Comments
 (0)