chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,449 @@
/**
* 审计日志管理器
*
* 记录所有沙箱操作,用于安全审计和回溯
*
* @version 1.0.0
* @updated 2026-03-22
*/
import fs from "fs";
import path from "path";
import { EventEmitter } from "events";
import log from "electron-log";
/**
* 安全事件类型
*/
export type SecurityEventType =
| "path_blocked"
| "path_allowed"
| "command_blocked"
| "command_allowed"
| "permission_requested"
| "permission_approved"
| "permission_denied"
| "permission_auto_approved"
| "operation_executed"
| "operation_failed"
| "sandbox_created"
| "sandbox_destroyed";
/**
* 审计日志条目
*/
export interface AuditLogEntry {
id: string;
timestamp: string;
sessionId: string;
eventType: SecurityEventType;
operation?: string;
target?: string;
allowed: boolean;
reason?: string;
approvedBy?: "system" | "user";
duration?: number;
error?: string;
}
/**
* 安全指标
*/
export interface SecurityMetrics {
totalOperations: number;
blockedOperations: number;
allowedOperations: number;
userConfirmations: number;
autoApprovals: number;
pathViolations: number;
commandViolations: number;
}
/**
* 审计日志配置
*/
export interface AuditLoggerConfig {
logDir: string;
maxLogSize?: number; // 最大日志文件大小 (MB)
maxLogFiles?: number; // 最大保留日志文件数
enableConsole?: boolean; // 是否输出到控制台
}
/**
* 审计日志管理器
*/
export class AuditLogger extends EventEmitter {
private config: AuditLoggerConfig;
private logPath: string;
private metrics: SecurityMetrics;
private recentEvents: AuditLogEntry[] = [];
private maxRecentEvents = 100;
constructor(config: AuditLoggerConfig) {
super();
this.config = {
maxLogSize: 10, // 默认 10MB
maxLogFiles: 5, // 默认保留 5 个
enableConsole: true,
...config,
};
// 确保日志目录存在
if (!fs.existsSync(this.config.logDir)) {
fs.mkdirSync(this.config.logDir, { recursive: true });
}
const date = new Date().toISOString().split("T")[0];
this.logPath = path.join(this.config.logDir, `audit-${date}.jsonl`);
this.metrics = {
totalOperations: 0,
blockedOperations: 0,
allowedOperations: 0,
userConfirmations: 0,
autoApprovals: 0,
pathViolations: 0,
commandViolations: 0,
};
log.info("[AuditLogger] Initialized at:", this.logPath);
}
/**
* 记录安全事件
*/
logEvent(entry: Omit<AuditLogEntry, "id" | "timestamp">): string {
const id = this.generateId();
const fullEntry: AuditLogEntry = {
...entry,
id,
timestamp: new Date().toISOString(),
};
// 更新指标
this.updateMetrics(fullEntry);
// 保存到最近事件
this.recentEvents.push(fullEntry);
if (this.recentEvents.length > this.maxRecentEvents) {
this.recentEvents.shift();
}
// 写入日志文件
this.writeToFile(fullEntry);
// 发送事件
this.emit("event", fullEntry);
if (this.config.enableConsole) {
const status = fullEntry.allowed ? "✅" : "❌";
log.info(
`${status} [Audit] ${fullEntry.eventType}: ${fullEntry.target || fullEntry.operation || "N/A"}`,
);
}
return id;
}
/**
* 记录路径被阻止
*/
logPathBlocked(sessionId: string, target: string, reason: string): string {
return this.logEvent({
sessionId,
eventType: "path_blocked",
target,
allowed: false,
reason,
});
}
/**
* 记录命令被阻止
*/
logCommandBlocked(
sessionId: string,
command: string,
reason: string,
): string {
return this.logEvent({
sessionId,
eventType: "command_blocked",
operation: command,
allowed: false,
reason,
});
}
/**
* 记录权限请求
*/
logPermissionRequested(
sessionId: string,
operation: string,
target: string,
): string {
return this.logEvent({
sessionId,
eventType: "permission_requested",
operation,
target,
allowed: false,
});
}
/**
* 记录权限批准
*/
logPermissionApproved(
sessionId: string,
operation: string,
target: string,
approvedBy: "system" | "user",
): string {
return this.logEvent({
sessionId,
eventType:
approvedBy === "system"
? "permission_auto_approved"
: "permission_approved",
operation,
target,
allowed: true,
approvedBy,
});
}
/**
* 记录权限拒绝
*/
logPermissionDenied(
sessionId: string,
operation: string,
target: string,
reason?: string,
): string {
return this.logEvent({
sessionId,
eventType: "permission_denied",
operation,
target,
allowed: false,
reason,
});
}
/**
* 记录操作执行
*/
logOperationExecuted(
sessionId: string,
operation: string,
target: string,
duration?: number,
): string {
return this.logEvent({
sessionId,
eventType: "operation_executed",
operation,
target,
allowed: true,
duration,
});
}
/**
* 记录操作失败
*/
logOperationFailed(
sessionId: string,
operation: string,
target: string,
error: string,
): string {
return this.logEvent({
sessionId,
eventType: "operation_failed",
operation,
target,
allowed: false,
error,
});
}
/**
* 获取安全指标
*/
getMetrics(): SecurityMetrics {
return { ...this.metrics };
}
/**
* 获取最近事件
*/
getRecentEvents(limit?: number): AuditLogEntry[] {
if (limit) {
return this.recentEvents.slice(-limit);
}
return [...this.recentEvents];
}
/**
* 获取特定会话的事件
*/
getSessionEvents(sessionId: string): AuditLogEntry[] {
return this.recentEvents.filter((e) => e.sessionId === sessionId);
}
/**
* 导出日志到指定路径
*/
async exportLogs(
outputPath: string,
startDate?: Date,
endDate?: Date,
): Promise<number> {
const entries: AuditLogEntry[] = [];
// 读取今天和昨天的日志
const dates = this.getLogDates(startDate, endDate);
for (const date of dates) {
const filePath = path.join(this.config.logDir, `audit-${date}.jsonl`);
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split("\n").filter(Boolean);
for (const line of lines) {
try {
const entry = JSON.parse(line) as AuditLogEntry;
if (this.shouldInclude(entry, startDate, endDate)) {
entries.push(entry);
}
} catch {
// 忽略解析错误
}
}
}
}
// 写入导出文件
fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2));
log.info(
`[AuditLogger] Exported ${entries.length} entries to ${outputPath}`,
);
return entries.length;
}
/**
* 清理过期日志
*/
cleanup(): void {
if (!fs.existsSync(this.config.logDir)) return;
const files = fs
.readdirSync(this.config.logDir)
.filter((f) => f.startsWith("audit-") && f.endsWith(".jsonl"))
.sort()
.reverse();
// 删除超过保留数量的旧文件
const toDelete = files.slice(this.config.maxLogFiles!);
for (const file of toDelete) {
const filePath = path.join(this.config.logDir, file);
fs.unlinkSync(filePath);
log.info("[AuditLogger] Deleted old log:", file);
}
// 检查并轮转当前日志文件
this.rotateIfNeeded();
}
// =========================================================================
// 私有方法
// =========================================================================
private generateId(): string {
return `audit_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
private updateMetrics(entry: AuditLogEntry): void {
this.metrics.totalOperations++;
if (!entry.allowed) {
this.metrics.blockedOperations++;
} else {
this.metrics.allowedOperations++;
}
switch (entry.eventType) {
case "path_blocked":
this.metrics.pathViolations++;
break;
case "command_blocked":
this.metrics.commandViolations++;
break;
case "permission_approved":
this.metrics.userConfirmations++;
break;
case "permission_auto_approved":
this.metrics.autoApprovals++;
break;
}
}
private writeToFile(entry: AuditLogEntry): void {
try {
const line = JSON.stringify(entry) + "\n";
fs.appendFileSync(this.logPath, line, "utf-8");
} catch (error) {
log.error("[AuditLogger] Failed to write to file:", error);
}
}
private getLogDates(startDate?: Date, endDate?: Date): string[] {
const dates: string[] = [];
const now = new Date();
const start = startDate || new Date(now.getTime() - 24 * 60 * 60 * 1000);
const end = endDate || now;
let current = new Date(start);
while (current <= end) {
dates.push(current.toISOString().split("T")[0]);
current.setDate(current.getDate() + 1);
}
return dates;
}
private shouldInclude(
entry: AuditLogEntry,
startDate?: Date,
endDate?: Date,
): boolean {
const entryDate = new Date(entry.timestamp);
if (startDate && entryDate < startDate) return false;
if (endDate && entryDate > endDate) return false;
return true;
}
private rotateIfNeeded(): void {
if (!fs.existsSync(this.logPath)) return;
const stats = fs.statSync(this.logPath);
const maxSize = (this.config.maxLogSize || 10) * 1024 * 1024; // MB to bytes
if (stats.size > maxSize) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const rotatedPath = this.logPath.replace(".jsonl", `-${timestamp}.jsonl`);
fs.renameSync(this.logPath, rotatedPath);
log.info("[AuditLogger] Rotated log to:", rotatedPath);
}
}
}
export default AuditLogger;

View File

@@ -0,0 +1,441 @@
/**
* 命令沙箱实现(非 Docker
*
* 支持:
* - none直接执行
* - macos-seatbeltsandbox-exec
* - linux-bwrapbubblewrap
* - windows-sandboxWindows Sandbox helper
*
* 调用构建委托给 SandboxInvoker文件操作委托给 SandboxFileOperations。
*
* @version 2.0.0
* @updated 2026-04-03
*/
import { spawn } from "child_process";
import * as fsp from "fs/promises";
import * as path from "path";
import log from "electron-log";
import { SandboxManager } from "./SandboxManager";
import { SandboxInvoker } from "./SandboxInvoker";
import { SandboxFileOperations } from "./SandboxFileOperations";
import type {
CleanupResult,
ExecuteOptions,
ExecuteResult,
FileInfo,
SandboxConfig,
Workspace,
} from "@shared/types/sandbox";
import {
FileOperationError,
SandboxError,
SandboxErrorCode,
WorkspaceError,
toSandboxError,
} from "@shared/errors/sandbox";
// Re-export Invocation for backward compatibility
export type { Invocation } from "./SandboxInvoker";
/**
* 三端命令沙箱(不依赖容器)
*/
export class CommandSandbox extends SandboxManager {
private readonly invoker: SandboxInvoker;
private backendAvailable: boolean = false;
constructor(
config: SandboxConfig,
options: {
linuxBwrapPath?: string;
windowsSandboxHelperPath?: string;
windowsSandboxMode?: "read-only" | "workspace-write";
} = {},
) {
super(config);
this.invoker = new SandboxInvoker(config.type, {
linuxBwrapPath: options.linuxBwrapPath,
windowsSandboxHelperPath: options.windowsSandboxHelperPath,
windowsSandboxMode: options.windowsSandboxMode,
networkEnabled: config.networkEnabled,
mode: config.mode,
});
}
async init(): Promise<void> {
await fsp.mkdir(this.config.workspaceRoot, { recursive: true });
log.debug("[CommandSandbox] config:", {
type: this.config.type,
platform: this.config.platform,
enabled: this.config.enabled,
workspaceRoot: this.config.workspaceRoot,
});
this.backendAvailable = await this.invoker.checkAvailable();
if (!this.backendAvailable) {
throw new SandboxError(
`Sandbox backend unavailable: ${this.config.type}`,
SandboxErrorCode.SANDBOX_UNAVAILABLE,
);
}
this.initialized = true;
log.info("[CommandSandbox] initialized:", this.config.type);
}
async isAvailable(): Promise<boolean> {
this.backendAvailable = await this.invoker.checkAvailable();
return this.backendAvailable;
}
async createWorkspace(sessionId: string): Promise<Workspace> {
if (this.workspaces.has(sessionId)) {
throw new WorkspaceError(
`Workspace already exists: ${sessionId}`,
SandboxErrorCode.WORKSPACE_EXISTS,
{ sessionId },
);
}
const workspaceId = this.generateWorkspaceId(sessionId);
const workspaceRoot = path.join(this.config.workspaceRoot, workspaceId);
try {
await SandboxFileOperations.createWorkspaceDirectories(workspaceRoot);
const workspace: Workspace = {
id: workspaceId,
sessionId,
rootPath: workspaceRoot,
projectsPath: path.join(workspaceRoot, "projects"),
nodeModulesPath: path.join(workspaceRoot, "node_modules"),
pythonEnvPath: path.join(workspaceRoot, "python-env"),
binPath: path.join(workspaceRoot, "bin"),
cachePath: path.join(workspaceRoot, "cache"),
sandboxConfig: this.config,
createdAt: new Date(),
lastAccessedAt: new Date(),
retentionPolicy: this.createDefaultRetentionPolicy(),
status: "active",
};
this.workspaces.set(sessionId, workspace);
this.emitEvent("workspace:created", { workspace });
return workspace;
} catch (error) {
await SandboxFileOperations.cleanupWorkspaceDirectory(workspaceRoot);
throw toSandboxError(
error,
"Failed to create workspace",
SandboxErrorCode.WORKSPACE_CREATE_FAILED,
{ sessionId },
);
}
}
async destroyWorkspace(sessionId: string): Promise<void> {
const workspace = this.validateWorkspaceExists(sessionId);
workspace.status = "destroying";
try {
if (workspace.retentionPolicy.mode !== "always") {
await SandboxFileOperations.cleanupWorkspaceDirectory(
workspace.rootPath,
);
}
workspace.status = "destroyed";
this.workspaces.delete(sessionId);
this.emitEvent("workspace:destroyed", {
workspaceId: workspace.id,
sessionId,
});
} catch (error) {
workspace.status = "error";
throw toSandboxError(
error,
"Failed to destroy workspace",
SandboxErrorCode.WORKSPACE_DESTROY_FAILED,
{ sessionId, workspaceId: workspace.id },
);
}
}
async execute(
sessionId: string,
command: string,
args: string[] = [],
options: ExecuteOptions = {},
): Promise<ExecuteResult> {
const workspace = this.validateWorkspaceExists(sessionId);
const startTime = Date.now();
const timeout = options.timeout ?? 300000;
const cwd = this.resolveExecutionCwd(workspace, options.cwd);
log.info("[CommandSandbox] execute:", {
type: this.config.type,
sessionId,
command,
args,
cwd,
});
this.emitEvent("execute:start", { sessionId, command, args });
try {
const invocation = await this.invoker.buildInvocation({
command,
args,
cwd,
env: options.env,
writablePaths: [workspace.rootPath],
networkEnabled: this.config.networkEnabled !== false,
subcommand: "run",
startupExecAllowlist: [command],
});
const run = await this.runInvocation(invocation, timeout);
const result: ExecuteResult = {
stdout: run.stdout,
stderr: run.stderr,
exitCode: run.exitCode,
timedOut: run.timedOut,
duration: Date.now() - startTime,
command,
args,
};
this.updateLastAccessed(sessionId);
this.emitEvent("execute:complete", { sessionId, result });
return result;
} catch (error) {
this.emitEvent("execute:error", {
sessionId,
command,
error: error instanceof Error ? error.message : String(error),
});
throw toSandboxError(
error,
"Command execution failed",
SandboxErrorCode.EXECUTION_FAILED,
{ sessionId },
);
}
}
async readFile(sessionId: string, filePath: string): Promise<string> {
const workspace = this.validateWorkspaceExists(sessionId);
this.validatePathInWorkspace(workspace, filePath);
try {
const content = await SandboxFileOperations.readFileContent(filePath);
this.updateLastAccessed(sessionId);
return content;
} catch (error) {
// SandboxFileOperations 已经抛出 FileOperationError直接透传
if (error instanceof FileOperationError) throw error;
throw new FileOperationError(
`File read failed: ${filePath}`,
SandboxErrorCode.FILE_READ_FAILED,
{ sessionId, cause: error as Error },
);
}
}
async writeFile(
sessionId: string,
filePath: string,
content: string,
): Promise<void> {
const workspace = this.validateWorkspaceExists(sessionId);
this.validatePathInWorkspace(workspace, filePath);
try {
await SandboxFileOperations.writeFileContent(filePath, content);
this.updateLastAccessed(sessionId);
} catch (error) {
throw new FileOperationError(
`File write failed: ${filePath}`,
SandboxErrorCode.FILE_WRITE_FAILED,
{ sessionId, cause: error as Error },
);
}
}
async readDir(sessionId: string, dirPath: string): Promise<FileInfo[]> {
const workspace = this.validateWorkspaceExists(sessionId);
this.validatePathInWorkspace(workspace, dirPath);
try {
const infos = await SandboxFileOperations.readDirectoryEntries(dirPath);
this.updateLastAccessed(sessionId);
return infos;
} catch (error) {
throw new FileOperationError(
`Directory read failed: ${dirPath}`,
SandboxErrorCode.DIRECTORY_OPERATION_FAILED,
{ sessionId, cause: error as Error },
);
}
}
async deleteFile(sessionId: string, filePath: string): Promise<void> {
const workspace = this.validateWorkspaceExists(sessionId);
this.validatePathInWorkspace(workspace, filePath);
try {
await SandboxFileOperations.deletePath(filePath);
this.updateLastAccessed(sessionId);
} catch (error) {
throw new FileOperationError(
`File delete failed: ${filePath}`,
SandboxErrorCode.FILE_DELETE_FAILED,
{ sessionId, cause: error as Error },
);
}
}
async cleanup(): Promise<CleanupResult> {
const result: CleanupResult = {
deletedCount: 0,
freedSpace: 0,
errors: [],
};
for (const workspace of this.listWorkspaces()) {
try {
result.freedSpace += await SandboxFileOperations.getDirectorySize(
workspace.rootPath,
);
await this.destroyWorkspace(workspace.sessionId);
result.deletedCount += 1;
} catch (error) {
result.errors.push(
`cleanup failed (${workspace.sessionId}): ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
return result;
}
/**
* 为长运行进程构建沙箱包装调用(不执行 spawn
*
* 与 execute() 不同,此方法不依赖 Workspace 对象,
* 而是接受显式的可写路径列表,适用于 ACP 引擎进程级沙箱化。
*
* @returns Invocation 对象,调用方自行 spawn
*/
async buildProcessInvocation(
command: string,
args: string[],
cwd: string,
options: {
env?: Record<string, string>;
writablePaths: string[];
networkEnabled: boolean;
},
): Promise<import("./SandboxInvoker").Invocation> {
return this.invoker.buildInvocation({
command,
args,
cwd,
env: options.env,
writablePaths: options.writablePaths,
networkEnabled: options.networkEnabled,
subcommand: "serve",
});
}
/**
* 执行沙箱包装后的命令(通用 spawn+capture
*/
private async runInvocation(
invocation: import("./SandboxInvoker").Invocation,
timeout: number,
): Promise<{
stdout: string;
stderr: string;
exitCode: number;
timedOut: boolean;
}> {
return new Promise((resolve, reject) => {
const proc = spawn(invocation.command, invocation.args, {
cwd: invocation.cwd,
env: {
...process.env,
...(invocation.env ?? {}),
},
shell: false,
windowsHide: true,
});
let stdout = "";
let stderr = "";
let timedOut = false;
proc.stdout?.on("data", (data: Buffer) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data: Buffer) => {
stderr += data.toString();
});
const timer = setTimeout(() => {
timedOut = true;
proc.kill("SIGKILL");
}, timeout);
proc.on("close", (code) => {
clearTimeout(timer);
// Windows Sandbox helper returns JSON: { exit_code, stdout, stderr, timed_out }
if (invocation.parseJson) {
try {
const parsed = JSON.parse(stdout) as {
exit_code: number;
stdout: string;
stderr: string;
timed_out: boolean;
};
resolve({
stdout: parsed.stdout,
stderr: parsed.stderr,
exitCode: parsed.exit_code,
timedOut: parsed.timed_out,
});
} catch {
resolve({
stdout,
stderr,
exitCode: code ?? (timedOut ? 137 : 1),
timedOut,
});
}
} else {
resolve({
stdout,
stderr,
exitCode: code ?? (timedOut ? 137 : 1),
timedOut,
});
}
});
proc.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
}
private resolveExecutionCwd(workspace: Workspace, cwd?: string): string {
const base = workspace.projectsPath;
const resolved = cwd
? path.isAbsolute(cwd)
? path.resolve(cwd)
: path.resolve(base, cwd)
: base;
this.validatePathInWorkspace(workspace, resolved);
return resolved;
}
}
export default CommandSandbox;

View File

