Procházet zdrojové kódy

feat(core): parse shell permission commands (#39567)

Aiden Cline před 2 týdny
rodič
revize
12971935ab

+ 13 - 2
bun.lock

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

+ 3 - 0
packages/cli/package.json

@@ -40,6 +40,9 @@
     "open": "10.1.2",
     "semver": "catalog:",
     "solid-js": "catalog:",
+    "tree-sitter-bash": "0.25.0",
+    "tree-sitter-powershell": "0.25.10",
+    "web-tree-sitter": "0.25.10",
     "uqr": "0.1.3",
     "ws": "8.21.0"
   },

+ 1 - 1
packages/cli/script/build-node.ts

@@ -62,8 +62,8 @@ for (const target of targets) {
   await rm("dist-node", { recursive: true, force: true })
   const assetHash = await hashNodeAssets(assets)
   const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
-  await build(mainConfig(input))
   await copyNodeAssets(assets)
+  await build(mainConfig(input))
 
   const host = target.platform === process.platform && target.arch === process.arch
   if (host) {

+ 5 - 1
packages/cli/script/node-assets.ts

@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
 import path from "node:path"
 import { fileURLToPath } from "node:url"
 import { getNodeAssets } from "@opentui/core/node-assets"
-import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
+import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
 
 const dir = path.resolve(import.meta.dirname, "..")
 
@@ -43,6 +43,10 @@ export async function collectNodeAssets(target: NodeTarget) {
       key: photonWasmAsset,
       source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
     },
+    ...Object.values(shellParserWasmAssets).map((key) => ({
+      key,
+      source: fileURLToPath(import.meta.resolve(key)),
+    })),
     ...attentionSoundAssets.map((key) => ({
       key,
       source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),

+ 5 - 0
packages/cli/src/node/target.ts

@@ -29,6 +29,11 @@ export function nodeTarget(platform: string, arch: string) {
 }
 
 export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
+export const shellParserWasmAssets = {
+  runtime: "web-tree-sitter/tree-sitter.wasm",
+  bash: "tree-sitter-bash/tree-sitter-bash.wasm",
+  powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
+} as const
 export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
 
 export const attentionSoundAssets = [

+ 10 - 1
packages/cli/vite.node.config.ts

@@ -3,7 +3,13 @@ import { readFile } from "node:fs/promises"
 import { createRequire } from "node:module"
 import { defineConfig, type Plugin, type UserConfig } from "vite"
 import solid from "vite-plugin-solid"
-import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
+import {
+  nodeExecArgv,
+  nodeTarget,
+  type NodeTarget,
+  photonWasmAsset,
+  shellParserWasmAssets,
+} from "./src/node/target"
 
 const dir = import.meta.dirname
 
@@ -194,6 +200,9 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
 process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
 process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
 process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
+process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
+process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
+process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
 process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
 process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
 try {

+ 8 - 0
packages/core/package.json

@@ -41,6 +41,11 @@
       "node": "./src/image/photon-wasm.node.ts",
       "default": "./src/image/photon-wasm.bun.ts"
     },
+    "#shell-parser-wasm": {
+      "bun": "./src/shell/parser-wasm.bun.ts",
+      "node": "./src/shell/parser-wasm.node.ts",
+      "default": "./src/shell/parser-wasm.bun.ts"
+    },
     "#process-lock-ffi": {
       "bun": "./src/util/process-lock-ffi.bun.ts",
       "node": "./src/util/process-lock-ffi.node.ts",
@@ -119,7 +124,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:"
   },

+ 296 - 0
packages/core/src/shell/parse.ts

@@ -0,0 +1,296 @@
+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 { shellParserWasm } from "#shell-parser-wasm"
+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) => directoryArgument(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 = directoryArgument(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 = directoryArgument(part.text, powershell, cwd, shell)
+    if (value) directories.push(value)
+  }
+  return directories
+}
+
+function directoryArgument(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 expandKnownDirectory(text)
+
+  // PowerShell exposes environment variables through $env:NAME and provides these
+  // automatic directory variables. Expand only values we can determine without executing code.
+  return expandKnownDirectory(
+    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 expandKnownDirectory(value: string) {
+  // Unknown shell expressions cannot be resolved safely during permission analysis.
+  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 (path.isAbsolute(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")
+  await Parser.init({ locateFile: () => resolve(shellParserWasm.runtime) })
+  const [bashLanguage, psLanguage] = await Promise.all([
+    Language.load(resolve(shellParserWasm.bash)),
+    Language.load(resolve(shellParserWasm.powershell)),
+  ])
+  const bash = new Parser()
+  bash.setLanguage(bashLanguage)
+  const ps = new Parser()
+  ps.setLanguage(psLanguage)
+  return { bash, ps }
+}

+ 8 - 0
packages/core/src/shell/parser-wasm.bun.ts

@@ -0,0 +1,8 @@
+// @ts-ignore Bun embeds static file imports when compiling the CLI.
+import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" }
+// @ts-ignore Bun embeds static file imports when compiling the CLI.
+import bash from "tree-sitter-bash/tree-sitter-bash.wasm" with { type: "file" }
+// @ts-ignore Bun embeds static file imports when compiling the CLI.
+import powershell from "tree-sitter-powershell/tree-sitter-powershell.wasm" with { type: "file" }
+
+export const shellParserWasm = { runtime, bash, powershell }

+ 12 - 0
packages/core/src/shell/parser-wasm.node.ts

@@ -0,0 +1,12 @@
+import { createRequire } from "node:module"
+
+const require = createRequire(import.meta.url)
+
+export const shellParserWasm = {
+  runtime: process.env.OPENCODE_TREE_SITTER_WASM_PATH ?? require.resolve("web-tree-sitter/tree-sitter.wasm"),
+  bash:
+    process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"),
+  powershell:
+    process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH ??
+    require.resolve("tree-sitter-powershell/tree-sitter-powershell.wasm"),
+}

+ 23 - 23
packages/core/src/tool/plugin/shell.ts

@@ -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({
+                          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({
-                          ...LocationMutation.externalDirectoryPermission(external),
+                          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}`))
                     }),

+ 55 - 0
packages/core/test/shell-parse.test.ts

@@ -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(["/workspace/src", "/usr/local/bin"])
+  })
+})

+ 74 - 2
packages/core/test/tool-shell.test.ts

@@ -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"
@@ -222,7 +223,10 @@ describe("ShellTool", () => {
               type: "text",
               text: expect.stringContaining("Command exited with code 0."),
             })
-            expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: [helloCommand] }])
+            expect(assertions).toMatchObject([
+              { sessionID, action: "shell", resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand] },
+            ])
+            expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
           }),
         )
       },
@@ -253,6 +257,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 +352,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()])),
@@ -466,7 +538,7 @@ describe("ShellTool", () => {
       (tmp) => {
           reset()
           return withSession(tmp.path, (registry) =>
-            executeTool(registry, call({ command: timeoutOutputCommand, timeout: 50 })),
+            executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 500 : 50 })),
           ).pipe(
             Effect.andThen((settled) =>
               Effect.sync(() => {