diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index 287cbc265..7d4b0cea2 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -25,3 +25,9 @@ - **Logging**: Use `Log.create({ service: "name" })` pattern - **Storage**: Use `Storage` namespace for persistence - **API Client**: Go TUI communicates with TypeScript server via stainless SDK. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, ask the user to generate a new client SDK to proceed with client-side changes. + +## Protected Mode + +If bash commands fail with "Permission denied" on specific files, you may be in protected mode. Run `whoami` - if it returns a restricted user (e.g., `opencode-agent`), the user has locked certain files with `chmod 600` permissions to prevent reading secrets. + +Certain commands (like `git`) may be blocked because the restricted user lacks permissions. If the user is in protected mode and wants to grant you access to these commands, they can whitelist them in `~/.opencode/security.json` under `whitelistedCommands`, then run `opencode protect lock` and restart OpenCode. diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 638ee7347..db9f4d3ac 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -27,6 +27,7 @@ import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" +import { ProtectCommand } from "./util/security" process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -98,6 +99,7 @@ const cli = yargs(hideBin(process.argv)) .command(GithubCommand) .command(PrCommand) .command(SessionCommand) + .command(ProtectCommand) .fail((msg) => { if ( msg.startsWith("Unknown argument") || diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 115d8f8b2..b622ba893 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -14,6 +14,9 @@ import { Permission } from "@/permission" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag.ts" import path from "path" +import { iife } from "@/util/iife" +import { loadSecurityConfig } from "@/util/security/config" +import { ProtectedExecutor } from "@/util/security/executor" import { Shell } from "@/shell/shell" const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000 @@ -195,6 +198,73 @@ export const BashTool = Tool.define("bash", async () => { }) } + // Check if protected mode is enabled + // TODO: Protected mode path duplicates result metadata building logic from normal + // execution path (lines 250-269 vs 388-406). Consider extracting shared helper. + const securityConfig = await loadSecurityConfig() + if (securityConfig?.protectedMode) { + // Execute via ProtectedExecutor + const executor = new ProtectedExecutor(securityConfig) + + // Initialize metadata with empty output + ctx.metadata({ + metadata: { + output: "", + description: params.description, + }, + }) + + const result = await executor.execute(params.command, { + cwd: Instance.directory, + description: params.description, + timeout, + abortSignal: ctx.abort, + onData: (output) => { + // Stream output updates to UI in real-time + if (output.length <= MAX_OUTPUT_LENGTH) { + ctx.metadata({ + metadata: { + output, + description: params.description, + }, + }) + } + }, + }) + + let output = result.stdout + result.stderr + let resultMetadata: string[] = [""] + + if (output.length > MAX_OUTPUT_LENGTH) { + output = output.slice(0, MAX_OUTPUT_LENGTH) + resultMetadata.push(`bash tool truncated output as it exceeded ${MAX_OUTPUT_LENGTH} char limit`) + } + + if (result.timedOut) { + resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`) + } + + if (result.aborted) { + resultMetadata.push("User aborted the command") + } + + if (resultMetadata.length > 1) { + resultMetadata.push("") + output += "\n\n" + resultMetadata.join("\n") + } + + return { + title: params.description, + metadata: { + output, + exit: result.exitCode, + description: params.description, + }, + output, + } + } + + // Normal execution (not protected mode) const proc = spawn(params.command, { shell, cwd, @@ -272,7 +342,7 @@ export const BashTool = Tool.define("bash", async () => { }) }) - let resultMetadata: String[] = [""] + let resultMetadata: string[] = [""] if (output.length > MAX_OUTPUT_LENGTH) { output = output.slice(0, MAX_OUTPUT_LENGTH) @@ -280,7 +350,7 @@ export const BashTool = Tool.define("bash", async () => { } if (timedOut) { - resultMetadata.push(`bash tool terminated commmand after exceeding timeout ${timeout} ms`) + resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`) } if (aborted) { diff --git a/packages/opencode/src/util/security/commands/lock.ts b/packages/opencode/src/util/security/commands/lock.ts new file mode 100644 index 000000000..3bc635e80 --- /dev/null +++ b/packages/opencode/src/util/security/commands/lock.ts @@ -0,0 +1,112 @@ +import { loadSecurityConfig } from "../config" +import { getFilePermissions, protectFile } from "../files" +import { validateWhitelistedCommands, rebuildSudoersFile, requestSudoAuth } from "../util" + +/** + * Apply security configuration + * - Validates whitelisted commands + * - Locks protected files (sets permissions to 600) + * - Configures sudoers rules for whitelisted commands + */ +export async function applySecurityConfiguration(): Promise { + console.log("šŸ”’ Applying security configuration...\n") + + console.log("This command requires administrator privileges.") + await requestSudoAuth() + console.log("") + + const config = await loadSecurityConfig() + + if (!config) { + console.error("āŒ Protected mode is not set up.") + console.error(" Run 'opencode protect setup' first.\n") + process.exit(1) + } + + if (!config.protectedMode) { + console.error("āŒ Protected mode is currently disabled.") + console.error(' Enable it by setting "protectedMode": true in ~/.opencode/security.json\n') + process.exit(1) + } + + // Validate whitelisted commands first (strict - must be correct) + if (config.whitelistedCommands.length === 0) { + console.log("- No whitelisted commands to validate") + } + + if (config.whitelistedCommands.length > 0) { + const valid = validateWhitelistedCommands(config.whitelistedCommands) + if (!valid) { + process.exit(1) + } + console.log("āœ“ All whitelisted commands are valid") + } + + if (config.protectedPaths.length === 0) { + console.log("- No paths configured to protect") + } + + if (config.protectedPaths.length > 0) { + const results = { + locked: [] as Array<{ path: string; from: string; to: string }>, + alreadyProtected: [] as string[], + skipped: [] as Array<{ path: string; reason: string }>, + failed: [] as Array<{ path: string; error: string }>, + } + + for (const filepath of config.protectedPaths) { + const currentPerms = await getFilePermissions(filepath) + + if (!currentPerms) { + results.skipped.push({ path: filepath, reason: "File does not exist or cannot be read" }) + console.log(` āš ļø ${filepath} (not found)`) + continue + } + + if (currentPerms === "600") { + results.alreadyProtected.push(filepath) + console.log(` āœ“ ${filepath} (already protected)`) + continue + } + + await protectFile(filepath) + .then(() => { + results.locked.push({ path: filepath, from: currentPerms || "unknown", to: "600" }) + console.log(` āœ“ ${filepath} (${currentPerms} → 600)`) + }) + .catch((error) => { + const errorMessage = error instanceof Error ? error.message : String(error) + results.failed.push({ path: filepath, error: errorMessage }) + console.log(` āŒ ${filepath} (${errorMessage})`) + }) + } + + const parts = [] + if (results.locked.length > 0) parts.push(`${results.locked.length} protected`) + if (results.alreadyProtected.length > 0) parts.push(`${results.alreadyProtected.length} already protected`) + if (results.skipped.length > 0) parts.push(`${results.skipped.length} skipped`) + if (results.failed.length > 0) parts.push(`${results.failed.length} failed`) + + console.log(`āœ“ ${parts.join(", ")}`) + } + + if (config.whitelistedCommands.length === 0) { + console.log("- No sudoers rules to configure") + } + + if (config.whitelistedCommands.length > 0) { + await rebuildSudoersFile(config) + .then(() => { + console.log("āœ“ Sudoers configuration updated") + }) + .catch((error) => { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`āŒ Failed to configure sudoers: ${errorMessage}`) + console.error(`File permissions updated. Fix sudo access and run 'opencode protect lock' again.`) + process.exit(1) + }) + } + + console.log("\nRun 'opencode protect status' to verify your configuration.") + console.log("āš ļø Restart OpenCode for changes to take effect.\n") +} diff --git a/packages/opencode/src/util/security/commands/setup.ts b/packages/opencode/src/util/security/commands/setup.ts new file mode 100644 index 000000000..6f665ab22 --- /dev/null +++ b/packages/opencode/src/util/security/commands/setup.ts @@ -0,0 +1,117 @@ +import { saveSecurityConfig, loadSecurityConfig, removeSecurityConfig } from "../config" +import { RESTRICTED_USER_NAME, SUDOERS_FILE_PATH, PLATFORM } from "../constants" +import { getPlatformSecurity, runSudoCommand, requestSudoAuth } from "../util" +import os from "os" + +/** + * Configure sudoers file for passwordless execution + */ +async function configureSudoers(currentUser: string, restrictedUser: string): Promise { + const sudoRule = `${currentUser} ALL=(${restrictedUser}) NOPASSWD: ${PLATFORM().SHELL}` + + // Check if rule already exists + const existing = await Bun.file(SUDOERS_FILE_PATH) + .text() + .catch(() => "") + + if (existing.includes(sudoRule)) { + console.log("āœ“ Sudo rule already configured") + return + } + + // Write to temp file, then install with sudo (sets root ownership + permissions) + const tempFile = `/tmp/opencode-sudoers-${Date.now()}.tmp` + await Bun.write(tempFile, sudoRule) + + const installResult = await runSudoCommand(`install -o root -g wheel -m 440 ${tempFile} ${SUDOERS_FILE_PATH}`) + if (installResult.exitCode !== 0) { + await runSudoCommand(`rm -f ${tempFile}`) + throw new Error(`Failed to configure sudo: ${installResult.stderr}`) + } + + console.log("āœ“ Configured sudo permissions") +} + +async function validateSetup(restrictedUser: string): Promise { + const platform = getPlatformSecurity() + if (!(await platform.userExists(restrictedUser))) { + throw new Error("Restricted user not found") + } + + const exists = await Bun.file(SUDOERS_FILE_PATH).exists() + if (!exists) { + throw new Error("Sudoers configuration not found") + } + + const proc = Bun.spawn(["sudo", "-n", "-u", restrictedUser, "whoami"], { + stdout: "pipe", + stderr: "pipe", + }) + const stdout = await new Response(proc.stdout).text() + const exitCode = await proc.exited + + if (exitCode !== 0 || stdout.trim() !== restrictedUser) { + throw new Error("Sudo execution test failed") + } + + console.log("āœ“ Setup validated successfully") +} + +async function rollback(restrictedUser: string): Promise { + console.log("\nRolling back changes...") + + await runSudoCommand(`rm -f ${SUDOERS_FILE_PATH}`) + .then(() => console.log("āœ“ Removed sudoers configuration")) + .catch((e) => console.warn("Could not remove sudoers:", e instanceof Error ? e.message : e)) + + await getPlatformSecurity() + .deleteUser(restrictedUser) + .then(() => console.log("āœ“ Removed restricted user")) + .catch((e) => console.warn("Could not remove user:", e instanceof Error ? e.message : e)) + + await removeSecurityConfig() + .then(() => console.log("āœ“ Removed configuration")) + .catch((e) => console.warn("Could not remove config:", e instanceof Error ? e.message : e)) +} + +function getCurrentUser(): string { + return process.env.USER || process.env.USERNAME || os.userInfo().username +} + +export async function setupProtectedMode(): Promise { + console.log("šŸ”’ OpenCode Protected Mode Setup\n") + + console.log("This setup requires administrator privileges.") + await requestSudoAuth() + + try { + const platform = getPlatformSecurity() + await platform.createUser(RESTRICTED_USER_NAME) + + await configureSudoers(getCurrentUser(), RESTRICTED_USER_NAME) + + // Load existing config to preserve user settings + const existingConfig = await loadSecurityConfig() + + await saveSecurityConfig({ + protectedMode: true, + restrictedUser: RESTRICTED_USER_NAME, + mainUser: getCurrentUser(), + whitelistedCommands: existingConfig?.whitelistedCommands || [], + protectedPaths: existingConfig?.protectedPaths || [], + }) + + await validateSetup(RESTRICTED_USER_NAME) + + console.log("\nConfiguration: ~/.opencode/security.json") + console.log("\nNext steps:") + console.log("1. Edit config: add protectedPaths + whitelistedCommands (e.g., git, npm)") + console.log("2. Run 'opencode protect lock' to apply protection") + console.log("3. Run 'opencode protect status' to verify") + console.log("\nāš ļø Restart OpenCode for changes to take effect.\n") + } catch (error) { + console.error("\nāŒ Setup failed:", error instanceof Error ? error.message : error) + await rollback(RESTRICTED_USER_NAME) + throw error + } +} diff --git a/packages/opencode/src/util/security/commands/status.ts b/packages/opencode/src/util/security/commands/status.ts new file mode 100644 index 000000000..d61b7732c --- /dev/null +++ b/packages/opencode/src/util/security/commands/status.ts @@ -0,0 +1,79 @@ +import { loadSecurityConfig } from "../config" +import { getFilePermissions } from "../files" + +export async function showSecurityStatus(): Promise { + const config = await loadSecurityConfig() + + if (!config) { + console.log("Protected Mode: DISABLED") + console.log("\nRun 'opencode protect setup' to enable protected mode.") + return + } + + const protectedMode = config.protectedMode + const protectedPaths = config.protectedPaths + const whitelistedCommands = config.whitelistedCommands + + console.log(`Protected Mode: ${protectedMode ? "ENABLED" : "DISABLED"}`) + console.log(`Restricted User: ${config.restrictedUser}`) + console.log(`Whitelisted Commands: ${whitelistedCommands.length > 0 ? whitelistedCommands.join(", ") : "None"}`) + console.log() + + console.log(`Protected Paths (${protectedPaths.length}):`) + + interface Issue { + path: string + issue: string + suggestion: string + } + + const issues: Issue[] = [] + + for (const filepath of protectedPaths) { + const perms = await getFilePermissions(filepath) + + if (!perms) { + console.log(` āš ļø ${filepath} (DOES NOT EXIST OR CANNOT BE READ)`) + issues.push({ + path: filepath, + issue: "does not exist or cannot be read", + suggestion: "Remove from config or create the file", + }) + continue + } + + if (perms === "600") { + console.log(` āœ“ ${filepath} (${perms})`) + continue + } + + console.log(` āš ļø ${filepath} (${perms}) - Should be 600`) + issues.push({ + path: filepath, + issue: `has permissions ${perms} (should be 600)`, + suggestion: "Run 'opencode protect lock' to fix permissions", + }) + } + + if (protectedPaths.length > 0) { + console.log() + } + + if (issues.length > 0) { + console.log() + console.log("Issues Found:") + for (const { path, issue, suggestion } of issues) { + console.log(` • ${path} ${issue}`) + console.log(` ${suggestion}`) + } + console.log() + } + + if (!protectedMode) { + console.log("āš ļø Protected mode is disabled. Run 'opencode protect setup' to enable.") + return + } + + console.log() + console.log("āš ļø Restart OpenCode for changes to take effect.") +} diff --git a/packages/opencode/src/util/security/config.ts b/packages/opencode/src/util/security/config.ts new file mode 100644 index 000000000..057d6507a --- /dev/null +++ b/packages/opencode/src/util/security/config.ts @@ -0,0 +1,54 @@ +import path from "path" +import os from "os" +import { chmod } from "fs/promises" + +/** + * Security Configuration + * + * Stored globally at ~/.opencode/security.json + */ +export interface SecurityConfig { + protectedMode: boolean + restrictedUser: string + mainUser: string + whitelistedCommands: string[] + protectedPaths: string[] +} + +const GLOBAL_CONFIG_PATH = path.join(os.homedir(), ".opencode", "security.json") + +/** + * Load security configuration from global config file + */ +export async function loadSecurityConfig(): Promise { + const file = Bun.file(GLOBAL_CONFIG_PATH) + const exists = await file.exists() + + if (!exists) { + return null + } + + return file.json().catch((error) => { + console.error(`āŒ Failed to load ${GLOBAL_CONFIG_PATH}`) + console.error(` Invalid JSON: ${error instanceof Error ? error.message : String(error)}`) + console.error(`\nFix the file or delete it and rerun 'opencode protect setup'.\n`) + process.exit(1) + }) +} + +/** + * Save security configuration to global config file + */ +export async function saveSecurityConfig(config: SecurityConfig): Promise { + await Bun.write(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2)) + await chmod(GLOBAL_CONFIG_PATH, 0o600) +} + +/** + * Remove security configuration file + */ +export async function removeSecurityConfig(): Promise { + await Bun.file(GLOBAL_CONFIG_PATH) + .unlink() + .catch(() => {}) +} diff --git a/packages/opencode/src/util/security/constants.ts b/packages/opencode/src/util/security/constants.ts new file mode 100644 index 000000000..078ba0047 --- /dev/null +++ b/packages/opencode/src/util/security/constants.ts @@ -0,0 +1,54 @@ +/** + * Security Constants + * + * Platform-agnostic constants and macOS-specific values for protected mode. + */ + +// ============================================================================ +// Platform-Agnostic Constants +// ============================================================================ + +/** Name of the restricted system user created for protected mode */ +export const RESTRICTED_USER_NAME = "opencode-agent" + +/** Path to sudoers configuration file */ +export const SUDOERS_FILE_PATH = "/etc/sudoers.d/opencode" + +// ============================================================================ +// Platform-Specific Constants +// ============================================================================ + +const MACOS_CONSTANTS = { + UID_RANGE: { START: 32767, END: 32700, FALLBACK: 499 }, + GROUP_ID: 20, + USER_HOME: "/var/empty", + SHELL: "/bin/bash", +} as const + +// Future Linux support: +// const LINUX_CONSTANTS = { +// UID_RANGE: { START: 1000, END: 65533, FALLBACK: 999 }, +// GROUP_ID: 100, +// USER_HOME: "/nonexistent", +// SHELL: "/bin/bash", +// } as const + +/** + * Platform-specific constants (runtime-detected) + * Currently supports: macOS + */ +function getPlatformConstants() { + switch (process.platform) { + case "darwin": + return MACOS_CONSTANTS + // case "linux": + // return LINUX_CONSTANTS + default: + throw new Error( + `Unsupported platform: ${process.platform}. Protected mode currently supports macOS. Linux support coming soon.`, + ) + } +} + +export const PLATFORM = getPlatformConstants +export const MACOS = MACOS_CONSTANTS // For darwin.ts diff --git a/packages/opencode/src/util/security/executor.ts b/packages/opencode/src/util/security/executor.ts new file mode 100644 index 000000000..9faa7d8e5 --- /dev/null +++ b/packages/opencode/src/util/security/executor.ts @@ -0,0 +1,240 @@ +import { spawn } from "child_process" +import { PLATFORM } from "./constants" +import type { SecurityConfig } from "./config" + +// TODO: This file duplicates process management logic from bash.ts (~100 lines including +// killTree, abort handling, timeout management, and process lifecycle). Consider extracting +// shared utilities to reduce duplication. + +const SIGKILL_TIMEOUT_MS = 1000 + +// Safe environment variables to pass through to restricted user +const SAFE_ENV_VARS = [ + "PATH", // Command lookup + "TERM", // Terminal type for formatting + "LANG", // Primary locale setting + "LC_CTYPE", // Character encoding (some commands check this specifically) + "COLUMNS", // Terminal width for output formatting + "LINES", // Terminal height for output formatting +] as const + +export interface ExecuteOptions { + cwd: string + description?: string + timeout?: number + abortSignal?: AbortSignal + onData?: (data: string) => void +} + +export interface ExecuteResult { + stdout: string + stderr: string + exitCode: number + timedOut: boolean + aborted: boolean +} + +/** + * Protected command executor + * + * Executes commands as a restricted user with no access to locked files (chmod 600). + * Whitelisted commands run as the main user for compatibility. + */ +export class ProtectedExecutor { + private restrictedUser: string + private whitelistedCommands: string[] + private mainUser: string + + constructor(config: SecurityConfig) { + this.restrictedUser = config.restrictedUser + this.whitelistedCommands = config.whitelistedCommands + this.mainUser = config.mainUser + } + + /** + * Check if command matches any whitelisted command prefix + * Returns the matching prefix if found, null otherwise + */ + private isWhitelistedCommand(command: string): string | null { + const trimmed = command.trim() + + for (const prefix of this.whitelistedCommands) { + // Check if command matches this prefix exactly or starts with prefix + space + if (trimmed === prefix || trimmed.startsWith(prefix + " ")) { + return prefix + } + } + + return null + } + + /** + * Wrap whitelisted command to run as main user + */ + private wrapWhitelistedCommand(command: string, prefix: string): string { + // Extract arguments after the prefix + const commandArgs = command.trim().substring(prefix.length).trim() + + // Build sudo command to run as main user + if (commandArgs) { + return `sudo -u ${this.mainUser} ${prefix} ${commandArgs}` + } + return `sudo -u ${this.mainUser} ${prefix}` + } + + /** + * Build isolated environment for restricted user + */ + private buildSafeEnv(): Record { + const env: Record = {} + + // Copy safe environment variables from parent process + for (const key of SAFE_ENV_VARS) { + const value = Bun.env[key] + if (value) { + env[key] = value + } + } + + // Set base environment (these override any parent values for security) + env.HOME = PLATFORM().USER_HOME // Match NFSHomeDirectory from user creation + env.USER = this.restrictedUser + env.SHELL = PLATFORM().SHELL + + return env + } + + /** + * Execute command as restricted user + */ + async execute(command: string, options: ExecuteOptions): Promise { + const timeout = options.timeout || 120000 + + // Build safe environment + const safeEnv = this.buildSafeEnv() + + // Wrap whitelisted commands to run as main user + const whitelistedPrefix = this.isWhitelistedCommand(command) + const actualCommand = whitelistedPrefix ? this.wrapWhitelistedCommand(command, whitelistedPrefix) : command + + // Set umask to create group-writable files (664 instead of 644) + const wrappedCommand = `umask 0002; ${actualCommand}` + + const proc = spawn( + "sudo", + [ + "-n", // Non-interactive (requires NOPASSWD) + "-u", + this.restrictedUser, + PLATFORM().SHELL, + "--noprofile", + "--norc", + "-c", + wrappedCommand, + ], + { + cwd: options.cwd, + env: safeEnv, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }, + ) + + let stdout = "" + let stderr = "" + let timedOut = false + let aborted = false + let exited = false + + // Stream output + proc.stdout?.on("data", (chunk) => { + const data = chunk.toString() + stdout += data + options.onData?.(stdout + stderr) + }) + + proc.stderr?.on("data", (chunk) => { + const data = chunk.toString() + stderr += data + options.onData?.(stdout + stderr) + }) + + const killTree = async () => { + const pid = proc.pid + if (!pid || exited) return + + if (process.platform === "win32") { + await new Promise((resolve) => { + const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], { stdio: "ignore" }) + killer.once("exit", resolve) + killer.once("error", resolve) + }) + return + } + + try { + process.kill(-pid, "SIGTERM") + await new Promise((resolve) => setTimeout(resolve, SIGKILL_TIMEOUT_MS)) + if (!exited) { + process.kill(-pid, "SIGKILL") + } + } catch { + proc.kill("SIGTERM") + await new Promise((resolve) => setTimeout(resolve, SIGKILL_TIMEOUT_MS)) + if (!exited) { + proc.kill("SIGKILL") + } + } + } + + if (options.abortSignal?.aborted) { + aborted = true + await killTree() + } + + const abortHandler = () => { + aborted = true + void killTree() + } + + options.abortSignal?.addEventListener("abort", abortHandler, { once: true }) + + const timeoutTimer = setTimeout(() => { + timedOut = true + void killTree() + }, timeout + 100) + + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timeoutTimer) + options.abortSignal?.removeEventListener("abort", abortHandler) + } + + proc.once("exit", () => { + exited = true + cleanup() + resolve() + }) + + proc.once("error", (error) => { + exited = true + cleanup() + reject(error) + }) + }) + + // Filter out cosmetic getcwd errors from stderr + const filteredStderr = stderr + .split("\n") + .filter((line) => !line.includes("shell-init") && !line.includes("getcwd")) + .join("\n") + + return { + stdout, + stderr: filteredStderr, + exitCode: proc.exitCode ?? -1, + timedOut, + aborted, + } + } +} diff --git a/packages/opencode/src/util/security/files.ts b/packages/opencode/src/util/security/files.ts new file mode 100644 index 000000000..ec8ed1b7e --- /dev/null +++ b/packages/opencode/src/util/security/files.ts @@ -0,0 +1,21 @@ +import { chmod } from "fs/promises" + +/** + * Get file permissions as octal string (e.g., "600", "644") + * Returns null if file doesn't exist or cannot be read + */ +export async function getFilePermissions(filepath: string): Promise { + const stats = await Bun.file(filepath) + .stat() + .catch(() => null) + if (!stats) return null + return (stats.mode & 0o777).toString(8) +} + +/** + * Protect a file by setting permissions to 600 (user read/write only) + * Throws error if file doesn't exist or chmod fails + */ +export async function protectFile(filepath: string): Promise { + await chmod(filepath, 0o600) +} diff --git a/packages/opencode/src/util/security/index.ts b/packages/opencode/src/util/security/index.ts new file mode 100644 index 000000000..c49303cd5 --- /dev/null +++ b/packages/opencode/src/util/security/index.ts @@ -0,0 +1,31 @@ +import type { Argv } from "yargs" +import { cmd } from "../../cli/cmd/cmd" +import { setupProtectedMode } from "./commands/setup" +import { showSecurityStatus } from "./commands/status" +import { applySecurityConfiguration } from "./commands/lock" + +export const ProtectCommand = cmd({ + command: "protect ", + describe: "manage protected mode", + builder: (yargs: Argv) => { + return yargs.positional("action", { + describe: "Action to perform", + type: "string", + choices: ["setup", "lock", "status"], + demandOption: true, + }) + }, + async handler(args) { + switch (args.action) { + case "setup": + await setupProtectedMode() + break + case "lock": + await applySecurityConfiguration() + break + case "status": + await showSecurityStatus() + break + } + }, +}) diff --git a/packages/opencode/src/util/security/platform/darwin.ts b/packages/opencode/src/util/security/platform/darwin.ts new file mode 100644 index 000000000..d267fdeb8 --- /dev/null +++ b/packages/opencode/src/util/security/platform/darwin.ts @@ -0,0 +1,74 @@ +import { $ } from "bun" +import { MACOS } from "../constants" +import type { PlatformSecurity, UserCreationResult, SudoCommand } from "./interface" + +/** + * macOS security implementation using dscl (Directory Service Command Line) + */ +export class DarwinSecurity implements PlatformSecurity { + constructor(private runSudoCommand: SudoCommand) {} + + async userExists(username: string): Promise { + const result = await $`dscl . -read /Users/${username}`.quiet().nothrow() + return result.exitCode === 0 + } + + async createUser(username: string): Promise { + if (await this.userExists(username)) { + console.log(`āœ“ User ${username} already exists`) + await this.updateUserGroup(username) + return { success: true, userCreated: false } + } + + const uid = await this.findAvailableUID() + + const createCommands = [ + `dscl . -create /Users/${username}`, + `dscl . -create /Users/${username} UserShell ${MACOS.SHELL}`, + `dscl . -create /Users/${username} UniqueID ${uid}`, + `dscl . -create /Users/${username} PrimaryGroupID ${MACOS.GROUP_ID}`, + `dscl . -create /Users/${username} NFSHomeDirectory ${MACOS.USER_HOME}`, + ] + + for (const cmd of createCommands) { + const result = await this.runSudoCommand(cmd) + if (result.exitCode !== 0) { + throw new Error(`Failed to create user: ${result.stderr}`) + } + } + + console.log(`āœ“ Created user ${username}`) + return { success: true, userCreated: true } + } + + async deleteUser(username: string): Promise { + const result = await this.runSudoCommand(`dscl . -delete /Users/${username}`) + if (result.exitCode !== 0) { + throw new Error(`Failed to delete user: ${result.stderr}`) + } + } + + async updateUserGroup(username: string): Promise { + const result = await this.runSudoCommand(`dscl . -create /Users/${username} PrimaryGroupID ${MACOS.GROUP_ID}`) + + if (result.exitCode !== 0) { + throw new Error(`Failed to update group: ${result.stderr}`) + } + + console.log("āœ“ Updated primary group") + } + + private async findAvailableUID(): Promise { + // Use high UID to avoid conflicts with system users + for (let uid = MACOS.UID_RANGE.START; uid >= MACOS.UID_RANGE.END; uid--) { + const inUse = await $`dscl . -list /Users UniqueID | grep -q " ${uid}$"`.quiet().nothrow() + + if (inUse.exitCode !== 0) { + return uid + } + } + + // Fallback if all high UIDs are taken + return MACOS.UID_RANGE.FALLBACK + } +} diff --git a/packages/opencode/src/util/security/platform/interface.ts b/packages/opencode/src/util/security/platform/interface.ts new file mode 100644 index 000000000..d9ab41e9c --- /dev/null +++ b/packages/opencode/src/util/security/platform/interface.ts @@ -0,0 +1,21 @@ +/** Platform-specific security operations interface */ + +export interface UserCreationResult { + success: boolean + userCreated: boolean // false if already existed + error?: string +} + +export interface PlatformSecurity { + userExists(username: string): Promise + + /** Create a restricted system user with appropriate UID and group */ + createUser(username: string): Promise + + deleteUser(username: string): Promise + + /** Update user's primary group membership */ + updateUserGroup(username: string): Promise +} + +export type SudoCommand = (cmd: string) => Promise<{ exitCode: number; stdout: string; stderr: string }> diff --git a/packages/opencode/src/util/security/util.ts b/packages/opencode/src/util/security/util.ts new file mode 100644 index 000000000..df6cad3fd --- /dev/null +++ b/packages/opencode/src/util/security/util.ts @@ -0,0 +1,176 @@ +import { PLATFORM, SUDOERS_FILE_PATH } from "./constants" +import type { SecurityConfig } from "./config" +import { DarwinSecurity } from "./platform/darwin" +import type { PlatformSecurity } from "./platform/interface" + +/** + * Run sudo command (non-interactive, assumes auth cached) + */ +export async function runSudoCommand(cmd: string): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const proc = Bun.spawn(["sudo", "-n", PLATFORM().SHELL, "-c", cmd], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + const exitCode = await proc.exited + + return { exitCode, stdout, stderr } +} + +/** + * Request sudo authentication upfront (interactive) + * Prompts user for password if needed + * Also validates that sudo is actually working (not just cached credentials) + */ +export async function requestSudoAuth(): Promise { + const proc = Bun.spawn(["sudo", "-v"], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }) + + const exitCode = await proc.exited + + if (exitCode !== 0) { + throw new Error("Sudo authentication failed") + } + + // Verify sudo actually works (catches issues like broken sudoers files) + const testProc = Bun.spawn(["sudo", "-n", "whoami"], { + stdout: "pipe", + stderr: "pipe", + }) + + const testOutput = await new Response(testProc.stdout).text() + const testExit = await testProc.exited + + if (testExit !== 0 || testOutput.trim() !== "root") { + throw new Error("Sudo validation failed - check sudoers configuration") + } +} + +/** + * Get platform-specific security implementation + * Currently supports macOS only + */ +export function getPlatformSecurity(): PlatformSecurity { + if (process.platform === "darwin") { + return new DarwinSecurity(runSudoCommand) + } + throw new Error(`Platform ${process.platform} not yet supported for protected mode`) +} + +/** + * Detect absolute path of a command binary + * Returns null if command not found in PATH + */ +export async function detectBinaryPath(command: string): Promise { + const proc = Bun.spawn(["which", command], { + stdout: "pipe", + stderr: "pipe", + }) + + const stdout = await new Response(proc.stdout).text() + const exitCode = await proc.exited + + if (exitCode !== 0 || !stdout.trim()) { + return null + } + + return stdout.trim() +} + +/** + * Validate whitelisted commands + * Returns false if validation fails (errors are logged to console) + */ +export function validateWhitelistedCommands(commands: string[]): boolean { + const errors: string[] = [] + + for (const command of commands) { + if (command.trim() === "") { + errors.push("Empty command found") + continue + } + + // Check for valid command name (alphanumeric + common separators) + if (!/^[a-zA-Z0-9_.\+-]+$/.test(command)) { + errors.push(`"${command}" - must be a simple command name`) + continue + } + } + + if (errors.length > 0) { + console.error("āŒ Invalid whitelisted commands found:") + for (const error of errors) { + console.error(` • ${error}`) + } + console.error("") + return false + } + + return true +} + +/** + * Rebuild sudoers file from security configuration + * Creates rules for: + * 1. Main user → restricted user (base rule for command execution) + * 2. Restricted user → main user for each whitelisted command + */ +export async function rebuildSudoersFile(config: SecurityConfig): Promise { + const rules: string[] = [] + + // Base rule: Allow main user to execute commands as restricted user + rules.push(`${config.mainUser} ALL=(${config.restrictedUser}) NOPASSWD: ${PLATFORM().SHELL}`) + + // Whitelisted command rules: Allow restricted user to run whitelisted commands as main user + for (const command of config.whitelistedCommands) { + const binaryPath = await detectBinaryPath(command) + + if (!binaryPath) { + console.warn(` āš ļø Command '${command}' not found in PATH. Skipping sudoers rule.`) + console.warn(` Install ${command} and run 'opencode protect lock' again to enable.`) + continue + } + + rules.push(`${config.restrictedUser} ALL=(${config.mainUser}) NOPASSWD: ${binaryPath}`) + } + + // Write sudoers file using Bun.write + const sudoersContent = rules.join("\n") + const userTempFile = `/tmp/opencode-sudoers-${Date.now()}.tmp` + const sudoersTempFile = `${SUDOERS_FILE_PATH}.tmp` + + // Write to temp file in /tmp (no sudo needed) + await Bun.write(userTempFile, sudoersContent) + + // Install to /etc/sudoers.d/ for validation (requires sudo, sets root ownership) + const installToValidateResult = await runSudoCommand( + `install -o root -g wheel -m 440 ${userTempFile} ${sudoersTempFile}`, + ) + if (installToValidateResult.exitCode !== 0) { + await runSudoCommand(`rm -f ${userTempFile}`) + throw new Error(`Failed to write sudoers file: ${installToValidateResult.stderr}`) + } + + // Validate syntax with visudo + const validateResult = await runSudoCommand(`visudo -c -f ${sudoersTempFile}`) + if (validateResult.exitCode !== 0) { + await runSudoCommand(`rm -f ${sudoersTempFile}`) + throw new Error(`Sudoers syntax validation failed: ${validateResult.stderr}`) + } + + // Install temp file to actual location (sets root ownership + permissions) + const installResult = await runSudoCommand(`install -o root -g wheel -m 440 ${sudoersTempFile} ${SUDOERS_FILE_PATH}`) + if (installResult.exitCode !== 0) { + await runSudoCommand(`rm -f ${sudoersTempFile}`) + throw new Error(`Failed to update sudoers file: ${installResult.stderr}`) + } + + // Clean up temp files on success + await runSudoCommand(`rm -f ${userTempFile} ${sudoersTempFile}`) +}