@@ -0,0 +1,926 @@
/**
* Docker 沙箱实现
*
* 使用 Docker 容器提供隔离的执行环境
*
* @version 1.0.0
* @updated 2026-03-22
*/
import { exec, spawn } from "child_process";
import { promisify } from "util";
import * as path from "path";
import * as fs from "fs/promises";
import * as os from "os";
import log from "electron-log";
import { SandboxManager } from "./SandboxManager";
import type {
SandboxConfig,
Workspace,
ExecuteOptions,
ExecuteResult,
FileInfo,
SandboxStatus,
CleanupResult,
ContainerInfo,
} from "@shared/types/sandbox";
import {
SandboxError,
SandboxErrorCode,
ExecutionError,
FileOperationError,
WorkspaceError,
toSandboxError,
} from "@shared/errors/sandbox";
const execAsync = promisify(exec);
/**
* Docker 沙箱配置
*/
export interface DockerSandboxConfig extends SandboxConfig {
/** Docker 镜像名称 */
dockerImage: string;
/** Docker Host 地址(可选) */
dockerHost?: string;
}
/**
* Docker 沙箱实现
*/
export class DockerSandbox extends SandboxManager {
private containerIds: Map<string, string> = new Map();
private dockerConfig: DockerSandboxConfig;
private dockerAvailable: boolean = false;
private dockerVersion: string | null = null;
constructor(config: DockerSandboxConfig) {
super(config);
this.dockerConfig = config;
// 验证配置
if (config.type !== "docker") {
throw new SandboxError(
'DockerSandbox requires type to be "docker"',
SandboxErrorCode.CONFIG_INVALID,
);
}
}
// ============================================================================
// 初始化与可用性检查
// ============================================================================
async init(): Promise<void> {
log.info("[DockerSandbox] Initializing Docker sandbox...");
log.debug("[DockerSandbox] config:", {
type: this.config.type,
platform: this.config.platform,
enabled: this.config.enabled,
workspaceRoot: this.config.workspaceRoot,
memoryLimit: this.config.memoryLimit,
cpuLimit: this.config.cpuLimit,
networkEnabled: this.config.networkEnabled,
dockerImage: this.dockerConfig.dockerImage,
dockerHost: this.dockerConfig.dockerHost,
});
try {
// 检查 Docker 是否可用
this.dockerAvailable = await this.checkDockerAvailable();
if (!this.dockerAvailable) {
throw new SandboxError(
"Docker is not available; ensure Docker Desktop is installed and running",
SandboxErrorCode.DOCKER_UNAVAILABLE,
);
}
// 拉取镜像(如果需要)
await this.ensureImageExists();
this.initialized = true;
log.info("[DockerSandbox] Initialization complete");
} catch (error) {
log.error("[DockerSandbox] Initialization failed:", error);
throw toSandboxError(
error,
"Docker sandbox initialization failed",
SandboxErrorCode.SANDBOX_UNAVAILABLE,
);
}
}
async isAvailable(): Promise<boolean> {
if (!this.dockerAvailable) {
this.dockerAvailable = await this.checkDockerAvailable();
}
return this.dockerAvailable;
}
/**
* 检查 Docker 是否可用
*/
private async checkDockerAvailable(): Promise<boolean> {
try {
const { stdout: versionOut } = await execAsync("docker --version", {
timeout: 5000,
});
// 提取版本号
const match = versionOut.match(/Docker version ([\d.]+)/);
this.dockerVersion = match ? match[1] : versionOut.trim();
log.debug("[DockerSandbox] docker --version:", versionOut.trim());
// 检查 Docker daemon 是否运行
const { stdout: infoOut } = await execAsync("docker info", {
timeout: 5000,
});
log.debug(
"[DockerSandbox] docker info (first 500 chars):",
infoOut.slice(0, 500),
);
log.info(
"[DockerSandbox] Docker available, version:",
this.dockerVersion,
);
return true;
} catch (error) {
log.warn("[DockerSandbox] Docker unavailable:", error);
return false;
}
}
/**
* 验证 Docker 镜像名称
* 只允许字母、数字、冒号、斜杠、点、下划线和连字符
*/
private validateImageName(image: string): void {
if (!/^[a-zA-Z0-9_/.:-]+$/.test(image)) {
throw new SandboxError(
`Invalid Docker image name: ${image}`,
SandboxErrorCode.CONFIG_INVALID,
);
}
}
/**
* 验证容器 ID
* 只允许字母、数字和连字符
*/
private validateContainerId(containerId: string): void {
if (!/^[a-zA-Z0-9-]+$/.test(containerId)) {
throw new SandboxError(
`Invalid container ID: ${containerId}`,
SandboxErrorCode.CONTAINER_OPERATION_FAILED,
);
}
}
/**
* 确保镜像存在
*/
private async ensureImageExists(): Promise<void> {
const image = this.dockerConfig.dockerImage;
this.validateImageName(image);
try {
// 检查镜像是否存在
await execAsync(`docker image inspect ${image}`, { timeout: 10000 });
log.info("[DockerSandbox] Image already exists:", image);
} catch {
// 镜像不存在,尝试拉取
log.info("[DockerSandbox] Pulling image:", image);
await execAsync(`docker pull ${image}`, { timeout: 300000 }); // 5 分钟超时
log.info("[DockerSandbox] Image pull complete:", image);
}
}
// ============================================================================
// 工作区管理
// ============================================================================
async createWorkspace(sessionId: string): Promise<Workspace> {
log.info("[DockerSandbox] Creating workspace:", sessionId);
// 检查是否已存在
if (this.workspaces.has(sessionId)) {
throw new WorkspaceError(
`Workspace already exists: ${sessionId}`,
SandboxErrorCode.WORKSPACE_EXISTS,
{ sessionId },
);
}
const workspaceId = this.generateWorkspaceId(sessionId);
const workspaceRoot = path.join(this.config.workspaceRoot, workspaceId);
try {
// 创建工作区目录
await this.createWorkspaceDirectories(workspaceRoot);
// 启动 Docker 容器
const containerId = await this.startContainer(sessionId, workspaceRoot);
this.containerIds.set(sessionId, containerId);
// 创建工作区对象
const workspace: Workspace = {
id: workspaceId,
sessionId,
rootPath: workspaceRoot,
projectsPath: path.join(workspaceRoot, "projects"),
nodeModulesPath: path.join(workspaceRoot, "node_modules"),
pythonEnvPath: path.join(workspaceRoot, "python-env"),
binPath: path.join(workspaceRoot, "bin"),
cachePath: path.join(workspaceRoot, "cache"),
sandboxConfig: this.config,
createdAt: new Date(),
lastAccessedAt: new Date(),
retentionPolicy: this.createDefaultRetentionPolicy(),
status: "active",
};
this.workspaces.set(sessionId, workspace);
// 发出事件
this.emitEvent("workspace:created", { workspace });
log.info(
"[DockerSandbox] Workspace created:",
sessionId,
"container:",
containerId,
);
return workspace;
} catch (error) {
// 清理已创建的资源
await this.cleanupFailedWorkspace(sessionId, workspaceRoot);
throw toSandboxError(
error,
"Failed to create workspace",
SandboxErrorCode.WORKSPACE_CREATE_FAILED,
{
sessionId,
},
);
}
}
async destroyWorkspace(sessionId: string): Promise<void> {
log.info("[DockerSandbox] Destroying workspace:", sessionId);
const workspace = this.validateWorkspaceExists(sessionId);
try {
workspace.status = "destroying";
// 停止并删除容器
const containerId = this.containerIds.get(sessionId);
if (containerId) {
await this.stopContainer(containerId);
this.containerIds.delete(sessionId);
}
// 删除工作区目录(根据保留策略)
if (workspace.retentionPolicy.mode !== "always") {
await this.deleteWorkspaceDirectory(workspace.rootPath);
}
// 从映射中移除
this.workspaces.delete(sessionId);
// 发出事件
this.emitEvent("workspace:destroyed", {
workspaceId: workspace.id,
sessionId,
});
log.info("[DockerSandbox] Workspace destruction complete:", sessionId);
} catch (error) {
workspace.status = "error";
throw toSandboxError(
error,
"Failed to destroy workspace",
SandboxErrorCode.WORKSPACE_DESTROY_FAILED,
{
sessionId,
workspaceId: workspace.id,
},
);
}
}
// ============================================================================
// 命令执行
// ============================================================================
async execute(
sessionId: string,
command: string,
args: string[] = [],
options: ExecuteOptions = {},
): Promise<ExecuteResult> {
const workspace = this.validateWorkspaceExists(sessionId);
this.updateLastAccessed(sessionId);
const containerId = this.containerIds.get(sessionId);
if (!containerId) {
throw new ExecutionError(
"Container is not running",
SandboxErrorCode.CONTAINER_OPERATION_FAILED,
{
sessionId,
},
);
}
const startTime = Date.now();
const timeout = options.timeout || 300000; // 默认 5 分钟
log.info(
"[DockerSandbox] Executing command:",
sessionId,
command,
args.join(" "),
);
log.debug("[DockerSandbox] execute detail:", {
sessionId,
command,
args,
timeout,
cwd: options.cwd,
envKeys: options.env ? Object.keys(options.env) : [],
});
// 发出执行开始事件
this.emitEvent("execute:start", { sessionId, command, args });
try {
// 构建 docker exec 命令
const dockerArgs = this.buildDockerExecArgs(
containerId,
command,
args,
options,
);
const result = await this.executeDockerCommand(dockerArgs, timeout);
const executeResult: ExecuteResult = {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
timedOut: result.timedOut,
duration: Date.now() - startTime,
command,
args,
};
// 发出执行完成事件
this.emitEvent("execute:complete", { sessionId, result: executeResult });
return executeResult;
} catch (error) {
const executeResult: ExecuteResult = {
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
exitCode: 1,
timedOut: false,
duration: Date.now() - startTime,
command,
args,
};
this.emitEvent("execute:error", {
sessionId,
error: error instanceof Error ? error.message : String(error),
command,
});
throw toSandboxError(
error,
"Command execution failed",
SandboxErrorCode.EXECUTION_FAILED,
{
sessionId,
},
);
}
}
// ============================================================================
// 文件操作
// ============================================================================
async readFile(sessionId: string, filePath: string): Promise<string> {
const workspace = this.validateWorkspaceExists(sessionId);
this.validatePathInWorkspace(workspace, filePath);
try {
// 直接从主机文件系统读取(因为目录已挂载)
const content = await fs.readFile(filePath, "utf-8");
this.updateLastAccessed(sessionId);
return content;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new FileOperationError(
`File not found: ${filePath}`,
SandboxErrorCode.FILE_NOT_FOUND,
{
sessionId,
cause: error as Error,
},
);
}
throw new FileOperationError(
`File read failed: ${filePath}`,
SandboxErrorCode.FILE_READ_FAILED,
{
sessionId,
cause: error as Error,
},
);
}
}
async writeFile(
sessionId: string,
filePath: string,
content: string,
): Promise<void> {
const workspace = this.validateWorkspaceExists(sessionId);
this.validatePathInWorkspace(workspace, filePath);
try {
// 确保目录存在
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
// 写入文件
await fs.writeFile(filePath, content, "utf-8");
this.updateLastAccessed(sessionId);
} catch (error) {
throw new FileOperationError(
`File write failed: ${filePath}`,
SandboxErrorCode.FILE_WRITE_FAILED,
{ sessionId, cause: error as Error },
);
}
}
async readDir(sessionId: string, dirPath: string): Promise<FileInfo[]> {
const workspace = this.validateWorkspaceExists(sessionId);
this.validatePathInWorkspace(workspace, dirPath);
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const fileInfos: FileInfo[] = [];
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const stats = await fs.stat(fullPath);
fileInfos.push({
name: entry.name,
path: fullPath,
isDirectory: entry.isDirectory(),
size: stats.size,
modifiedAt: stats.mtime,
});
}
this.updateLastAccessed(sessionId);
return fileInfos;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new FileOperationError(
`Directory not found: ${dirPath}`,
SandboxErrorCode.FILE_NOT_FOUND,
{ sessionId, cause: error as Error },
);
}
throw new FileOperationError(
`Directory read failed: ${dirPath}`,
SandboxErrorCode.DIRECTORY_OPERATION_FAILED,
{ sessionId, cause: error as Error },
);
}
}
async deleteFile(sessionId: string, filePath: string): Promise<void> {
const workspace = this.validateWorkspaceExists(sessionId);
this.validatePathInWorkspace(workspace, filePath);
try {
await fs.unlink(filePath);
this.updateLastAccessed(sessionId);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new FileOperationError(
`File not found: ${filePath}`,
SandboxErrorCode.FILE_NOT_FOUND,
{
sessionId,
cause: error as Error,
},
);
}
throw new FileOperationError(
`File delete failed: ${filePath}`,
SandboxErrorCode.FILE_DELETE_FAILED,
{ sessionId, cause: error as Error },
);
}
}
// ============================================================================
// 清理
// ============================================================================
async cleanup(): Promise<CleanupResult> {
log.info("[DockerSandbox] Starting cleanup...");
const result: CleanupResult = {
deletedCount: 0,
freedSpace: 0,
errors: [],
};
// 停止所有容器
for (const [sessionId, containerId] of this.containerIds) {
try {
await this.stopContainer(containerId);
result.deletedCount++;
} catch (error) {
result.errors.push(`Failed to stop container ${sessionId}: ${error}`);
}
}
this.containerIds.clear();
// 清理工作区目录
for (const [sessionId, workspace] of this.workspaces) {
try {
if (workspace.retentionPolicy.mode !== "always") {
const size = await this.getDirectorySize(workspace.rootPath);
await this.deleteWorkspaceDirectory(workspace.rootPath);
result.freedSpace += size;
}
} catch (error) {
result.errors.push(`Failed to clean workspace ${sessionId}: ${error}`);
}
}
this.workspaces.clear();
log.info("[DockerSandbox] Cleanup complete:", result);
this.emitEvent("cleanup:complete", { result });
return result;
}
// ============================================================================
// Docker 特有方法
// ============================================================================
/**
* 列出所有容器
*/
async listContainers(): Promise<ContainerInfo[]> {
try {
const { stdout } = await execAsync(
'docker ps -a --filter "name=qiming-sandbox-" --format "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.CreatedAt}}"',
);
const lines = stdout.trim().split("\n").filter(Boolean);
const containers: ContainerInfo[] = [];
for (const line of lines) {
const [id, name, image, status, createdAt] = line.split("\t");
// 从容器名称中提取 sessionId
const sessionMatch = name.match(/qiming-sandbox-(.+)/);
const sessionId = sessionMatch ? sessionMatch[1] : undefined;
containers.push({
id,
name,
image,
status: this.parseDockerStatus(status),
sessionId,
createdAt: new Date(createdAt),
});
}
return containers;
} catch (error) {
log.error("[DockerSandbox] Failed to list containers:", error);
return [];
}
}
/**
* 获取容器日志
*/
async getContainerLogs(sessionId: string): Promise<string> {
const containerId = this.containerIds.get(sessionId);
if (!containerId) {
throw new SandboxError(
"Container not found",
SandboxErrorCode.CONTAINER_OPERATION_FAILED,
{
sessionId,
},
);
}
this.validateContainerId(containerId);
try {
const { stdout } = await execAsync(`docker logs ${containerId}`, {
timeout: 10000,
});
return stdout;
} catch (error) {
log.error("[DockerSandbox] Failed to get container logs:", error);
return "";
}
}
/**
* 获取沙箱状态(包含 Docker 信息)
*/
async getStatus(): Promise<SandboxStatus> {
const baseStatus = await super.getStatus();
return {
...baseStatus,
docker: {
running: this.dockerAvailable,
containerCount: this.containerIds.size,
version: this.dockerVersion || undefined,
},
};
}
// ============================================================================
// 私有方法
// ============================================================================
/**
* 创建工作区目录结构
*/
private async createWorkspaceDirectories(
workspaceRoot: string,
): Promise<void> {
const directories = [
workspaceRoot,
path.join(workspaceRoot, "projects"),
path.join(workspaceRoot, "node_modules"),
path.join(workspaceRoot, "python-env"),
path.join(workspaceRoot, "bin"),
path.join(workspaceRoot, "cache"),
];
for (const dir of directories) {
await fs.mkdir(dir, { recursive: true });
}
}
/**
* 启动 Docker 容器
*/
private async startContainer(
sessionId: string,
workspaceRoot: string,
): Promise<string> {
const containerName = `qiming-sandbox-${sessionId}`;
const image = this.dockerConfig.dockerImage;
// 构建容器参数
const args = [
"run",
"-d",
"--name",
containerName,
// 挂载工作区
"-v",
`${workspaceRoot}:/workspace`,
// 内存限制
"--memory",
this.config.memoryLimit || "2g",
// CPU 限制
"--cpus",
String(this.config.cpuLimit || 2),
// 禁用网络(如果配置)
...(this.config.networkEnabled === false ? ["--network", "none"] : []),
// 工作目录
"-w",
"/workspace",
// 环境变量
"-e",
"HOME=/workspace",
"-e",
"PATH=/usr/local/bin:/usr/bin:/bin:/workspace/bin",
// 镜像
image,
// 保持容器运行
"tail",
"-f",
"/dev/null",
];
log.debug("[DockerSandbox] startContainer args:", args.join(" "));
const { stdout } = await execAsync(`docker ${args.join(" ")}`, {
timeout: 60000,
});
const containerId = stdout.trim();
log.info(
"[DockerSandbox] Container started:",
containerId,
"name:",
containerName,
);
return containerId;
}
/**
* 停止并删除容器
*/
private async stopContainer(containerId: string): Promise<void> {
this.validateContainerId(containerId);
try {
await execAsync(`docker stop ${containerId}`, { timeout: 30000 });
await execAsync(`docker rm ${containerId}`, { timeout: 30000 });
log.info("[DockerSandbox] Container stopped and removed:", containerId);
} catch (error) {
log.error("[DockerSandbox] Failed to stop container:", error);
throw error;
}
}
/**
* 构建 docker exec 参数
*/
private buildDockerExecArgs(
containerId: string,
command: string,
args: string[],
options: ExecuteOptions,
): string[] {
const dockerArgs = ["exec"];
// 工作目录
if (options.cwd) {
dockerArgs.push("-w", options.cwd);
}
// 环境变量
if (options.env) {
for (const [key, value] of Object.entries(options.env)) {
dockerArgs.push("-e", `${key}=${value}`);
}
}
// 执行命令
dockerArgs.push(containerId, command, ...args);
log.debug("[DockerSandbox] docker exec args:", dockerArgs.join(" "));
return dockerArgs;
}
/**
* 执行 Docker 命令
*/
private async executeDockerCommand(
dockerArgs: string[],
timeout: number,
): Promise<{
stdout: string;
stderr: string;
exitCode: number;
timedOut: boolean;
}> {
return new Promise((resolve, reject) => {
const proc = spawn("docker", dockerArgs, { shell: false });
let stdout = "";
let stderr = "";
let timedOut = false;
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
// 超时处理
const timer = setTimeout(() => {
timedOut = true;
proc.kill("SIGKILL");
}, timeout);
proc.on("close", (code) => {
clearTimeout(timer);
log.debug("[DockerSandbox] docker exec completed:", {
exitCode: code,
timedOut,
stdoutLen: stdout.length,
stderrLen: stderr.length,
stderrPreview: stderr.slice(0, 200),
});
resolve({
stdout,
stderr,
exitCode: code ?? (timedOut ? 137 : 1),
timedOut,
});
});
proc.on("error", (error) => {
clearTimeout(timer);
log.debug("[DockerSandbox] docker exec error:", error.message);
reject(error);
});
});
}
/**
* 解析 Docker 状态字符串
*/
private parseDockerStatus(
status: string,
): "running" | "exited" | "paused" | "created" {
if (status.includes("Up")) return "running";
if (status.includes("Exited")) return "exited";
if (status.includes("Paused")) return "paused";
return "created";
}
/**
* 获取目录大小
*/
private async getDirectorySize(dirPath: string): Promise<number> {
try {
let totalSize = 0;
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
totalSize += await this.getDirectorySize(fullPath);
} else {
const stats = await fs.stat(fullPath);
totalSize += stats.size;
}
}
return totalSize;
} catch {
return 0;
}
}
/**
* 删除工作区目录
*/
private async deleteWorkspaceDirectory(dirPath: string): Promise<void> {
try {
await fs.rm(dirPath, { recursive: true, force: true });
} catch (error) {
log.error("[DockerSandbox] Failed to delete directory:", dirPath, error);
throw error;
}
}
/**
* 清理失败的工作区
*/
private async cleanupFailedWorkspace(
sessionId: string,
workspaceRoot: string,
): Promise<void> {
try {
// 停止可能已创建的容器
const containerId = this.containerIds.get(sessionId);
if (containerId) {
await this.stopContainer(containerId);
this.containerIds.delete(sessionId);
}
// 删除目录
await this.deleteWorkspaceDirectory(workspaceRoot);
} catch (error) {
log.error("[DockerSandbox] Error cleaning failed workspace:", error);
}
}
}
export default DockerSandbox;

View File

