fix(interactive-bash): prohibit tmux kill-server

This commit is contained in:
YeonGyu-Kim
2026-05-13 13:07:13 +09:00
parent 286f5ccfdf
commit 3f92264311
3 changed files with 141 additions and 25 deletions
+4
View File
@@ -11,6 +11,10 @@ export const BLOCKED_TMUX_SUBCOMMANDS = [
"pipep",
]
export const PROHIBITED_TMUX_SUBCOMMANDS = [
"kill-server",
]
export const INTERACTIVE_BASH_DESCRIPTION = `WARNING: This is TMUX ONLY. Pass tmux subcommands directly (without 'tmux' prefix).
Examples: new-session -d -s omo-dev, send-keys -t omo-dev "vim" Enter
+42
View File
@@ -0,0 +1,42 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import { interactive_bash } from "./tools"
const mockContext = {
sessionID: "test-session",
messageID: "test-message",
agent: "test-agent",
directory: "/project",
worktree: "/project",
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
} satisfies ToolContext
describe("interactive_bash", () => {
test("#given kill-server command #when executed #then returns a strong prohibition without running tmux", async () => {
// given
const args = { tmux_command: "kill-server" }
// when
const output = await interactive_bash.execute(args, mockContext)
// then
expect(output).toContain("Error: 'kill-server' is prohibited in interactive_bash.")
expect(output).toContain("NEVER EVER run tmux kill-server from interactive_bash.")
expect(output).toContain("Do not retry kill-server with Bash or any other tool.")
})
test("#given kill-server after tmux global options #when executed #then still prohibits it", async () => {
// given
const args = { tmux_command: "-L omo-socket kill-server" }
// when
const output = await interactive_bash.execute(args, mockContext)
// then
expect(output).toContain("Error: 'kill-server' is prohibited in interactive_bash.")
})
})
+95 -25
View File
@@ -1,9 +1,16 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
import { isCmuxCompatEnvironment } from "../../shared/tmux/cmux-detect"
import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
import {
BLOCKED_TMUX_SUBCOMMANDS,
DEFAULT_TIMEOUT_MS,
INTERACTIVE_BASH_DESCRIPTION,
PROHIBITED_TMUX_SUBCOMMANDS,
} from "./constants"
import { getCachedTmuxPath } from "./tmux-path-resolver"
const GLOBAL_TMUX_OPTIONS_WITH_ARGS = new Set(["-L", "-S", "-f", "-c", "-T"])
function resolveTmuxExecutable(tmuxPath: string): string[] {
if (!isCmuxCompatEnvironment()) {
return [tmuxPath]
@@ -59,6 +66,84 @@ export function tokenizeCommand(cmd: string): string[] {
return tokens
}
function findSubcommandIndex(parts: string[]): number {
let index = 0
while (index < parts.length) {
const part = parts[index] ?? ""
if (part === "--") {
return index + 1 < parts.length ? index + 1 : -1
}
if (GLOBAL_TMUX_OPTIONS_WITH_ARGS.has(part)) {
index += 2
continue
}
if (part.startsWith("-")) {
index++
continue
}
return index
}
return -1
}
function getTargetSessionName(parts: string[]): string {
const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t"))
if (sessionIdx === -1) {
return "omo-session"
}
const sessionToken = parts[sessionIdx] ?? ""
const nextToken = parts[sessionIdx + 1]
if (sessionToken === "-t" && nextToken) {
return nextToken
}
if (sessionToken.startsWith("-t")) {
return sessionToken.slice(2)
}
return "omo-session"
}
function buildBlockedTmuxCommandMessage(command: string, parts: string[]): string {
const sessionName = getTargetSessionName(parts)
return `Error: '${command}' is blocked in interactive_bash.
**USE BASH TOOL INSTEAD:**
\`\`\`bash
# Capture terminal output
tmux capture-pane -p -t ${sessionName}
# Or capture with history (last 1000 lines)
tmux capture-pane -p -t ${sessionName} -S -1000
\`\`\`
The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.`
}
function buildProhibitedTmuxCommandMessage(command: string): string {
return `Error: '${command}' is prohibited in interactive_bash.
NEVER EVER run tmux kill-server from interactive_bash.
It terminates the entire tmux server, destroying every tmux session and pane that the user, Codex, or other agents may be using.
Use scoped cleanup only:
\`\`\`bash
tmux kill-session -t <session-name>
\`\`\`
If you created an omo-* session, kill only that exact session. Do not retry kill-server with Bash or any other tool.`
}
export const interactive_bash: ToolDefinition = tool({
description: INTERACTIVE_BASH_DESCRIPTION,
args: {
@@ -74,31 +159,16 @@ export const interactive_bash: ToolDefinition = tool({
return "Error: Empty tmux command"
}
const subcommand = parts[0].toLowerCase()
const subcommandIndex = findSubcommandIndex(parts)
const rawSubcommand = subcommandIndex === -1 ? "" : parts[subcommandIndex]
const subcommand = rawSubcommand.toLowerCase()
if (PROHIBITED_TMUX_SUBCOMMANDS.includes(subcommand)) {
return buildProhibitedTmuxCommandMessage(rawSubcommand)
}
if (BLOCKED_TMUX_SUBCOMMANDS.includes(subcommand)) {
const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t"))
let sessionName = "omo-session"
if (sessionIdx !== -1) {
if (parts[sessionIdx] === "-t" && parts[sessionIdx + 1]) {
sessionName = parts[sessionIdx + 1]
} else if (parts[sessionIdx].startsWith("-t")) {
sessionName = parts[sessionIdx].slice(2)
}
}
return `Error: '${parts[0]}' is blocked in interactive_bash.
**USE BASH TOOL INSTEAD:**
\`\`\`bash
# Capture terminal output
tmux capture-pane -p -t ${sessionName}
# Or capture with history (last 1000 lines)
tmux capture-pane -p -t ${sessionName} -S -1000
\`\`\`
The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.`
return buildBlockedTmuxCommandMessage(rawSubcommand, parts)
}
const proc = spawnWithWindowsHide([...resolveTmuxExecutable(tmuxPath), ...parts], {