From e64aba0b239bccd457e191c47d59926e1d28599c Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 30 Mar 2026 15:06:36 +0300 Subject: [PATCH] test(cli): add Ask agent permission tests Verify readOnlyBash allowlist, git write denials, gh ask rules, disabled() behavior for edit/task/bash tools, and dynamic MCP permission generation with server name sanitization. --- .../kilocode/ask-agent-permissions.test.ts | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 packages/opencode/test/kilocode/ask-agent-permissions.test.ts diff --git a/packages/opencode/test/kilocode/ask-agent-permissions.test.ts b/packages/opencode/test/kilocode/ask-agent-permissions.test.ts new file mode 100644 index 00000000000..a727b014a00 --- /dev/null +++ b/packages/opencode/test/kilocode/ask-agent-permissions.test.ts @@ -0,0 +1,300 @@ +import { test, expect, describe } from "bun:test" +import { PermissionNext } from "../../src/permission/next" + +// Reconstruct the Ask agent's readOnlyBash allowlist (mirrors agent.ts) +const readOnlyBash: Record = { + "*": "deny", + "cat *": "allow", + "head *": "allow", + "tail *": "allow", + "less *": "allow", + "ls *": "allow", + "tree *": "allow", + "pwd *": "allow", + "echo *": "allow", + "wc *": "allow", + "which *": "allow", + "type *": "allow", + "file *": "allow", + "diff *": "allow", + "du *": "allow", + "df *": "allow", + "date *": "allow", + "uname *": "allow", + "whoami *": "allow", + "printenv *": "allow", + "man *": "allow", + "find *": "allow", + "grep *": "allow", + "rg *": "allow", + "ag *": "allow", + "sort *": "allow", + "uniq *": "allow", + "cut *": "allow", + "tr *": "allow", + "jq *": "allow", + "git *": "allow", + "git add *": "deny", + "git commit *": "deny", + "git push *": "deny", + "git merge *": "deny", + "git rebase *": "deny", + "git cherry-pick *": "deny", + "git reset *": "deny", + "git checkout *": "deny", + "git switch *": "deny", + "git stash *": "deny", + "git tag *": "deny", + "git am *": "deny", + "git apply *": "deny", + "git remote set-url *": "deny", + "git remote add *": "deny", + "git remote remove *": "deny", + "git clean *": "deny", + "git mv *": "deny", + "git rm *": "deny", + "gh *": "ask", +} + +/** Build the Ask agent ruleset without MCP servers */ +function askRuleset() { + return PermissionNext.fromConfig({ + "*": "deny", + bash: readOnlyBash, + read: { + "*": "allow", + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow", + }, + grep: "allow", + glob: "allow", + list: "allow", + question: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + codebase_search: "allow", + }) +} + +/** Build the Ask agent ruleset WITH MCP servers */ +function askRulesetWithMcp(servers: string[]) { + const mcpRules: Record = {} + for (const key of servers) { + const sanitized = key.replace(/[^a-zA-Z0-9_-]/g, "_") + mcpRules[sanitized + "_*"] = "ask" + } + return PermissionNext.fromConfig({ + "*": "deny", + bash: readOnlyBash, + read: { + "*": "allow", + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow", + }, + grep: "allow", + glob: "allow", + list: "allow", + question: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + codebase_search: "allow", + ...mcpRules, + }) +} + +describe("Ask agent bash permissions", () => { + const ruleset = askRuleset() + + describe("allowed read-only commands", () => { + const allowed: [string, string][] = [ + ["cat", "cat README.md"], + ["ls", "ls -la"], + ["grep", "grep -r TODO src/"], + ["rg", "rg pattern"], + ["find", "find . -name '*.ts'"], + ["jq", "jq '.name' package.json"], + ["head", "head -n 10 file.txt"], + ["tail", "tail -f log.txt"], + ["wc", "wc -l src/index.ts"], + ["diff", "diff a.txt b.txt"], + ["sort", "sort names.txt"], + ["tree", "tree src/"], + ["echo", "echo hello"], + ["pwd", "pwd"], + ["date", "date"], + ["whoami", "whoami"], + ] + + for (const [name, cmd] of allowed) { + test(`${name}: "${cmd}" → allow`, () => { + const result = PermissionNext.evaluate("bash", cmd, ruleset) + expect(result.action).toBe("allow") + }) + } + }) + + describe("allowed git read commands", () => { + const allowed = [ + "git log --oneline -10", + "git diff HEAD~1", + "git show HEAD:src/index.ts", + "git status", + "git branch -a", + "git log --graph", + "git blame src/index.ts", + "git rev-parse HEAD", + ] + + for (const cmd of allowed) { + test(`"${cmd}" → allow`, () => { + const result = PermissionNext.evaluate("bash", cmd, ruleset) + expect(result.action).toBe("allow") + }) + } + }) + + describe("denied git write commands", () => { + const denied = [ + "git commit -m 'test'", + "git push origin main", + "git merge feature", + "git rebase main", + "git reset --hard HEAD~1", + "git checkout -b new-branch", + "git switch main", + "git stash", + "git tag v1.0", + "git cherry-pick abc123", + "git am patch.diff", + "git apply changes.patch", + "git clean -fd", + "git mv old.ts new.ts", + "git rm file.ts", + "git add .", + "git remote add origin url", + "git remote remove upstream", + "git remote set-url origin url", + ] + + for (const cmd of denied) { + test(`"${cmd}" → deny`, () => { + const result = PermissionNext.evaluate("bash", cmd, ruleset) + expect(result.action).toBe("deny") + }) + } + }) + + describe("denied write/execute commands", () => { + const denied = [ + "touch newfile.ts", + "mkdir src/new", + "cp a.ts b.ts", + "mv old.ts new.ts", + "tsc --noEmit", + "tar xzf archive.tar.gz", + "npm install", + "python3 script.py", + "rm -rf /", + "node server.js", + "bun run dev", + "curl http://example.com", + ] + + for (const cmd of denied) { + test(`"${cmd}" → deny`, () => { + const result = PermissionNext.evaluate("bash", cmd, ruleset) + expect(result.action).toBe("deny") + }) + } + }) + + test("gh commands → ask", () => { + expect(PermissionNext.evaluate("bash", "gh pr view 123", ruleset).action).toBe("ask") + expect(PermissionNext.evaluate("bash", "gh issue list", ruleset).action).toBe("ask") + expect(PermissionNext.evaluate("bash", "gh api repos/org/repo", ruleset).action).toBe("ask") + }) +}) + +describe("Ask agent tool disabled checks", () => { + const ruleset = askRuleset() + + test("bash tool is NOT disabled (has specific allow rules after deny)", () => { + const result = PermissionNext.disabled(["bash"], ruleset) + expect(result.has("bash")).toBe(false) + }) + + test("allowed tools are not disabled", () => { + const tools = ["read", "grep", "glob", "list", "question", "webfetch", "websearch", "codesearch", "codebase_search"] + const result = PermissionNext.disabled(tools, ruleset) + for (const tool of tools) { + expect(result.has(tool)).toBe(false) + } + }) + + test("edit tools are disabled", () => { + const tools = ["edit", "write", "patch", "multiedit"] + const result = PermissionNext.disabled(tools, ruleset) + for (const tool of tools) { + expect(result.has(tool)).toBe(true) + } + }) + + test("task tool is disabled", () => { + const result = PermissionNext.disabled(["task"], ruleset) + expect(result.has("task")).toBe(true) + }) + + test("todowrite and todoread are disabled", () => { + const result = PermissionNext.disabled(["todowrite", "todoread"], ruleset) + expect(result.has("todowrite")).toBe(true) + expect(result.has("todoread")).toBe(true) + }) +}) + +describe("Ask agent MCP permissions", () => { + test("MCP tools not disabled when servers configured", () => { + const ruleset = askRulesetWithMcp(["my-server", "another_server"]) + const result = PermissionNext.disabled(["my-server_sometool", "another_server_listthing"], ruleset) + expect(result.has("my-server_sometool")).toBe(false) + expect(result.has("another_server_listthing")).toBe(false) + }) + + test("MCP tools evaluate to ask", () => { + const ruleset = askRulesetWithMcp(["my-server"]) + const result = PermissionNext.evaluate("my-server_read_file", "*", ruleset) + expect(result.action).toBe("ask") + }) + + test("MCP tools disabled without server config", () => { + const ruleset = askRuleset() + const result = PermissionNext.disabled(["my-server_sometool"], ruleset) + expect(result.has("my-server_sometool")).toBe(true) + }) + + test("server names with special characters are sanitized", () => { + const ruleset = askRulesetWithMcp(["my.special server!"]) + // "my.special server!" → "my_special_server_" + const result = PermissionNext.disabled(["my_special_server__sometool"], ruleset) + expect(result.has("my_special_server__sometool")).toBe(false) + + const eval_ = PermissionNext.evaluate("my_special_server__sometool", "*", ruleset) + expect(eval_.action).toBe("ask") + }) + + test("MCP rules don't interfere with built-in tool permissions", () => { + const ruleset = askRulesetWithMcp(["server1"]) + // Built-in tools should still work normally + expect(PermissionNext.evaluate("read", "src/index.ts", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("bash", "ls -la", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("bash", "git commit -m test", ruleset).action).toBe("deny") + + // Edit tools should still be disabled + const disabled = PermissionNext.disabled(["edit", "write"], ruleset) + expect(disabled.has("edit")).toBe(true) + expect(disabled.has("write")).toBe(true) + }) +})