@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("electron-log", () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import { PermissionManager } from "./PermissionManager";
describe("PermissionManager matrix behavior", () => {
it("should allow safe commands from safeCommands allowlist", async () => {
const manager = new PermissionManager();
const result = await manager.checkPermission(
"sess-safe",
"command:execute",
"git status",
);
expect(result.allowed).toBe(true);
expect(result.reason).toBe("Safe command");
});
it("should block dangerous commands from blacklist patterns", async () => {
const manager = new PermissionManager();
const result = await manager.checkPermission(
"sess-danger",
"command:execute",
"sudo ls /",
);
expect(result.allowed).toBe(false);
expect(result.reason).toContain("Dangerous operation");
});
it("should block sensitive ssh paths", async () => {
const manager = new PermissionManager();
const result = await manager.checkPermission(
"sess-path",
"file:read",
"/Users/demo/.ssh/id_rsa",
);
expect(result.allowed).toBe(false);
expect(result.reason).toContain("SSH");
});
it("should block denied permission types", async () => {
const manager = new PermissionManager();
const result = await manager.checkPermission(
"sess-deny",
"package:install:system",
"apt-get install vim",
);
expect(result.allowed).toBe(false);
expect(result.reason).toContain("blocked by security policy");
});
});

View File

@@ -0,0 +1,684 @@
/**
* 权限管理器
*
* 管理沙箱操作权限,包括:
* - 自动批准策略
* - 需要确认的操作
* - 禁止的操作
* - 用户确认流程
*
* @version 1.0.0
* @updated 2026-03-22
*/
import { EventEmitter } from "events";
const uuidv4 = () =>
Math.random().toString(36).substring(2) + Date.now().toString(36);
import log from "electron-log";
import type {
PermissionType,
PermissionPolicy,
PermissionResult,
PermissionRequest,
Permission,
} from "@shared/types/sandbox";
import {
SandboxError,
SandboxErrorCode,
PermissionError,
} from "@shared/errors/sandbox";
/**
* 默认权限策略
*/
export const DEFAULT_PERMISSION_POLICY: PermissionPolicy = {
// 自动批准的权限类型
autoApprove: ["file:read"],
// 需要确认的权限类型
requireConfirm: [
"file:write",
"file:delete",
"command:execute",
"network:access",
"network:download",
"package:install:npm",
"package:install:python",
],
// 禁止的权限类型
denyList: ["package:install:system"],
// 只允许工作区内操作
workspaceOnly: true,
// 安全命令白名单
safeCommands: [
// Node.js 相关
"node",
"npm",
"npx",
"pnpm",
"yarn",
"bun",
// Python 相关
"python",
"python3",
"pip",
"pip3",
"uv",
// Rust 相关
"cargo",
"rustc",
"rustup",
// 构建工具
"make",
"cmake",
// Git
"git",
// 通用工具
"ls",
"cat",
"head",
"tail",
"grep",
"find",
"mkdir",
"touch",
"cp",
"mv",
"echo",
"pwd",
"which",
"env",
"date",
],
};
/**
* 危险命令黑名单
* 注意:在沙箱内 rm -rf 是允许的,因为沙箱是隔离的
*/
export const DANGEROUS_COMMANDS = [
// 权限提升(沙箱内也不允许)
"sudo",
"su",
"chmod 777",
"chown",
// 系统包管理(可能需要 root
"apt-get install",
"apt install",
"yum install",
"dnf install",
"brew install",
"pacman -S",
"snap install",
// 网络危险操作
"nc -l",
"netcat",
"nmap",
"masscan",
];
/**
* 权限管理器
*/
export class PermissionManager extends EventEmitter {
private policy: PermissionPolicy;
private pendingRequests: Map<string, PermissionRequest> = new Map();
private approvedCache: Map<string, Permission> = new Map();
private deniedCache: Set<string> = new Set();
constructor(policy: Partial<PermissionPolicy> = {}) {
super();
this.policy = { ...DEFAULT_PERMISSION_POLICY, ...policy };
}
// ============================================================================
// 权限检查
// ============================================================================
/**
* 检查权限
* @param sessionId 会话 ID
* @param type 权限类型
* @param target 目标资源
* @returns 权限检查结果
*/
async checkPermission(
sessionId: string,
type: PermissionType,
target: string,
): Promise<PermissionResult> {
log.info("[PermissionManager] Checking permission:", {
sessionId,
type,
target,
});
// 检查是否在禁止列表
if (this.policy.denyList.includes(type)) {
log.warn("[PermissionManager] Permission denied:", type);
return {
allowed: false,
reason: `Permission type ${type} is blocked by security policy`,
};
}
// 检查是否是危险操作
const dangerCheck = this.checkDangerousOperation(type, target);
if (dangerCheck.isDangerous) {
log.warn("[PermissionManager] Dangerous operation detected:", target);
return {
allowed: false,
reason: dangerCheck.reason || "Unknown",
};
}
// 检查缓存
const cacheKey = this.getCacheKey(sessionId, type, target);
// 检查是否已批准
if (this.approvedCache.has(cacheKey)) {
log.info("[PermissionManager] Using cached approval");
return { allowed: true, reason: "Approved (cached)" };
}
// 检查是否已拒绝
if (this.deniedCache.has(cacheKey)) {
log.info("[PermissionManager] Using cached rejection");
return { allowed: false, reason: "Denied (cached)" };
}
// 检查是否在自动批准列表
if (this.policy.autoApprove.includes(type)) {
// 如果设置了 workspaceOnly检查目标是否在工作区内
if (this.policy.workspaceOnly && this.isPathPermission(type)) {
// 这个检查由 WorkspaceManager 在执行时进行
}
// 自动批准
const permission = this.createPermission(
sessionId,
type,
target,
"system",
);
this.approvedCache.set(cacheKey, permission);
log.info("[PermissionManager] Auto-approved:", type);
return { allowed: true, reason: "Auto-approved" };
}
// 检查是否需要确认
if (this.policy.requireConfirm.includes(type)) {
// 对于命令执行,检查是否是安全命令
if (type === "command:execute") {
const command = target.split(" ")[0];
if (this.policy.safeCommands.includes(command)) {
const permission = this.createPermission(
sessionId,
type,
target,
"policy",
);
this.approvedCache.set(cacheKey, permission);
log.info("[PermissionManager] Safe command auto-approved:", command);
return { allowed: true, reason: "Safe command" };
}
}
// 需要用户确认
const request = await this.createRequest(sessionId, type, target);
log.info("[PermissionManager] User confirmation required:", request.id);
return {
allowed: false,
reason: "User confirmation required",
requestId: request.id,
};
}
// 默认拒绝
return {
allowed: false,
reason: "Permission type undefined",
};
}
/**
* 请求权限
* @param sessionId 会话 ID
* @param type 权限类型
* @param target 目标资源
* @param reason 请求原因
* @returns 权限对象
*/
async requestPermission(
sessionId: string,
type: PermissionType,
target: string,
reason?: string,
): Promise<Permission> {
log.info("[PermissionManager] Requesting permission:", {
sessionId,
type,
target,
reason,
});
// 先检查权限
const result = await this.checkPermission(sessionId, type, target);
if (result.allowed) {
// 已批准,返回缓存的权限
const cacheKey = this.getCacheKey(sessionId, type, target);
const cached = this.approvedCache.get(cacheKey);
if (cached) {
return cached;
}
// 创建新的权限对象
return this.createPermission(sessionId, type, target, "system");
}
// 需要用户确认
const request = await this.createRequest(sessionId, type, target, reason);
// 等待用户响应
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(request.id);
reject(
new PermissionError("Permission request timed out", {
sessionId,
details: { requestId: request.id },
}),
);
}, 60000); // 60 秒超时
this.once(`permission:${request.id}`, (permission: Permission) => {
clearTimeout(timeout);
if (permission.approvedBy === "denied") {
reject(
new PermissionError("Permission denied", {
sessionId,
details: { requestId: request.id, reason: permission.reason },
}),
);
} else {
resolve(permission);
}
});
});
}
// ============================================================================
// 权限审批
// ============================================================================
/**
* 批准权限请求
* @param requestId 请求 ID
* @param approvedBy 批准来源
* @param reason 原因
*/
async approve(
requestId: string,
approvedBy: "user" | "system" = "user",
reason?: string,
): Promise<void> {
const request = this.pendingRequests.get(requestId);
if (!request) {
throw new SandboxError(
"Request not found",
SandboxErrorCode.PERMISSION_DENIED,
{
details: { requestId },
},
);
}
// 创建权限对象
const permission = this.createPermission(
request.sessionId,
request.type,
request.target,
approvedBy,
reason,
);
// 缓存批准
const cacheKey = this.getCacheKey(
request.sessionId,
request.type,
request.target,
);
this.approvedCache.set(cacheKey, permission);
// 更新请求状态
request.status = "approved";
// 从待处理列表中移除
this.pendingRequests.delete(requestId);
// 发出事件
this.emitEvent("permission:approved", { permission });
this.emit(`permission:${requestId}`, permission);
log.info("[PermissionManager] Permission approved:", requestId);
}
/**
* 拒绝权限请求
* @param requestId 请求 ID
* @param reason 原因
*/
async deny(requestId: string, reason?: string): Promise<void> {
const request = this.pendingRequests.get(requestId);
if (!request) {
throw new SandboxError(
"Request not found",
SandboxErrorCode.PERMISSION_DENIED,
{
details: { requestId },
},
);
}
// 缓存拒绝
const cacheKey = this.getCacheKey(
request.sessionId,
request.type,
request.target,
);
this.deniedCache.add(cacheKey);
// 更新请求状态
request.status = "denied";
// 创建拒绝的权限对象
const permission = this.createPermission(
request.sessionId,
request.type,
request.target,
"denied",
reason,
);
// 从待处理列表中移除
this.pendingRequests.delete(requestId);
// 发出事件
this.emitEvent("permission:denied", {
requestId,
sessionId: request.sessionId,
reason,
});
this.emit(`permission:${requestId}`, permission);
log.info("[PermissionManager] Permission rejected:", requestId);
}
/**
* 批量批准权限请求
* @param requestIds 请求 ID 列表
*/
async approveBatch(requestIds: string[]): Promise<void> {
for (const requestId of requestIds) {
await this.approve(requestId, "user");
}
}
// ============================================================================
// 待处理请求
// ============================================================================
/**
* 获取待处理的权限请求
* @param sessionId 可选的会话 ID 过滤
*/
getPendingRequests(sessionId?: string): PermissionRequest[] {
const requests = Array.from(this.pendingRequests.values());
if (sessionId) {
return requests.filter((r) => r.sessionId === sessionId);
}
return requests;
}
/**
* 获取待处理请求的数量
*/
getPendingCount(): number {
return this.pendingRequests.size;
}
// ============================================================================
// 缓存管理
// ============================================================================
/**
* 清除缓存
*/
clearCache(): void {
this.approvedCache.clear();
this.deniedCache.clear();
log.info("[PermissionManager] Cache cleared");
}
/**
* 清除会话相关的缓存
* @param sessionId 会话 ID
*/
clearSessionCache(sessionId: string): void {
// 清除批准缓存
for (const [key, permission] of this.approvedCache) {
if (permission.sessionId === sessionId) {
this.approvedCache.delete(key);
}
}
// 清除拒绝缓存
for (const key of this.deniedCache) {
if (key.startsWith(sessionId)) {
this.deniedCache.delete(key);
}
}
// 移除待处理请求
for (const [requestId, request] of this.pendingRequests) {
if (request.sessionId === sessionId) {
this.pendingRequests.delete(requestId);
}
}
log.info("[PermissionManager] Session cache cleared:", sessionId);
}
// ============================================================================
// 策略管理
// ============================================================================
/**
* 获取当前策略
*/
getPolicy(): PermissionPolicy {
return { ...this.policy };
}
/**
* 更新策略
* @param policy 新的策略(部分)
*/
updatePolicy(policy: Partial<PermissionPolicy>): void {
this.policy = { ...this.policy, ...policy };
log.info("[PermissionManager] Policy updated");
}
/**
* 添加安全命令
* @param command 命令
*/
addSafeCommand(command: string): void {
if (!this.policy.safeCommands.includes(command)) {
this.policy.safeCommands.push(command);
log.info("[PermissionManager] Safe command added:", command);
}
}
/**
* 移除安全命令
* @param command 命令
*/
removeSafeCommand(command: string): void {
const index = this.policy.safeCommands.indexOf(command);
if (index !== -1) {
this.policy.safeCommands.splice(index, 1);
log.info("[PermissionManager] Safe command removed:", command);
}
}
// ============================================================================
// 私有方法
// ============================================================================
/**
* 创建权限请求
*/
private async createRequest(
sessionId: string,
type: PermissionType,
target: string,
reason?: string,
): Promise<PermissionRequest> {
const request: PermissionRequest = {
id: uuidv4(),
sessionId,
type,
target,
reason,
requestedAt: new Date(),
status: "pending",
};
this.pendingRequests.set(request.id, request);
// 发出请求事件
this.emitEvent("permission:requested", { request });
return request;
}
/**
* 创建权限对象
*/
private createPermission(
sessionId: string,
type: PermissionType,
target: string,
approvedBy: "system" | "user" | "policy" | "denied",
reason?: string,
): Permission {
return {
id: uuidv4(),
type,
target,
sessionId,
approvedBy,
timestamp: new Date(),
reason,
};
}
/**
* 生成缓存键
*/
private getCacheKey(
sessionId: string,
type: PermissionType,
target: string,
): string {
return `${sessionId}:${type}:${target}`;
}
/**
* 检查是否是路径权限
*/
private isPathPermission(type: PermissionType): boolean {
return ["file:read", "file:write", "file:delete"].includes(type);
}
/**
* 检查危险操作
*/
private checkDangerousOperation(
type: PermissionType,
target: string,
): { isDangerous: boolean; reason?: string } {
const lowerTarget = target.toLowerCase();
// 检查危险命令
for (const dangerous of DANGEROUS_COMMANDS) {
// Check with word boundaries to avoid partial matches
const escapedDangerous = dangerous.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const dangerousPattern = new RegExp(
`(^|\\s|/|\\b)${escapedDangerous}(\\s|/|$|\\b)`,
"i",
);
if (dangerousPattern.test(lowerTarget)) {
return {
isDangerous: true,
reason: `Dangerous operation detected: ${dangerous}`,
};
}
}
// 检查敏感路径
if (this.isPathPermission(type)) {
// 检查 SSH 目录
if (lowerTarget.includes(".ssh") || lowerTarget.includes("/.ssh")) {
return {
isDangerous: true,
reason: "SSH directory access is blocked",
};
}
// 检查系统配置文件
// Check specific sensitive /etc paths
const sensitiveEtcPaths = [
"/etc/passwd",
"/etc/shadow",
"/etc/sudoers",
"/etc/group",
];
if (sensitiveEtcPaths.some((path) => lowerTarget === path)) {
return {
isDangerous: true,
reason: "System config path access is blocked",
};
}
}
return { isDangerous: false };
}
/**
* 发出事件
*/
private emitEvent<T extends string>(event: T, data?: unknown): void {
this.emit(event, data);
}
}
export default PermissionManager;

View File

@@ -0,0 +1,238 @@
/**
* 沙箱文件操作工具类
*
* 从 CommandSandbox 和 DockerSandbox 中抽取的共享文件操作方法,
* 消除两者之间的代码重复。
*
* 所有方法均为 static不持有状态可被任意沙箱实现复用。
*
* @version 1.0.0
*/
import * as fsp from "fs/promises";
import * as path from "path";
import log from "electron-log";
import type { FileInfo } from "@shared/types/sandbox";
import { FileOperationError, SandboxErrorCode } from "@shared/errors/sandbox";
const TAG = "[SandboxFileOps]";
/**
* 沙箱文件操作工具集
*
* 提供工作区目录创建、清理、文件读写、目录遍历等通用操作。
* 方法不依赖沙箱实例状态,仅操作底层文件系统。
*/
export class SandboxFileOperations {
// ============================================================================
// 工作区目录结构
// ============================================================================
/**
* 创建标准工作区目录结构。
*
* 在 workspaceRoot 下创建以下子目录:
* - projects/
* - node_modules/
* - python-env/
* - bin/
* - cache/
*
* @param workspaceRoot - 工作区根目录路径
*/
static async createWorkspaceDirectories(
workspaceRoot: string,
): Promise<void> {
const dirs = [
workspaceRoot,
path.join(workspaceRoot, "projects"),
path.join(workspaceRoot, "node_modules"),
path.join(workspaceRoot, "python-env"),
path.join(workspaceRoot, "bin"),
path.join(workspaceRoot, "cache"),
];
log.debug(TAG, "createWorkspaceDirectories:", workspaceRoot);
for (const dir of dirs) {
await fsp.mkdir(dir, { recursive: true });
}
}
// ============================================================================
// 目录清理
// ============================================================================
/**
* 递归删除指定目录。
*
* 等同于 `rm -rf`即使目录不存在也不会抛出异常force: true
*
* @param dirPath - 要删除的目录路径
*/
static async cleanupWorkspaceDirectory(dirPath: string): Promise<void> {
log.debug(TAG, "cleanupWorkspaceDirectory:", dirPath);
await fsp.rm(dirPath, { recursive: true, force: true });
}
// ============================================================================
// 目录大小计算
// ============================================================================
/**
* 递归计算目录总大小(字节)。
*
* 遍历目录下所有文件并累加文件尺寸。遇到不可访问的文件或目录时
* 不会抛出异常,而是返回已累加的大小。
*
* @param dirPath - 目标目录路径
* @returns 目录总大小(字节)
*/
static async getDirectorySize(dirPath: string): Promise<number> {
try {
let total = 0;
const entries = await fsp.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
total += await SandboxFileOperations.getDirectorySize(full);
} else {
const st = await fsp.stat(full);
total += st.size;
}
}
return total;
} catch {
return 0;
}
}
// ============================================================================
// 文件读写
// ============================================================================
/**
* 读取文件内容UTF-8
*
* @param filePath - 文件绝对路径
* @returns 文件内容字符串
* @throws {FileOperationError} 文件不存在FILE_NOT_FOUND或读取失败FILE_READ_FAILED
*/
static async readFileContent(filePath: string): Promise<string> {
try {
const content = await fsp.readFile(filePath, "utf-8");
log.debug(TAG, "readFileContent:", filePath);
return content;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
throw new FileOperationError(
`File not found: ${filePath}`,
SandboxErrorCode.FILE_NOT_FOUND,
{ cause: error as Error },
);
}
throw new FileOperationError(
`File read failed: ${filePath}`,
SandboxErrorCode.FILE_READ_FAILED,
{ cause: error as Error },
);
}
}
/**
* 写入文件内容UTF-8自动创建父目录。
*
* @param filePath - 文件绝对路径
* @param content - 要写入的内容
* @throws {FileOperationError} 写入失败FILE_WRITE_FAILED
*/
static async writeFileContent(
filePath: string,
content: string,
): Promise<void> {
try {
await fsp.mkdir(path.dirname(filePath), { recursive: true });
await fsp.writeFile(filePath, content, "utf-8");
log.debug(TAG, "writeFileContent:", filePath);
} catch (error) {
throw new FileOperationError(
`File write failed: ${filePath}`,
SandboxErrorCode.FILE_WRITE_FAILED,
{ cause: error as Error },
);
}
}
// ============================================================================
// 目录遍历
// ============================================================================
/**
* 读取目录条目并返回文件信息列表。
*
* @param dirPath - 目录绝对路径
* @returns 文件信息数组
* @throws {FileOperationError} 目录不存在FILE_NOT_FOUND或读取失败DIRECTORY_OPERATION_FAILED
*/
static async readDirectoryEntries(dirPath: string): Promise<FileInfo[]> {
try {
const entries = await fsp.readdir(dirPath, { withFileTypes: true });
const infos = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(dirPath, entry.name);
const st = await fsp.stat(fullPath);
const item: FileInfo = {
name: entry.name,
path: fullPath,
isDirectory: entry.isDirectory(),
size: st.size,
modifiedAt: st.mtime,
};
return item;
}),
);
log.debug(TAG, "readDirectoryEntries:", dirPath, "count:", infos.length);
return infos;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
throw new FileOperationError(
`Directory not found: ${dirPath}`,
SandboxErrorCode.FILE_NOT_FOUND,
{ cause: error as Error },
);
}
throw new FileOperationError(
`Directory read failed: ${dirPath}`,
SandboxErrorCode.DIRECTORY_OPERATION_FAILED,
{ cause: error as Error },
);
}
}
// ============================================================================
// 删除
// ============================================================================
/**
* 删除文件或目录(递归)。
*
* 对文件和目录均使用 `rm -rf` 语义,不存在时不报错。
*
* @param filePath - 文件或目录路径
* @throws {FileOperationError} 删除失败FILE_DELETE_FAILED
*/
static async deletePath(filePath: string): Promise<void> {
try {
await fsp.rm(filePath, { recursive: true, force: true });
log.debug(TAG, "deletePath:", filePath);
} catch (error) {
throw new FileOperationError(
`File delete failed: ${filePath}`,
SandboxErrorCode.FILE_DELETE_FAILED,
{ cause: error as Error },
);
}
}
}

View File

@@ -0,0 +1,994 @@
/**
* SandboxInvoker 单元测试
*
* @version 1.0.0
* @updated 2026-04-08
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as path from "path";
import log from "electron-log";
import type { SandboxType } from "@shared/types/sandbox";
import type { SandboxInvocationParams } from "./SandboxInvoker";
// ============================================================================
// Mock fs module (non-configurable properties require vi.mock)
// ============================================================================
const mockFsExistsSync = vi.fn((p: string) => {
if (p.includes("sandbox-exec")) return true;
if (p.includes("qiming-sandbox-helper")) return true;
return true;
});
const mockFsRealpathSync = vi.fn((p: string) => p);
const mockFspWriteFile = vi.fn(() => Promise.resolve());
const mockFsMkdirSync = vi.fn();
vi.mock("fs", () => ({
existsSync: (...args: unknown[]) => mockFsExistsSync(...args),
realpathSync: (...args: unknown[]) => mockFsRealpathSync(...args),
mkdirSync: (...args: unknown[]) => mockFsMkdirSync(...args),
}));
vi.mock("fs/promises", () => ({
writeFile: (...args: unknown[]) => mockFspWriteFile(...args),
}));
vi.mock("os", () => ({
tmpdir: () => "/tmp",
}));
vi.mock("electron-log", () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Import after mocks are set up
import { SandboxInvoker } from "./SandboxInvoker";
import { SandboxError, SandboxErrorCode } from "@shared/errors/sandbox";
// ============================================================================
// 测试辅助
// ============================================================================
/** 默认调用参数 */
const defaultParams: SandboxInvocationParams = {
command: "/usr/bin/node",
args: ["--version"],
cwd: "/home/user/project",
env: { PATH: "/usr/bin:/bin" },
writablePaths: ["/home/user/project"],
networkEnabled: false,
};
/** 创建基本参数覆盖的辅助函数 */
function makeParams(
overrides: Partial<SandboxInvocationParams> = {},
): SandboxInvocationParams {
return { ...defaultParams, ...overrides };
}
// ============================================================================
// 平台 mock 辅助
// ============================================================================
async function withPlatform(
platform: NodeJS.Platform,
fn: () => void | Promise<void>,
): Promise<void> {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
});
try {
await fn();
} finally {
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
}
}
// ============================================================================
// Suite: none 类型
// ============================================================================
describe("SandboxInvoker - none type", () => {
it("should return unwrapped command and args directly", async () => {
const invoker = new SandboxInvoker("none");
const result = await invoker.buildInvocation(makeParams());
expect(result.command).toBe("/usr/bin/node");
expect(result.args).toEqual(["--version"]);
});
it("should preserve cwd and env", async () => {
const invoker = new SandboxInvoker("none");
const params = makeParams({ cwd: "/custom/cwd", env: { FOO: "bar" } });
const result = await invoker.buildInvocation(params);
expect(result.cwd).toBe("/custom/cwd");
expect(result.env).toEqual({ FOO: "bar" });
});
it("should not add any sandbox wrapper properties", async () => {
const invoker = new SandboxInvoker("none");
const result = await invoker.buildInvocation(makeParams());
expect(result.seatbeltProfilePath).toBeUndefined();
expect(result.parseJson).toBeUndefined();
});
});
// ============================================================================
// Suite: docker 类型
// ============================================================================
describe("SandboxInvoker - docker type", () => {
it("should return unwrapped command and args with warn log", async () => {
const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => {});
const invoker = new SandboxInvoker("docker");
const result = await invoker.buildInvocation(makeParams());
expect(result.command).toBe("/usr/bin/node");
expect(result.args).toEqual(["--version"]);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Docker process-level sandbox not supported"),
);
warnSpy.mockRestore();
});
it("should preserve cwd and env", async () => {
const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => {});
const invoker = new SandboxInvoker("docker");
const params = makeParams({ cwd: "/custom/cwd" });
const result = await invoker.buildInvocation(params);
expect(result.cwd).toBe("/custom/cwd");
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
});
// ============================================================================
// Suite: macOS seatbelt
// ============================================================================
describe("SandboxInvoker - macos-seatbelt", () => {
beforeEach(() => {
mockFsRealpathSync.mockImplementation((p: string) => p);
mockFsExistsSync.mockImplementation((p: string) => {
if (p.includes("sandbox-exec")) return true;
return true;
});
mockFspWriteFile.mockClear();
});
it("should build sandbox-exec invocation with profile", async () => {
const invoker = new SandboxInvoker("macos-seatbelt");
const result = await invoker.buildInvocation(makeParams());
expect(result.command).toBe("/usr/bin/sandbox-exec");
expect(result.args[0]).toBe("-f");
expect(result.args[1]).toMatch(/qimingclaw-sandbox-\d+-[a-z0-9]+\.sb$/);
expect(result.args[2]).toBe("/usr/bin/node");
expect(result.args[3]).toBe("--version");
expect(result.seatbeltProfilePath).toBeDefined();
expect(result.seatbeltProfilePath).toMatch(
/qimingclaw-sandbox-\d+-[a-z0-9]+\.sb$/,
);
});
describe("seatbelt profile content", () => {
it("should start with (version 1) and (deny default)", async () => {
const invoker = new SandboxInvoker("macos-seatbelt");
await invoker.buildInvocation(makeParams());
const writeCall = mockFspWriteFile.mock.calls[0];
const profile = writeCall[1] as string;
expect(profile).toMatch(/^\(version 1\)/);
expect(profile).toMatch(/\(deny default\)/);
});
it("should include (allow network*) when networkEnabled is true", async () => {
const invoker = new SandboxInvoker("macos-seatbelt");
await invoker.buildInvocation(makeParams({ networkEnabled: true }));
const writeCall = mockFspWriteFile.mock.calls[0];
const profile = writeCall[1] as string;
expect(profile).toMatch(/\(allow network\*\)/);
});
it("should NOT include network* when networkEnabled is false", async () => {
const invoker = new SandboxInvoker("macos-seatbelt");
await invoker.buildInvocation(makeParams({ networkEnabled: false }));
const writeCall = mockFspWriteFile.mock.calls[0];
const profile = writeCall[1] as string;
expect(profile).not.toMatch(/\(allow network\*\)/);
});
it("should generate (allow file-write* (subpath path)) for each writablePath", async () => {
const invoker = new SandboxInvoker("macos-seatbelt");
const params = makeParams({
writablePaths: ["/home/user/project", "/tmp/cache"],
});
await invoker.buildInvocation(params);
const writeCall = mockFspWriteFile.mock.calls[0];
const profile = writeCall[1] as string;
expect(profile).toMatch(
/\(allow file-write\* \(subpath "\/home\/user\/project"\)\)/,
);
expect(profile).toMatch(
/\(allow file-write\* \(subpath "\/tmp\/cache"\)\)/,
);
});
it("should add literal /dev/null, /dev/dtracehelper, /dev/urandom writes", async () => {
const invoker = new SandboxInvoker("macos-seatbelt");
await invoker.buildInvocation(makeParams());
const writeCall = mockFspWriteFile.mock.calls[0];
const profile = writeCall[1] as string;
expect(profile).toMatch(
/\(allow file-write\* \(literal "\/dev\/null"\)\)/,
);
expect(profile).toMatch(
/\(allow file-write\* \(literal "\/dev\/dtracehelper"\)\)/,
);
expect(profile).toMatch(
/\(allow file-write\* \(literal "\/dev\/urandom"\)\)/,
);
});
it("should include standard allowed operations", async () => {
const invoker = new SandboxInvoker("macos-seatbelt");
await invoker.buildInvocation(makeParams());
const writeCall = mockFspWriteFile.mock.calls[0];
const profile = writeCall[1] as string;
expect(profile).toMatch(/\(allow file-read\*\)/);
expect(profile).toMatch(/\(allow process-exec \(regex/);
expect(profile).toMatch(/\(allow process-fork\)/);
expect(profile).toMatch(/\(allow signal \(target self\)\)/);
expect(profile).toMatch(/\(allow sysctl-read\)/);
expect(profile).toMatch(/\(allow mach-lookup\)/);
expect(profile).toMatch(/\(allow ipc-posix\*\)/);
expect(profile).toMatch(/\(allow file-lock\)/);
expect(profile).not.toMatch(/\(allow file-write\*\)\n/);
});
it("should include compat startup exec allowlist for absolute command path", async () => {
const invoker = new SandboxInvoker("macos-seatbelt", { mode: "compat" });
await invoker.buildInvocation(
makeParams({
command: "/mock/resources/node/darwin-arm64/bin/node",
}),
);
const writeCall = mockFspWriteFile.mock.calls[0];
const profile = writeCall[1] as string;
expect(profile).toContain(
'(allow process-exec (literal "/mock/resources/node/darwin-arm64/bin/node"))',
);
});
it("should include command literal but not startup-chain allowlist in strict mode", async () => {
const invoker = new SandboxInvoker("macos-seatbelt", { mode: "strict" });
await invoker.buildInvocation(
makeParams({
command: "/mock/resources/node/darwin-arm64/bin/node",
startupExecAllowlist: [
"/mock/resources/claude-code-acp-ts/dist/index.js",
],
}),
);
const writeCall = mockFspWriteFile.mock.calls[0];
const profile = writeCall[1] as string;
expect(profile).toContain(
'(allow process-exec (literal "/mock/resources/node/darwin-arm64/bin/node"))',
);
expect(profile).not.toContain(
'(allow process-exec (literal "/mock/resources/claude-code-acp-ts/dist/index.js"))',
);
});
});
it("should handle symlink paths for writablePaths by adding both", async () => {
// Mock fs.realpathSync to return a different path (simulating symlink)
mockFsRealpathSync.mockImplementation((p: string) => {
if (p === "/home/user/project") return "/private/home/user/project";
return p;
});
const invoker = new SandboxInvoker("macos-seatbelt");
await invoker.buildInvocation(
makeParams({ writablePaths: ["/home/user/project"] }),
);
const writeCall = mockFspWriteFile.mock.calls[0];
const profile = writeCall[1] as string;
expect(profile).toMatch(
/\(allow file-write\* \(subpath "\/home\/user\/project"\)\)/,
);
expect(profile).toMatch(
/\(allow file-write\* \(subpath "\/private\/home\/user\/project"\)\)/,
);
});
it("should not duplicate paths that resolve to the same realpath", async () => {
const invoker = new SandboxInvoker("macos-seatbelt");
await invoker.buildInvocation(makeParams({ writablePaths: ["/tmp/same"] }));
const writeCall = mockFspWriteFile.mock.calls[0];
const profile = writeCall[1] as string;
// Should only appear once despite realpath returning the same
const matches = profile.match(
/\(allow file-write\* \(subpath "\/tmp\/same"\)\)/g,
);
expect(matches).toHaveLength(1);
});
it("should write profile to temp directory", async () => {
const invoker = new SandboxInvoker("macos-seatbelt");
await invoker.buildInvocation(makeParams());
const writeCall = mockFspWriteFile.mock.calls[0];
const profilePath = writeCall[0] as string;
expect(profilePath).toMatch(/^\/tmp\/qimingclaw-sandbox-\d+-[a-z0-9]+\.sb$/);
});
});
// ============================================================================
// Suite: Linux bwrap
// ============================================================================
describe("SandboxInvoker - linux-bwrap", () => {
beforeEach(() => {
mockFsExistsSync.mockReturnValue(true);
});
it("should build bwrap invocation", async () => {
await withPlatform("linux", async () => {
const invoker = new SandboxInvoker("linux-bwrap");
const result = await invoker.buildInvocation(makeParams());
expect(result.command).toBe("bwrap");
expect(result.cwd).toBe("/home/user/project");
});
});
describe("bwrap args structure", () => {
it("should include --ro-bind / / by default", async () => {
await withPlatform("linux", async () => {
const invoker = new SandboxInvoker("linux-bwrap");
const result = await invoker.buildInvocation(makeParams());
expect(result.args).toContain("--ro-bind");
expect(result.args).toContain("/");
// Should have --ro-bind / / as consecutive args
const idx = result.args.indexOf("--ro-bind");
expect(idx).toBeGreaterThanOrEqual(0);
expect(result.args[idx + 1]).toBe("/");
expect(result.args[idx + 2]).toBe("/");
});
});
it("should include --tmpfs /tmp by default", async () => {
await withPlatform("linux", async () => {
const invoker = new SandboxInvoker("linux-bwrap");
const result = await invoker.buildInvocation(makeParams());
const idx = result.args.indexOf("--tmpfs");
expect(idx).toBeGreaterThanOrEqual(0);
expect(result.args[idx + 1]).toBe("/tmp");
});
});
it("should include minimal /dev allowlist by default", async () => {
await withPlatform("linux", async () => {
const invoker = new SandboxInvoker("linux-bwrap");
const result = await invoker.buildInvocation(makeParams());
expect(result.args).toContain("/dev/null");
expect(result.args).toContain("/dev/urandom");
expect(result.args).toContain("/dev/zero");
const fullDevIdx = result.args.findIndex(
(arg, i) =>
arg === "--dev-bind" &&
result.args[i + 1] === "/dev" &&
result.args[i + 2] === "/dev",
);
expect(fullDevIdx).toBe(-1);
});
});
it("should include --unshare-net when networkEnabled is false", async () => {
await withPlatform("linux", async () => {
const invoker = new SandboxInvoker("linux-bwrap");
await invoker.buildInvocation(makeParams({ networkEnabled: false }));
const result = (invoker as any).buildBwrap(
makeParams({ networkEnabled: false }),
);
expect(result.args).toContain("--unshare-net");
});
});
it("should NOT include --unshare-net when networkEnabled is true", async () => {
await withPlatform("linux", async () => {
const invoker = new SandboxInvoker("linux-bwrap");
const result = (invoker as any).buildBwrap(
makeParams({ networkEnabled: true }),
);
expect(result.args).not.toContain("--unshare-net");
});
});
it("should bind each writablePath", async () => {
await withPlatform("linux", async () => {
const invoker = new SandboxInvoker("linux-bwrap");
const params = makeParams({
writablePaths: ["/home/user/project", "/tmp/cache"],
});
const result = (invoker as any).buildBwrap(params);
const projectIdx = result.args.indexOf("/home/user/project");
expect(projectIdx).toBeGreaterThanOrEqual(0);
expect(result.args[projectIdx - 1]).toBe("--bind");
expect(result.args[projectIdx + 1]).toBe("/home/user/project");
});
});
it("should place --chdir before -- separator", async () => {
await withPlatform("linux", async () => {
const invoker = new SandboxInvoker("linux-bwrap");
const result = (invoker as any).buildBwrap(makeParams());
const chdirIdx = result.args.indexOf("--chdir");
const separatorIdx = result.args.indexOf("--");
expect(chdirIdx).toBeGreaterThanOrEqual(0);
expect(separatorIdx).toBeGreaterThanOrEqual(0);
expect(chdirIdx).toBeLessThan(separatorIdx);
});
});
it("should place original command and args after -- separator at end", async () => {
await withPlatform("linux", async () => {
const invoker = new SandboxInvoker("linux-bwrap");
const params = makeParams({
command: "/usr/bin/node",
args: ["--version"],
});
const result = (invoker as any).buildBwrap(params);
const separatorIdx = result.args.indexOf("--");
expect(separatorIdx).toBeGreaterThanOrEqual(0);
// Everything after -- should be the original command and args
const afterSeparator = result.args.slice(separatorIdx + 1);
expect(afterSeparator[0]).toBe("/usr/bin/node");
expect(afterSeparator[1]).toBe("--version");
});
});
it("should use custom bwrap path when provided", async () => {
await withPlatform("linux", async () => {
const invoker = new SandboxInvoker("linux-bwrap", {
linuxBwrapPath: "/usr/local/bin/bwrap",
});
const result = await invoker.buildInvocation(makeParams());
expect(result.command).toBe("/usr/local/bin/bwrap");
});
});
it("should include all required bwrap flags", async () => {
await withPlatform("linux", async () => {
const invoker = new SandboxInvoker("linux-bwrap");
const result = (invoker as any).buildBwrap(makeParams());
const requiredFlags = [
"--die-with-parent",
"--new-session",
"--unshare-user-try",
"--unshare-pid",
"--unshare-uts",
"--unshare-cgroup-try",
"--ro-bind",
"--dev-bind",
"--proc",
"/proc",
];
for (const flag of requiredFlags) {
expect(result.args).toContain(flag);
}
});
});
it("should use permissive mode when explicitly configured", async () => {
await withPlatform("linux", async () => {
const invoker = new SandboxInvoker("linux-bwrap", {
mode: "permissive",
});
const result = (invoker as any).buildBwrap(makeParams());
expect(result.args).toContain("--bind");
expect(result.args).toContain("/dev");
expect(result.args).not.toContain("--unshare-pid");
});
});
it("should avoid full root ro-bind in strict mode", async () => {
await withPlatform("linux", async () => {
const invoker = new SandboxInvoker("linux-bwrap", { mode: "strict" });
const result = (invoker as any).buildBwrap(makeParams());
const rootRoBindIdx = result.args.findIndex(
(arg, i) =>
arg === "--ro-bind" &&
result.args[i + 1] === "/" &&
result.args[i + 2] === "/",
);
expect(rootRoBindIdx).toBe(-1);
});
});
});
});
// ============================================================================
// Suite: Windows sandbox
// ============================================================================
describe("SandboxInvoker - windows-sandbox", () => {
const fakeHelperPath = "C:\\tools\\qiming-sandbox-helper.exe";
beforeEach(() => {
mockFsExistsSync.mockImplementation((p: string) => {
if (p.includes("qiming-sandbox-helper")) return true;
return true;
});
});
it("should throw SANDBOX_UNAVAILABLE when helper does not exist", async () => {
mockFsExistsSync.mockImplementation((p: string) => {
if (p.includes("qiming-sandbox-helper")) return false;
return true;
});
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
});
await expect(invoker.buildInvocation(makeParams())).rejects.toThrow(
expect.objectContaining({ code: SandboxErrorCode.SANDBOX_UNAVAILABLE }),
);
});
// Reset mock
mockFsExistsSync.mockImplementation((p: string) => {
if (p.includes("qiming-sandbox-helper")) return true;
return true;
});
});
it("should throw when windowsSandboxHelperPath is not provided", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {});
await expect(invoker.buildInvocation(makeParams())).rejects.toThrow(
expect.objectContaining({ code: SandboxErrorCode.SANDBOX_UNAVAILABLE }),
);
});
});
describe("helper invocation args", () => {
it("should build helper invocation with --policy-json", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
});
const result = await invoker.buildInvocation(makeParams());
expect(result.args).toContain("--policy-json");
});
});
it("should include network_access: false when networkEnabled is false", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
});
await invoker.buildInvocation(makeParams({ networkEnabled: false }));
const result = await invoker.buildInvocation(
makeParams({ networkEnabled: false }),
);
const policyArgIdx = result.args.indexOf("--policy-json");
const policyStr = result.args[policyArgIdx + 1];
const policy = JSON.parse(policyStr);
expect(policy.network_access).toBe(false);
});
});
it("should include network_access: true when networkEnabled is true", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
networkEnabled: true,
});
const result = await invoker.buildInvocation(
makeParams({ networkEnabled: true }),
);
const policyArgIdx = result.args.indexOf("--policy-json");
const policyStr = result.args[policyArgIdx + 1];
const policy = JSON.parse(policyStr);
expect(policy.network_access).toBe(true);
});
});
it("should include writable_roots in workspace-write mode with writablePaths", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
windowsSandboxMode: "workspace-write",
});
const params = makeParams({
writablePaths: ["C:\\projects", "D:\\temp"],
});
const result = await invoker.buildInvocation(params);
const policyArgIdx = result.args.indexOf("--policy-json");
const policyStr = result.args[policyArgIdx + 1];
const policy = JSON.parse(policyStr);
expect(policy.type).toBe("workspace-write");
expect(policy.writable_roots).toEqual(["C:\\projects", "D:\\temp"]);
});
});
it("should use read-only mode when specified", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
windowsSandboxMode: "read-only",
});
const result = await invoker.buildInvocation(makeParams());
const policyArgIdx = result.args.indexOf("--policy-json");
const policyStr = result.args[policyArgIdx + 1];
const policy = JSON.parse(policyStr);
expect(policy.type).toBe("read-only");
});
});
it("should set parseJson to true for run subcommand", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
});
const result = await invoker.buildInvocation(
makeParams({ subcommand: "run" }),
);
expect(result.parseJson).toBe(true);
});
});
it("should set parseJson to undefined for serve subcommand", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
});
const result = await invoker.buildInvocation(
makeParams({ subcommand: "serve" }),
);
// parseJson is false for "serve" (since subcommand !== "run")
expect(result.parseJson).toBe(false);
});
});
it("should include --cwd with correct path", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
});
const result = await invoker.buildInvocation(
makeParams({ cwd: "C:\\projects\\myapp" }),
);
const cwdIdx = result.args.indexOf("--cwd");
expect(cwdIdx).toBeGreaterThanOrEqual(0);
expect(result.args[cwdIdx + 1]).toBe("C:\\projects\\myapp");
});
});
it("should include --mode with correct mode", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
windowsSandboxMode: "workspace-write",
});
const result = await invoker.buildInvocation(makeParams());
const modeIdx = result.args.indexOf("--mode");
expect(modeIdx).toBeGreaterThanOrEqual(0);
expect(result.args[modeIdx + 1]).toBe("workspace-write");
});
});
it("should place original command and args after -- separator", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
});
const params = makeParams({ command: "node.exe", args: ["--version"] });
const result = await invoker.buildInvocation(params);
const separatorIdx = result.args.indexOf("--");
expect(separatorIdx).toBeGreaterThanOrEqual(0);
const afterSeparator = result.args.slice(separatorIdx + 1);
expect(afterSeparator[0]).toBe("node.exe");
expect(afterSeparator[1]).toBe("--version");
});
});
});
describe("sandbox mode (strict / compat / permissive)", () => {
it("strict: should limit writable_roots to first path only", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
windowsSandboxMode: "workspace-write",
mode: "strict",
});
const params = makeParams({
writablePaths: ["C:\\workspace", "C:\\temp"],
});
const result = await invoker.buildInvocation(params);
const policyArgIdx = result.args.indexOf("--policy-json");
const policyStr = result.args[policyArgIdx + 1];
const policy = JSON.parse(policyStr);
expect(policy.type).toBe("workspace-write");
expect(policy.writable_roots).toEqual(["C:\\workspace"]);
});
});
it("strict: should NOT include --no-write-restricted", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
mode: "strict",
});
const result = await invoker.buildInvocation(makeParams());
expect(result.args).not.toContain("--no-write-restricted");
});
});
it("compat: should include all writable_roots", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
windowsSandboxMode: "workspace-write",
mode: "compat",
});
const params = makeParams({
writablePaths: ["C:\\workspace", "C:\\temp"],
});
const result = await invoker.buildInvocation(params);
const policyArgIdx = result.args.indexOf("--policy-json");
const policyStr = result.args[policyArgIdx + 1];
const policy = JSON.parse(policyStr);
expect(policy.writable_roots).toEqual(["C:\\workspace", "C:\\temp"]);
});
});
it("permissive: should include --no-write-restricted flag for run subcommand", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
mode: "permissive",
});
const result = await invoker.buildInvocation(
makeParams({ subcommand: "run" }),
);
expect(result.args).toContain("--no-write-restricted");
});
});
it("permissive: should NOT include --no-write-restricted for serve subcommand", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
mode: "permissive",
});
const result = await invoker.buildInvocation(
makeParams({ subcommand: "serve" }),
);
expect(result.args).not.toContain("--no-write-restricted");
});
});
it("permissive: should include all writable_roots", async () => {
await withPlatform("win32", async () => {
const invoker = new SandboxInvoker("windows-sandbox", {
windowsSandboxHelperPath: fakeHelperPath,
windowsSandboxMode: "workspace-write",
mode: "permissive",
});
const params = makeParams({
writablePaths: ["C:\\workspace", "C:\\temp"],
});
const result = await invoker.buildInvocation(params);
const policyArgIdx = result.args.indexOf("--policy-json");
const policyStr = result.args[policyArgIdx + 1];
const policy = JSON.parse(policyStr);
expect(policy.writable_roots).toEqual(["C:\\workspace", "C:\\temp"]);
});
});
});
});
// ============================================================================
// Suite: checkAvailable
// ============================================================================
describe("SandboxInvoker - checkAvailable", () => {
it("none type always returns true", async () => {
const invoker = new SandboxInvoker("none");
await expect(invoker.checkAvailable()).resolves.toBe(true);
});
it("docker type always returns false (not supported)", async () => {
const invoker = new SandboxInvoker("docker");
await expect(invoker.checkAvailable()).resolves.toBe(false);
});
it("macos-seatbelt checkAvailable does not throw", async () => {
const invoker = new SandboxInvoker("macos-seatbelt");
const result = await invoker.checkAvailable();
expect(typeof result).toBe("boolean");
});
it("unknown type returns false", async () => {
// Use an invalid type that doesn't exist in SandboxType union
const invoker = new SandboxInvoker("completely-invalid-type" as any);
await expect(invoker.checkAvailable()).resolves.toBe(false);
});
});
// ============================================================================
// Suite: 错误处理
// ============================================================================
describe("SandboxInvoker - error handling", () => {
it("should throw for unsupported sandbox type", async () => {
const mockType = "invalid-type" as any;
const invoker = new SandboxInvoker(mockType);
await expect(invoker.buildInvocation(makeParams())).rejects.toThrow(
SandboxError,
);
await expect(invoker.buildInvocation(makeParams())).rejects.toMatchObject({
code: SandboxErrorCode.CONFIG_INVALID,
});
});
});
// ============================================================================
// Suite: effectiveMode 一致性
// ============================================================================
describe("SandboxInvoker - effectiveMode consistency", () => {
it("defaults to compat when no mode is provided", async () => {
const invoker = new SandboxInvoker("none");
// No mode → defaults to compat. Verify by checking log output via
// buildBwrap on Linux (would log mode=compat).
await withPlatform("linux", async () => {
const result = (invoker as any).buildBwrap(makeParams());
// compat mode uses --ro-bind / /
expect(result.args).toContain("--ro-bind");
const roBindIdx = result.args.indexOf("--ro-bind");
expect(result.args[roBindIdx + 1]).toBe("/");
});
});
it("respects explicit mode passed in options", async () => {
const invoker = new SandboxInvoker("none", { mode: "strict" });
await withPlatform("linux", async () => {
const result = (invoker as any).buildBwrap(makeParams());
// strict mode does NOT use --ro-bind / /
const fullRootBind = result.args.findIndex(
(arg: string, i: number) =>
arg === "--ro-bind" &&
result.args[i + 1] === "/" &&
result.args[i + 2] === "/",
);
expect(fullRootBind).toBe(-1);
});
});
});
// ============================================================================
// Suite: Linux strict ro-bind 包含 /sbin 和 /usr/local
// ============================================================================
describe("SandboxInvoker - linux strict ro-bind paths", () => {
it("should include /sbin and /usr/local in strict mode", async () => {
await withPlatform("linux", async () => {
mockFsExistsSync.mockReturnValue(true);
const invoker = new SandboxInvoker("linux-bwrap", { mode: "strict" });
const result = (invoker as any).buildBwrap(makeParams());
// Check that /sbin and /usr/local are in the ro-bind targets
const roBindTargets: string[] = [];
for (let i = 0; i < result.args.length; i++) {
if (result.args[i] === "--ro-bind") {
roBindTargets.push(result.args[i + 1]);
}
}
expect(roBindTargets).toContain("/sbin");
expect(roBindTargets).toContain("/usr/local");
expect(roBindTargets).toContain("/usr");
expect(roBindTargets).toContain("/bin");
expect(roBindTargets).toContain("/etc");
});
});
it("should skip non-existent paths in strict mode", async () => {
await withPlatform("linux", async () => {
// Make /sbin appear non-existent
mockFsExistsSync.mockImplementation((p: string) => {
if (p === "/sbin") return false;
return true;
});
const invoker = new SandboxInvoker("linux-bwrap", { mode: "strict" });
const result = (invoker as any).buildBwrap(makeParams());
const roBindTargets: string[] = [];
for (let i = 0; i < result.args.length; i++) {
if (result.args[i] === "--ro-bind") {
roBindTargets.push(result.args[i + 1]);
}
}
expect(roBindTargets).not.toContain("/sbin");
expect(roBindTargets).toContain("/usr"); // still present
});
});
});

View File

