chore: initialize qiming workspace repository
This commit is contained in:
195
qimingcode/packages/opencode/src/sandbox/bash-helper.ts
Normal file
195
qimingcode/packages/opencode/src/sandbox/bash-helper.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import type { SandboxPolicy } from "./policy"
|
||||
import { resolveWritableRoots } from "./path"
|
||||
|
||||
const SAFE_ENV_KEYS = [
|
||||
"PATH",
|
||||
"Path",
|
||||
"SYSTEMROOT",
|
||||
"SystemRoot",
|
||||
"WINDIR",
|
||||
"windir",
|
||||
"SYSTEMDRIVE",
|
||||
"SystemDrive",
|
||||
"COMSPEC",
|
||||
"ComSpec",
|
||||
"PATHEXT",
|
||||
"PATHExt",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"USERPROFILE",
|
||||
"HOME",
|
||||
"LOCALAPPDATA",
|
||||
"APPDATA",
|
||||
"COMPUTERNAME",
|
||||
"USERNAME",
|
||||
"OS",
|
||||
"PROCESSOR_ARCHITECTURE",
|
||||
"LANG",
|
||||
"TZ",
|
||||
] as const
|
||||
|
||||
function buildHelperEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const key of SAFE_ENV_KEYS) {
|
||||
const value = baseEnv[key]
|
||||
if (value !== undefined && value !== null) {
|
||||
env[key] = String(value)
|
||||
}
|
||||
}
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
return env
|
||||
}
|
||||
|
||||
function resolveHelperShell(shellPath: string): { cmd: string; args: string[] } {
|
||||
const gitBash = Shell.gitbash()
|
||||
if (gitBash && Shell.posix(shellPath)) {
|
||||
return { cmd: gitBash, args: ["-c"] }
|
||||
}
|
||||
return {
|
||||
cmd: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||||
args: ["-NoProfile", "-NonInteractive", "-Command"],
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSandboxCwd(requestedCwd: string, policy: SandboxPolicy): string {
|
||||
const winMode = policy.sandbox.mode ?? "workspace-write"
|
||||
if (winMode !== "workspace-write") return path.resolve(requestedCwd)
|
||||
|
||||
const roots = resolveWritableRoots(policy.sandbox, policy.instanceDirectory)
|
||||
const resolved = path.resolve(requestedCwd)
|
||||
if (roots.some((root) => {
|
||||
const rel = path.relative(root, resolved)
|
||||
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))
|
||||
})) {
|
||||
return resolved
|
||||
}
|
||||
return roots[0] ?? resolved
|
||||
}
|
||||
|
||||
export type SandboxBashResult = {
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
timedOut: boolean
|
||||
}
|
||||
|
||||
export const runBashViaSandboxHelper = Effect.fn("Sandbox.runBashViaSandboxHelper")(function* (input: {
|
||||
policy: SandboxPolicy
|
||||
shell: string
|
||||
command: string
|
||||
cwd: string
|
||||
timeout: number
|
||||
}) {
|
||||
const helperPath = input.policy.sandbox.helper_path
|
||||
if (!helperPath || !fs.existsSync(helperPath)) {
|
||||
return yield* Effect.fail(new Error(`Sandbox helper not found: ${helperPath ?? "(unset)"}`))
|
||||
}
|
||||
|
||||
const winMode = input.policy.sandbox.mode ?? "workspace-write"
|
||||
const networkEnabled = input.policy.sandbox.network_enabled ?? true
|
||||
const writableRoots = resolveWritableRoots(input.policy.sandbox, input.policy.instanceDirectory)
|
||||
const sandboxCwd = resolveSandboxCwd(input.cwd, input.policy)
|
||||
|
||||
const policyJson = {
|
||||
type: winMode === "workspace-write" ? "workspace-write" : "read-only",
|
||||
network_access: networkEnabled,
|
||||
sandbox_mode: input.policy.sandbox.sandbox_mode,
|
||||
...(writableRoots.length > 0 ? { writable_roots: writableRoots } : {}),
|
||||
}
|
||||
|
||||
const shellWrap = resolveHelperShell(input.shell)
|
||||
const helperArgs = [
|
||||
"run",
|
||||
"--mode",
|
||||
winMode,
|
||||
"--cwd",
|
||||
sandboxCwd,
|
||||
"--policy-json",
|
||||
JSON.stringify(policyJson),
|
||||
"--",
|
||||
shellWrap.cmd,
|
||||
...shellWrap.args,
|
||||
input.command,
|
||||
]
|
||||
|
||||
const env = buildHelperEnv(process.env)
|
||||
const cappedTimeout = Math.min(input.timeout > 0 ? input.timeout : 120_000, 120_000)
|
||||
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
new Promise<SandboxBashResult>((resolve, reject) => {
|
||||
const child = spawn(helperPath, helperArgs, {
|
||||
cwd: sandboxCwd,
|
||||
env,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
if (cappedTimeout > 0) {
|
||||
timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL")
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, cappedTimeout)
|
||||
}
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString()
|
||||
})
|
||||
|
||||
child.on("error", (err) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
exit_code?: number
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
timed_out?: boolean
|
||||
}
|
||||
resolve({
|
||||
exitCode: parsed.exit_code ?? code ?? 1,
|
||||
stdout: parsed.stdout ?? "",
|
||||
stderr: parsed.stderr ?? "",
|
||||
timedOut: parsed.timed_out ?? signal === "SIGKILL",
|
||||
})
|
||||
} catch {
|
||||
resolve({
|
||||
exitCode: code ?? 1,
|
||||
stdout,
|
||||
stderr,
|
||||
timedOut: signal === "SIGKILL",
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
})
|
||||
})
|
||||
|
||||
export function shouldUseSandboxHelper(policy: SandboxPolicy | null): policy is SandboxPolicy {
|
||||
return (
|
||||
policy !== null &&
|
||||
process.platform === "win32" &&
|
||||
!!policy.sandbox.helper_path &&
|
||||
fs.existsSync(policy.sandbox.helper_path)
|
||||
)
|
||||
}
|
||||
18
qimingcode/packages/opencode/src/sandbox/env.ts
Normal file
18
qimingcode/packages/opencode/src/sandbox/env.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Log } from "@/util"
|
||||
import type { Info as SandboxInfo } from "@/config/sandbox"
|
||||
|
||||
const log = Log.create({ service: "sandbox-env" })
|
||||
|
||||
/** Parse `QIMING_AGENT_SANDBOX_CONFIG` injected by qiming-agent (spawn / config merge). */
|
||||
export function parseQimingAgentSandboxConfig(): SandboxInfo | undefined {
|
||||
const raw = process.env.QIMING_AGENT_SANDBOX_CONFIG?.trim()
|
||||
if (!raw) return undefined
|
||||
try {
|
||||
return JSON.parse(raw) as SandboxInfo
|
||||
} catch (error) {
|
||||
log.warn("invalid QIMING_AGENT_SANDBOX_CONFIG", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
11
qimingcode/packages/opencode/src/sandbox/error.ts
Normal file
11
qimingcode/packages/opencode/src/sandbox/error.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export class SandboxPathError extends Error {
|
||||
readonly filepath: string
|
||||
readonly writableRoots: string[]
|
||||
|
||||
constructor(filepath: string, writableRoots: string[]) {
|
||||
super(`Path outside writable roots: ${filepath}`)
|
||||
this.name = "SandboxPathError"
|
||||
this.filepath = filepath
|
||||
this.writableRoots = writableRoots
|
||||
}
|
||||
}
|
||||
7
qimingcode/packages/opencode/src/sandbox/index.ts
Normal file
7
qimingcode/packages/opencode/src/sandbox/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./path"
|
||||
export * from "./error"
|
||||
export * from "./env"
|
||||
export * from "./policy"
|
||||
export * from "./policy-sync"
|
||||
export * from "./bash-helper"
|
||||
export * from "./tool-guard"
|
||||
88
qimingcode/packages/opencode/src/sandbox/path.ts
Normal file
88
qimingcode/packages/opencode/src/sandbox/path.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import path from "path"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import type { Info as SandboxInfo } from "@/config/sandbox"
|
||||
|
||||
export function isWithinRoot(candidate: string, root: string): boolean {
|
||||
const resolvedCandidate = path.resolve(candidate)
|
||||
const resolvedRoot = path.resolve(root)
|
||||
const rel = path.relative(resolvedRoot, resolvedCandidate)
|
||||
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))
|
||||
}
|
||||
|
||||
export function normalizeSandboxPath(filepath: string): string {
|
||||
const resolved = path.resolve(filepath)
|
||||
return process.platform === "win32" ? AppFileSystem.normalizePath(resolved) : resolved
|
||||
}
|
||||
|
||||
export function collectTempDirs(): string[] {
|
||||
const dirs: string[] = []
|
||||
for (const key of ["TMP", "TEMP", "TMPDIR"] as const) {
|
||||
const value = process.env[key]
|
||||
if (value) dirs.push(path.resolve(value))
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
export function collectCompatAppDataDirs(): string[] {
|
||||
if (process.platform !== "win32") return []
|
||||
const dirs: string[] = []
|
||||
for (const key of ["APPDATA", "LOCALAPPDATA"] as const) {
|
||||
const value = process.env[key]
|
||||
if (value) dirs.push(path.resolve(value))
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
/** Pick the most specific (deepest) directory when one path contains the other. */
|
||||
export function pickNarrowestDirectory(...candidates: string[]): string {
|
||||
const resolved = [...new Set(candidates.filter(Boolean).map((p) => path.resolve(p)))]
|
||||
if (resolved.length === 0) return path.resolve(".")
|
||||
let narrowest = resolved[0]!
|
||||
for (const candidate of resolved.slice(1)) {
|
||||
if (isWithinRoot(candidate, narrowest)) {
|
||||
narrowest = candidate
|
||||
} else if (!isWithinRoot(narrowest, candidate)) {
|
||||
// Unrelated paths — keep the first candidate (HTTP ?directory= wins over stale DB).
|
||||
continue
|
||||
}
|
||||
}
|
||||
return narrowest
|
||||
}
|
||||
|
||||
export function resolveWritableRoots(sandbox: SandboxInfo, instanceDirectory: string): string[] {
|
||||
const mode = sandbox.sandbox_mode
|
||||
if (mode === "permissive") return []
|
||||
|
||||
const roots = new Set<string>()
|
||||
// Strict: only the session/instance directory, never config writable_roots or broad temp.
|
||||
if (mode === "strict") {
|
||||
roots.add(path.resolve(instanceDirectory))
|
||||
} else {
|
||||
const configured = sandbox.writable_roots?.map((p) => path.resolve(p)) ?? []
|
||||
if (configured.length > 0) {
|
||||
for (const root of configured) roots.add(root)
|
||||
} else {
|
||||
roots.add(path.resolve(instanceDirectory))
|
||||
}
|
||||
}
|
||||
|
||||
if (mode !== "strict") {
|
||||
for (const temp of collectTempDirs()) {
|
||||
roots.add(temp)
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "compat") {
|
||||
for (const dir of collectCompatAppDataDirs()) {
|
||||
roots.add(dir)
|
||||
}
|
||||
}
|
||||
|
||||
return [...roots]
|
||||
}
|
||||
|
||||
export function isPathWritable(targetPath: string, roots: string[]): boolean {
|
||||
if (roots.length === 0) return true
|
||||
const normalized = normalizeSandboxPath(targetPath)
|
||||
return roots.some((root) => isWithinRoot(normalized, root))
|
||||
}
|
||||
23
qimingcode/packages/opencode/src/sandbox/policy-sync.ts
Normal file
23
qimingcode/packages/opencode/src/sandbox/policy-sync.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import path from "path"
|
||||
import { Instance } from "@/project/instance"
|
||||
import type { SandboxPolicy } from "./policy"
|
||||
import { parseQimingAgentSandboxConfig } from "./env"
|
||||
import { resolveWritableRoots } from "./path"
|
||||
|
||||
/**
|
||||
* Sync sandbox policy for bash helper routing (no Config/Session services).
|
||||
* Uses `QIMING_AGENT_SANDBOX_CONFIG` + current `Instance.directory` (session cwd).
|
||||
*/
|
||||
export function getSandboxPolicySync(): SandboxPolicy | null {
|
||||
const sandbox = parseQimingAgentSandboxConfig()
|
||||
if (!sandbox || sandbox.sandbox_mode === "permissive") {
|
||||
return null
|
||||
}
|
||||
const instanceDirectory = path.resolve(Instance.directory)
|
||||
const writableRoots = resolveWritableRoots(sandbox, instanceDirectory)
|
||||
return {
|
||||
sandbox,
|
||||
writableRoots,
|
||||
instanceDirectory,
|
||||
}
|
||||
}
|
||||
136
qimingcode/packages/opencode/src/sandbox/policy.ts
Normal file
136
qimingcode/packages/opencode/src/sandbox/policy.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import path from "path"
|
||||
|
||||
import { Effect } from "effect"
|
||||
|
||||
import { Config } from "@/config"
|
||||
|
||||
import type { Info as SandboxInfo } from "@/config/sandbox"
|
||||
|
||||
import { InstanceState } from "@/effect"
|
||||
|
||||
import { Log } from "@/util"
|
||||
|
||||
import { Session } from "@/session"
|
||||
|
||||
import type { SessionID } from "@/session/schema"
|
||||
|
||||
import { resolveWritableRoots, normalizeSandboxPath, isPathWritable, pickNarrowestDirectory } from "./path"
|
||||
|
||||
import { SandboxPathError } from "./error"
|
||||
|
||||
import { parseQimingAgentSandboxConfig } from "./env"
|
||||
|
||||
const log = Log.create({ service: "sandbox-policy" })
|
||||
|
||||
export type SandboxPolicy = {
|
||||
sandbox: SandboxInfo
|
||||
writableRoots: string[]
|
||||
instanceDirectory: string
|
||||
}
|
||||
|
||||
|
||||
export const getSandboxPolicy = Effect.fn("Sandbox.getSandboxPolicy")(function* (sessionID?: SessionID) {
|
||||
|
||||
const cfg = yield* Config.Service.use((svc) => svc.get())
|
||||
|
||||
const sandbox = parseQimingAgentSandboxConfig() ?? cfg.sandbox
|
||||
|
||||
if (!sandbox || sandbox.sandbox_mode === "permissive") {
|
||||
|
||||
return null
|
||||
|
||||
}
|
||||
|
||||
const ins = yield* InstanceState.context
|
||||
|
||||
const instanceDir = path.resolve(ins.directory)
|
||||
|
||||
let instanceDirectory = instanceDir
|
||||
|
||||
|
||||
|
||||
if (sessionID && sandbox.sandbox_mode === "strict") {
|
||||
|
||||
const sessions = yield* Session.Service
|
||||
|
||||
const session = yield* sessions.get(sessionID)
|
||||
|
||||
const sessionDir = path.resolve(session.directory)
|
||||
|
||||
instanceDirectory = pickNarrowestDirectory(instanceDir, sessionDir)
|
||||
|
||||
} else if (sessionID) {
|
||||
|
||||
const sessions = yield* Session.Service
|
||||
|
||||
const session = yield* sessions.get(sessionID)
|
||||
|
||||
instanceDirectory = path.resolve(session.directory)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const writableRoots = resolveWritableRoots(sandbox, instanceDirectory)
|
||||
|
||||
return {
|
||||
|
||||
sandbox,
|
||||
|
||||
writableRoots,
|
||||
|
||||
instanceDirectory,
|
||||
|
||||
} satisfies SandboxPolicy
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
export const assertSandboxWritable = Effect.fn("Sandbox.assertSandboxWritable")(function* (
|
||||
|
||||
targetPath: string,
|
||||
|
||||
sessionID?: SessionID,
|
||||
|
||||
) {
|
||||
|
||||
const policy = yield* getSandboxPolicy(sessionID)
|
||||
|
||||
if (!policy) return
|
||||
|
||||
|
||||
|
||||
const resolved =
|
||||
|
||||
path.isAbsolute(targetPath) || (process.platform === "win32" && /^[a-zA-Z]:[\\/]/.test(targetPath))
|
||||
|
||||
? normalizeSandboxPath(targetPath)
|
||||
|
||||
: normalizeSandboxPath(path.join(policy.instanceDirectory, targetPath))
|
||||
|
||||
|
||||
|
||||
if (!isPathWritable(resolved, policy.writableRoots)) {
|
||||
|
||||
log.warn("sandbox write blocked", {
|
||||
|
||||
target: resolved,
|
||||
|
||||
sessionID,
|
||||
|
||||
instanceDirectory: policy.instanceDirectory,
|
||||
|
||||
writableRoots: policy.writableRoots,
|
||||
|
||||
sandbox_mode: policy.sandbox.sandbox_mode,
|
||||
|
||||
})
|
||||
|
||||
return yield* Effect.fail(new SandboxPathError(resolved, policy.writableRoots))
|
||||
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
|
||||
42
qimingcode/packages/opencode/src/sandbox/tool-guard.ts
Normal file
42
qimingcode/packages/opencode/src/sandbox/tool-guard.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import type { Info as SandboxInfo } from "@/config/sandbox"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { SandboxPathError } from "./error"
|
||||
import { parseQimingAgentSandboxConfig } from "./env"
|
||||
import { isPathWritable, normalizeSandboxPath, resolveWritableRoots } from "./path"
|
||||
|
||||
function resolveTargetPath(targetPath: string, instanceDirectory: string): string {
|
||||
if (
|
||||
path.isAbsolute(targetPath) ||
|
||||
(process.platform === "win32" && /^[a-zA-Z]:[\\/]/.test(targetPath))
|
||||
) {
|
||||
return normalizeSandboxPath(targetPath)
|
||||
}
|
||||
return normalizeSandboxPath(path.join(instanceDirectory, targetPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous write guard for built-in tools (edit/write/apply_patch).
|
||||
* Uses `QIMING_AGENT_SANDBOX_CONFIG` + `Instance.directory` (session cwd) — no Effect services.
|
||||
*/
|
||||
export function assertSandboxWritableInTool(targetPath: string, sandbox?: SandboxInfo): void {
|
||||
const active = sandbox ?? parseQimingAgentSandboxConfig()
|
||||
if (!active || active.sandbox_mode === "permissive") return
|
||||
|
||||
const instanceDirectory = path.resolve(Instance.directory)
|
||||
const writableRoots = resolveWritableRoots(active, instanceDirectory)
|
||||
const resolved = resolveTargetPath(targetPath, instanceDirectory)
|
||||
|
||||
if (!isPathWritable(resolved, writableRoots)) {
|
||||
throw new SandboxPathError(resolved, writableRoots)
|
||||
}
|
||||
}
|
||||
|
||||
/** Effect wrapper for built-in tool `execute` (maps to `Error`, no extra services). */
|
||||
export function assertSandboxWritableEffect(targetPath: string) {
|
||||
return Effect.try({
|
||||
try: () => assertSandboxWritableInTool(targetPath),
|
||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user