From 3606d9aa504ce2e1d680a7a02409360ee878074c Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 25 Dec 2025 23:15:43 +0000 Subject: [PATCH] Enforce workspace-only paths for background processes --- packages/opencode-config/plugin/codenomad.ts | 5 +- .../plugin/lib/background-process.ts | 151 +++++++++++++++++- 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/packages/opencode-config/plugin/codenomad.ts b/packages/opencode-config/plugin/codenomad.ts index bd80e18e..b04322d0 100644 --- a/packages/opencode-config/plugin/codenomad.ts +++ b/packages/opencode-config/plugin/codenomad.ts @@ -1,10 +1,11 @@ +import type { PluginInput } from "@opencode-ai/plugin" import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client" import { createBackgroundProcessTools } from "./lib/background-process" -export async function CodeNomadPlugin() { +export async function CodeNomadPlugin(input: PluginInput) { const config = getCodeNomadConfig() const client = createCodeNomadClient(config) - const backgroundProcessTools = createBackgroundProcessTools(config) + const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory }) await client.startEvents((event) => { if (event.type === "codenomad.ping") { diff --git a/packages/opencode-config/plugin/lib/background-process.ts b/packages/opencode-config/plugin/lib/background-process.ts index b8d4cec7..91da1f45 100644 --- a/packages/opencode-config/plugin/lib/background-process.ts +++ b/packages/opencode-config/plugin/lib/background-process.ts @@ -1,3 +1,4 @@ +import path from "path" import { tool } from "@opencode-ai/plugin/tool" type BackgroundProcess = { @@ -16,7 +17,16 @@ type CodeNomadConfig = { baseUrl: string } -export function createBackgroundProcessTools(config: CodeNomadConfig) { +type BackgroundProcessOptions = { + baseDir: string +} + +type ParsedCommand = { + head: string + args: string[] +} + +export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) { const request = async (path: string, init?: RequestInit): Promise => { const base = config.baseUrl.replace(/\/+$/, "") @@ -52,6 +62,7 @@ export function createBackgroundProcessTools(config: CodeNomadConfig) { command: tool.schema.string().describe("Shell command to run in the workspace"), }, async execute(args) { + assertCommandWithinBase(args.command, options.baseDir) const process = await request("", { method: "POST", body: JSON.stringify({ title: args.title, command: args.command }), @@ -138,6 +149,144 @@ export function createBackgroundProcessTools(config: CodeNomadConfig) { } } +const FILE_COMMANDS = new Set(["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"]) +const EXPANSION_CHARS = /[~*$?\[\]`$]/ + +function assertCommandWithinBase(command: string, baseDir: string) { + const normalizedBase = path.resolve(baseDir) + const commands = splitCommands(command) + + for (const item of commands) { + if (!FILE_COMMANDS.has(item.head)) { + continue + } + + for (const arg of item.args) { + if (!arg) continue + if (arg.startsWith("-") || (item.head === "chmod" && arg.startsWith("+"))) continue + + const literalArg = unquote(arg) + if (EXPANSION_CHARS.test(literalArg)) { + throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`) + } + + const resolved = path.isAbsolute(literalArg) ? path.normalize(literalArg) : path.resolve(normalizedBase, literalArg) + if (!isWithinBase(normalizedBase, resolved)) { + throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`) + } + } + } +} + +function splitCommands(command: string): ParsedCommand[] { + const tokens = tokenize(command) + const commands: ParsedCommand[] = [] + let current: string[] = [] + + for (const token of tokens) { + if (isSeparator(token)) { + if (current.length > 0) { + commands.push({ head: current[0], args: current.slice(1) }) + current = [] + } + continue + } + current.push(token) + } + + if (current.length > 0) { + commands.push({ head: current[0], args: current.slice(1) }) + } + + return commands +} + +function tokenize(input: string): string[] { + const tokens: string[] = [] + let current = "" + let quote: "'" | '"' | null = null + let escape = false + + const flush = () => { + if (current.length > 0) { + tokens.push(current) + current = "" + } + } + + for (let index = 0; index < input.length; index += 1) { + const char = input[index] + + if (escape) { + current += char + escape = false + continue + } + + if (char === "\\" && quote !== "'") { + escape = true + continue + } + + if (quote) { + current += char + if (char === quote) { + quote = null + } + continue + } + + if (char === "'" || char === '"') { + quote = char + current += char + continue + } + + if (char === " " || char === "\n" || char === "\t") { + flush() + continue + } + + if (char === "|" || char === "&" || char === ";") { + flush() + const next = input[index + 1] + if ((char === "|" || char === "&") && next === char) { + tokens.push(char + next) + index += 1 + } else { + tokens.push(char) + } + continue + } + + current += char + } + + flush() + return tokens +} + +function isSeparator(token: string) { + return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&" +} + +function unquote(value: string) { + if (value.length >= 2) { + const first = value[0] + const last = value[value.length - 1] + if ((first === "'" && last === "'") || (first === '"' && last === '"')) { + return value.slice(1, -1) + } + } + return value +} + +function isWithinBase(baseDir: string, target: string) { + const relative = path.relative(baseDir, target) + if (!relative) return true + return !relative.startsWith("..") && !path.isAbsolute(relative) +} + function normalizeHeaders(headers: HeadersInit | undefined): Record { const output: Record = {} if (!headers) return output