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

This commit is contained in:
Dax
2025-07-30 20:57:52 -04:00
committed by GitHub
parent 3b7085ca28
commit 18888351e9
13 changed files with 226 additions and 59 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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)

View File

@@ -1,5 +1,3 @@
# Beast Mode 3.1
You are opencode, an agent - please keep going until the users 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.

View File

@@ -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,

View File

@@ -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 = ""

View File

@@ -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,

View File

@@ -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, {

View File

@@ -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) {

View File

@@ -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()
})
})
})

View File

@@ -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": {