mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
Merge 5e1df65935 into 83397ebde2
This commit is contained in:
commit
9695531561
14 changed files with 1059 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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") ||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
112
packages/opencode/src/util/security/commands/lock.ts
Normal file
112
packages/opencode/src/util/security/commands/lock.ts
Normal 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")
|
||||
}
|
||||
117
packages/opencode/src/util/security/commands/setup.ts
Normal file
117
packages/opencode/src/util/security/commands/setup.ts
Normal 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
|
||||
}
|
||||
}
|
||||
79
packages/opencode/src/util/security/commands/status.ts
Normal file
79
packages/opencode/src/util/security/commands/status.ts
Normal 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.")
|
||||
}
|
||||
54
packages/opencode/src/util/security/config.ts
Normal file
54
packages/opencode/src/util/security/config.ts
Normal 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(() => {})
|
||||
}
|
||||
54
packages/opencode/src/util/security/constants.ts
Normal file
54
packages/opencode/src/util/security/constants.ts
Normal 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
|
||||
240
packages/opencode/src/util/security/executor.ts
Normal file
240
packages/opencode/src/util/security/executor.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/opencode/src/util/security/files.ts
Normal file
21
packages/opencode/src/util/security/files.ts
Normal 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)
|
||||
}
|
||||
31
packages/opencode/src/util/security/index.ts
Normal file
31
packages/opencode/src/util/security/index.ts
Normal 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
|
||||
}
|
||||
},
|
||||
})
|
||||
74
packages/opencode/src/util/security/platform/darwin.ts
Normal file
74
packages/opencode/src/util/security/platform/darwin.ts
Normal 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
|
||||
}
|
||||
}
|
||||
21
packages/opencode/src/util/security/platform/interface.ts
Normal file
21
packages/opencode/src/util/security/platform/interface.ts
Normal 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 }>
|
||||
176
packages/opencode/src/util/security/util.ts
Normal file
176
packages/opencode/src/util/security/util.ts
Normal 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}`)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue