bash.test.ts 13 KB

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