This commit is contained in:
Will Marella 2025-12-23 15:42:48 +08:00 committed by GitHub
commit 9695531561
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1059 additions and 2 deletions

View file

@ -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.

View file

@ -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") ||

View file

@ -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[] = ["<bash_metadata>"]
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("</bash_metadata>")
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[] = ["<bash_metadata>"]
let resultMetadata: string[] = ["<bash_metadata>"]
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) {

View file

@ -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<void> {
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")
}

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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
}
}

View file

@ -0,0 +1,79 @@
import { loadSecurityConfig } from "../config"
import { getFilePermissions } from "../files"
export async function showSecurityStatus(): Promise<void> {
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.")
}

View file

@ -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<SecurityConfig | null> {
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<void> {
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<void> {
await Bun.file(GLOBAL_CONFIG_PATH)
.unlink()
.catch(() => {})
}

View file

@ -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

View file

@ -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<string, string> {
const env: Record<string, string> = {}
// 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<ExecuteResult> {
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<void>((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<void>((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,
}
}
}

View file

@ -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<string | null> {
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<void> {
await chmod(filepath, 0o600)
}

View file

@ -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 <action>",
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
}
},
})

View file

@ -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<boolean> {
const result = await $`dscl . -read /Users/${username}`.quiet().nothrow()
return result.exitCode === 0
}
async createUser(username: string): Promise<UserCreationResult> {
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<void> {
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<void> {
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<number> {
// 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
}
}

View file

@ -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<boolean>
/** Create a restricted system user with appropriate UID and group */
createUser(username: string): Promise<UserCreationResult>
deleteUser(username: string): Promise<void>
/** Update user's primary group membership */
updateUserGroup(username: string): Promise<void>
}
export type SudoCommand = (cmd: string) => Promise<{ exitCode: number; stdout: string; stderr: string }>

View file

@ -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<void> {
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<string | null> {
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<void> {
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}`)
}