@@ -0,0 +1,528 @@
/**
* 统一沙箱调用构建器
*
* 合并 CommandSandbox 中 buildInvocation() 和 buildProcessInvocation()
* 的重复平台分支逻辑,提供单一入口构建所有平台的沙箱包装调用。
*
* 支持平台:
* - none直接执行
* - macos-seatbeltsandbox-exec
* - linux-bwrapbubblewrap
* - windows-sandboxqiming-sandbox-helper
*
* @version 1.0.0
* @updated 2026-04-03
*/
import * as fs from "fs";
import * as fsp from "fs/promises";
import * as os from "os";
import * as path from "path";
import log from "electron-log";
import { checkCommand } from "../system/shellEnv";
import { createPlatformAdapter } from "../system/platformAdapter";
import type {
SandboxMode,
SandboxType,
WindowsSandboxMode,
} from "@shared/types/sandbox";
import { SandboxError, SandboxErrorCode } from "@shared/errors/sandbox";
// ============================================================================
// 类型
// ============================================================================
/** 沙箱调用器配置 */
export interface SandboxInvokerOptions {
/** Linux bwrap 二进制路径(可选,默认 PATH 查找) */
linuxBwrapPath?: string;
/** Windows sandbox helper 路径 */
windowsSandboxHelperPath?: string;
/** Windows sandbox 模式 */
windowsSandboxMode?: WindowsSandboxMode;
/** 是否允许网络访问 */
networkEnabled?: boolean;
/** 沙箱模式 */
mode?: SandboxMode;
}
/** 沙箱调用参数 */
export interface SandboxInvocationParams {
/** 待包装的命令 */
command: string;
/** 命令参数 */
args: string[];
/** 工作目录 */
cwd: string;
/** 环境变量 */
env?: Record<string, string>;
/** 可写路径列表 */
writablePaths: string[];
/** 是否允许网络访问 */
networkEnabled: boolean;
/** 子命令模式run=捕获输出返回 JSONserve=双向 stdio 转发 */
subcommand?: "run" | "serve";
/** 额外可执行路径白名单compat 启动链路) */
startupExecAllowlist?: string[];
}
/** 沙箱包装后的调用描述 */
export interface Invocation {
/** 包装后的命令(可能是 sandbox-exec / bwrap / qiming-sandbox-helper */
command: string;
/** 包装后的参数 */
args: string[];
/** 工作目录 */
cwd: string;
/** 环境变量 */
env?: Record<string, string>;
/** helper run 模式返回 JSON stdout需要解析 */
parseJson?: boolean;
/** macOS seatbelt profile 文件路径(调用方负责清理) */
seatbeltProfilePath?: string;
}
// ============================================================================
// SandboxInvoker
// ============================================================================
/**
* 统一沙箱调用构建器
*
* 所有平台分支逻辑集中在此类中CommandSandbox 和 sandboxProcessWrapper
* 均委托给它来构建调用。
*/
export class SandboxInvoker {
private readonly type: SandboxType;
private readonly options: SandboxInvokerOptions;
/** Resolved sandbox mode (strict / compat / permissive), used by all platform builders */
private readonly effectiveMode: SandboxMode;
constructor(type: SandboxType, options: SandboxInvokerOptions = {}) {
this.type = type;
this.options = options;
this.effectiveMode = options.mode ?? "compat";
}
/**
* 统一构建沙箱包装调用
*/
async buildInvocation(params: SandboxInvocationParams): Promise<Invocation> {
switch (this.type) {
case "none":
return this.buildNone(params);
case "macos-seatbelt":
return this.buildSeatbelt(params);
case "linux-bwrap":
return this.buildBwrap(params);
case "windows-sandbox":
return this.buildWindowsHelper(params);
case "docker":
log.warn(
"[SandboxInvoker] Docker process-level sandbox not supported yet, returning unwrapped call",
);
return {
command: params.command,
args: params.args,
cwd: params.cwd,
env: params.env,
};
default:
throw new SandboxError(
`Unsupported sandbox type: ${String(this.type)}`,
SandboxErrorCode.CONFIG_INVALID,
);
}
}
/**
* 检测当前后端是否可用
*/
async checkAvailable(): Promise<boolean> {
const platformAdapter = createPlatformAdapter();
switch (this.type) {
case "none":
return true;
case "macos-seatbelt":
return (
platformAdapter.isMacOS && fs.existsSync("/usr/bin/sandbox-exec")
);
case "linux-bwrap": {
if (!platformAdapter.isLinux) return false;
if (
this.options.linuxBwrapPath &&
fs.existsSync(this.options.linuxBwrapPath)
) {
return true;
}
return checkCommand("bwrap");
}
case "windows-sandbox": {
if (!platformAdapter.isWindows) return false;
return (
!!this.options.windowsSandboxHelperPath &&
fs.existsSync(this.options.windowsSandboxHelperPath)
);
}
default:
return false;
}
}
// ============================================================================
// 平台实现
// ============================================================================
private buildNone(params: SandboxInvocationParams): Invocation {
return {
command: params.command,
args: params.args,
cwd: params.cwd,
env: params.env,
};
}
private async buildSeatbelt(
params: SandboxInvocationParams,
): Promise<Invocation> {
const mode = this.effectiveMode;
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const profilePath = path.join(
fs.realpathSync(os.tmpdir()),
`qimingclaw-sandbox-${uniqueSuffix}.sb`,
);
const profile = this.buildSeatbeltProfile(
params.command,
params.writablePaths,
params.networkEnabled,
params.startupExecAllowlist,
);
await fsp.writeFile(profilePath, profile, "utf-8");
log.info("[SandboxInvoker] seatbelt profile written:", {
profilePath,
mode,
command: params.command,
startupExecAllowlistCount: params.startupExecAllowlist?.length ?? 0,
networkEnabled: params.networkEnabled,
});
return {
command: "/usr/bin/sandbox-exec",
args: ["-f", profilePath, params.command, ...params.args],
cwd: params.cwd,
env: params.env,
seatbeltProfilePath: profilePath,
};
}
private buildBwrap(params: SandboxInvocationParams): Invocation {
const bwrapPath = this.options.linuxBwrapPath || "bwrap";
const mode = this.effectiveMode;
const permissive = mode === "permissive";
const strict = mode === "strict";
const bwrapArgs: string[] = ["--die-with-parent", "--new-session"];
if (permissive) {
// 宽松模式用于排障,不作为默认安全策略。
bwrapArgs.push(
"--bind",
"/",
"/",
"--dev-bind",
"/dev",
"/dev",
"--proc",
"/proc",
);
} else {
bwrapArgs.push(
"--unshare-user-try",
"--unshare-pid",
"--unshare-uts",
"--unshare-cgroup-try",
...(params.networkEnabled ? [] : ["--unshare-net"]),
"--dev-bind",
"/dev/null",
"/dev/null",
"--dev-bind",
"/dev/urandom",
"/dev/urandom",
"--dev-bind",
"/dev/zero",
"/dev/zero",
"--proc",
"/proc",
"--tmpfs",
"/tmp",
);
if (strict) {
const roBindTargets = new Set<string>();
const addRoBind = (p: string) => {
if (!p || !path.isAbsolute(p) || !fs.existsSync(p)) return;
roBindTargets.add(p);
try {
const resolved = fs.realpathSync(p);
if (resolved !== p && fs.existsSync(resolved)) {
roBindTargets.add(resolved);
}
} catch {
// ignore realpath failures
}
};
// Minimal system runtime surface
for (const p of [
"/usr",
"/bin",
"/sbin",
"/lib",
"/lib64",
"/etc",
"/opt",
"/usr/local",
]) {
addRoBind(p);
}
// Ensure launched binaries/scripts are visible inside strict bwrap
addRoBind(path.dirname(params.command));
for (const arg of params.args) {
if (!path.isAbsolute(arg)) continue;
addRoBind(
fs.existsSync(arg) && fs.statSync(arg).isDirectory()
? arg
: path.dirname(arg),
);
}
for (const p of roBindTargets) {
bwrapArgs.push("--ro-bind", p, p);
}
} else {
bwrapArgs.push("--ro-bind", "/", "/");
}
for (const wp of params.writablePaths) {
bwrapArgs.push("--bind", wp, wp);
}
}
bwrapArgs.push("--chdir", params.cwd, "--", params.command, ...params.args);
log.info("[SandboxInvoker] bwrap invocation:", {
bwrapPath,
mode,
permissive,
networkEnabled: params.networkEnabled,
cwd: params.cwd,
writablePathCount: params.writablePaths.length,
});
return {
command: bwrapPath,
args: bwrapArgs,
cwd: params.cwd,
env: params.env,
};
}
private buildWindowsHelper(params: SandboxInvocationParams): Invocation {
const helper = this.options.windowsSandboxHelperPath;
if (!helper || !fs.existsSync(helper)) {
throw new SandboxError(
"Sandbox helper not found",
SandboxErrorCode.SANDBOX_UNAVAILABLE,
);
}
const sandboxMode = this.effectiveMode;
const winMode = this.options.windowsSandboxMode ?? "workspace-write";
const subcommand = params.subcommand ?? "run";
const sandboxPolicy: Record<string, unknown> = {
type: winMode === "read-only" ? "read-only" : "workspace-write",
network_access: params.networkEnabled,
sandbox_mode: sandboxMode, // strict/compat/permissive — Rust helper uses this for APPDATA allowance
};
// Writable roots:
// - strict: only keep the first root (workspace-first contract)
// - compat/permissive: keep full writable roots list
if (winMode === "workspace-write" && params.writablePaths.length > 0) {
sandboxPolicy.writable_roots =
sandboxMode === "strict"
? [params.writablePaths[0]]
: params.writablePaths;
}
const helperArgs = [
subcommand,
"--mode",
winMode,
"--cwd",
params.cwd,
"--policy-json",
JSON.stringify(sandboxPolicy),
];
// Permissive mode: relax token-level write restrictions so child
// processes (e.g. Git Bash) can create pipes and modify DACLs.
// Only valid for the "run" subcommand.
if (sandboxMode === "permissive" && subcommand === "run") {
helperArgs.push("--no-write-restricted");
}
// Serve mode: never enable WRITE_RESTRICTED.
// WRITE_RESTRICTED adds restricting SIDs (logon, everyone, capability) to the
// token, which blocks the restricted process from spawning child processes.
// In serve mode the ACP engine (claude-code-acp-ts / qimingcode) MUST spawn
// MCP server sub-processes during session/new — restricting SIDs causes
// EPERM on every child spawn, making the session unusable.
//
// Filesystem write protection in serve mode is enforced by:
// 1. DACL ACEs (ALLOW paths applied by the sandbox helper)
// 2. sandboxed-bash MCP + sandboxed-fs MCP (tool-level interception)
// 3. evaluateStrictWritePermission proactive guard (qimingcode)
const serveWriteRestricted = false;
if (subcommand === "serve") {
log.info(
"[SandboxInvoker] WRITE_RESTRICTED disabled for serve mode (spawn EPERM prevention)",
);
}
helperArgs.push("--", params.command, ...params.args);
log.info("[SandboxInvoker] windows-sandbox invocation:", {
helper,
subcommand,
sandboxMode,
winMode,
serveWriteRestricted,
cwd: params.cwd,
policy: sandboxPolicy,
});
return {
command: helper,
args: helperArgs,
cwd: params.cwd,
env: params.env,
parseJson: subcommand === "run",
};
}
// ============================================================================
// macOS Seatbelt Profile 构建
// ============================================================================
/**
* 构建 macOS Seatbelt 沙箱 profile
*
* 注意macOS 上 /var 是 /private/var 的符号链接,
* seatbelt 使用真实路径匹配,因此需要对可写路径做 realpath 解析。
*/
private buildSeatbeltProfile(
command: string,
writablePaths: string[],
networkEnabled: boolean,
startupExecAllowlist: string[] = [],
): string {
const mode = this.effectiveMode;
const permissive = mode === "permissive";
const compat = mode === "compat";
const lines: string[] = ["(version 1)", "(deny default)"];
if (networkEnabled) {
lines.push("(allow network*)");
}
lines.push("(allow file-read*)");
if (permissive) {
lines.push(
"(allow file-write*)",
"(allow process-exec)",
"(allow signal)",
);
log.warn("[SandboxInvoker] seatbelt permissive mode enabled", {
command,
startupExecAllowlistCount: startupExecAllowlist.length,
});
} else {
lines.push(
'(allow process-exec (regex #"^/usr/bin/"))',
'(allow process-exec (regex #"^/bin/"))',
'(allow process-exec (regex #"^/usr/lib/"))',
);
const execAllow = new Set<string>([command]);
// strict mode keeps a minimal exec surface and does not include
// startup chain allowlist entries.
if (compat) {
for (const p of startupExecAllowlist) execAllow.add(p);
}
for (const p of execAllow) {
if (!p || !path.isAbsolute(p)) continue;
const addPath = (candidate: string) => {
lines.push(`(allow process-exec (literal "${candidate}"))`);
};
addPath(p);
try {
const resolved = fs.realpathSync(p);
if (resolved !== p) addPath(resolved);
} catch {
// ignore realpath failures for non-existing startup path
}
}
log.info("[SandboxInvoker] seatbelt exec allowlist resolved", {
mode,
command,
execAllowCount: execAllow.size,
includeStartupChain: compat,
});
lines.push("(allow signal (target self))");
}
lines.push(
"(allow process-fork)",
"(allow sysctl-read)",
"(allow mach-lookup)",
"(allow ipc-posix*)",
"(allow file-lock)",
);
// 可写路径 — 同时添加原始路径和 realpath处理 macOS 符号链接)
// 对每个可写路径,同时允许 process-exec引擎内部二进制如 rg 需要执行)
if (!permissive) {
const seen = new Set<string>();
for (const wp of writablePaths) {
const pathsToAdd = [wp];
try {
const resolved = fs.realpathSync(wp);
if (resolved !== wp) {
pathsToAdd.push(resolved);
}
} catch {
// 路径尚不存在,跳过 realpath
}
for (const p of pathsToAdd) {
if (!seen.has(p)) {
seen.add(p);
lines.push(`(allow file-write* (subpath "${p}"))`);
lines.push(`(allow process-exec (subpath "${p}"))`);
}
}
}
}
// 必要的系统写入
lines.push(
'(allow file-write* (literal "/dev/null"))',
'(allow file-write* (literal "/dev/dtracehelper"))',
'(allow file-write* (literal "/dev/urandom"))',
);
return lines.join("\n") + "\n";
}
}
export default SandboxInvoker;

View File

