bash.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. import { describe, expect, test } from "bun:test"
  2. import path from "path"
  3. import { BashTool } from "../../src/tool/bash"
  4. import { Instance } from "../../src/project/instance"
  5. import { tmpdir } from "../fixture/fixture"
  6. import type { PermissionNext } from "../../src/permission/next"
  7. import { Truncate } from "../../src/tool/truncation"
  8. const ctx = {
  9. sessionID: "test",
  10. messageID: "",
  11. callID: "",
  12. agent: "build",
  13. abort: AbortSignal.any([]),
  14. messages: [],
  15. metadata: () => {},
  16. ask: async () => {},
  17. }
  18. const projectRoot = path.join(__dirname, "../..")
  19. describe("tool.bash", () => {
  20. test("basic", async () => {
  21. await Instance.provide({
  22. directory: projectRoot,
  23. fn: async () => {
  24. const bash = await BashTool.init()
  25. const result = await bash.execute(
  26. {
  27. command: "echo 'test'",
  28. description: "Echo test message",
  29. },
  30. ctx,
  31. )
  32. expect(result.metadata.exit).toBe(0)
  33. expect(result.metadata.output).toContain("test")
  34. },
  35. })
  36. })
  37. })
  38. describe("tool.bash permissions", () => {
  39. test("asks for bash permission with correct pattern", async () => {
  40. await using tmp = await tmpdir({ git: true })
  41. await Instance.provide({
  42. directory: tmp.path,
  43. fn: async () => {
  44. const bash = await BashTool.init()
  45. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  46. const testCtx = {
  47. ...ctx,
  48. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  49. requests.push(req)
  50. },
  51. }
  52. await bash.execute(
  53. {
  54. command: "echo hello",
  55. description: "Echo hello",
  56. },
  57. testCtx,
  58. )
  59. expect(requests.length).toBe(1)
  60. expect(requests[0].permission).toBe("bash")
  61. expect(requests[0].patterns).toContain("echo hello")
  62. },
  63. })
  64. })
  65. test("asks for bash permission with multiple commands", async () => {
  66. await using tmp = await tmpdir({ git: true })
  67. await Instance.provide({
  68. directory: tmp.path,
  69. fn: async () => {
  70. const bash = await BashTool.init()
  71. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  72. const testCtx = {
  73. ...ctx,
  74. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  75. requests.push(req)
  76. },
  77. }
  78. await bash.execute(
  79. {
  80. command: "echo foo && echo bar",
  81. description: "Echo twice",
  82. },
  83. testCtx,
  84. )
  85. expect(requests.length).toBe(1)
  86. expect(requests[0].permission).toBe("bash")
  87. expect(requests[0].patterns).toContain("echo foo")
  88. expect(requests[0].patterns).toContain("echo bar")
  89. },
  90. })
  91. })
  92. test("asks for external_directory permission when cd to parent", async () => {
  93. await using tmp = await tmpdir({ git: true })
  94. await Instance.provide({
  95. directory: tmp.path,
  96. fn: async () => {
  97. const bash = await BashTool.init()
  98. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  99. const testCtx = {
  100. ...ctx,
  101. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  102. requests.push(req)
  103. },
  104. }
  105. await bash.execute(
  106. {
  107. command: "cd ../",
  108. description: "Change to parent directory",
  109. },
  110. testCtx,
  111. )
  112. const extDirReq = requests.find((r) => r.permission === "external_directory")
  113. expect(extDirReq).toBeDefined()
  114. },
  115. })
  116. })
  117. test("asks for external_directory permission when workdir is outside project", async () => {
  118. await using tmp = await tmpdir({ git: true })
  119. await Instance.provide({
  120. directory: tmp.path,
  121. fn: async () => {
  122. const bash = await BashTool.init()
  123. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  124. const testCtx = {
  125. ...ctx,
  126. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  127. requests.push(req)
  128. },
  129. }
  130. await bash.execute(
  131. {
  132. command: "ls",
  133. workdir: "/tmp",
  134. description: "List /tmp",
  135. },
  136. testCtx,
  137. )
  138. const extDirReq = requests.find((r) => r.permission === "external_directory")
  139. expect(extDirReq).toBeDefined()
  140. expect(extDirReq!.patterns).toContain("/tmp/*")
  141. },
  142. })
  143. })
  144. test("asks for external_directory permission when file arg is outside project", async () => {
  145. await using outerTmp = await tmpdir({
  146. init: async (dir) => {
  147. await Bun.write(path.join(dir, "outside.txt"), "x")
  148. },
  149. })
  150. await using tmp = await tmpdir({ git: true })
  151. await Instance.provide({
  152. directory: tmp.path,
  153. fn: async () => {
  154. const bash = await BashTool.init()
  155. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  156. const testCtx = {
  157. ...ctx,
  158. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  159. requests.push(req)
  160. },
  161. }
  162. const filepath = path.join(outerTmp.path, "outside.txt")
  163. await bash.execute(
  164. {
  165. command: `cat ${filepath}`,
  166. description: "Read external file",
  167. },
  168. testCtx,
  169. )
  170. const extDirReq = requests.find((r) => r.permission === "external_directory")
  171. const expected = path.join(outerTmp.path, "*")
  172. expect(extDirReq).toBeDefined()
  173. expect(extDirReq!.patterns).toContain(expected)
  174. expect(extDirReq!.always).toContain(expected)
  175. },
  176. })
  177. })
  178. test("does not ask for external_directory permission when rm inside project", async () => {
  179. await using tmp = await tmpdir({ git: true })
  180. await Instance.provide({
  181. directory: tmp.path,
  182. fn: async () => {
  183. const bash = await BashTool.init()
  184. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  185. const testCtx = {
  186. ...ctx,
  187. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  188. requests.push(req)
  189. },
  190. }
  191. await Bun.write(path.join(tmp.path, "tmpfile"), "x")
  192. await bash.execute(
  193. {
  194. command: "rm tmpfile",
  195. description: "Remove tmpfile",
  196. },
  197. testCtx,
  198. )
  199. const extDirReq = requests.find((r) => r.permission === "external_directory")
  200. expect(extDirReq).toBeUndefined()
  201. },
  202. })
  203. })
  204. test("includes always patterns for auto-approval", async () => {
  205. await using tmp = await tmpdir({ git: true })
  206. await Instance.provide({
  207. directory: tmp.path,
  208. fn: async () => {
  209. const bash = await BashTool.init()
  210. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  211. const testCtx = {
  212. ...ctx,
  213. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  214. requests.push(req)
  215. },
  216. }
  217. await bash.execute(
  218. {
  219. command: "git log --oneline -5",
  220. description: "Git log",
  221. },
  222. testCtx,
  223. )
  224. expect(requests.length).toBe(1)
  225. expect(requests[0].always.length).toBeGreaterThan(0)
  226. expect(requests[0].always.some((p) => p.endsWith("*"))).toBe(true)
  227. },
  228. })
  229. })
  230. test("does not ask for bash permission when command is cd only", async () => {
  231. await using tmp = await tmpdir({ git: true })
  232. await Instance.provide({
  233. directory: tmp.path,
  234. fn: async () => {
  235. const bash = await BashTool.init()
  236. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  237. const testCtx = {
  238. ...ctx,
  239. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  240. requests.push(req)
  241. },
  242. }
  243. await bash.execute(
  244. {
  245. command: "cd .",
  246. description: "Stay in current directory",
  247. },
  248. testCtx,
  249. )
  250. const bashReq = requests.find((r) => r.permission === "bash")
  251. expect(bashReq).toBeUndefined()
  252. },
  253. })
  254. })
  255. test("matches redirects in permission pattern", async () => {
  256. await using tmp = await tmpdir({ git: true })
  257. await Instance.provide({
  258. directory: tmp.path,
  259. fn: async () => {
  260. const bash = await BashTool.init()
  261. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  262. const testCtx = {
  263. ...ctx,
  264. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  265. requests.push(req)
  266. },
  267. }
  268. await bash.execute({ command: "cat > /tmp/output.txt", description: "Redirect ls output" }, testCtx)
  269. const bashReq = requests.find((r) => r.permission === "bash")
  270. expect(bashReq).toBeDefined()
  271. expect(bashReq!.patterns).toContain("cat > /tmp/output.txt")
  272. },
  273. })
  274. })
  275. test("always pattern has space before wildcard to not include different commands", async () => {
  276. await using tmp = await tmpdir({ git: true })
  277. await Instance.provide({
  278. directory: tmp.path,
  279. fn: async () => {
  280. const bash = await BashTool.init()
  281. const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
  282. const testCtx = {
  283. ...ctx,
  284. ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
  285. requests.push(req)
  286. },
  287. }
  288. await bash.execute({ command: "ls -la", description: "List" }, testCtx)
  289. const bashReq = requests.find((r) => r.permission === "bash")
  290. expect(bashReq).toBeDefined()
  291. const pattern = bashReq!.always[0]
  292. expect(pattern).toBe("ls *")
  293. },
  294. })
  295. })
  296. })
  297. describe("tool.bash truncation", () => {
  298. test("truncates output exceeding line limit", async () => {
  299. await Instance.provide({
  300. directory: projectRoot,
  301. fn: async () => {
  302. const bash = await BashTool.init()
  303. const lineCount = Truncate.MAX_LINES + 500
  304. const result = await bash.execute(
  305. {
  306. command: `seq 1 ${lineCount}`,
  307. description: "Generate lines exceeding limit",
  308. },
  309. ctx,
  310. )
  311. expect((result.metadata as any).truncated).toBe(true)
  312. expect(result.output).toContain("truncated")
  313. expect(result.output).toContain("The tool call succeeded but the output was truncated")
  314. },
  315. })
  316. })
  317. test("truncates output exceeding byte limit", async () => {
  318. await Instance.provide({
  319. directory: projectRoot,
  320. fn: async () => {
  321. const bash = await BashTool.init()
  322. const byteCount = Truncate.MAX_BYTES + 10000
  323. const result = await bash.execute(
  324. {
  325. command: `head -c ${byteCount} /dev/zero | tr '\\0' 'a'`,
  326. description: "Generate bytes exceeding limit",
  327. },
  328. ctx,
  329. )
  330. expect((result.metadata as any).truncated).toBe(true)
  331. expect(result.output).toContain("truncated")
  332. expect(result.output).toContain("The tool call succeeded but the output was truncated")
  333. },
  334. })
  335. })
  336. test("does not truncate small output", async () => {
  337. await Instance.provide({
  338. directory: projectRoot,
  339. fn: async () => {
  340. const bash = await BashTool.init()
  341. const result = await bash.execute(
  342. {
  343. command: "echo hello",
  344. description: "Echo hello",
  345. },
  346. ctx,
  347. )
  348. expect((result.metadata as any).truncated).toBe(false)
  349. expect(result.output).toBe("hello\n")
  350. },
  351. })
  352. })
  353. test("full output is saved to file when truncated", async () => {
  354. await Instance.provide({
  355. directory: projectRoot,
  356. fn: async () => {
  357. const bash = await BashTool.init()
  358. const lineCount = Truncate.MAX_LINES + 100
  359. const result = await bash.execute(
  360. {
  361. command: `seq 1 ${lineCount}`,
  362. description: "Generate lines for file check",
  363. },
  364. ctx,
  365. )
  366. expect((result.metadata as any).truncated).toBe(true)
  367. const filepath = (result.metadata as any).outputPath
  368. expect(filepath).toBeTruthy()
  369. const saved = await Bun.file(filepath).text()
  370. const lines = saved.trim().split("\n")
  371. expect(lines.length).toBe(lineCount)
  372. expect(lines[0]).toBe("1")
  373. expect(lines[lineCount - 1]).toBe(String(lineCount))
  374. },
  375. })
  376. })
  377. })