bash.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import z from "zod"
  2. import { spawn } from "child_process"
  3. import { Tool } from "./tool"
  4. import DESCRIPTION from "./bash.txt"
  5. import { Log } from "../util/log"
  6. import { Instance } from "../project/instance"
  7. import { lazy } from "@/util/lazy"
  8. import { Language } from "web-tree-sitter"
  9. import { Agent } from "@/agent/agent"
  10. import { $ } from "bun"
  11. import { Filesystem } from "@/util/filesystem"
  12. import { Wildcard } from "@/util/wildcard"
  13. import { Permission } from "@/permission"
  14. import { fileURLToPath } from "url"
  15. import { Flag } from "@/flag/flag.ts"
  16. import path from "path"
  17. import { Shell } from "@/shell/shell"
  18. const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000
  19. const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
  20. export const log = Log.create({ service: "bash-tool" })
  21. const resolveWasm = (asset: string) => {
  22. if (asset.startsWith("file://")) return fileURLToPath(asset)
  23. if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset
  24. const url = new URL(asset, import.meta.url)
  25. return fileURLToPath(url)
  26. }
  27. const parser = lazy(async () => {
  28. const { Parser } = await import("web-tree-sitter")
  29. const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
  30. with: { type: "wasm" },
  31. })
  32. const treePath = resolveWasm(treeWasm)
  33. await Parser.init({
  34. locateFile() {
  35. return treePath
  36. },
  37. })
  38. const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
  39. with: { type: "wasm" },
  40. })
  41. const bashPath = resolveWasm(bashWasm)
  42. const bashLanguage = await Language.load(bashPath)
  43. const p = new Parser()
  44. p.setLanguage(bashLanguage)
  45. return p
  46. })
  47. // TODO: we may wanna rename this tool so it works better on other shells
  48. export const BashTool = Tool.define("bash", async () => {
  49. const shell = Shell.acceptable()
  50. log.info("bash tool using shell", { shell })
  51. return {
  52. description: DESCRIPTION.replaceAll("${directory}", Instance.directory),
  53. parameters: z.object({
  54. command: z.string().describe("The command to execute"),
  55. timeout: z.number().describe("Optional timeout in milliseconds").optional(),
  56. workdir: z
  57. .string()
  58. .describe(
  59. `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`,
  60. )
  61. .optional(),
  62. description: z
  63. .string()
  64. .describe(
  65. "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
  66. ),
  67. }),
  68. async execute(params, ctx) {
  69. const cwd = params.workdir || Instance.directory
  70. if (params.timeout !== undefined && params.timeout < 0) {
  71. throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
  72. }
  73. const timeout = params.timeout ?? DEFAULT_TIMEOUT
  74. const tree = await parser().then((p) => p.parse(params.command))
  75. if (!tree) {
  76. throw new Error("Failed to parse command")
  77. }
  78. const agent = await Agent.get(ctx.agent)
  79. const checkExternalDirectory = async (dir: string) => {
  80. if (Filesystem.contains(Instance.directory, dir)) return
  81. const title = `This command references paths outside of ${Instance.directory}`
  82. if (agent.permission.external_directory === "ask") {
  83. await Permission.ask({
  84. type: "external_directory",
  85. pattern: [dir, path.join(dir, "*")],
  86. sessionID: ctx.sessionID,
  87. messageID: ctx.messageID,
  88. callID: ctx.callID,
  89. title,
  90. metadata: {
  91. command: params.command,
  92. },
  93. })
  94. } else if (agent.permission.external_directory === "deny") {
  95. throw new Permission.RejectedError(
  96. ctx.sessionID,
  97. "external_directory",
  98. ctx.callID,
  99. {
  100. command: params.command,
  101. },
  102. `${title} so this command is not allowed to be executed.`,
  103. )
  104. }
  105. }
  106. await checkExternalDirectory(cwd)
  107. const permissions = agent.permission.bash
  108. const askPatterns = new Set<string>()
  109. for (const node of tree.rootNode.descendantsOfType("command")) {
  110. if (!node) continue
  111. const command = []
  112. for (let i = 0; i < node.childCount; i++) {
  113. const child = node.child(i)
  114. if (!child) continue
  115. if (
  116. child.type !== "command_name" &&
  117. child.type !== "word" &&
  118. child.type !== "string" &&
  119. child.type !== "raw_string" &&
  120. child.type !== "concatenation"
  121. ) {
  122. continue
  123. }
  124. command.push(child.text)
  125. }
  126. // not an exhaustive list, but covers most common cases
  127. if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"].includes(command[0])) {
  128. for (const arg of command.slice(1)) {
  129. if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
  130. const resolved = await $`realpath ${arg}`
  131. .quiet()
  132. .nothrow()
  133. .text()
  134. .then((x) => x.trim())
  135. log.info("resolved path", { arg, resolved })
  136. if (resolved) {
  137. // Git Bash on Windows returns Unix-style paths like /c/Users/...
  138. const normalized =
  139. process.platform === "win32" && resolved.match(/^\/[a-z]\//)
  140. ? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\")
  141. : resolved
  142. await checkExternalDirectory(normalized)
  143. }
  144. }
  145. }
  146. // always allow cd if it passes above check
  147. if (command[0] !== "cd") {
  148. const action = Wildcard.allStructured({ head: command[0], tail: command.slice(1) }, permissions)
  149. if (action === "deny") {
  150. throw new Error(
  151. `The user has specifically restricted access to this command: "${command.join(" ")}", you are not allowed to execute it. The user has these settings configured: ${JSON.stringify(permissions)}`,
  152. )
  153. }
  154. if (action === "ask") {
  155. const pattern = (() => {
  156. if (command.length === 0) return
  157. const head = command[0]
  158. // Find first non-flag argument as subcommand
  159. const sub = command.slice(1).find((arg) => !arg.startsWith("-"))
  160. return sub ? `${head} ${sub} *` : `${head} *`
  161. })()
  162. if (pattern) {
  163. askPatterns.add(pattern)
  164. }
  165. }
  166. }
  167. }
  168. if (askPatterns.size > 0) {
  169. const patterns = Array.from(askPatterns)
  170. await Permission.ask({
  171. type: "bash",
  172. pattern: patterns,
  173. sessionID: ctx.sessionID,
  174. messageID: ctx.messageID,
  175. callID: ctx.callID,
  176. title: params.command,
  177. metadata: {
  178. command: params.command,
  179. patterns,
  180. },
  181. })
  182. }
  183. const proc = spawn(params.command, {
  184. shell,
  185. cwd,
  186. env: {
  187. ...process.env,
  188. },
  189. stdio: ["ignore", "pipe", "pipe"],
  190. detached: process.platform !== "win32",
  191. })
  192. let output = ""
  193. // Initialize metadata with empty output
  194. ctx.metadata({
  195. metadata: {
  196. output: "",
  197. description: params.description,
  198. },
  199. })
  200. const append = (chunk: Buffer) => {
  201. if (output.length <= MAX_OUTPUT_LENGTH) {
  202. output += chunk.toString()
  203. ctx.metadata({
  204. metadata: {
  205. output,
  206. description: params.description,
  207. },
  208. })
  209. }
  210. }
  211. proc.stdout?.on("data", append)
  212. proc.stderr?.on("data", append)
  213. let timedOut = false
  214. let aborted = false
  215. let exited = false
  216. const kill = () => Shell.killTree(proc, { exited: () => exited })
  217. if (ctx.abort.aborted) {
  218. aborted = true
  219. await kill()
  220. }
  221. const abortHandler = () => {
  222. aborted = true
  223. void kill()
  224. }
  225. ctx.abort.addEventListener("abort", abortHandler, { once: true })
  226. const timeoutTimer = setTimeout(() => {
  227. timedOut = true
  228. void kill()
  229. }, timeout + 100)
  230. await new Promise<void>((resolve, reject) => {
  231. const cleanup = () => {
  232. clearTimeout(timeoutTimer)
  233. ctx.abort.removeEventListener("abort", abortHandler)
  234. }
  235. proc.once("exit", () => {
  236. exited = true
  237. cleanup()
  238. resolve()
  239. })
  240. proc.once("error", (error) => {
  241. exited = true
  242. cleanup()
  243. reject(error)
  244. })
  245. })
  246. let resultMetadata: String[] = ["<bash_metadata>"]
  247. if (output.length > MAX_OUTPUT_LENGTH) {
  248. output = output.slice(0, MAX_OUTPUT_LENGTH)
  249. resultMetadata.push(`bash tool truncated output as it exceeded ${MAX_OUTPUT_LENGTH} char limit`)
  250. }
  251. if (timedOut) {
  252. resultMetadata.push(`bash tool terminated commmand after exceeding timeout ${timeout} ms`)
  253. }
  254. if (aborted) {
  255. resultMetadata.push("User aborted the command")
  256. }
  257. if (resultMetadata.length > 1) {
  258. resultMetadata.push("</bash_metadata>")
  259. output += "\n\n" + resultMetadata.join("\n")
  260. }
  261. return {
  262. title: params.description,
  263. metadata: {
  264. output,
  265. exit: proc.exitCode,
  266. description: params.description,
  267. },
  268. output,
  269. }
  270. },
  271. }
  272. })