@@ -0,0 +1,371 @@
/**
* 沙箱管理器抽象基类
*
* 定义所有沙箱实现必须遵循的接口规范。
* 支持的沙箱类型Docker、macOS Seatbelt、Linux bwrap、Windows Sandbox
*
* @version 1.1.0
* @updated 2026-04-03
*/
import { EventEmitter } from "events";
import type {
SandboxConfig,
SandboxType,
Workspace,
ExecuteOptions,
ExecuteResult,
FileInfo,
SandboxStatus,
CleanupResult,
RetentionPolicy,
} from "@shared/types/sandbox";
import {
SandboxError,
SandboxErrorCode,
WorkspaceError,
isSandboxError,
} from "@shared/errors/sandbox";
import {
createPlatformAdapter,
isSupportedPlatform,
} from "../system/platformAdapter";
/**
* 沙箱管理器抽象基类
*/
export abstract class SandboxManager extends EventEmitter {
protected config: SandboxConfig;
protected workspaces: Map<string, Workspace> = new Map();
protected initialized: boolean = false;
constructor(config: SandboxConfig) {
super();
this.config = config;
}
// ============================================================================
// 抽象方法 - 子类必须实现
// ============================================================================
/**
* 初始化沙箱
* - 检查沙箱环境是否可用
* - 准备必要的资源
*/
abstract init(): Promise<void>;
/**
* 检查沙箱是否可用
*/
abstract isAvailable(): Promise<boolean>;
/**
* 创建工作区
* @param sessionId 会话 ID
*/
abstract createWorkspace(sessionId: string): Promise<Workspace>;
/**
* 销毁工作区
* @param sessionId 会话 ID
*/
abstract destroyWorkspace(sessionId: string): Promise<void>;
/**
* 在沙箱中执行命令
* @param sessionId 会话 ID
* @param command 命令
* @param args 参数
* @param options 执行选项
*/
abstract execute(
sessionId: string,
command: string,
args: string[],
options?: ExecuteOptions,
): Promise<ExecuteResult>;
/**
* 读取文件
* @param sessionId 会话 ID
* @param path 文件路径
*/
abstract readFile(sessionId: string, path: string): Promise<string>;
/**
* 写入文件
* @param sessionId 会话 ID
* @param path 文件路径
* @param content 文件内容
*/
abstract writeFile(
sessionId: string,
path: string,
content: string,
): Promise<void>;
/**
* 读取目录
* @param sessionId 会话 ID
* @param path 目录路径
*/
abstract readDir(sessionId: string, path: string): Promise<FileInfo[]>;
/**
* 删除文件
* @param sessionId 会话 ID
* @param path 文件路径
*/
abstract deleteFile(sessionId: string, path: string): Promise<void>;
/**
* 清理所有资源
*/
abstract cleanup(): Promise<CleanupResult>;
// ============================================================================
// 具体方法 - 通用实现
// ============================================================================
/**
* 获取工作区
* @param sessionId 会话 ID
*/
getWorkspace(sessionId: string): Workspace | undefined {
return this.workspaces.get(sessionId);
}
/**
* 列出所有工作区
*/
listWorkspaces(): Workspace[] {
return Array.from(this.workspaces.values());
}
/**
* 检查工作区是否存在
* @param sessionId 会话 ID
*/
hasWorkspace(sessionId: string): boolean {
return this.workspaces.has(sessionId);
}
/**
* 获取活跃工作区数量
*/
getActiveWorkspaceCount(): number {
return this.workspaces.size;
}
/**
* 获取沙箱状态
*/
async getStatus(): Promise<SandboxStatus> {
const available = await this.isAvailable();
return {
available,
type: this.config.type,
platform: this.config.platform,
activeWorkspaces: this.workspaces.size,
};
}
/**
* 获取沙箱配置
*/
getConfig(): SandboxConfig {
return { ...this.config };
}
/**
* 检查是否已初始化
*/
isInitialized(): boolean {
return this.initialized;
}
/**
* 更新工作区最后访问时间
* @param sessionId 会话 ID
*/
protected updateLastAccessed(sessionId: string): void {
const workspace = this.workspaces.get(sessionId);
if (workspace) {
workspace.lastAccessedAt = new Date();
}
}
/**
* 验证工作区存在
* @param sessionId 会话 ID
* @throws WorkspaceError 如果工作区不存在
*/
protected validateWorkspaceExists(sessionId: string): Workspace {
const workspace = this.workspaces.get(sessionId);
if (!workspace) {
throw new WorkspaceError(
`Workspace not found: ${sessionId}`,
SandboxErrorCode.WORKSPACE_NOT_FOUND,
{ sessionId },
);
}
return workspace;
}
/**
* 验证路径在工作区内
* @param workspace 工作区
* @param path 要验证的路径
* @throws SandboxError 如果路径在工作区外
*/
protected validatePathInWorkspace(workspace: Workspace, path: string): void {
const normalizedPath = this.normalizePath(path);
const normalizedRoot = this.normalizePath(workspace.rootPath);
if (!normalizedPath.startsWith(normalizedRoot)) {
throw new SandboxError(
`Path outside workspace: ${path}`,
SandboxErrorCode.PERMISSION_DENIED,
{
sessionId: workspace.sessionId,
details: { path, rootPath: workspace.rootPath },
},
);
}
}
/**
* 规范化路径
* @param path 路径
*/
protected normalizePath(path: string): string {
// 子类可以覆盖此方法以处理平台特定的路径规范化
return path.replace(/\\/g, "/").toLowerCase();
}
/**
* 生成工作区 ID
* @param sessionId 会话 ID
*/
protected generateWorkspaceId(sessionId: string): string {
return `workspace-${sessionId}-${Date.now()}`;
}
/**
* 创建默认保留策略
*/
protected createDefaultRetentionPolicy(): RetentionPolicy {
return {
mode: "timeout",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天
maxWorkspaces: 10,
preserveOnError: false,
};
}
/**
* 发出事件
*/
protected emitEvent<T extends string>(event: T, data?: unknown): void {
this.emit(event, data);
}
/**
* 销毁(清理所有资源)
*/
async destroy(): Promise<void> {
try {
await this.cleanup();
this.workspaces.clear();
this.initialized = false;
this.removeAllListeners();
} catch (error) {
// 记录错误但不抛出,确保清理继续
console.error("[SandboxManager] Destroy error:", error);
}
}
// ============================================================================
// 静态工具方法
// ============================================================================
/**
* 检测当前平台
*/
static detectPlatform(): NodeJS.Platform {
if (isSupportedPlatform(process.platform)) {
return createPlatformAdapter(process.platform).platform;
}
return process.platform;
}
/**
* 获取推荐的沙箱类型(按平台)
*/
static getRecommendedSandboxType(): SandboxType {
if (!isSupportedPlatform(process.platform)) {
return "none";
}
return createPlatformAdapter(process.platform).getRecommendedSandboxType();
}
/**
* 解析内存限制字符串
* @param memoryLimit 内存限制(如 "2g", "512m"
* @returns 字节数
*/
static parseMemoryLimit(memoryLimit: string): number {
const match = memoryLimit.match(/^(\d+(?:\.\d+)?)([kmg]?)$/i);
if (!match) {
return 0;
}
const value = parseFloat(match[1]);
const unit = match[2].toLowerCase();
switch (unit) {
case "k":
return Math.floor(value * 1024);
case "m":
return Math.floor(value * 1024 * 1024);
case "g":
return Math.floor(value * 1024 * 1024 * 1024);
default:
return Math.floor(value);
}
}
/**
* 格式化内存大小
* @param bytes 字节数
* @returns 格式化字符串(如 "2 GB"
*/
static formatMemory(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(1)} ${units[unitIndex]}`;
}
/**
* 判断是否为沙箱错误
*/
static isSandboxError(error: unknown): error is SandboxError {
return isSandboxError(error);
}
}
/**
* 沙箱管理器类型
*/
export type SandboxManagerType = typeof SandboxManager;
export default SandboxManager;

View File

@@ -0,0 +1,650 @@
/**
* 工作区管理器
*
* 整合 SandboxManager 和 PermissionManager提供统一的工作区生命周期管理
*
* @version 1.0.0
* @updated 2026-03-22
*/
import { EventEmitter } from "events";
import log from "electron-log";
import type {
Workspace,
CreateWorkspaceOptions,
ExecuteOptions,
ExecuteResult,
RetentionPolicy,
CleanupResult,
PermissionType,
PermissionResult,
PermissionRequest,
SandboxStatus,
SandboxConfig,
FileInfo,
} from "@shared/types/sandbox";
import { SandboxManager } from "./SandboxManager";
import { PermissionManager } from "./PermissionManager";
import {
SandboxError,
SandboxErrorCode,
WorkspaceError,
toSandboxError,
} from "@shared/errors/sandbox";
/**
* 工作区管理器配置
*/
export interface WorkspaceManagerConfig {
/** 沙箱管理器 */
sandboxManager: SandboxManager;
/** 权限管理器 */
permissionManager: PermissionManager;
}
/**
* 工作区管理器
*
* 负责协调沙箱和权限管理,提供统一的工作区操作接口
*/
export class WorkspaceManager extends EventEmitter {
private sandboxManager: SandboxManager;
private permissionManager: PermissionManager;
constructor(config: WorkspaceManagerConfig) {
super();
this.sandboxManager = config.sandboxManager;
this.permissionManager = config.permissionManager;
// 转发沙箱事件
this.forwardSandboxEvents();
}
// ============================================================================
// 初始化
// ============================================================================
/**
* 初始化工作区管理器
*/
async init(): Promise<void> {
log.info("[WorkspaceManager] Initializing...");
try {
await this.sandboxManager.init();
log.info("[WorkspaceManager] Initialization complete");
} catch (error) {
log.error("[WorkspaceManager] Initialization failed:", error);
throw error;
}
}
/**
* 销毁工作区管理器
*/
async destroy(): Promise<void> {
log.info("[WorkspaceManager] Destroying...");
try {
await this.cleanupAll();
await this.sandboxManager.destroy();
this.removeAllListeners();
log.info("[WorkspaceManager] Destruction complete");
} catch (error) {
log.error("[WorkspaceManager] Destruction failed:", error);
}
}
// ============================================================================
// 工作区生命周期
// ============================================================================
/**
* 创建工作区
* @param sessionId 会话 ID
* @param options 创建选项
*/
async create(
sessionId: string,
options: CreateWorkspaceOptions = {},
): Promise<Workspace> {
log.info("[WorkspaceManager] Creating workspace:", sessionId);
try {
// 创建沙箱工作区
const workspace = await this.sandboxManager.createWorkspace(sessionId);
// 应用保留策略
if (options.retention) {
workspace.retentionPolicy = {
...workspace.retentionPolicy,
...options.retention,
};
}
// 发出事件
this.emitEvent("workspace:created", { workspace });
log.info("[WorkspaceManager] Workspace created successfully:", sessionId);
return workspace;
} catch (error) {
log.error("[WorkspaceManager] Failed to create workspace:", error);
throw toSandboxError(
error,
"Failed to create workspace",
SandboxErrorCode.WORKSPACE_CREATE_FAILED,
{
sessionId,
},
);
}
}
/**
* 兼容旧接口createWorkspace
*/
async createWorkspace(
sessionId: string,
options: CreateWorkspaceOptions = {},
): Promise<Workspace> {
return this.create(sessionId, options);
}
/**
* 销毁工作区
* @param sessionId 会话 ID
* @param force 是否强制销毁
*/
async destroyWorkspace(
sessionId: string,
force: boolean = false,
): Promise<void> {
log.info(
"[WorkspaceManager] Destroying workspace:",
sessionId,
"force:",
force,
);
try {
// 清理会话权限缓存
this.permissionManager.clearSessionCache(sessionId);
// 销毁沙箱工作区
await this.sandboxManager.destroyWorkspace(sessionId);
// 发出事件
this.emitEvent("workspace:destroyed", {
workspaceId: sessionId,
sessionId,
});
log.info(
"[WorkspaceManager] Workspace destroyed successfully:",
sessionId,
);
} catch (error) {
log.error("[WorkspaceManager] Failed to destroy workspace:", error);
if (force) {
// 强制模式:忽略错误
log.warn("[WorkspaceManager] Force destroying, ignoring errors");
} else {
throw toSandboxError(
error,
"Failed to destroy workspace",
SandboxErrorCode.WORKSPACE_DESTROY_FAILED,
{
sessionId,
},
);
}
}
}
/**
* 获取工作区
* @param sessionId 会话 ID
*/
async get(sessionId: string): Promise<Workspace | undefined> {
return this.sandboxManager.getWorkspace(sessionId);
}
/**
* 兼容旧接口getWorkspace同步
*/
getWorkspace(sessionId: string): Workspace | undefined {
return this.sandboxManager.getWorkspace(sessionId);
}
/**
* 列出所有工作区
*/
async list(): Promise<Workspace[]> {
return this.sandboxManager.listWorkspaces();
}
/**
* 兼容旧接口listWorkspaces同步
*/
listWorkspaces(): Workspace[] {
return this.sandboxManager.listWorkspaces();
}
// ============================================================================
// 工作区操作(带权限检查)
// ============================================================================
/**
* 读取文件(带权限检查)
* @param sessionId 会话 ID
* @param path 文件路径
*/
async readFile(sessionId: string, path: string): Promise<string> {
// 检查权限
await this.checkPermission(sessionId, "file:read", path);
return this.sandboxManager.readFile(sessionId, path);
}
/**
* 写入文件(带权限检查)
* @param sessionId 会话 ID
* @param path 文件路径
* @param content 文件内容
*/
async writeFile(
sessionId: string,
path: string,
content: string,
): Promise<void> {
// 检查权限
await this.checkPermission(sessionId, "file:write", path);
return this.sandboxManager.writeFile(sessionId, path, content);
}
/**
* 读取目录
* @param sessionId 会话 ID
* @param path 目录路径
*/
async readDir(sessionId: string, path: string): Promise<FileInfo[]> {
// 读取目录通常视为 file:read 权限
await this.checkPermission(sessionId, "file:read", path);
return this.sandboxManager.readDir(sessionId, path);
}
/**
* 删除文件(带权限检查)
* @param sessionId 会话 ID
* @param path 文件路径
*/
async deleteFile(sessionId: string, path: string): Promise<void> {
// 检查权限
await this.checkPermission(sessionId, "file:delete", path);
return this.sandboxManager.deleteFile(sessionId, path);
}
/**
* 执行命令(带权限检查)
* @param sessionId 会话 ID
* @param command 命令
* @param args 参数
* @param options 执行选项
*/
async execute(
sessionId: string,
command: string,
args: string[] = [],
options: ExecuteOptions = {},
): Promise<ExecuteResult> {
// 跳过权限检查(如果明确指定)
if (!options.skipPermissionCheck) {
const commandStr = `${command} ${args.join(" ")}`.trim();
try {
await this.checkPermission(sessionId, "command:execute", commandStr);
} catch (error) {
throw error;
}
}
const result = await this.sandboxManager.execute(
sessionId,
command,
args,
options,
);
return result;
}
// ============================================================================
// 权限管理
// ============================================================================
/**
* 检查权限
* @param sessionId 会话 ID
* @param type 权限类型
* @param target 目标资源
*/
async checkPermission(
sessionId: string,
type: PermissionType,
target: string,
): Promise<PermissionResult> {
// 验证工作区存在
const workspace = this.sandboxManager.getWorkspace(sessionId);
if (!workspace) {
throw new WorkspaceError(
`Workspace not found: ${sessionId}`,
SandboxErrorCode.WORKSPACE_NOT_FOUND,
{ sessionId },
);
}
// 检查路径是否在工作区内(对于文件操作)
if (this.isPathPermission(type)) {
this.validatePathInWorkspace(workspace, target);
}
return this.permissionManager.checkPermission(sessionId, type, target);
}
/**
* 请求权限
* @param sessionId 会话 ID
* @param type 权限类型
* @param target 目标资源
* @param reason 请求原因
*/
async requestPermission(
sessionId: string,
type: PermissionType,
target: string,
reason?: string,
): Promise<void> {
// 验证工作区存在
const workspace = this.sandboxManager.getWorkspace(sessionId);
if (!workspace) {
throw new WorkspaceError(
`Workspace not found: ${sessionId}`,
SandboxErrorCode.WORKSPACE_NOT_FOUND,
{ sessionId },
);
}
await this.permissionManager.requestPermission(
sessionId,
type,
target,
reason,
);
}
/**
* 批准权限请求
* @param requestId 请求 ID
*/
async approvePermission(requestId: string): Promise<void> {
await this.permissionManager.approve(requestId, "user");
}
/**
* 拒绝权限请求
* @param requestId 请求 ID
* @param reason 原因
*/
async denyPermission(requestId: string, reason?: string): Promise<void> {
await this.permissionManager.deny(requestId, reason);
}
/**
* 获取待处理的权限请求
* @param sessionId 可选的会话 ID 过滤
*/
getPendingPermissionRequests(sessionId?: string): PermissionRequest[] {
return this.permissionManager.getPendingRequests(sessionId);
}
// ============================================================================
// 清理
// ============================================================================
/**
* 清理过期的工作区
*/
async cleanupExpired(): Promise<CleanupResult> {
log.info("[WorkspaceManager] Cleaning expired workspaces...");
const workspaces = await this.list();
const now = Date.now();
const result: CleanupResult = {
deletedCount: 0,
freedSpace: 0,
errors: [],
};
for (const workspace of workspaces) {
const policy = workspace.retentionPolicy;
// 检查是否过期
let shouldCleanup = false;
if (policy.mode === "timeout" && policy.maxAge) {
const age = now - workspace.lastAccessedAt.getTime();
if (age > policy.maxAge) {
shouldCleanup = true;
}
}
// 检查工作区数量限制
if (policy.maxWorkspaces && workspaces.length > policy.maxWorkspaces) {
// 按访问时间排序,清理最旧的
const sorted = [...workspaces].sort(
(a, b) => a.lastAccessedAt.getTime() - b.lastAccessedAt.getTime(),
);
const toRemove = sorted.slice(
0,
workspaces.length - policy.maxWorkspaces,
);
if (toRemove.some((w) => w.id === workspace.id)) {
shouldCleanup = true;
}
}
if (shouldCleanup) {
try {
await this.destroyWorkspace(workspace.sessionId);
result.deletedCount++;
} catch (error) {
result.errors.push(
`Failed to clean workspace ${workspace.sessionId}: ${error}`,
);
}
}
}
log.info("[WorkspaceManager] Cleanup complete:", result);
this.emitEvent("cleanup:complete", { result });
return result;
}
/**
* 兼容旧接口cleanup
*/
async cleanup(): Promise<CleanupResult> {
return this.cleanupExpired();
}
/**
* 清理所有工作区
*/
async cleanupAll(): Promise<void> {
log.info("[WorkspaceManager] Cleaning all workspaces...");
const workspaces = await this.list();
for (const workspace of workspaces) {
try {
await this.destroyWorkspace(workspace.sessionId, true);
} catch (error) {
log.error(
"[WorkspaceManager] Failed to clean workspace:",
workspace.sessionId,
error,
);
}
}
log.info("[WorkspaceManager] All workspaces cleaned");
}
// ============================================================================
// 保留策略
// ============================================================================
/**
* 设置保留策略
* @param sessionId 会话 ID
* @param policy 保留策略
*/
async setRetentionPolicy(
sessionId: string,
policy: Partial<RetentionPolicy>,
): Promise<void> {
const workspace = this.sandboxManager.getWorkspace(sessionId);
if (!workspace) {
throw new WorkspaceError(
`Workspace not found: ${sessionId}`,
SandboxErrorCode.WORKSPACE_NOT_FOUND,
{ sessionId },
);
}
workspace.retentionPolicy = {
...workspace.retentionPolicy,
...policy,
};
}
/**
* 获取保留策略
* @param sessionId 会话 ID
*/
async getRetentionPolicy(sessionId: string): Promise<RetentionPolicy> {
const workspace = this.sandboxManager.getWorkspace(sessionId);
if (!workspace) {
throw new WorkspaceError(
`Workspace not found: ${sessionId}`,
SandboxErrorCode.WORKSPACE_NOT_FOUND,
{ sessionId },
);
}
return workspace.retentionPolicy;
}
// ============================================================================
// 状态
// ============================================================================
/**
* 获取沙箱状态
*/
async getStatus(): Promise<SandboxStatus> {
return this.sandboxManager.getStatus();
}
/**
* 获取沙箱配置
*/
getConfig(): SandboxConfig {
return this.sandboxManager.getConfig();
}
/**
* 检查沙箱是否可用
*/
async isAvailable(): Promise<boolean> {
return this.sandboxManager.isAvailable();
}
// ============================================================================
// 私有方法
// ============================================================================
/**
* 检查是否是路径权限
*/
private isPathPermission(type: PermissionType): boolean {
return ["file:read", "file:write", "file:delete"].includes(type);
}
/**
* 验证路径在工作区内
*/
private validatePathInWorkspace(workspace: Workspace, path: string): void {
const normalizedPath = path.replace(/\\/g, "/").toLowerCase();
const normalizedRoot = workspace.rootPath.replace(/\\/g, "/").toLowerCase();
if (!normalizedPath.startsWith(normalizedRoot)) {
throw new SandboxError(
`Path outside workspace: ${path}`,
SandboxErrorCode.PERMISSION_DENIED,
{
sessionId: workspace.sessionId,
details: { path, rootPath: workspace.rootPath },
},
);
}
}
/**
* 转发沙箱事件
*/
private forwardSandboxEvents(): void {
const events = [
"workspace:created",
"workspace:destroyed",
"workspace:accessed",
"execute:start",
"execute:complete",
"execute:error",
"cleanup:complete",
"sandbox:unavailable",
"sandbox:recovered",
];
for (const event of events) {
this.sandboxManager.on(event, (data) => {
this.emitEvent(event, data);
});
}
// 转发权限事件
const permissionEvents = [
"permission:requested",
"permission:approved",
"permission:denied",
];
for (const event of permissionEvents) {
this.permissionManager.on(event, (data) => {
this.emitEvent(event, data);
});
}
}
/**
* 发出事件
*/
private emitEvent<T extends string>(event: T, data?: unknown): void {
this.emit(event, data);
}
}
export default WorkspaceManager;

View File

@@ -0,0 +1,37 @@
/**
* 沙箱模块导出
*
* @version 2.0.0
* @updated 2026-04-03
*/
export { SandboxManager } from "./SandboxManager";
export { DockerSandbox, type DockerSandboxConfig } from "./DockerSandbox";
export { CommandSandbox } from "./CommandSandbox";
export { SandboxInvoker } from "./SandboxInvoker";
export { SandboxFileOperations } from "./SandboxFileOperations";
export {
PermissionManager,
DEFAULT_PERMISSION_POLICY,
} from "./PermissionManager";
export {
WorkspaceManager,
type WorkspaceManagerConfig,
} from "./WorkspaceManager";
export { AuditLogger } from "./AuditLogger";
export {
DEFAULT_SANDBOX_POLICY,
SANDBOX_POLICY_KEY,
getSandboxPolicy,
setSandboxPolicy,
getSandboxCapabilities,
resolveSandboxType,
getBundledLinuxBwrapPath,
getBundledWindowsSandboxHelperPath,
getBundledSandboxHelperPath,
} from "./policy";
export {
startSandboxService,
stopSandboxService,
getSandboxServiceStatus,
} from "./serviceBootstrap";

View File

@@ -0,0 +1,538 @@
/**
* 单元测试: sandbox/policy
*
* 测试沙箱策略解析与能力探测
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock electron
vi.mock("electron", () => ({
app: {
getPath: vi.fn((name: string) => {
if (name === "home") return "/mock/home";
return "/mock/appdata";
}),
getAppPath: vi.fn(() => "/mock/app"),
emit: vi.fn(),
},
}));
// Mock electron-log
vi.mock("electron-log", () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Mock fs - configured per test
const mockExistsSync = vi.fn(() => true);
vi.mock("fs", () => ({
existsSync: (...args: unknown[]) => mockExistsSync(...args),
}));
// Mock db
const mockReadSetting = vi.fn(() => ({}));
const mockWriteSetting = vi.fn();
vi.mock("../../db", () => ({
readSetting: (...args: unknown[]) => mockReadSetting(...args),
writeSetting: (...args: unknown[]) => mockWriteSetting(...args),
}));
// Mock checkCommand
const mockCheckCommand = vi.fn(() => Promise.resolve(true));
vi.mock("../system/shellEnv", () => ({
checkCommand: (...args: unknown[]) => mockCheckCommand(...args),
}));
// Mock getResourcesPath
vi.mock("../system/dependencies", () => ({
getResourcesPath: vi.fn(() => "/mock/resources"),
}));
describe("sandbox/policy", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
// Default: all paths exist, commands available
mockExistsSync.mockReturnValue(true);
mockCheckCommand.mockResolvedValue(true);
mockReadSetting.mockReturnValue({});
// Default platform
Object.defineProperty(process, "platform", {
value: "darwin",
writable: true,
configurable: true,
});
});
afterEach(() => {
mockExistsSync.mockRestore?.();
});
describe("resolveSandboxType", () => {
describe("enabled=false", () => {
it("should return type=none with degraded=false when sandbox is disabled", async () => {
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: false, backend: "auto" as const };
const result = await resolveSandboxType(policy);
expect(result.type).toBe("none");
expect(result.degraded).toBe(false);
expect(result.reason).toBe("sandbox policy is disabled");
});
it("should ignore backend setting when disabled", async () => {
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: false, backend: "docker" as const };
const result = await resolveSandboxType(policy);
expect(result.type).toBe("none");
expect(result.degraded).toBe(false);
});
});
describe("enabled=true but all backends unavailable", () => {
it("should return degraded=true with type=none when all backends unavailable on darwin", async () => {
// Make seatbelt unavailable
mockExistsSync.mockImplementation((p: string) => {
if (p === "/usr/bin/sandbox-exec") return false;
return true;
});
mockCheckCommand.mockResolvedValue(false);
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: true, backend: "auto" as const };
const result = await resolveSandboxType(policy);
expect(result.type).toBe("none");
expect(result.degraded).toBe(true);
expect(result.reason).toBeTruthy();
expect(typeof result.reason).toBe("string");
});
it("should return degraded=true when docker unavailable", async () => {
// Make docker unavailable
mockCheckCommand.mockResolvedValue(false);
mockExistsSync.mockImplementation((p: string) => {
if (p === "/usr/bin/sandbox-exec") return false;
return false;
});
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: true, backend: "docker" as const };
const result = await resolveSandboxType(policy);
expect(result.type).toBe("none");
expect(result.degraded).toBe(true);
expect(result.reason).toContain("docker");
});
it("should return degraded=true when linux-bwrap unavailable on linux", async () => {
Object.defineProperty(process, "platform", {
value: "linux",
writable: true,
configurable: true,
});
// Make bwrap unavailable both as command and bundled
mockCheckCommand.mockResolvedValue(false);
mockExistsSync.mockImplementation((p: string) => {
if (p.includes("bwrap")) return false;
return true;
});
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: true, backend: "linux-bwrap" as const };
const result = await resolveSandboxType(policy);
expect(result.type).toBe("none");
expect(result.degraded).toBe(true);
expect(result.reason).toContain("bwrap");
});
it("should return degraded=true when windows-sandbox unavailable on windows", async () => {
Object.defineProperty(process, "platform", {
value: "win32",
writable: true,
configurable: true,
});
// Make sandbox helper unavailable
mockExistsSync.mockImplementation((p: string) => {
if (p.includes("qiming-sandbox-helper")) return false;
return true;
});
mockCheckCommand.mockResolvedValue(true);
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: true, backend: "windows-sandbox" as const };
const result = await resolveSandboxType(policy);
expect(result.type).toBe("none");
expect(result.degraded).toBe(true);
expect(result.reason).toContain("windows sandbox helper not found");
});
it("should throw SANDBOX_UNAVAILABLE when autoFallback=manual", async () => {
mockExistsSync.mockImplementation((p: string) => {
if (p === "/usr/bin/sandbox-exec") return false;
return true;
});
mockCheckCommand.mockResolvedValue(false);
const { resolveSandboxType } = await import("./policy");
await expect(
resolveSandboxType({
enabled: true,
backend: "auto",
mode: "strict",
autoFallback: "manual",
windowsMode: "workspace-write",
}),
).rejects.toMatchObject({
code: "SANDBOX_UNAVAILABLE",
});
});
});
describe("enabled=true and backend available", () => {
it("should return type=macos-seatbelt with degraded=false on darwin when seatbelt available", async () => {
// seatbelt exists
mockExistsSync.mockImplementation((p: string) => {
if (p === "/usr/bin/sandbox-exec") return true;
return true;
});
mockCheckCommand.mockResolvedValue(false); // docker not needed here
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: true, backend: "auto" as const };
const result = await resolveSandboxType(policy);
expect(result.type).toBe("macos-seatbelt");
expect(result.degraded).toBe(false);
});
it("should return type=linux-bwrap with degraded=false on linux when bwrap available", async () => {
Object.defineProperty(process, "platform", {
value: "linux",
writable: true,
configurable: true,
});
mockCheckCommand.mockResolvedValue(true); // bwrap command available
mockExistsSync.mockImplementation((p: string) => {
if (p.includes("bwrap")) return true;
return true;
});
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: true, backend: "auto" as const };
const result = await resolveSandboxType(policy);
expect(result.type).toBe("linux-bwrap");
expect(result.degraded).toBe(false);
});
it("should return type=linux-bwrap when using bundled bwrap on linux", async () => {
Object.defineProperty(process, "platform", {
value: "linux",
writable: true,
configurable: true,
});
// bwrap command not available, but bundled exists
mockCheckCommand.mockResolvedValue(false);
mockExistsSync.mockImplementation((p: string) => {
if (p === "/usr/bin/bwrap") return false;
if (p.includes("bwrap")) return true; // bundled path exists
return true;
});
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: true, backend: "linux-bwrap" as const };
const result = await resolveSandboxType(policy);
expect(result.type).toBe("linux-bwrap");
expect(result.degraded).toBe(false);
});
it("should return type=docker with degraded=false when docker available", async () => {
mockCheckCommand.mockResolvedValue(true); // docker available
mockExistsSync.mockImplementation(() => true);
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: true, backend: "docker" as const };
const result = await resolveSandboxType(policy);
expect(result.type).toBe("docker");
expect(result.degraded).toBe(false);
});
});
describe("backend=auto degradation logic", () => {
it("should auto map to macos-seatbelt on darwin", async () => {
mockExistsSync.mockImplementation((p: string) => {
if (p === "/usr/bin/sandbox-exec") return true;
return true;
});
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: true, backend: "auto" as const };
const result = await resolveSandboxType(policy);
expect(result.type).toBe("macos-seatbelt");
});
it("should auto map to linux-bwrap on linux", async () => {
Object.defineProperty(process, "platform", {
value: "linux",
writable: true,
configurable: true,
});
mockCheckCommand.mockResolvedValue(true);
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: true, backend: "auto" as const };
const result = await resolveSandboxType(policy);
expect(result.type).toBe("linux-bwrap");
});
it("should auto map to windows-sandbox on win32", async () => {
Object.defineProperty(process, "platform", {
value: "win32",
writable: true,
configurable: true,
});
mockExistsSync.mockImplementation((p: string) => {
if (p.includes("qiming-sandbox-helper")) return true;
return true;
});
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: true, backend: "auto" as const };
const result = await resolveSandboxType(policy);
expect(result.type).toBe("windows-sandbox");
});
});
describe("fallback result validation", () => {
it("should always return type=none when degraded, never another available type", async () => {
// This test verifies the core fallback invariant:
// when degraded=true, type must be "none", not any other sandbox type
// Scenario: auto backend on darwin, but seatbelt unavailable
Object.defineProperty(process, "platform", {
value: "darwin",
writable: true,
configurable: true,
});
mockExistsSync.mockImplementation((p: string) => {
if (p === "/usr/bin/sandbox-exec") return false;
return true;
});
mockCheckCommand.mockResolvedValue(false);
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: true, backend: "auto" as const };
const result = await resolveSandboxType(policy);
// The type must be "none" - not "macos-seatbelt" or any other type
expect(result.type).toBe("none");
expect(result.degraded).toBe(true);
expect(result.reason).toBeTruthy();
// Ensure it's not accidentally set to another type
expect(result.type).not.toBe("macos-seatbelt");
expect(result.type).not.toBe("docker");
expect(result.type).not.toBe("linux-bwrap");
expect(result.type).not.toBe("windows-sandbox");
});
it("should include non-empty reason when degraded=true", async () => {
Object.defineProperty(process, "platform", {
value: "linux",
writable: true,
configurable: true,
});
// All backends unavailable
mockCheckCommand.mockResolvedValue(false);
mockExistsSync.mockReturnValue(false);
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: true, backend: "auto" as const };
const result = await resolveSandboxType(policy);
expect(result.degraded).toBe(true);
expect(result.reason).toBeDefined();
expect(typeof result.reason).toBe("string");
expect(result.reason!.length).toBeGreaterThan(0);
});
it("should not have reason field when not degraded", async () => {
mockExistsSync.mockImplementation((p: string) => {
if (p === "/usr/bin/sandbox-exec") return true;
return true;
});
const { resolveSandboxType } = await import("./policy");
const policy = { enabled: true, backend: "auto" as const };
const result = await resolveSandboxType(policy);
expect(result.degraded).toBe(false);
expect(result.reason).toBeUndefined();
});
});
});
describe("getSandboxCapabilities", () => {
it("should detect docker availability", async () => {
mockCheckCommand.mockResolvedValue(true);
const { getSandboxCapabilities } = await import("./policy");
const caps = await getSandboxCapabilities();
expect(caps.docker.available).toBe(true);
});
it("should report docker unavailable when command not found", async () => {
mockCheckCommand.mockResolvedValue(false);
const { getSandboxCapabilities } = await import("./policy");
const caps = await getSandboxCapabilities();
expect(caps.docker.available).toBe(false);
});
it("should detect macos seatbelt on darwin", async () => {
mockExistsSync.mockImplementation((p: string) => {
if (p === "/usr/bin/sandbox-exec") return true;
return true;
});
const { getSandboxCapabilities } = await import("./policy");
const caps = await getSandboxCapabilities();
expect(caps.platform).toBe("darwin");
expect(caps.macosSeatbelt.available).toBe(true);
});
it("should not report macos seatbelt available on linux", async () => {
Object.defineProperty(process, "platform", {
value: "linux",
writable: true,
configurable: true,
});
const { getSandboxCapabilities } = await import("./policy");
const caps = await getSandboxCapabilities();
expect(caps.platform).toBe("linux");
expect(caps.macosSeatbelt.available).toBe(false);
expect(caps.macosSeatbelt.reason).toBe("not on macOS");
});
});
describe("getBundledLinuxBwrapPath", () => {
it("should return path when bundled bwrap exists", async () => {
mockExistsSync.mockImplementation((p: string) => {
if (p.includes("bwrap")) return true;
return false;
});
const { getBundledLinuxBwrapPath } = await import("./policy");
const result = getBundledLinuxBwrapPath();
expect(result).toContain("bwrap");
});
it("should return null when no bundled bwrap found", async () => {
mockExistsSync.mockReturnValue(false);
const { getBundledLinuxBwrapPath } = await import("./policy");
const result = getBundledLinuxBwrapPath();
expect(result).toBeNull();
});
});
describe("getBundledSandboxHelperPath", () => {
it("should return path when helper exists", async () => {
mockExistsSync.mockImplementation((p: string) => {
if (p.includes("qiming-sandbox-helper")) return true;
return false;
});
const { getBundledSandboxHelperPath } = await import("./policy");
const result = getBundledSandboxHelperPath();
expect(result).toContain("qiming-sandbox-helper");
});
it("should return null when helper not found", async () => {
mockExistsSync.mockReturnValue(false);
const { getBundledSandboxHelperPath } = await import("./policy");
const result = getBundledSandboxHelperPath();
expect(result).toBeNull();
});
});
describe("getSandboxPolicy / setSandboxPolicy", () => {
it("should return default policy when no stored policy", async () => {
mockReadSetting.mockReturnValue({});
const { getSandboxPolicy } = await import("./policy");
const policy = getSandboxPolicy();
expect(policy.enabled).toBe(false);
expect(policy.backend).toBe("auto");
expect(policy.mode).toBe("compat");
expect(policy.autoFallback).toBe("startup-only");
});
it("should persist policy changes via writeSetting", async () => {
mockReadSetting.mockReturnValue({});
const { setSandboxPolicy } = await import("./policy");
setSandboxPolicy({ enabled: false, backend: "docker" });
expect(mockWriteSetting).toHaveBeenCalledWith(
"sandbox_policy",
expect.objectContaining({
enabled: false,
backend: "docker",
}),
);
});
});
});

View File

@@ -0,0 +1,293 @@
import * as fs from "fs";
import log from "electron-log";
import { app } from "electron";
import { readSetting, writeSetting } from "../../db";
import { checkCommand } from "../system/shellEnv";
import { getResourcesPath } from "../system/dependencies";
import {
createPlatformAdapter,
getCurrentPlatform,
} from "../system/platformAdapter";
import type {
SandboxAutoFallback,
Platform,
SandboxBackend,
SandboxCapabilities,
SandboxCapabilityItem,
SandboxMode,
SandboxPolicy,
SandboxType,
WindowsSandboxMode,
} from "@shared/types/sandbox";
import { SandboxError, SandboxErrorCode } from "@shared/errors/sandbox";
import { setCachedSandboxPolicy } from "./policyCache";
export const SANDBOX_POLICY_KEY = "sandbox_policy";
export const DEFAULT_SANDBOX_POLICY: SandboxPolicy = {
enabled: false,
backend: "auto",
mode: "compat",
autoFallback: "startup-only",
windowsMode: "workspace-write",
};
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object";
}
function normalizeSandboxPolicy(input: unknown): SandboxPolicy {
if (!isObject(input)) {
return { ...DEFAULT_SANDBOX_POLICY };
}
const enabled =
typeof input.enabled === "boolean"
? input.enabled
: DEFAULT_SANDBOX_POLICY.enabled;
const backend: SandboxBackend =
input.backend === "auto" ||
input.backend === "docker" ||
input.backend === "macos-seatbelt" ||
input.backend === "linux-bwrap" ||
input.backend === "windows-sandbox"
? input.backend
: DEFAULT_SANDBOX_POLICY.backend;
const mode: SandboxMode =
input.mode === "strict" ||
input.mode === "compat" ||
input.mode === "permissive"
? input.mode
: DEFAULT_SANDBOX_POLICY.mode;
const autoFallback: SandboxAutoFallback =
input.autoFallback === "startup-only" ||
input.autoFallback === "session" ||
input.autoFallback === "manual"
? input.autoFallback
: DEFAULT_SANDBOX_POLICY.autoFallback;
// 兼容旧格式windows.sandbox.mode → windowsMode
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const legacyWindowsMode = (input.windows as any)?.sandbox?.mode as
| string
| undefined;
const rawWindowsMode = input.windowsMode ?? legacyWindowsMode;
const windowsMode: WindowsSandboxMode =
rawWindowsMode === "read-only" || rawWindowsMode === "workspace-write"
? rawWindowsMode
: DEFAULT_SANDBOX_POLICY.windowsMode;
return { enabled, backend, mode, autoFallback, windowsMode };
}
function mergeSandboxPolicy(
current: SandboxPolicy,
patch: Partial<SandboxPolicy>,
): SandboxPolicy {
return normalizeSandboxPolicy({ ...current, ...patch });
}
export function getSandboxPolicy(): SandboxPolicy {
const policy = normalizeSandboxPolicy(readSetting(SANDBOX_POLICY_KEY));
setCachedSandboxPolicy(policy);
return policy;
}
export function setSandboxPolicy(patch: Partial<SandboxPolicy>): SandboxPolicy {
const current = getSandboxPolicy();
const next = mergeSandboxPolicy(current, patch);
writeSetting(SANDBOX_POLICY_KEY, next);
setCachedSandboxPolicy(next);
log.info("[SandboxPolicy] updated:", next);
return next;
}
function getPlatform(): Platform {
return getCurrentPlatform();
}
export function getBundledLinuxBwrapPath(): string | null {
const adapter = createPlatformAdapter();
return adapter.resolveBundledLinuxBwrapPath(getResourcesPath());
}
/**
* 解析内置 Sandbox helper 路径(跨平台)。
* Windows: qiming-sandbox-helper.exe
* macOS/Linux: qiming-sandbox-helper
*/
export function getBundledSandboxHelperPath(): string | null {
const adapter = createPlatformAdapter();
return adapter.resolveBundledSandboxHelperPath(getResourcesPath());
}
/**
* 解析内置 Windows Sandbox helper 路径。
* 仅在 Windows 上返回有效路径,其他平台返回 null。
* @deprecated 使用 getBundledSandboxHelperPath() 代替
*/
export function getBundledWindowsSandboxHelperPath(): string | null {
const adapter = createPlatformAdapter();
if (!adapter.isWindows) {
return null;
}
return getBundledSandboxHelperPath();
}
function unavailable(reason: string): SandboxCapabilityItem {
return { available: false, reason };
}
function getRecommendedBackend(platform: Platform): SandboxBackend {
return createPlatformAdapter(platform).getRecommendedSandboxBackend();
}
export async function getSandboxCapabilities(): Promise<SandboxCapabilities> {
const platform = getPlatform();
const adapter = createPlatformAdapter(platform);
const recommendedBackend = getRecommendedBackend(platform);
const dockerAvailable = await checkCommand("docker");
const bwrapCmdAvailable = adapter.isLinux
? await checkCommand("bwrap")
: false;
const bundledBwrap = getBundledLinuxBwrapPath();
const sandboxHelper = getBundledWindowsSandboxHelperPath();
const seatbeltPath = adapter.getSeatbeltPath();
const docker: SandboxCapabilityItem = dockerAvailable
? { available: true }
: unavailable("docker command not found");
const macosSeatbelt: SandboxCapabilityItem = !adapter.isMacOS
? unavailable("not on macOS")
: seatbeltPath && fs.existsSync(seatbeltPath)
? { available: true, binaryPath: seatbeltPath }
: unavailable("sandbox-exec not found");
const linuxBwrap: SandboxCapabilityItem = !adapter.isLinux
? unavailable("not on Linux")
: bwrapCmdAvailable
? { available: true, binaryPath: "bwrap" }
: bundledBwrap
? { available: true, binaryPath: bundledBwrap }
: unavailable("bwrap not found (system or bundled)");
const windowsSandbox: SandboxCapabilityItem = !adapter.isWindows
? unavailable("not on Windows")
: sandboxHelper
? { available: true, binaryPath: sandboxHelper }
: unavailable("windows sandbox helper not found");
return {
platform,
recommendedBackend,
docker,
macosSeatbelt,
linuxBwrap,
windowsSandbox,
};
}
function backendToSandboxType(
backend: SandboxBackend,
platform: Platform,
): SandboxType {
if (backend === "auto") {
return createPlatformAdapter(platform).getRecommendedSandboxType();
}
if (backend === "docker") return "docker";
if (backend === "macos-seatbelt") return "macos-seatbelt";
if (backend === "linux-bwrap") return "linux-bwrap";
return "windows-sandbox";
}
function isBackendAvailable(
type: SandboxType,
caps: SandboxCapabilities,
): boolean {
switch (type) {
case "docker":
return caps.docker.available;
case "macos-seatbelt":
return caps.macosSeatbelt.available;
case "linux-bwrap":
return caps.linuxBwrap.available;
case "windows-sandbox":
return caps.windowsSandbox.available;
case "none":
return true;
default:
return true;
}
}
function getBackendUnavailableReason(
type: SandboxType,
caps: SandboxCapabilities,
): string {
switch (type) {
case "docker":
return caps.docker.reason ?? "docker unavailable";
case "macos-seatbelt":
return caps.macosSeatbelt.reason ?? "macos seatbelt unavailable";
case "linux-bwrap":
return caps.linuxBwrap.reason ?? "linux bwrap unavailable";
case "windows-sandbox":
return caps.windowsSandbox.reason ?? "Windows Sandbox unavailable";
default:
return "backend unavailable";
}
}
export async function resolveSandboxType(
policy: SandboxPolicy,
): Promise<{ type: SandboxType; degraded: boolean; reason?: string }> {
const autoFallback = policy.autoFallback ?? "startup-only";
if (!policy.enabled) {
log.debug("[SandboxPolicy] resolve: disabled");
return {
type: "none",
degraded: false,
reason: "sandbox policy is disabled",
};
}
const caps = await getSandboxCapabilities();
const selectedType = backendToSandboxType(policy.backend, caps.platform);
log.debug(
"[SandboxPolicy] resolve: backend=%s → type=%s platform=%s",
policy.backend,
selectedType,
caps.platform,
);
if (isBackendAvailable(selectedType, caps)) {
log.debug("[SandboxPolicy] resolve: backend %s available", selectedType);
return { type: selectedType, degraded: false };
}
const reason = getBackendUnavailableReason(selectedType, caps);
log.warn(
"[SandboxPolicy] resolve: backend %s unavailable, reason=%s, fallback=%s",
selectedType,
reason,
autoFallback,
);
// Emit sandbox:unavailable event so UI can notify the user
app.emit("sandbox:unavailable", {
reason,
backend: selectedType,
fallback: autoFallback,
});
if (autoFallback === "manual") {
throw new SandboxError(
`sandbox backend unavailable (manual fallback required): ${reason}`,
SandboxErrorCode.SANDBOX_UNAVAILABLE,
);
}
// 后端不可用时始终降级为 off不阻断执行
return { type: "none", degraded: true, reason };
}

View File

@@ -0,0 +1,19 @@
import type { SandboxPolicy } from "@shared/types/sandbox";
const DEFAULT_POLICY: SandboxPolicy = {
enabled: false,
backend: "auto",
mode: "compat",
autoFallback: "startup-only",
windowsMode: "workspace-write",
};
let cachedPolicy: SandboxPolicy = { ...DEFAULT_POLICY };
export function getCachedSandboxPolicy(): SandboxPolicy {
return { ...cachedPolicy };
}
export function setCachedSandboxPolicy(policy: SandboxPolicy): void {
cachedPolicy = { ...policy };
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
SANDBOX_OPERATIONS,
generateSandboxMatrixDocument,
renderSandboxMatrixMarkdown,
stringifySandboxMatrixJson,
} from "./sandboxMatrix";
describe("sandboxMatrix", () => {
it("should generate complete rules for every sandbox combination", () => {
const doc = generateSandboxMatrixDocument();
const sandboxRules = doc.rules.filter((r) => r.layer === "sandbox");
const expectedCombinations = 3 + 3 + 6 + 3; // darwin + linux + windows + docker
const expectedRuleCount = expectedCombinations * SANDBOX_OPERATIONS.length;
expect(sandboxRules).toHaveLength(expectedRuleCount);
});
it("should mark docker backend as unsupported", () => {
const doc = generateSandboxMatrixDocument();
const dockerRules = doc.rules.filter((r) => r.backend === "docker");
expect(dockerRules.length).toBeGreaterThan(0);
for (const row of dockerRules) {
expect(row.verdict).toBe("unsupported");
}
});
it("should include both command allowlist and denylist", () => {
const doc = generateSandboxMatrixDocument();
expect(doc.permissionLists.commandAllowlist.length).toBeGreaterThan(0);
expect(doc.permissionLists.commandDenylist.length).toBeGreaterThan(0);
});
it("should produce deterministic json and markdown outputs", () => {
const doc = generateSandboxMatrixDocument();
const json = stringifySandboxMatrixJson(doc);
const md = renderSandboxMatrixMarkdown(doc);
expect(json.endsWith("\n")).toBe(true);
expect(md.endsWith("\n")).toBe(true);
expect(md).toContain("Sandbox Whitelist / Blacklist Matrix (Generated)");
expect(md).toContain(
"| layer | platform | backend | mode | windowsMode | operationId | verdict | reason |",
);
});
});

View File

@@ -0,0 +1,518 @@
import type {
PermissionType,
Platform,
SandboxMode,
WindowsSandboxMode,
} from "@shared/types/sandbox";
import {
DEFAULT_PERMISSION_POLICY,
DANGEROUS_COMMANDS,
} from "./PermissionManager";
export type MatrixVerdict = "allow" | "block" | "conditional" | "unsupported";
export type MatrixLayer = "sandbox" | "permission";
export type MatrixPlatform = Platform | "all";
export type MatrixBackend =
| "macos-seatbelt"
| "linux-bwrap"
| "windows-sandbox"
| "docker"
| "permission-manager";
export type MatrixMode = SandboxMode | "n/a";
export type MatrixWindowsMode = WindowsSandboxMode | "n/a";
export interface SandboxMatrixRule {
layer: MatrixLayer;
platform: MatrixPlatform;
backend: MatrixBackend;
mode: MatrixMode;
windowsMode: MatrixWindowsMode;
operationId: string;
verdict: MatrixVerdict;
reason: string;
evidence: string[];
}
export interface SandboxMatrixDocument {
schemaVersion: "1.0.0";
operations: string[];
permissionLists: {
commandAllowlist: string[];
commandDenylist: string[];
permissionTypeAutoApprove: PermissionType[];
permissionTypeDeny: PermissionType[];
};
rules: SandboxMatrixRule[];
}
export const SANDBOX_OPERATIONS = [
"fs.write.workspace",
"fs.write.outside_workspace",
"fs.delete.system_path",
"network.external",
"network.loopback",
"exec.startup_chain_extra",
"command.dangerous.system",
"fallback.backend_unavailable",
] as const;
const PERMISSION_OPERATIONS = [
"permission.command.safe",
"permission.command.dangerous",
"permission.path.sensitive",
"permission.type.deny",
] as const;
type SandboxOperation = (typeof SANDBOX_OPERATIONS)[number];
interface Combo {
platform: MatrixPlatform;
backend: Exclude<MatrixBackend, "permission-manager">;
mode: MatrixMode;
windowsMode: MatrixWindowsMode;
}
function rule(
combo: Combo,
operationId: string,
verdict: MatrixVerdict,
reason: string,
evidence: string[],
): SandboxMatrixRule {
return {
layer: "sandbox",
platform: combo.platform,
backend: combo.backend,
mode: combo.mode,
windowsMode: combo.windowsMode,
operationId,
verdict,
reason,
evidence,
};
}
function darwinRuleVerdict(
operationId: SandboxOperation,
mode: SandboxMode,
): Pick<SandboxMatrixRule, "verdict" | "reason"> {
const strictLike = mode === "strict" || mode === "compat";
switch (operationId) {
case "fs.write.workspace":
return {
verdict: "allow",
reason: "workspace path is explicitly writable",
};
case "fs.write.outside_workspace":
case "fs.delete.system_path":
if (strictLike) {
return {
verdict: "block",
reason: "seatbelt non-permissive mode only allows writablePaths",
};
}
return {
verdict: "allow",
reason: "permissive mode enables file-write* globally",
};
case "network.external":
case "network.loopback":
return {
verdict: "conditional",
reason: "depends on networkEnabled -> (allow network*)",
};
case "exec.startup_chain_extra":
if (mode === "strict") {
return {
verdict: "block",
reason: "strict mode does not include startupExecAllowlist",
};
}
if (mode === "compat") {
return {
verdict: "conditional",
reason:
"compat supports startupExecAllowlist but depends on caller input",
};
}
return {
verdict: "allow",
reason: "permissive mode allows process-exec globally",
};
case "command.dangerous.system":
return {
verdict: "conditional",
reason:
"blocked primarily by PermissionManager; sandbox outcome may vary by command path",
};
case "fallback.backend_unavailable":
return {
verdict: "conditional",
reason: "manual fails closed; startup-only/session degrade to none",
};
}
}
function linuxRuleVerdict(
operationId: SandboxOperation,
mode: SandboxMode,
): Pick<SandboxMatrixRule, "verdict" | "reason"> {
switch (operationId) {
case "fs.write.workspace":
return {
verdict: "allow",
reason: "workspace path is bind-mounted writable",
};
case "fs.write.outside_workspace":
case "fs.delete.system_path":
if (mode === "permissive") {
return {
verdict: "allow",
reason: "permissive mode bind-mounts root writable",
};
}
return {
verdict: "block",
reason: "strict/compat keep host root read-only outside writablePaths",
};
case "network.external":
if (mode === "permissive") {
return {
verdict: "allow",
reason: "permissive mode skips network namespace isolation",
};
}
return {
verdict: "conditional",
reason: "depends on networkEnabled -> --unshare-net",
};
case "network.loopback":
if (mode === "permissive") {
return {
verdict: "allow",
reason: "no net namespace isolation in permissive mode",
};
}
return {
verdict: "conditional",
reason: "loopback behavior depends on namespace/runtime tooling",
};
case "exec.startup_chain_extra":
if (mode === "strict") {
return {
verdict: "conditional",
reason: "strict only ro-binds minimal paths + command related dirs",
};
}
return {
verdict: "allow",
reason: "compat/permissive keep full root visibility for exec",
};
case "command.dangerous.system":
return {
verdict: "conditional",
reason:
"blocked by PermissionManager first; sandbox-level outcome varies by command/capability",
};
case "fallback.backend_unavailable":
return {
verdict: "conditional",
reason: "manual fails closed; startup-only/session degrade to none",
};
}
}
function windowsRuleVerdict(
operationId: SandboxOperation,
mode: SandboxMode,
windowsMode: WindowsSandboxMode,
): Pick<SandboxMatrixRule, "verdict" | "reason"> {
const workspaceWrite = windowsMode === "workspace-write";
switch (operationId) {
case "fs.write.workspace":
if (!workspaceWrite) {
return {
verdict: "block",
reason: "read-only mode blocks workspace write",
};
}
return {
verdict: "allow",
reason: "workspace-write allows workspace root writes",
};
case "fs.write.outside_workspace":
if (!workspaceWrite) {
return { verdict: "block", reason: "read-only mode blocks writes" };
}
if (mode === "strict") {
return {
verdict: "conditional",
reason:
"strict limits writable_roots but helper still allows cwd/temp paths",
};
}
return {
verdict: "conditional",
reason:
"compat/permissive include wider writable roots and cwd-dependent allowances",
};
case "fs.delete.system_path":
return {
verdict: "conditional",
reason: "depends on helper ACL application and writable root boundary",
};
case "network.external":
case "network.loopback":
if (!workspaceWrite) {
return {
verdict: "conditional",
reason:
"read-only policy enforces no full network but relies on helper best-effort controls",
};
}
return {
verdict: "conditional",
reason:
"network_access is helper best-effort, not kernel-level isolation",
};
case "exec.startup_chain_extra":
return {
verdict: "allow",
reason:
"helper executes command chain; restriction is policy/ACL not exec allowlist",
};
case "command.dangerous.system":
return {
verdict: "conditional",
reason: "blocked mainly by PermissionManager and ACL boundaries",
};
case "fallback.backend_unavailable":
return {
verdict: "conditional",
reason: "manual fails closed; startup-only/session degrade to none",
};
}
}
function buildSandboxRules(): SandboxMatrixRule[] {
const rules: SandboxMatrixRule[] = [];
const modes: SandboxMode[] = ["strict", "compat", "permissive"];
// macOS seatbelt
for (const mode of modes) {
const combo: Combo = {
platform: "darwin",
backend: "macos-seatbelt",
mode,
windowsMode: "n/a",
};
for (const operationId of SANDBOX_OPERATIONS) {
const decision = darwinRuleVerdict(operationId, mode);
rules.push(
rule(combo, operationId, decision.verdict, decision.reason, [
"src/main/services/sandbox/SandboxInvoker.ts",
"src/main/services/sandbox/policy.ts",
]),
);
}
}
// Linux bwrap
for (const mode of modes) {
const combo: Combo = {
platform: "linux",
backend: "linux-bwrap",
mode,
windowsMode: "n/a",
};
for (const operationId of SANDBOX_OPERATIONS) {
const decision = linuxRuleVerdict(operationId, mode);
rules.push(
rule(combo, operationId, decision.verdict, decision.reason, [
"src/main/services/sandbox/SandboxInvoker.ts",
"src/main/services/sandbox/policy.ts",
]),
);
}
}
// Windows helper
const windowsModes: WindowsSandboxMode[] = ["read-only", "workspace-write"];
for (const mode of modes) {
for (const windowsMode of windowsModes) {
const combo: Combo = {
platform: "win32",
backend: "windows-sandbox",
mode,
windowsMode,
};
for (const operationId of SANDBOX_OPERATIONS) {
const decision = windowsRuleVerdict(operationId, mode, windowsMode);
rules.push(
rule(combo, operationId, decision.verdict, decision.reason, [
"src/main/services/sandbox/SandboxInvoker.ts",
"src/main/services/sandbox/policy.ts",
]),
);
}
}
}
// Docker (currently unsupported at process-level sandbox)
for (const mode of modes) {
const combo: Combo = {
platform: "all",
backend: "docker",
mode,
windowsMode: "n/a",
};
for (const operationId of SANDBOX_OPERATIONS) {
rules.push(
rule(
combo,
operationId,
"unsupported",
"docker process-level sandbox is not implemented",
["src/main/services/sandbox/SandboxInvoker.ts"],
),
);
}
}
return rules;
}
function buildPermissionRules(): SandboxMatrixRule[] {
const safeSample = DEFAULT_PERMISSION_POLICY.safeCommands[0] ?? "node";
const dangerousSample = DANGEROUS_COMMANDS[0] ?? "sudo";
return [
{
layer: "permission",
platform: "all",
backend: "permission-manager",
mode: "n/a",
windowsMode: "n/a",
operationId: PERMISSION_OPERATIONS[0],
verdict: "allow",
reason: `safeCommands includes "${safeSample}" and is auto-approved for command:execute`,
evidence: ["src/main/services/sandbox/PermissionManager.ts"],
},
{
layer: "permission",
platform: "all",
backend: "permission-manager",
mode: "n/a",
windowsMode: "n/a",
operationId: PERMISSION_OPERATIONS[1],
verdict: "block",
reason: `dangerous command pattern (e.g. "${dangerousSample}") is blocked`,
evidence: ["src/main/services/sandbox/PermissionManager.ts"],
},
{
layer: "permission",
platform: "all",
backend: "permission-manager",
mode: "n/a",
windowsMode: "n/a",
operationId: PERMISSION_OPERATIONS[2],
verdict: "block",
reason:
"sensitive paths (.ssh, /etc/passwd, /etc/shadow, /etc/sudoers, /etc/group) are blocked",
evidence: ["src/main/services/sandbox/PermissionManager.ts"],
},
{
layer: "permission",
platform: "all",
backend: "permission-manager",
mode: "n/a",
windowsMode: "n/a",
operationId: PERMISSION_OPERATIONS[3],
verdict: "block",
reason: "denyList permission types are blocked",
evidence: ["src/main/services/sandbox/PermissionManager.ts"],
},
];
}
export function generateSandboxMatrixDocument(): SandboxMatrixDocument {
const permissionLists = {
commandAllowlist: [...DEFAULT_PERMISSION_POLICY.safeCommands].sort(),
commandDenylist: [...DANGEROUS_COMMANDS].sort(),
permissionTypeAutoApprove: [
...DEFAULT_PERMISSION_POLICY.autoApprove,
].sort(),
permissionTypeDeny: [...DEFAULT_PERMISSION_POLICY.denyList].sort(),
};
const rules = [...buildSandboxRules(), ...buildPermissionRules()];
const operations = [
...SANDBOX_OPERATIONS,
...PERMISSION_OPERATIONS,
] as string[];
return {
schemaVersion: "1.0.0",
operations,
permissionLists,
rules,
};
}
export function renderSandboxMatrixMarkdown(
doc: SandboxMatrixDocument,
): string {
const lines: string[] = [];
lines.push("# Sandbox Whitelist / Blacklist Matrix (Generated)");
lines.push("");
lines.push("## Metadata");
lines.push("");
lines.push(`- Schema version: \`${doc.schemaVersion}\``);
lines.push(`- Total operations: \`${doc.operations.length}\``);
lines.push(`- Total rules: \`${doc.rules.length}\``);
lines.push(
`- Command allowlist size: \`${doc.permissionLists.commandAllowlist.length}\``,
);
lines.push(
`- Command denylist size: \`${doc.permissionLists.commandDenylist.length}\``,
);
lines.push("");
lines.push("## Permission Lists");
lines.push("");
lines.push("### Command Allowlist");
lines.push("");
for (const cmd of doc.permissionLists.commandAllowlist) {
lines.push(`- \`${cmd}\``);
}
lines.push("");
lines.push("### Command Denylist");
lines.push("");
for (const cmd of doc.permissionLists.commandDenylist) {
lines.push(`- \`${cmd}\``);
}
lines.push("");
lines.push("## Rules");
lines.push("");
lines.push(
"| layer | platform | backend | mode | windowsMode | operationId | verdict | reason |",
);
lines.push("| --- | --- | --- | --- | --- | --- | --- | --- |");
for (const row of doc.rules) {
lines.push(
`| ${row.layer} | ${row.platform} | ${row.backend} | ${row.mode} | ${row.windowsMode} | ${row.operationId} | ${row.verdict} | ${row.reason.replace(/\|/g, "\\|")} |`,
);
}
lines.push("");
lines.push("## Evidence");
lines.push("");
lines.push("- `src/main/services/sandbox/SandboxInvoker.ts`");
lines.push("- `src/main/services/sandbox/policy.ts`");
lines.push("- `src/main/services/sandbox/PermissionManager.ts`");
lines.push("");
return `${lines.join("\n")}\n`;
}
export function stringifySandboxMatrixJson(doc: SandboxMatrixDocument): string {
return `${JSON.stringify(doc, null, 2)}\n`;
}

