chore: initialize qiming workspace repository
This commit is contained in:
@@ -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;
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* 命令沙箱实现(非 Docker)
|
||||
*
|
||||
* 支持:
|
||||
* - none(直接执行)
|
||||
* - macos-seatbelt(sandbox-exec)
|
||||
* - linux-bwrap(bubblewrap)
|
||||
* - windows-sandbox(Windows 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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* 统一沙箱调用构建器
|
||||
*
|
||||
* 合并 CommandSandbox 中 buildInvocation() 和 buildProcessInvocation()
|
||||
* 的重复平台分支逻辑,提供单一入口构建所有平台的沙箱包装调用。
|
||||
*
|
||||
* 支持平台:
|
||||
* - none(直接执行)
|
||||
* - macos-seatbelt(sandbox-exec)
|
||||
* - linux-bwrap(bubblewrap)
|
||||
* - windows-sandbox(qiming-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=捕获输出返回 JSON,serve=双向 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 |",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user