Просмотр исходного кода

use treesitter to parse bash commands and catch commands that go outside of cwd (#1443)

Dax 6 месяцев назад
Родитель
Сommit
18888351e9

+ 21 - 16
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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected].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/[email protected].1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Z8SPncMtS3RsoXITmT7NVwrAq6M44dmw0DoUOYJqNNtCu8iMWuxB8Nxsoqpa0uEEy9R1V1ZThJAXTYgjTUxl3w=="],
+    "@ai-sdk/provider": ["@ai-sdk/[email protected].2", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-vqhtZA7R24q1XnmfmIb1fZSmHMIaJH1BVQ+0kFnNJgqWsc+V8i+yfetZ37gUc4fXATFmBuS/6O7+RPoHsZ2Fqg=="],
 
-    "@ai-sdk/provider-utils": ["@ai-sdk/[email protected].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/[email protected].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/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
 
@@ -783,7 +786,7 @@
 
     "aggregate-error": ["[email protected]", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="],
 
-    "ai": ["[email protected].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": ["[email protected].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": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ=="],
 
+    "node-gyp-build": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
 
     "node-mock-http": ["[email protected]", "", {}, "sha512-0gJJgENizp4ghds/Ywu2FCmcRsgBTmRQzYPZm61wy+Em2sBarSka0OhQS5huLBg6od1zkNpnWMCZloQDFVvOMQ=="],
@@ -2161,6 +2166,10 @@
 
     "tr46": ["[email protected]", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
 
+    "tree-sitter": ["[email protected]", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg=="],
+
+    "tree-sitter-bash": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
 
     "trough": ["[email protected]", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
@@ -2195,7 +2204,7 @@
 
     "typed-array-buffer": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
 
-    "typescript": ["[email protected].3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
+    "typescript": ["[email protected].2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
 
     "typescript-eslint": ["[email protected]", "", { "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/[email protected]", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
 
-    "@opencode/function/typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
-
     "@opencode/web/@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
 
-    "@opencode/web/typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
-
     "@oslojs/jwt/@oslojs/encoding": ["@oslojs/[email protected]", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
 
     "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/[email protected]", "", {}, "sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw=="],
@@ -2563,10 +2568,6 @@
 
     "miniflare/zod": ["[email protected]", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
 
-    "opencode/remeda": ["[email protected]", "", { "dependencies": { "type-fest": "^4.40.1" } }, "sha512-Ka6965m9Zu9OLsysWxVf3jdJKmp6+PKzDv7HWHinEevf0JOJ9y02YpjiC/sKxRpCqGhVyvm1U+0YIj+E6DMgKw=="],
-
-    "opencode/typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
-
     "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="],
@@ -2617,6 +2618,10 @@
 
     "to-buffer/isarray": ["[email protected]", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
 
+    "tree-sitter/node-addon-api": ["[email protected]", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
+
+    "tree-sitter-bash/node-addon-api": ["[email protected]", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
+
     "ts-node/diff": ["[email protected]", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="],
 
     "tsc-multi/yargs": ["[email protected]", "", { "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=="],

+ 3 - 2
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": {

+ 3 - 1
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",

+ 9 - 0
packages/opencode/src/config/config.ts

@@ -187,6 +187,9 @@ export namespace Config {
   })
   export type Layout = z.infer<typeof Layout>
 
+  export const Permission = z.union([z.literal("ask"), z.literal("allow")])
+  export type Permission = z.infer<typeof Permission>
+
   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

+ 9 - 2
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)

+ 0 - 2
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.

+ 82 - 1
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,

+ 18 - 10
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 = ""

+ 18 - 13
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</file>"
 
     // 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,

+ 17 - 10
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, {

+ 1 - 1
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) {

+ 44 - 0
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()
+    })
+  })
+})

+ 1 - 1
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": {