View File

@@ -0,0 +1,546 @@
/**
* Unit tests for sandboxProcessWrapper
*
* Tests buildSandboxedSpawnArgs() behavior:
* - enabled=false: returns original command/args with NOOP_CLEANUP
* - docker type: logs warning and returns original command/args
* - macos-seatbelt type: returns sandbox-exec command
* - linux-bwrap type: returns bwrap command
* - error handling: re-throws errors from SandboxInvoker.buildInvocation
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as fs from "fs";
import * as path from "path";
import log from "electron-log";
import { SandboxInvoker } from "./SandboxInvoker";
import { buildSandboxedSpawnArgs } from "./sandboxProcessWrapper";
import type { SandboxProcessConfig } from "@shared/types/sandbox";
// =============================================================================
// Mock electron-log
// =============================================================================
const mockLogWarn = vi.fn();
const mockLogInfo = vi.fn();
const mockLogError = vi.fn();
vi.mock("electron-log", () => ({
default: {
warn: (...args: unknown[]) => mockLogWarn(...args),
info: (...args: unknown[]) => mockLogInfo(...args),
error: (...args: unknown[]) => mockLogError(...args),
},
}));
// =============================================================================
// Mock fs
// =============================================================================
const mockExistsSync = vi.fn();
const mockMkdirSync = vi.fn();
const mockUnlinkSync = vi.fn();
const mockRealpathSync = vi.fn();
vi.mock("fs", () => ({
existsSync: (...args: unknown[]) => mockExistsSync(...args),
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
unlinkSync: (...args: unknown[]) => mockUnlinkSync(...args),
realpathSync: (...args: unknown[]) => mockRealpathSync(...args),
}));
// =============================================================================
// Mock SandboxInvoker
// =============================================================================
const mockBuildInvocation = vi.fn();
vi.mock("./SandboxInvoker", () => ({
SandboxInvoker: vi.fn().mockImplementation(() => ({
buildInvocation: mockBuildInvocation,
})),
}));
// =============================================================================
// Test helpers
// =============================================================================
const NOOP_CLEANUP = () => {};
const createBaseConfig = (): SandboxProcessConfig => ({
enabled: true,
type: "macos-seatbelt",
projectWorkspaceDir: "/tmp/ws",
networkEnabled: false,
fallback: "degrade_to_off",
});
// =============================================================================
// Tests
// =============================================================================
describe("buildSandboxedSpawnArgs", () => {
beforeEach(() => {
vi.clearAllMocks();
mockExistsSync.mockReturnValue(true); // Default: paths exist
mockMkdirSync.mockReturnValue(undefined);
mockUnlinkSync.mockReturnValue(undefined);
mockRealpathSync.mockImplementation((p: string) => p);
mockBuildInvocation.mockReset();
});
// ---------------------------------------------------------------------------
// Test: enabled=false returns original command/args with NOOP_CLEANUP
// ---------------------------------------------------------------------------
describe("when enabled is false", () => {
it("should return original command and args unchanged", async () => {
const config: SandboxProcessConfig = {
enabled: false,
type: "macos-seatbelt",
projectWorkspaceDir: "/tmp/ws",
networkEnabled: false,
fallback: "degrade_to_off",
};
const result = await buildSandboxedSpawnArgs(
"/bin/ls",
["-la"],
"/tmp",
config,
);
expect(result.command).toBe("/bin/ls");
expect(result.args).toEqual(["-la"]);
});
it("should return NOOP_CLEANUP function", async () => {
const config: SandboxProcessConfig = {
enabled: false,
type: "macos-seatbelt",
projectWorkspaceDir: "/tmp/ws",
networkEnabled: false,
fallback: "degrade_to_off",
};
const result = await buildSandboxedSpawnArgs(
"/bin/ls",
["-la"],
"/tmp",
config,
);
// cleanupSandbox should be a noop function (truthy and returns undefined)
expect(typeof result.cleanupSandbox).toBe("function");
expect(result.cleanupSandbox()).toBeUndefined();
});
it("should not call SandboxInvoker when disabled", async () => {
const config: SandboxProcessConfig = {
enabled: false,
type: "macos-seatbelt",
projectWorkspaceDir: "/tmp/ws",
networkEnabled: false,
fallback: "degrade_to_off",
};
await buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config);
expect(SandboxInvoker).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Test: docker type logs warning and returns original command/args
// ---------------------------------------------------------------------------
describe("when type is docker", () => {
it("should log warning about docker not supported", async () => {
const config: SandboxProcessConfig = {
...createBaseConfig(),
type: "docker",
};
await buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config);
expect(mockLogWarn).toHaveBeenCalledWith(
expect.stringContaining("Docker process-level sandbox not supported"),
);
});
it("should return original command and args unchanged", async () => {
const config: SandboxProcessConfig = {
...createBaseConfig(),
type: "docker",
};
const result = await buildSandboxedSpawnArgs(
"/bin/ls",
["-la"],
"/tmp",
config,
);
expect(result.command).toBe("/bin/ls");
expect(result.args).toEqual(["-la"]);
});
it("should return NOOP_CLEANUP", async () => {
const config: SandboxProcessConfig = {
...createBaseConfig(),
type: "docker",
};
const result = await buildSandboxedSpawnArgs(
"/bin/ls",
["-la"],
"/tmp",
config,
);
// cleanupSandbox should be a noop function (truthy and returns undefined)
expect(typeof result.cleanupSandbox).toBe("function");
expect(result.cleanupSandbox()).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Test: macos-seatbelt type returns sandbox-exec command
// ---------------------------------------------------------------------------
describe("when type is macos-seatbelt", () => {
it("should return sandbox-exec command", async () => {
const config: SandboxProcessConfig = {
...createBaseConfig(),
type: "macos-seatbelt",
};
mockBuildInvocation.mockResolvedValue({
command: "/usr/bin/sandbox-exec",
args: ["-f", "/tmp/profile.sb", "/bin/ls", "-la"],
cwd: "/tmp",
seatbeltProfilePath: "/tmp/qimingclaw-sandbox-123.sb",
});
const result = await buildSandboxedSpawnArgs(
"/bin/ls",
["-la"],
"/tmp",
config,
);
expect(result.command).toBe("/usr/bin/sandbox-exec");
expect(result.args[0]).toBe("-f");
expect(result.args[2]).toBe("/bin/ls");
});
it("should create workspace directory if it does not exist", async () => {
const config: SandboxProcessConfig = {
...createBaseConfig(),
projectWorkspaceDir: "/tmp/nonexistent-ws",
};
mockExistsSync.mockImplementation((p: string) => {
if (p === "/tmp/nonexistent-ws") return false;
return true;
});
mockBuildInvocation.mockResolvedValue({
command: "/usr/bin/sandbox-exec",
args: ["-f", "/tmp/profile.sb", "/bin/ls", "-la"],
cwd: "/tmp",
seatbeltProfilePath: "/tmp/qimingclaw-sandbox-123.sb",
});
await buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config);
expect(mockMkdirSync).toHaveBeenCalledWith("/tmp/nonexistent-ws", {
recursive: true,
});
});
it("should return a cleanup function that deletes seatbelt profile", async () => {
const config: SandboxProcessConfig = {
...createBaseConfig(),
type: "macos-seatbelt",
};
mockBuildInvocation.mockResolvedValue({
command: "/usr/bin/sandbox-exec",
args: ["-f", "/tmp/profile.sb", "/bin/ls", "-la"],
cwd: "/tmp",
seatbeltProfilePath: "/tmp/qimingclaw-sandbox-123.sb",
});
const result = await buildSandboxedSpawnArgs(
"/bin/ls",
["-la"],
"/tmp",
config,
);
// cleanupSandbox should be a function (not NOOP_CLEANUP)
expect(typeof result.cleanupSandbox).toBe("function");
expect(result.cleanupSandbox).not.toBe(NOOP_CLEANUP);
// Calling cleanup should delete the profile file
result.cleanupSandbox();
expect(mockUnlinkSync).toHaveBeenCalledWith(
"/tmp/qimingclaw-sandbox-123.sb",
);
});
it("should handle cleanup gracefully when profile file does not exist", async () => {
const config: SandboxProcessConfig = {
...createBaseConfig(),
type: "macos-seatbelt",
};
mockBuildInvocation.mockResolvedValue({
command: "/usr/bin/sandbox-exec",
args: ["-f", "/tmp/profile.sb", "/bin/ls", "-la"],
cwd: "/tmp",
seatbeltProfilePath: "/tmp/qimingclaw-sandbox-123.sb",
});
mockUnlinkSync.mockImplementation(() => {
throw new Error("ENOENT: no such file");
});
const result = await buildSandboxedSpawnArgs(
"/bin/ls",
["-la"],
"/tmp",
config,
);
// Should not throw
expect(() => result.cleanupSandbox()).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// Test: linux-bwrap type returns bwrap command
// ---------------------------------------------------------------------------
describe("when type is linux-bwrap", () => {
it("should return bwrap command", async () => {
const config: SandboxProcessConfig = {
...createBaseConfig(),
type: "linux-bwrap",
projectWorkspaceDir: "/tmp/ws",
linuxBwrapPath: "/usr/bin/bwrap",
};
mockBuildInvocation.mockResolvedValue({
command: "/usr/bin/bwrap",
args: [
"--die-with-parent",
"--new-session",
"--unshare-user-try",
"--unshare-pid",
"--unshare-uts",
"--unshare-cgroup-try",
"--dev-bind",
"/dev",
"/dev",
"--proc",
"/proc",
"--tmpfs",
"/tmp",
"--ro-bind",
"/",
"/",
"--bind",
"/tmp/ws",
"/tmp/ws",
"--chdir",
"/tmp",
"--",
"/bin/ls",
"-la",
],
cwd: "/tmp",
});
const result = await buildSandboxedSpawnArgs(
"/bin/ls",
["-la"],
"/tmp",
config,
);
expect(result.command).toBe("/usr/bin/bwrap");
expect(result.args).toContain("--bind");
expect(result.args).toContain("/tmp/ws");
});
it("should pass writablePaths to SandboxInvoker", async () => {
const config: SandboxProcessConfig = {
...createBaseConfig(),
type: "linux-bwrap",
projectWorkspaceDir: "/tmp/ws",
linuxBwrapPath: "/usr/bin/bwrap",
};
mockBuildInvocation.mockResolvedValue({
command: "/usr/bin/bwrap",
args: ["--die-with-parent", "--", "/bin/ls", "-la"],
cwd: "/tmp",
});
await buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config, [
"/tmp/extra-writable",
]);
expect(mockBuildInvocation).toHaveBeenCalledWith(
expect.objectContaining({
writablePaths: ["/tmp/ws", "/tmp/extra-writable"],
}),
);
});
it("should filter out empty writablePaths", async () => {
const config: SandboxProcessConfig = {
...createBaseConfig(),
type: "linux-bwrap",
projectWorkspaceDir: "/tmp/ws",
linuxBwrapPath: "/usr/bin/bwrap",
};
mockBuildInvocation.mockResolvedValue({
command: "/usr/bin/bwrap",
args: ["--die-with-parent", "--", "/bin/ls", "-la"],
cwd: "/tmp",
});
// Only truly empty strings (length 0) are filtered; whitespace strings pass through
await buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config, [
"",
"/tmp/valid-path",
]);
expect(mockBuildInvocation).toHaveBeenCalledWith(
expect.objectContaining({
writablePaths: ["/tmp/ws", "/tmp/valid-path"],
}),
);
});
});
// ---------------------------------------------------------------------------
// Test: error handling - re-throws errors from SandboxInvoker.buildInvocation
// ---------------------------------------------------------------------------
describe("error handling", () => {
it("should re-throw errors from SandboxInvoker.buildInvocation", async () => {
const config: SandboxProcessConfig = {
...createBaseConfig(),
type: "macos-seatbelt",
};
const expectedError = new Error("Sandbox invocation failed");
mockBuildInvocation.mockRejectedValue(expectedError);
await expect(
buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config),
).rejects.toThrow("Sandbox invocation failed");
});
it("should log error before re-throwing", async () => {
const config: SandboxProcessConfig = {
...createBaseConfig(),
type: "linux-bwrap",
};
const expectedError = new Error("bwrap not found");
mockBuildInvocation.mockRejectedValue(expectedError);
await expect(
buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config),
).rejects.toThrow();
expect(mockLogError).toHaveBeenCalledWith(
"[SandboxProcessWrapper] Sandbox wrapping failed:",
expectedError,
);
});
});
// ---------------------------------------------------------------------------
// Test: extraWritablePaths parameter
// ---------------------------------------------------------------------------
describe("extraWritablePaths parameter", () => {
it("should include extraWritablePaths in writable paths", async () => {
const config: SandboxProcessConfig = {
...createBaseConfig(),
type: "macos-seatbelt",
};
mockBuildInvocation.mockResolvedValue({
command: "/usr/bin/sandbox-exec",
args: ["-f", "/tmp/profile.sb", "/bin/ls", "-la"],
cwd: "/tmp",
seatbeltProfilePath: "/tmp/qimingclaw-sandbox-123.sb",
});
await buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config, [
"/tmp/extra1",
"/tmp/extra2",
]);
expect(mockBuildInvocation).toHaveBeenCalledWith(
expect.objectContaining({
writablePaths: ["/tmp/ws", "/tmp/extra1", "/tmp/extra2"],
}),
);
});
it("should handle empty extraWritablePaths", async () => {
const config: SandboxProcessConfig = {
...createBaseConfig(),
type: "macos-seatbelt",
};
mockBuildInvocation.mockResolvedValue({
command: "/usr/bin/sandbox-exec",
args: ["-f", "/tmp/profile.sb", "/bin/ls", "-la"],
cwd: "/tmp",
seatbeltProfilePath: "/tmp/qimingclaw-sandbox-123.sb",
});
await buildSandboxedSpawnArgs("/bin/ls", ["-la"], "/tmp", config, []);
expect(mockBuildInvocation).toHaveBeenCalledWith(
expect.objectContaining({
writablePaths: ["/tmp/ws"],
}),
);
});
});
// ---------------------------------------------------------------------------
// Test: sandboxConfig undefined (same as enabled=false)
// ---------------------------------------------------------------------------
describe("when sandboxConfig is undefined", () => {
it("should return original command and args", async () => {
const result = await buildSandboxedSpawnArgs(
"/bin/ls",
["-la"],
"/tmp",
undefined,
);
expect(result.command).toBe("/bin/ls");
expect(result.args).toEqual(["-la"]);
});
it("should return NOOP_CLEANUP", async () => {
const result = await buildSandboxedSpawnArgs(
"/bin/ls",
["-la"],
"/tmp",
undefined,
);
// cleanupSandbox should be a noop function (truthy and returns undefined)
expect(typeof result.cleanupSandbox).toBe("function");
expect(result.cleanupSandbox()).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,140 @@
/**
* ACP 引擎进程级沙箱桥接模块
*
* 提供 buildSandboxedSpawnArgs() 将 ACP 引擎的 spawn 参数
* 包装为沙箱化的调用参数,供 acpClient.ts 使用。
*
* 内部直接使用 SandboxInvoker 构建调用,不再创建临时 CommandSandbox 实例。
*
* @version 2.0.0
* @updated 2026-04-03
*/
import * as fs from "fs";
import * as path from "path";
import log from "electron-log";
import { SandboxInvoker } from "./SandboxInvoker";
import type { Invocation } from "./SandboxInvoker";
import type { SandboxProcessConfig } from "@shared/types/sandbox";
const NOOP_CLEANUP = () => {};
export interface SandboxedSpawn {
/** 包装后的命令(可能是 sandbox-exec / bwrap / qiming sandbox helper */
command: string;
/** 包装后的参数 */
args: string[];
/** 清理沙箱临时资源(如 .sb profile 文件) */
cleanupSandbox: () => void;
}
/**
* 为 ACP 引擎进程构建沙箱化的 spawn 参数
*
* @param originalCommand 原始二进制路径(或 process.execPath
* @param originalArgs 原始参数
* @param cwd 工作目录
* @param sandboxConfig 沙箱配置undefined 表示不启用)
* @param extraWritablePaths 运行时创建的额外可写路径(如 isolatedHome
* @returns 沙箱化的 spawn 参数 + 清理函数
*/
export async function buildSandboxedSpawnArgs(
originalCommand: string,
originalArgs: string[],
cwd: string,
sandboxConfig: SandboxProcessConfig | undefined,
extraWritablePaths: string[] = [],
): Promise<SandboxedSpawn> {
// 未配置沙箱或已禁用,直接返回原始参数
if (!sandboxConfig?.enabled) {
return {
command: originalCommand,
args: originalArgs,
cleanupSandbox: NOOP_CLEANUP,
};
}
const { type, projectWorkspaceDir, networkEnabled } = sandboxConfig;
// Docker 后端暂不支持进程级包装
if (type === "docker") {
log.warn(
"[SandboxProcessWrapper] Docker process-level sandbox not supported yet, skipping wrapping",
);
return {
command: originalCommand,
args: originalArgs,
cleanupSandbox: NOOP_CLEANUP,
};
}
// 直接使用 SandboxInvoker 构建调用
// Windows: serve 子命令根据 sandbox mode 决定是否启用 WRITE_RESTRICTED
// APPDATA/LOCALAPPDATA 由 Rust helper 的 compute_allow_paths() 根据模式决定
const invoker = new SandboxInvoker(type, {
linuxBwrapPath: sandboxConfig.linuxBwrapPath,
windowsSandboxHelperPath: sandboxConfig.windowsSandboxHelperPath,
windowsSandboxMode: sandboxConfig.windowsSandboxMode,
networkEnabled,
mode: sandboxConfig.mode,
});
// 可写路径:工作区 + 额外路径(如 isolatedHome
const writablePaths = [projectWorkspaceDir, ...extraWritablePaths].filter(
(p): p is string => typeof p === "string" && p.length > 0,
);
// 确保可写路径存在bwrap --bind 要求路径存在)
for (const wp of writablePaths) {
if (!fs.existsSync(wp)) {
fs.mkdirSync(wp, { recursive: true });
}
}
try {
const invocation: Invocation = await invoker.buildInvocation({
command: originalCommand,
args: originalArgs,
cwd,
writablePaths,
networkEnabled,
subcommand: "serve",
startupExecAllowlist: [originalCommand],
});
log.info("[SandboxProcessWrapper] Sandbox wrapping succeeded:", {
type,
mode: sandboxConfig.mode ?? "compat",
autoFallback: sandboxConfig.autoFallback ?? "startup-only",
originalCommand,
wrappedCommand: invocation.command,
writablePaths,
});
// 为 macOS seatbelt profile 文件注册清理
const profilePath = invocation.seatbeltProfilePath ?? null;
const cleanupSandbox = () => {
if (profilePath) {
try {
fs.unlinkSync(profilePath);
log.debug(
"[SandboxProcessWrapper] Removed seatbelt profile:",
profilePath,
);
} catch {
// 忽略清理错误
}
}
};
return {
command: invocation.command,
args: invocation.args,
cleanupSandbox,
};
} catch (error) {
log.error("[SandboxProcessWrapper] Sandbox wrapping failed:", error);
throw error;
}
}

