|
|
@@ -3,11 +3,12 @@ import path from "path"
|
|
|
import { BashTool } from "../../src/tool/bash"
|
|
|
import { Instance } from "../../src/project/instance"
|
|
|
import { Permission } from "../../src/permission"
|
|
|
+import { tmpdir } from "../fixture/fixture"
|
|
|
|
|
|
const ctx = {
|
|
|
sessionID: "test",
|
|
|
messageID: "",
|
|
|
- toolCallID: "",
|
|
|
+ callID: "",
|
|
|
agent: "build",
|
|
|
abort: AbortSignal.any([]),
|
|
|
metadata: () => {},
|
|
|
@@ -33,23 +34,401 @@ describe("tool.bash", () => {
|
|
|
},
|
|
|
})
|
|
|
})
|
|
|
+})
|
|
|
+
|
|
|
+describe("tool.bash permissions", () => {
|
|
|
+ test("allows command matching allow pattern", async () => {
|
|
|
+ await using tmp = await tmpdir({
|
|
|
+ init: async (dir) => {
|
|
|
+ await Bun.write(
|
|
|
+ path.join(dir, "opencode.json"),
|
|
|
+ JSON.stringify({
|
|
|
+ permission: {
|
|
|
+ bash: {
|
|
|
+ "echo *": "allow",
|
|
|
+ "*": "deny",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await Instance.provide({
|
|
|
+ directory: tmp.path,
|
|
|
+ fn: async () => {
|
|
|
+ const bash = await BashTool.init()
|
|
|
+ const result = await bash.execute(
|
|
|
+ {
|
|
|
+ command: "echo hello",
|
|
|
+ description: "Echo hello",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ )
|
|
|
+ expect(result.metadata.exit).toBe(0)
|
|
|
+ expect(result.metadata.output).toContain("hello")
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
|
|
|
- // TODO: better test
|
|
|
- // test("cd ../ should ask for permission for external directory", async () => {
|
|
|
- // await Instance.provide({
|
|
|
- // directory: projectRoot,
|
|
|
- // fn: async () => {
|
|
|
- // bash.execute(
|
|
|
- // {
|
|
|
- // command: "cd ../",
|
|
|
- // description: "Try to cd to parent directory",
|
|
|
- // },
|
|
|
- // ctx,
|
|
|
- // )
|
|
|
- // // Give time for permission to be asked
|
|
|
- // await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
|
- // expect(Permission.pending()[ctx.sessionID]).toBeDefined()
|
|
|
- // },
|
|
|
- // })
|
|
|
- // })
|
|
|
+ test("denies command matching deny pattern", async () => {
|
|
|
+ await using tmp = await tmpdir({
|
|
|
+ init: async (dir) => {
|
|
|
+ await Bun.write(
|
|
|
+ path.join(dir, "opencode.json"),
|
|
|
+ JSON.stringify({
|
|
|
+ permission: {
|
|
|
+ bash: {
|
|
|
+ "curl *": "deny",
|
|
|
+ "*": "allow",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await Instance.provide({
|
|
|
+ directory: tmp.path,
|
|
|
+ fn: async () => {
|
|
|
+ const bash = await BashTool.init()
|
|
|
+ await expect(
|
|
|
+ bash.execute(
|
|
|
+ {
|
|
|
+ command: "curl https://example.com",
|
|
|
+ description: "Fetch URL",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ ),
|
|
|
+ ).rejects.toThrow("restricted")
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ test("denies all commands with wildcard deny", async () => {
|
|
|
+ await using tmp = await tmpdir({
|
|
|
+ init: async (dir) => {
|
|
|
+ await Bun.write(
|
|
|
+ path.join(dir, "opencode.json"),
|
|
|
+ JSON.stringify({
|
|
|
+ permission: {
|
|
|
+ bash: {
|
|
|
+ "*": "deny",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await Instance.provide({
|
|
|
+ directory: tmp.path,
|
|
|
+ fn: async () => {
|
|
|
+ const bash = await BashTool.init()
|
|
|
+ await expect(
|
|
|
+ bash.execute(
|
|
|
+ {
|
|
|
+ command: "ls",
|
|
|
+ description: "List files",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ ),
|
|
|
+ ).rejects.toThrow("restricted")
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ test("more specific pattern overrides general pattern", async () => {
|
|
|
+ await using tmp = await tmpdir({
|
|
|
+ init: async (dir) => {
|
|
|
+ await Bun.write(
|
|
|
+ path.join(dir, "opencode.json"),
|
|
|
+ JSON.stringify({
|
|
|
+ permission: {
|
|
|
+ bash: {
|
|
|
+ "*": "deny",
|
|
|
+ "ls *": "allow",
|
|
|
+ "pwd*": "allow",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await Instance.provide({
|
|
|
+ directory: tmp.path,
|
|
|
+ fn: async () => {
|
|
|
+ const bash = await BashTool.init()
|
|
|
+ // ls should be allowed
|
|
|
+ const result = await bash.execute(
|
|
|
+ {
|
|
|
+ command: "ls -la",
|
|
|
+ description: "List files",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ )
|
|
|
+ expect(result.metadata.exit).toBe(0)
|
|
|
+
|
|
|
+ // pwd should be allowed
|
|
|
+ const pwd = await bash.execute(
|
|
|
+ {
|
|
|
+ command: "pwd",
|
|
|
+ description: "Print working directory",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ )
|
|
|
+ expect(pwd.metadata.exit).toBe(0)
|
|
|
+
|
|
|
+ // cat should be denied
|
|
|
+ await expect(
|
|
|
+ bash.execute(
|
|
|
+ {
|
|
|
+ command: "cat /etc/passwd",
|
|
|
+ description: "Read file",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ ),
|
|
|
+ ).rejects.toThrow("restricted")
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ test("denies dangerous subcommands while allowing safe ones", async () => {
|
|
|
+ await using tmp = await tmpdir({
|
|
|
+ init: async (dir) => {
|
|
|
+ await Bun.write(
|
|
|
+ path.join(dir, "opencode.json"),
|
|
|
+ JSON.stringify({
|
|
|
+ permission: {
|
|
|
+ bash: {
|
|
|
+ "find *": "allow",
|
|
|
+ "find * -delete*": "deny",
|
|
|
+ "find * -exec*": "deny",
|
|
|
+ "*": "deny",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await Instance.provide({
|
|
|
+ directory: tmp.path,
|
|
|
+ fn: async () => {
|
|
|
+ const bash = await BashTool.init()
|
|
|
+ // Basic find should work
|
|
|
+ const result = await bash.execute(
|
|
|
+ {
|
|
|
+ command: "find . -name '*.ts'",
|
|
|
+ description: "Find typescript files",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ )
|
|
|
+ expect(result.metadata.exit).toBe(0)
|
|
|
+
|
|
|
+ // find -delete should be denied
|
|
|
+ await expect(
|
|
|
+ bash.execute(
|
|
|
+ {
|
|
|
+ command: "find . -name '*.tmp' -delete",
|
|
|
+ description: "Delete temp files",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ ),
|
|
|
+ ).rejects.toThrow("restricted")
|
|
|
+
|
|
|
+ // find -exec should be denied
|
|
|
+ await expect(
|
|
|
+ bash.execute(
|
|
|
+ {
|
|
|
+ command: "find . -name '*.ts' -exec cat {} \\;",
|
|
|
+ description: "Find and cat files",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ ),
|
|
|
+ ).rejects.toThrow("restricted")
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ test("allows git read commands while denying writes", async () => {
|
|
|
+ await using tmp = await tmpdir({
|
|
|
+ git: true,
|
|
|
+ init: async (dir) => {
|
|
|
+ await Bun.write(
|
|
|
+ path.join(dir, "opencode.json"),
|
|
|
+ JSON.stringify({
|
|
|
+ permission: {
|
|
|
+ bash: {
|
|
|
+ "git status*": "allow",
|
|
|
+ "git log*": "allow",
|
|
|
+ "git diff*": "allow",
|
|
|
+ "git branch": "allow",
|
|
|
+ "git commit *": "deny",
|
|
|
+ "git push *": "deny",
|
|
|
+ "*": "deny",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await Instance.provide({
|
|
|
+ directory: tmp.path,
|
|
|
+ fn: async () => {
|
|
|
+ const bash = await BashTool.init()
|
|
|
+ // git status should work
|
|
|
+ const status = await bash.execute(
|
|
|
+ {
|
|
|
+ command: "git status",
|
|
|
+ description: "Git status",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ )
|
|
|
+ expect(status.metadata.exit).toBe(0)
|
|
|
+
|
|
|
+ // git log should work
|
|
|
+ const log = await bash.execute(
|
|
|
+ {
|
|
|
+ command: "git log --oneline -5",
|
|
|
+ description: "Git log",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ )
|
|
|
+ expect(log.metadata.exit).toBe(0)
|
|
|
+
|
|
|
+ // git commit should be denied
|
|
|
+ await expect(
|
|
|
+ bash.execute(
|
|
|
+ {
|
|
|
+ command: "git commit -m 'test'",
|
|
|
+ description: "Git commit",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ ),
|
|
|
+ ).rejects.toThrow("restricted")
|
|
|
+
|
|
|
+ // git push should be denied
|
|
|
+ await expect(
|
|
|
+ bash.execute(
|
|
|
+ {
|
|
|
+ command: "git push origin main",
|
|
|
+ description: "Git push",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ ),
|
|
|
+ ).rejects.toThrow("restricted")
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ test("denies external directory access when permission is deny", async () => {
|
|
|
+ await using tmp = await tmpdir({
|
|
|
+ init: async (dir) => {
|
|
|
+ await Bun.write(
|
|
|
+ path.join(dir, "opencode.json"),
|
|
|
+ JSON.stringify({
|
|
|
+ permission: {
|
|
|
+ external_directory: "deny",
|
|
|
+ bash: {
|
|
|
+ "*": "allow",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await Instance.provide({
|
|
|
+ directory: tmp.path,
|
|
|
+ fn: async () => {
|
|
|
+ const bash = await BashTool.init()
|
|
|
+ // Should deny cd to parent directory (cd is checked for external paths)
|
|
|
+ await expect(
|
|
|
+ bash.execute(
|
|
|
+ {
|
|
|
+ command: "cd ../",
|
|
|
+ description: "Change to parent directory",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ ),
|
|
|
+ ).rejects.toThrow()
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ test("denies workdir outside project when external_directory is deny", async () => {
|
|
|
+ await using tmp = await tmpdir({
|
|
|
+ init: async (dir) => {
|
|
|
+ await Bun.write(
|
|
|
+ path.join(dir, "opencode.json"),
|
|
|
+ JSON.stringify({
|
|
|
+ permission: {
|
|
|
+ external_directory: "deny",
|
|
|
+ bash: {
|
|
|
+ "*": "allow",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await Instance.provide({
|
|
|
+ directory: tmp.path,
|
|
|
+ fn: async () => {
|
|
|
+ const bash = await BashTool.init()
|
|
|
+ await expect(
|
|
|
+ bash.execute(
|
|
|
+ {
|
|
|
+ command: "ls",
|
|
|
+ workdir: "/tmp",
|
|
|
+ description: "List /tmp",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ ),
|
|
|
+ ).rejects.toThrow()
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ test("handles multiple commands in sequence", async () => {
|
|
|
+ await using tmp = await tmpdir({
|
|
|
+ init: async (dir) => {
|
|
|
+ await Bun.write(
|
|
|
+ path.join(dir, "opencode.json"),
|
|
|
+ JSON.stringify({
|
|
|
+ permission: {
|
|
|
+ bash: {
|
|
|
+ "echo *": "allow",
|
|
|
+ "curl *": "deny",
|
|
|
+ "*": "deny",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await Instance.provide({
|
|
|
+ directory: tmp.path,
|
|
|
+ fn: async () => {
|
|
|
+ const bash = await BashTool.init()
|
|
|
+ // echo && echo should work
|
|
|
+ const result = await bash.execute(
|
|
|
+ {
|
|
|
+ command: "echo foo && echo bar",
|
|
|
+ description: "Echo twice",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ )
|
|
|
+ expect(result.metadata.output).toContain("foo")
|
|
|
+ expect(result.metadata.output).toContain("bar")
|
|
|
+
|
|
|
+ // echo && curl should fail (curl is denied)
|
|
|
+ await expect(
|
|
|
+ bash.execute(
|
|
|
+ {
|
|
|
+ command: "echo hi && curl https://example.com",
|
|
|
+ description: "Echo then curl",
|
|
|
+ },
|
|
|
+ ctx,
|
|
|
+ ),
|
|
|
+ ).rejects.toThrow("restricted")
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
})
|