From 18888351e99857a776601c857d6d70e058a4794a Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 30 Jul 2025 20:57:52 -0400 Subject: [PATCH] use treesitter to parse bash commands and catch commands that go outside of cwd (#1443) --- bun.lock | 37 +++++---- package.json | 5 +- packages/opencode/package.json | 4 +- packages/opencode/src/config/config.ts | 9 ++ packages/opencode/src/session/index.ts | 11 ++- .../opencode/src/session/prompt/beast.txt | 2 - packages/opencode/src/tool/bash.ts | 83 ++++++++++++++++++- packages/opencode/src/tool/edit.ts | 28 ++++--- packages/opencode/src/tool/read.ts | 31 ++++--- packages/opencode/src/tool/write.ts | 27 +++--- packages/opencode/src/util/filesystem.ts | 2 +- packages/opencode/test/tool/bash.test.ts | 44 ++++++++++ packages/sdk/package.json | 2 +- 13 files changed, 226 insertions(+), 59 deletions(-) create mode 100644 packages/opencode/test/tool/bash.test.ts diff --git a/bun.lock b/bun.lock index 65ecfe78..ee1e1f6e 100644 --- a/bun.lock +++ b/bun.lock @@ -48,7 +48,9 @@ "hono-openapi": "0.4.8", "isomorphic-git": "1.32.1", "open": "10.1.2", - "remeda": "2.22.3", + "remeda": "catalog:", + "tree-sitter": "0.22.4", + "tree-sitter-bash": "0.23.3", "turndown": "7.2.0", "vscode-jsonrpc": "8.2.1", "xdg-basedir": "5.1.0", @@ -92,7 +94,7 @@ "ts-node": "^10.5.0", "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.8/tsc-multi.tgz", "tsconfig-paths": "^4.0.0", - "typescript": "5.8.3", + "typescript": "catalog:", "typescript-eslint": "8.31.1", }, }, @@ -135,8 +137,9 @@ ], "catalog": { "@types/node": "22.13.9", - "ai": "5.0.0-beta.21", + "ai": "5.0.0-beta.33", "hono": "4.7.10", + "remeda": "2.26.0", "typescript": "5.8.2", "zod": "3.25.49", }, @@ -155,11 +158,11 @@ "@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.0-beta.8", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.3" }, "peerDependencies": { "zod": "^3.25.49 || ^4" } }, "sha512-D2SqYRT/42JTiRxUuiWtn5cYQFscpb9Z14UNvJx7lnurBUXx57zy7TbLH0h7O+WbCluTQN5G6146JpUZ/SRyzw=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.0-beta.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.2", "@ai-sdk/provider-utils": "3.0.0-beta.9" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-1K5L7mY04ZwpngkDPLaiBiCivVj1h7gDiCZjAIgXtVp0S2zQ+1efnM/K/o2Pig6rUbt559rDLLalwZUgvn0vig=="], - "@ai-sdk/provider": ["@ai-sdk/provider@2.0.0-beta.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Z8SPncMtS3RsoXITmT7NVwrAq6M44dmw0DoUOYJqNNtCu8iMWuxB8Nxsoqpa0uEEy9R1V1ZThJAXTYgjTUxl3w=="], + "@ai-sdk/provider": ["@ai-sdk/provider@2.0.0-beta.2", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-vqhtZA7R24q1XnmfmIb1fZSmHMIaJH1BVQ+0kFnNJgqWsc+V8i+yfetZ37gUc4fXATFmBuS/6O7+RPoHsZ2Fqg=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0-beta.3", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.49 || ^4" } }, "sha512-4gZ392GxjzMF7TnReF2eTKhOSyiSS3ydRVq4I7jxkeV5sdEuMoH3gzfItmlctsqGxlMU1/+zKPwl5yYz9O2dzg=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0-beta.9", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.2", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-RJMeoqFA9mGo1XOE20bpVv4/ikVbZMHo00vmF4RweN7GHS+nEXU3SHFgtcp7NBG3j8W15b9MAitOBycRMYxecg=="], "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -783,7 +786,7 @@ "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], - "ai": ["ai@5.0.0-beta.21", "", { "dependencies": { "@ai-sdk/gateway": "1.0.0-beta.8", "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.3", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.49 || ^4" }, "bin": { "ai": "dist/bin/ai.min.js" } }, "sha512-ZmgUoEIXb2G2HLtK1U3UB+hSDa3qrVIeAfgXf3SIE9r5Vqj6xHG1pN/7fHIZDSgb1TCaypG0ANVB0O9WmnMfiw=="], + "ai": ["ai@5.0.0-beta.33", "", { "dependencies": { "@ai-sdk/gateway": "1.0.0-beta.18", "@ai-sdk/provider": "2.0.0-beta.2", "@ai-sdk/provider-utils": "3.0.0-beta.9", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-TKDOYDRhS6kSmfbTj3lLFmS8kBx8OOHsIfhYKJBKnAPwlbkI3/byZRBty8tfKBrwsUAbSro3GB7rFeSthft37Q=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], @@ -1747,6 +1750,8 @@ "node-fetch-native": ["node-fetch-native@1.6.6", "", {}, "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ=="], + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], "node-mock-http": ["node-mock-http@1.0.1", "", {}, "sha512-0gJJgENizp4ghds/Ywu2FCmcRsgBTmRQzYPZm61wy+Em2sBarSka0OhQS5huLBg6od1zkNpnWMCZloQDFVvOMQ=="], @@ -2161,6 +2166,10 @@ "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tree-sitter": ["tree-sitter@0.22.4", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg=="], + + "tree-sitter-bash": ["tree-sitter-bash@0.23.3", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-36cg/GQ2YmIbeiBeqeuh4fBJ6i4kgVouDaqTxqih5ysPag+zHufyIaxMOFeM8CeplwAK/Luj1o5XHqgdAfoCZg=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -2195,7 +2204,7 @@ "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], "typescript-eslint": ["typescript-eslint@8.31.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.31.1", "@typescript-eslint/parser": "8.31.1", "@typescript-eslint/utils": "8.31.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-j6DsEotD/fH39qKzXTQRwYYWlt7D+0HmfpOK+DVhwJOFLcdmn92hq3mBb7HlKJHbjjI/gTOqEcc9d6JfpFf/VA=="], @@ -2437,12 +2446,8 @@ "@opencode/function/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@opencode/function/typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], - "@opencode/web/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@opencode/web/typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], - "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.0.2", "", {}, "sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw=="], @@ -2563,10 +2568,6 @@ "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], - "opencode/remeda": ["remeda@2.22.3", "", { "dependencies": { "type-fest": "^4.40.1" } }, "sha512-Ka6965m9Zu9OLsysWxVf3jdJKmp6+PKzDv7HWHinEevf0JOJ9y02YpjiC/sKxRpCqGhVyvm1U+0YIj+E6DMgKw=="], - - "opencode/typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], - "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="], @@ -2617,6 +2618,10 @@ "to-buffer/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "tree-sitter/node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="], + + "tree-sitter-bash/node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="], + "ts-node/diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="], "tsc-multi/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], diff --git a/package.json b/package.json index bd6218a0..92e4a276 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,11 @@ ], "catalog": { "@types/node": "22.13.9", - "ai": "5.0.0-beta.21", + "ai": "5.0.0-beta.33", "hono": "4.7.10", "typescript": "5.8.2", - "zod": "3.25.49" + "zod": "3.25.49", + "remeda": "2.26.0" } }, "devDependencies": { diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 36155ced..0ed93677 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -46,8 +46,10 @@ "hono-openapi": "0.4.8", "isomorphic-git": "1.32.1", "open": "10.1.2", - "remeda": "2.22.3", + "remeda": "catalog:", "turndown": "7.2.0", + "tree-sitter": "0.22.4", + "tree-sitter-bash": "0.23.3", "vscode-jsonrpc": "8.2.1", "xdg-basedir": "5.1.0", "yargs": "18.0.0", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 24903da5..b0a7fb89 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -187,6 +187,9 @@ export namespace Config { }) export type Layout = z.infer + export const Permission = z.union([z.literal("ask"), z.literal("allow")]) + export type Permission = z.infer + export const Info = z .object({ $schema: z.string().optional().describe("JSON schema reference for configuration validation"), @@ -250,6 +253,12 @@ export namespace Config { mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"), instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), layout: Layout.optional().describe("@deprecated Always uses stretch layout."), + permission: z + .object({ + edit: Permission.optional(), + bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), + }) + .optional(), experimental: z .object({ hook: z diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 09641aa0..097e266a 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -290,6 +290,9 @@ export namespace Session { export function abort(sessionID: string) { const controller = state().pending.get(sessionID) if (!controller) return false + log.info("aborting", { + sessionID, + }) controller.abort() state().pending.delete(sessionID) return true @@ -765,7 +768,11 @@ export namespace Session { } const stream = streamText({ - onError() {}, + onError(e) { + log.error("streamText error", { + error: e, + }) + }, async prepareStep({ messages }) { const queue = (state().queued.get(input.sessionID) ?? []).filter((x) => !x.processed) if (queue.length) { @@ -1030,7 +1037,7 @@ export namespace Session { } break - case "text": + case "text-delta": if (currentText) { currentText.text += value.text if (currentText.text) await updatePart(currentText) diff --git a/packages/opencode/src/session/prompt/beast.txt b/packages/opencode/src/session/prompt/beast.txt index 3bb541c2..3f0a9f84 100644 --- a/packages/opencode/src/session/prompt/beast.txt +++ b/packages/opencode/src/session/prompt/beast.txt @@ -1,5 +1,3 @@ -# Beast Mode 3.1 - You are opencode, an agent - please keep going until the user’s query is completely resolved, before ending your turn and yielding back to the user. Your thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough. diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index f9c85614..d9a325f1 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -2,11 +2,21 @@ import { z } from "zod" import { Tool } from "./tool" import DESCRIPTION from "./bash.txt" import { App } from "../app/app" +import path from "path" + +import Parser from "tree-sitter" +import Bash from "tree-sitter-bash" +import { Config } from "../config/config" +import { Filesystem } from "../util/filesystem" +import { Permission } from "../permission" const MAX_OUTPUT_LENGTH = 30000 const DEFAULT_TIMEOUT = 1 * 60 * 1000 const MAX_TIMEOUT = 10 * 60 * 1000 +const parser = new Parser() +parser.setLanguage(Bash.language as any) + export const BashTool = Tool.define("bash", { description: DESCRIPTION, parameters: z.object({ @@ -20,10 +30,81 @@ export const BashTool = Tool.define("bash", { }), async execute(params, ctx) { const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT) + const tree = parser.parse(params.command) + const cfg = await Config.get() + const app = App.info() + const permissions = (() => { + const value = cfg.permission?.bash + if (!value) + return { + "*": "allow", + } + if (typeof value === "string") + return { + "*": value, + } + return value + })() + + let needsAsk = false + for (const node of tree.rootNode.descendantsOfType("command")) { + const command = [] + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i) + if (!child) continue + if ( + child.type !== "command_name" && + child.type !== "word" && + child.type !== "string" && + child.type !== "raw_string" && + child.type !== "concatenation" + ) { + continue + } + command.push(child.text) + } + + // not an exhaustive list, but covers most common cases + if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"].includes(command[0])) { + for (const arg of command.slice(1)) { + if (arg.startsWith("-")) continue + const resolved = path.resolve(app.path.cwd, arg) + if (!Filesystem.contains(app.path.cwd, resolved)) { + throw new Error( + `This command references paths outside of ${app.path.cwd} so it is not allowed to be executed.`, + ) + } + } + } + + // always allow cd if it passes above check + if (!needsAsk && command[0] !== "cd") { + const ask = (() => { + for (const [pattern, value] of Object.entries(permissions)) { + if (new Bun.Glob(pattern).match(node.text)) { + return value + } + } + return "ask" + })() + if (ask === "ask") needsAsk = true + } + } + + if (needsAsk) { + await Permission.ask({ + id: "basj", + sessionID: ctx.sessionID, + title: params.command, + metadata: { + command: params.command, + }, + }) + } const process = Bun.spawn({ cmd: ["bash", "-c", params.command], - cwd: App.info().path.cwd, + cwd: app.path.cwd, maxBuffer: MAX_OUTPUT_LENGTH, signal: ctx.abort, timeout: timeout, diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 3949beae..798d1a67 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -2,6 +2,7 @@ // https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts // https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts // https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts + import { z } from "zod" import * as path from "path" import { Tool } from "./tool" @@ -13,6 +14,8 @@ import { App } from "../app/app" import { File } from "../file" import { Bus } from "../bus" import { FileTime } from "../file/time" +import { Config } from "../config/config" +import { Filesystem } from "../util/filesystem" export const EditTool = Tool.define("edit", { description: DESCRIPTION, @@ -33,17 +36,22 @@ export const EditTool = Tool.define("edit", { const app = App.info() const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath) + if (!Filesystem.contains(app.path.cwd, filepath)) { + throw new Error(`File ${filepath} is not in the current working directory`) + } - await Permission.ask({ - id: "edit", - sessionID: ctx.sessionID, - title: "Edit this file: " + filepath, - metadata: { - filePath: filepath, - oldString: params.oldString, - newString: params.newString, - }, - }) + const cfg = await Config.get() + if (cfg.permission?.edit === "ask") + await Permission.ask({ + id: "edit", + sessionID: ctx.sessionID, + title: "Edit this file: " + filepath, + metadata: { + filePath: filepath, + oldString: params.oldString, + newString: params.newString, + }, + }) let contentOld = "" let contentNew = "" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index c6671641..79357930 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -6,6 +6,7 @@ import { LSP } from "../lsp" import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" import { App } from "../app/app" +import { Filesystem } from "../util/filesystem" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -18,15 +19,19 @@ export const ReadTool = Tool.define("read", { limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(), }), async execute(params, ctx) { - let filePath = params.filePath - if (!path.isAbsolute(filePath)) { - filePath = path.join(process.cwd(), filePath) + let filepath = params.filePath + if (!path.isAbsolute(filepath)) { + filepath = path.join(process.cwd(), filepath) + } + const app = App.info() + if (!Filesystem.contains(app.path.cwd, filepath)) { + throw new Error(`File ${filepath} is not in the current working directory`) } - const file = Bun.file(filePath) + const file = Bun.file(filepath) if (!(await file.exists())) { - const dir = path.dirname(filePath) - const base = path.basename(filePath) + const dir = path.dirname(filepath) + const base = path.basename(filepath) const dirEntries = fs.readdirSync(dir) const suggestions = dirEntries @@ -38,18 +43,18 @@ export const ReadTool = Tool.define("read", { .slice(0, 3) if (suggestions.length > 0) { - throw new Error(`File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`) + throw new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`) } - throw new Error(`File not found: ${filePath}`) + throw new Error(`File not found: ${filepath}`) } const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 - const isImage = isImageFile(filePath) + const isImage = isImageFile(filepath) if (isImage) throw new Error(`This is an image file of type: ${isImage}\nUse a different tool to process images`) const isBinary = await isBinaryFile(file) - if (isBinary) throw new Error(`Cannot read binary file: ${filePath}`) + if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`) const lines = await file.text().then((text) => text.split("\n")) const raw = lines.slice(offset, offset + limit).map((line) => { return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line @@ -68,11 +73,11 @@ export const ReadTool = Tool.define("read", { output += "\n" // just warms the lsp client - LSP.touchFile(filePath, false) - FileTime.read(ctx.sessionID, filePath) + LSP.touchFile(filepath, false) + FileTime.read(ctx.sessionID, filepath) return { - title: path.relative(App.info().path.root, filePath), + title: path.relative(App.info().path.root, filepath), output, metadata: { preview, diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index aac44d13..47517281 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -8,6 +8,8 @@ import { App } from "../app/app" import { Bus } from "../bus" import { File } from "../file" import { FileTime } from "../file/time" +import { Config } from "../config/config" +import { Filesystem } from "../util/filesystem" export const WriteTool = Tool.define("write", { description: DESCRIPTION, @@ -18,21 +20,26 @@ export const WriteTool = Tool.define("write", { async execute(params, ctx) { const app = App.info() const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath) + if (!Filesystem.contains(app.path.cwd, filepath)) { + throw new Error(`File ${filepath} is not in the current working directory`) + } const file = Bun.file(filepath) const exists = await file.exists() if (exists) await FileTime.assert(ctx.sessionID, filepath) - await Permission.ask({ - id: "write", - sessionID: ctx.sessionID, - title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath, - metadata: { - filePath: filepath, - content: params.content, - exists, - }, - }) + const cfg = await Config.get() + if (cfg.permission?.edit === "ask") + await Permission.ask({ + id: "write", + sessionID: ctx.sessionID, + title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath, + metadata: { + filePath: filepath, + content: params.content, + exists, + }, + }) await Bun.write(filepath, params.content) await Bus.publish(File.Event.Edited, { diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index b893819e..a3dcfc70 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -9,7 +9,7 @@ export namespace Filesystem { } export function contains(parent: string, child: string) { - return relative(parent, child).startsWith("..") + return !relative(parent, child).startsWith("..") } export async function findUp(target: string, start: string, stop?: string) { diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts new file mode 100644 index 00000000..ab92de45 --- /dev/null +++ b/packages/opencode/test/tool/bash.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { App } from "../../src/app/app" +import path from "path" +import { BashTool } from "../../src/tool/bash" +import { Log } from "../../src/util/log" + +const ctx = { + sessionID: "test", + messageID: "", + abort: AbortSignal.any([]), + metadata: () => {}, +} + +const bash = await BashTool.init() +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("tool.bash", () => { + test("basic", async () => { + await App.provide({ cwd: projectRoot }, async () => { + await bash.execute( + { + command: "cd foo/bar && ls", + description: "List files in foo/bar", + }, + ctx, + ) + }) + }) + + test("cd ../ should fail", async () => { + await App.provide({ cwd: projectRoot }, async () => { + expect( + bash.execute( + { + command: "cd ../", + description: "Try to cd to parent directory", + }, + ctx, + ), + ).rejects.toThrow() + }) + }) +}) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index ea1f9f58..12f57b1d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -43,7 +43,7 @@ "ts-node": "^10.5.0", "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.8/tsc-multi.tgz", "tsconfig-paths": "^4.0.0", - "typescript": "5.8.3", + "typescript": "catalog:", "typescript-eslint": "8.31.1" }, "imports": {