View File

@@ -0,0 +1,287 @@
/**
* 沙箱服务启动
*
* 初始化沙箱服务并注入到 IPC handlers
*
* @version 1.1.0
* @updated 2026-03-27
*/
import log from "electron-log";
import * as path from "path";
import { app } from "electron";
import { t } from "../i18n";
import { getCurrentPlatform } from "../system/platformAdapter";
import { DockerSandbox } from "./DockerSandbox";
import { CommandSandbox } from "./CommandSandbox";
import {
PermissionManager,
DEFAULT_PERMISSION_POLICY,
} from "./PermissionManager";
import { WorkspaceManager } from "./WorkspaceManager";
import type { SandboxManager } from "./SandboxManager";
import {
setSandboxControlService,
setSandboxService,
setPermissionService,
} from "../../ipc/sandboxHandlers";
import type {
SandboxConfig,
SandboxPolicy,
SandboxCapabilities,
Platform,
SandboxType,
} from "@shared/types/sandbox";
import {
DEFAULT_SANDBOX_POLICY,
getBundledLinuxBwrapPath,
getBundledWindowsSandboxHelperPath,
getSandboxCapabilities,
getSandboxPolicy,
resolveSandboxType,
setSandboxPolicy,
} from "./policy";
/**
* 获取当前平台
*/
function getPlatform(): Platform {
return getCurrentPlatform();
}
/**
* 获取沙箱工作区根目录
*/
function getWorkspaceRoot(): string {
const homeDir = app.getPath("home");
return path.join(homeDir, ".qimingclaw", "sandboxes");
}
/**
* 获取默认沙箱配置
*/
function getDefaultSandboxConfig(
type: SandboxType,
): SandboxConfig & { dockerImage: string } {
const platform = getPlatform();
return {
type,
platform,
enabled: type !== "none",
workspaceRoot: getWorkspaceRoot(),
memoryLimit: "2g",
cpuLimit: 2,
diskQuota: "10g",
networkEnabled: true,
dockerImage: "qiming/sandbox-base:latest",
readOnly: false,
};
}
/**
* 沙箱服务实例
*/
let workspaceManager: WorkspaceManager | null = null;
let sandboxManager: SandboxManager | null = null;
let permissionManager: PermissionManager | null = null;
let lastPolicy: SandboxPolicy = DEFAULT_SANDBOX_POLICY;
let lastCapabilities: SandboxCapabilities | null = null;
let activeSandboxType: SandboxType = "none";
let degraded: boolean = false;
let degradeReason: string | undefined;
function createSandboxManager(type: SandboxType): SandboxManager {
const config = getDefaultSandboxConfig(type);
// Propagate policy mode into the config so SandboxInvoker can use it.
config.mode = lastPolicy.mode;
if (type === "docker") {
return new DockerSandbox(config);
}
return new CommandSandbox(config, {
linuxBwrapPath: getBundledLinuxBwrapPath() ?? undefined,
windowsSandboxHelperPath: getBundledWindowsSandboxHelperPath() ?? undefined,
windowsSandboxMode: lastPolicy.windowsMode,
});
}
function setupControlService(): void {
setSandboxControlService({
async getPolicy() {
return getSandboxPolicy();
},
async setPolicy(patch) {
const nextPolicy = setSandboxPolicy(patch);
await stopSandboxService();
await startSandboxService();
return nextPolicy;
},
async getCapabilities() {
return getSandboxCapabilities();
},
async setup(_params) {
const helperPath = getBundledWindowsSandboxHelperPath();
if (!helperPath) {
return {
success: false,
message: "(Windows only) Sandbox helper not found",
};
}
return {
success: true,
message: t("Claw.Sandbox.helperReady"),
};
},
});
}
/**
* 启动沙箱服务
*/
export async function startSandboxService(): Promise<void> {
log.info("[SandboxService] Starting sandbox service...");
try {
lastPolicy = getSandboxPolicy();
lastCapabilities = await getSandboxCapabilities();
const resolved = await resolveSandboxType(lastPolicy);
activeSandboxType = resolved.type;
degraded = resolved.degraded;
degradeReason = resolved.reason;
log.debug("[SandboxService] resolved:", {
type: resolved.type,
degraded: resolved.degraded,
reason: resolved.reason,
});
log.debug("[SandboxService] policy:", {
enabled: lastPolicy.enabled,
backend: lastPolicy.backend,
windowsMode: lastPolicy.windowsMode,
});
log.debug("[SandboxService] capabilities:", lastCapabilities);
log.debug("[SandboxService] workspaceRoot:", getWorkspaceRoot());
sandboxManager = createSandboxManager(activeSandboxType);
await sandboxManager.init();
permissionManager = new PermissionManager(DEFAULT_PERMISSION_POLICY);
workspaceManager = new WorkspaceManager({
sandboxManager,
permissionManager,
});
setSandboxService({
createWorkspace: (sessionId, options) =>
workspaceManager!.createWorkspace(sessionId, options),
destroyWorkspace: (sessionId) =>
workspaceManager!.destroyWorkspace(sessionId),
listWorkspaces: () => workspaceManager!.listWorkspaces(),
getWorkspace: (sessionId) => workspaceManager!.getWorkspace(sessionId),
execute: (sessionId, command, args, options) =>
workspaceManager!.execute(sessionId, command, args, options),
readFile: (sessionId, filePath) =>
workspaceManager!.readFile(sessionId, filePath),
writeFile: (sessionId, filePath, content) =>
workspaceManager!.writeFile(sessionId, filePath, content),
isAvailable: () => sandboxManager!.isAvailable(),
getStatus: async () => {
const status = await sandboxManager!.getStatus();
// type=none 时 CommandSandbox 仍回报 available=true仅表示进程侧无报错
// 与策略降级并存会令 UI 出现「none / available / degraded」矛盾表述。
const available = degraded ? false : status.available;
return {
...status,
available,
backend: lastPolicy.backend,
degraded,
reason: degradeReason,
capabilities: lastCapabilities ?? undefined,
};
},
cleanup: () => workspaceManager!.cleanup(),
});
setPermissionService({
checkPermission: (sessionId, type, target) =>
permissionManager!.checkPermission(sessionId, type, target),
requestPermission: (sessionId, type, target, reason) =>
permissionManager!.requestPermission(sessionId, type, target, reason),
getPendingRequests: (sessionId) =>
permissionManager!.getPendingRequests(sessionId),
approve: (requestId, approvedBy, reason) =>
permissionManager!.approve(requestId, approvedBy, reason),
deny: (requestId, reason) => permissionManager!.deny(requestId, reason),
});
setupControlService();
log.info("[SandboxService] backend started:", {
type: activeSandboxType,
backend: lastPolicy.backend,
degraded,
reason: degradeReason,
});
log.info("[SandboxService] Sandbox service started successfully");
} catch (error) {
log.error("[SandboxService] Failed to start sandbox service:", error);
throw error;
}
}
/**
* 停止沙箱服务
*/
export async function stopSandboxService(): Promise<void> {
log.info("[SandboxService] Stopping sandbox service...");
try {
if (workspaceManager) {
await workspaceManager.cleanup();
}
if (sandboxManager) {
await sandboxManager.destroy();
}
workspaceManager = null;
sandboxManager = null;
permissionManager = null;
setSandboxService(null);
setPermissionService(null);
setupControlService();
log.info("[SandboxService] Sandbox service stopped");
} catch (error) {
log.error("[SandboxService] Failed to stop sandbox service:", error);
throw error;
}
}
/**
* 获取沙箱服务状态
*/
export function getSandboxServiceStatus(): {
initialized: boolean;
available: boolean;
type: string;
platform: string;
backend: string;
degraded: boolean;
reason?: string;
} {
return {
initialized: workspaceManager !== null || sandboxManager !== null,
available: sandboxManager?.isInitialized() ?? false,
type: activeSandboxType,
platform: getPlatform(),
backend: lastPolicy.backend,
degraded,
reason: degradeReason,
};
}
/**
* 导出服务实例(用于高级用例)
*/
export { workspaceManager, sandboxManager, permissionManager };