mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 22:26:12 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1dc43a806 | ||
|
|
6b8a4992e6 | ||
|
|
d66cb67d2c | ||
|
|
a373b73700 | ||
|
|
cf150b2dbe | ||
|
|
a195f60631 | ||
|
|
b4e862ce7d |
@@ -402,8 +402,11 @@
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "^7.6.3",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -1073,9 +1076,11 @@
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"tree-sitter-powershell",
|
||||
"protobufjs",
|
||||
"electron",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
@@ -4072,10 +4077,10 @@
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
@@ -5634,6 +5639,10 @@
|
||||
|
||||
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
|
||||
|
||||
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
|
||||
|
||||
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
|
||||
|
||||
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
@@ -6856,6 +6865,8 @@
|
||||
|
||||
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="],
|
||||
|
||||
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
||||
|
||||
@@ -119,7 +119,10 @@
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "^7.6.3",
|
||||
"turndown": "7.2.0",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
export * as ShellParse from "./parse"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { Node } from "web-tree-sitter"
|
||||
import { ShellSelect } from "./select"
|
||||
|
||||
type Part = { type: string; text: string }
|
||||
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
|
||||
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
|
||||
|
||||
const ARITY: Record<string, number> = {
|
||||
cat: 1,
|
||||
cd: 1,
|
||||
chmod: 1,
|
||||
chown: 1,
|
||||
cp: 1,
|
||||
echo: 1,
|
||||
env: 1,
|
||||
export: 1,
|
||||
grep: 1,
|
||||
kill: 1,
|
||||
killall: 1,
|
||||
ln: 1,
|
||||
ls: 1,
|
||||
mkdir: 1,
|
||||
mv: 1,
|
||||
ps: 1,
|
||||
pwd: 1,
|
||||
rm: 1,
|
||||
rmdir: 1,
|
||||
sleep: 1,
|
||||
source: 1,
|
||||
tail: 1,
|
||||
touch: 1,
|
||||
unset: 1,
|
||||
which: 1,
|
||||
aws: 3,
|
||||
az: 3,
|
||||
bazel: 2,
|
||||
brew: 2,
|
||||
bun: 2,
|
||||
"bun run": 3,
|
||||
"bun x": 3,
|
||||
cargo: 2,
|
||||
"cargo add": 3,
|
||||
"cargo run": 3,
|
||||
cdk: 2,
|
||||
cf: 2,
|
||||
cmake: 2,
|
||||
composer: 2,
|
||||
consul: 2,
|
||||
"consul kv": 3,
|
||||
crictl: 2,
|
||||
deno: 2,
|
||||
"deno task": 3,
|
||||
doctl: 3,
|
||||
docker: 2,
|
||||
"docker builder": 3,
|
||||
"docker compose": 3,
|
||||
"docker container": 3,
|
||||
"docker image": 3,
|
||||
"docker network": 3,
|
||||
"docker volume": 3,
|
||||
eksctl: 2,
|
||||
"eksctl create": 3,
|
||||
firebase: 2,
|
||||
flyctl: 2,
|
||||
gcloud: 3,
|
||||
gh: 3,
|
||||
git: 2,
|
||||
"git config": 3,
|
||||
"git remote": 3,
|
||||
"git stash": 3,
|
||||
go: 2,
|
||||
gradle: 2,
|
||||
helm: 2,
|
||||
heroku: 2,
|
||||
hugo: 2,
|
||||
ip: 2,
|
||||
"ip addr": 3,
|
||||
"ip link": 3,
|
||||
"ip netns": 3,
|
||||
"ip route": 3,
|
||||
kind: 2,
|
||||
"kind create": 3,
|
||||
kubectl: 2,
|
||||
"kubectl kustomize": 3,
|
||||
"kubectl rollout": 3,
|
||||
kustomize: 2,
|
||||
make: 2,
|
||||
mc: 2,
|
||||
"mc admin": 3,
|
||||
minikube: 2,
|
||||
mongosh: 2,
|
||||
mysql: 2,
|
||||
mvn: 2,
|
||||
ng: 2,
|
||||
npm: 2,
|
||||
"npm exec": 3,
|
||||
"npm init": 3,
|
||||
"npm run": 3,
|
||||
"npm view": 3,
|
||||
npx: 2,
|
||||
nvm: 2,
|
||||
nx: 2,
|
||||
openssl: 2,
|
||||
"openssl req": 3,
|
||||
"openssl x509": 3,
|
||||
pip: 2,
|
||||
pipenv: 2,
|
||||
pnpm: 2,
|
||||
"pnpm dlx": 3,
|
||||
"pnpm exec": 3,
|
||||
"pnpm run": 3,
|
||||
poetry: 2,
|
||||
podman: 2,
|
||||
"podman container": 3,
|
||||
"podman image": 3,
|
||||
psql: 2,
|
||||
pulumi: 2,
|
||||
"pulumi stack": 3,
|
||||
python: 2,
|
||||
pyenv: 2,
|
||||
rake: 2,
|
||||
rbenv: 2,
|
||||
"redis-cli": 2,
|
||||
rustup: 2,
|
||||
serverless: 2,
|
||||
sfdx: 3,
|
||||
skaffold: 2,
|
||||
sls: 2,
|
||||
sst: 2,
|
||||
swift: 2,
|
||||
systemctl: 2,
|
||||
terraform: 2,
|
||||
"terraform workspace": 3,
|
||||
tmux: 2,
|
||||
turbo: 2,
|
||||
ufw: 2,
|
||||
vault: 2,
|
||||
"vault auth": 3,
|
||||
"vault kv": 3,
|
||||
vercel: 2,
|
||||
volta: 2,
|
||||
wp: 2,
|
||||
yarn: 2,
|
||||
"yarn dlx": 3,
|
||||
"yarn run": 3,
|
||||
}
|
||||
|
||||
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
|
||||
const parsers = yield* Effect.promise(load)
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
|
||||
if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command"))
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.succeed(tree),
|
||||
(tree) =>
|
||||
Effect.sync(() =>
|
||||
tree.rootNode.descendantsOfType("command").reduce(
|
||||
(result, node) => {
|
||||
if (!node) return result
|
||||
const command = parts(node)
|
||||
const tokens = command.map((part) => part.text)
|
||||
if (tokens.length === 0) return result
|
||||
const name = powershell ? tokens[0].toLowerCase() : tokens[0]
|
||||
if (CWD.has(name)) {
|
||||
result.directories.push(...directoryArgs(command, powershell, cwd, shell))
|
||||
return result
|
||||
}
|
||||
result.commands.push({
|
||||
resource: (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim(),
|
||||
save: `${prefix(tokens).join(" ")} *`,
|
||||
})
|
||||
return result
|
||||
},
|
||||
{ commands: [] as Array<{ resource: string; save: string }>, directories: [] as string[] },
|
||||
),
|
||||
),
|
||||
(tree) => Effect.sync(() => tree.delete()),
|
||||
)
|
||||
})
|
||||
|
||||
function parts(node: Node) {
|
||||
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
|
||||
const child = node.child(index)
|
||||
if (!child) return []
|
||||
if (child.type === "command_elements")
|
||||
return Array.from({ length: child.childCount }).flatMap((_, itemIndex): Part[] => {
|
||||
const item = child.child(itemIndex)
|
||||
if (!item || item.type === "command_argument_sep" || item.type === "redirection") return []
|
||||
return [{ type: item.type, text: item.text }]
|
||||
})
|
||||
if (!["command_name", "command_name_expr", "word", "string", "raw_string", "concatenation"].includes(child.type))
|
||||
return []
|
||||
return [{ type: child.type, text: child.text }]
|
||||
})
|
||||
}
|
||||
|
||||
function directoryArgs(command: Part[], powershell: boolean, cwd: string, shell: string) {
|
||||
if (!powershell)
|
||||
return command
|
||||
.slice(1)
|
||||
.filter((part) => !part.text.startsWith("-"))
|
||||
.map((part) => directory(part.text, powershell, cwd, shell))
|
||||
.filter((part) => part !== undefined)
|
||||
|
||||
const directories: string[] = []
|
||||
let path = false
|
||||
for (const part of command.slice(1)) {
|
||||
if (path) {
|
||||
const value = directory(part.text, powershell, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
path = false
|
||||
continue
|
||||
}
|
||||
if (part.type === "command_parameter") {
|
||||
path = POWERSHELL_PATH_FLAGS.has(part.text.toLowerCase())
|
||||
continue
|
||||
}
|
||||
const value = directory(part.text, powershell, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
}
|
||||
return directories
|
||||
}
|
||||
|
||||
function directory(value: string, powershell: boolean, cwd: string, shell: string) {
|
||||
const quote = value[0]
|
||||
const text = (quote === '"' || quote === "'") && value.at(-1) === quote ? value.slice(1, -1) : value
|
||||
if (!powershell) return staticDirectory(text)
|
||||
|
||||
return staticDirectory(
|
||||
text
|
||||
.replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => environment(key) ?? "")
|
||||
.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => environment(key) ?? "")
|
||||
.replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => {
|
||||
if (key.toUpperCase() === "HOME") return os.homedir()
|
||||
if (key.toUpperCase() === "PWD") return cwd
|
||||
return path.dirname(shell)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function staticDirectory(value: string) {
|
||||
if (value.includes("$") || value.includes("`") || value.startsWith("(")) return
|
||||
if (value === "~") return os.homedir()
|
||||
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2))
|
||||
return value
|
||||
}
|
||||
|
||||
function environment(key: string) {
|
||||
if (process.platform !== "win32") return process.env[key]
|
||||
const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase())
|
||||
return name ? process.env[name] : undefined
|
||||
}
|
||||
|
||||
function prefix(tokens: string[]) {
|
||||
for (let length = tokens.length; length > 0; length--) {
|
||||
const arity = ARITY[tokens.slice(0, length).join(" ")]
|
||||
if (arity !== undefined) return tokens.slice(0, arity)
|
||||
}
|
||||
return tokens.slice(0, 1)
|
||||
}
|
||||
|
||||
function resolve(asset: string) {
|
||||
if (asset.startsWith("file://")) return fileURLToPath(asset)
|
||||
if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset
|
||||
return fileURLToPath(new URL(asset, import.meta.url))
|
||||
}
|
||||
|
||||
const load = (() => {
|
||||
let loading: ReturnType<typeof initialize> | undefined
|
||||
return () => (loading ??= initialize())
|
||||
})()
|
||||
|
||||
async function initialize() {
|
||||
const { Parser, Language } = await import("web-tree-sitter")
|
||||
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { with: { type: "wasm" } })
|
||||
await Parser.init({ locateFile: () => resolve(treeWasm) })
|
||||
const [{ default: bashWasm }, { default: psWasm }] = await Promise.all([
|
||||
import("tree-sitter-bash/tree-sitter-bash.wasm" as string, { with: { type: "wasm" } }),
|
||||
import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, { with: { type: "wasm" } }),
|
||||
])
|
||||
const [bashLanguage, psLanguage] = await Promise.all([
|
||||
Language.load(resolve(bashWasm)),
|
||||
Language.load(resolve(psWasm)),
|
||||
])
|
||||
const bash = new Parser()
|
||||
bash.setLanguage(bashLanguage)
|
||||
const ps = new Parser()
|
||||
ps.setLanguage(psLanguage)
|
||||
return { bash, ps }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as ShellTool from "./shell"
|
||||
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
@@ -11,6 +12,7 @@ import { PluginRuntime } from "../../plugin/runtime"
|
||||
import { NonNegativeInt } from "../../schema"
|
||||
import { SessionSchema } from "../../session/schema"
|
||||
import { Shell } from "../../shell"
|
||||
import { ShellParse } from "../../shell/parse"
|
||||
|
||||
export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
@@ -75,18 +77,6 @@ const modelOutput = (output: Output): string | undefined => {
|
||||
return `Command exited with code ${output.exit}.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal core shell boundary. Keep parity debt visible without pulling the
|
||||
* legacy shell runtime into core.
|
||||
*/
|
||||
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
|
||||
// TODO: Port BashArity reusable command-prefix approvals.
|
||||
// TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
|
||||
// TODO: Persist job status and define restart recovery before exposing remote observation.
|
||||
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
|
||||
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
|
||||
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.shell",
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||
@@ -158,24 +148,34 @@ export const Plugin = {
|
||||
(invocation) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical)
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }),
|
||||
)
|
||||
invocation.cwd = target.canonical
|
||||
finalTimeout = invocation.timeout
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
const external = [target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined)
|
||||
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
|
||||
if (external.length > 0)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
action: "external_directory",
|
||||
resources: external.map((item) => item.resource),
|
||||
save: external.map((item) => item.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [invocation.command],
|
||||
save: [invocation.command],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { ShellParse } from "@opencode-ai/core/shell/parse"
|
||||
|
||||
describe("ShellParse", () => {
|
||||
test("splits bash commands and derives reusable prefixes", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
ShellParse.scan("git status && npm run test -- --watch", "/bin/bash", "/workspace"),
|
||||
)
|
||||
expect(result).toEqual({
|
||||
commands: [
|
||||
{ resource: "git status", save: "git status *" },
|
||||
{ resource: "npm run test -- --watch", save: "npm run test *" },
|
||||
],
|
||||
directories: [],
|
||||
})
|
||||
})
|
||||
|
||||
test("splits PowerShell commands case-insensitively", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
ShellParse.scan("Get-ChildItem; Write-Output 'done'", "C:\\Program Files\\PowerShell\\7\\pwsh.exe", "C:\\workspace"),
|
||||
)
|
||||
expect(result.commands).toEqual([
|
||||
{ resource: "Get-ChildItem", save: "Get-ChildItem *" },
|
||||
{ resource: "Write-Output 'done'", save: "Write-Output *" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not permission directory changes separately", async () => {
|
||||
const result = await Effect.runPromise(ShellParse.scan("cd 'src dir' && git status", "/bin/bash", "/workspace"))
|
||||
expect(result).toEqual({
|
||||
commands: [{ resource: "git status", save: "git status *" }],
|
||||
directories: ["src dir"],
|
||||
})
|
||||
})
|
||||
|
||||
test("extracts PowerShell directory parameters", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
ShellParse.scan("Set-Location -LiteralPath '..\\outside'; Get-ChildItem", "pwsh", "C:\\workspace"),
|
||||
)
|
||||
expect(result.directories).toEqual(["..\\outside"])
|
||||
})
|
||||
|
||||
test("expands deterministic directory variables", async () => {
|
||||
const bash = await Effect.runPromise(ShellParse.scan("cd ~/src", "/bin/bash", "/workspace"))
|
||||
expect(bash.directories).toEqual([path.join(os.homedir(), "src")])
|
||||
|
||||
const powershell = await Effect.runPromise(
|
||||
ShellParse.scan('Set-Location "$PWD/src"; Set-Location $PSHOME', "/usr/local/bin/pwsh", "/workspace"),
|
||||
)
|
||||
expect(powershell.directories).toEqual([path.join("/workspace", "src"), "/usr/local/bin"])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "fs/promises"
|
||||
import { realpathSync } from "node:fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
||||
@@ -223,6 +224,7 @@ describe("ShellTool", () => {
|
||||
text: expect.stringContaining("Command exited with code 0."),
|
||||
})
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: [helloCommand] }])
|
||||
expect(assertions[0]?.save).toEqual(["printf *"])
|
||||
}),
|
||||
)
|
||||
},
|
||||
@@ -253,6 +255,29 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("permissions compound commands separately", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions).toHaveLength(1)
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: ["printf one", "printf two"],
|
||||
save: ["printf *", "printf *"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("captures stderr-only and mixed stdout/stderr output", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -325,6 +350,51 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("approves an external directory used by a directory-change command", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
const command = isWindows
|
||||
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
|
||||
: `cd '${outside.path}' && pwd`
|
||||
return withSession(active.path, (registry) => executeTool(registry, call({ command }, "call-external-cd"))).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("approves an expanded external home directory", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const command = isWindows ? "Set-Location $HOME; (Get-Location).Path" : "cd ~ && pwd"
|
||||
return withSession(tmp.path, (registry) => executeTool(registry, call({ command }, "call-external-home"))).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
||||
expect(assertions[0]?.resources[0]).toStartWith(os.homedir().replaceAll("\\", "/"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not execute after external-directory or shell denial", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
|
||||
Reference in New Issue
Block a user