This commit is contained in:
LukeParkerDev 2026-04-24 08:03:16 +10:00
parent cffb8eb1e3
commit 26d77add77
4 changed files with 137 additions and 4 deletions

View file

@ -22,6 +22,7 @@ import { Auth } from "@/auth"
import { Installation } from "@/installation"
import { InstallationVersion } from "@/installation/version"
import { EffectBridge } from "@/effect"
import { ShellToolID } from "@/tool/shell/id"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
@ -453,7 +454,12 @@ function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission"
Object.keys(input.tools),
Permission.merge(input.agent.permission, input.permission ?? []),
)
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
return Record.filter(input.tools, (_, k) => {
const userTool = input.user.tools?.[k]
if (userTool !== undefined) return userTool !== false && !disabled.has(k)
if (k === ShellToolID.id && input.user.tools?.[ShellToolID.legacy] === false) return false
return !disabled.has(k)
})
}
// Check if messages contain any tool-call content

View file

@ -13,14 +13,14 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag"
import { Shell } from "@/shell/shell"
import { ShellToolID } from "./shell/id"
import { ShellKind, ShellToolID } from "./shell/id"
import { BashArity } from "@/permission/arity"
import * as Truncate from "./truncate"
import { Plugin } from "@/plugin"
import { Effect, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { ShellArity } from "./shell/arity"
const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@ -551,6 +551,7 @@ export const ShellTool = Tool.define(
patterns: new Set<string>(),
always: new Set<string>(),
}
const shellKind = ShellKind.from(Shell.name(shell))
for (const node of commands(root)) {
const command = parts(node)
@ -569,7 +570,7 @@ export const ShellTool = Tool.define(
if (tokens.length && (!cmd || !CWD.has(cmd))) {
scan.patterns.add(source(node))
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
scan.always.add(ShellArity.prefix(tokens, shellKind).join(" ") + " *")
}
}

View file

@ -561,6 +561,100 @@ describe("session.llm.stream", () => {
})
})
test("disables shell when user message uses legacy bash override", async () => {
const server = state.server
if (!server) {
throw new Error("Server not initialized")
}
const providerID = "alibaba"
const modelID = "qwen-plus"
const fixture = await loadFixture(providerID, modelID)
const model = fixture.model
const request = waitRequest(
"/chat/completions",
new Response(createChatStream("Hello"), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
}),
)
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: [providerID],
provider: {
[providerID]: {
options: {
apiKey: "test-key",
baseURL: `${server.url.origin}/v1`,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-legacy-bash-tools")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [],
} satisfies Agent.Info
const user = {
id: MessageID.make("user-legacy-bash-tools"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
tools: { bash: false },
} satisfies MessageV2.User
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {
shell: tool({
description: "Run a shell command",
inputSchema: z.object({ command: z.string() }),
execute: async () => ({ output: "" }),
}),
read: tool({
description: "Read a file",
inputSchema: z.object({ filePath: z.string() }),
execute: async () => ({ output: "" }),
}),
},
})
const capture = await request
const names =
(capture.body.tools as Array<{ function?: { name?: string } }> | undefined)?.flatMap((item) =>
item.function?.name ? [item.function.name] : [],
) ?? []
expect(names).not.toContain("shell")
expect(names).toContain("read")
},
})
})
test("sends responses API payload for OpenAI models", async () => {
const server = state.server
if (!server) {

View file

@ -237,6 +237,38 @@ describe("tool.shell permissions", () => {
)
}
for (const item of ps) {
test(
`uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`,
withShell(item, async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await initShell()
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect(
Effect.runPromise(
bash.execute(
{
command: "Remove-Item -Recurse tmp",
description: "Remove a temp directory",
},
capture(requests, err),
),
),
).rejects.toThrow(err.message)
const bashReq = requests.find((r) => r.permission === expectedPermission)
expect(bashReq).toBeDefined()
expect(bashReq!.always).toContain("Remove-Item *")
expect(bashReq!.always).not.toContain("Remove-Item -Recurse *")
},
})
}),
)
}
each("asks for external_directory permission for wildcard external paths", async () => {
await Instance.provide({
directory: projectRoot,