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}`)
+}