fix(sandbox): support real development workspaces

- raise finite development defaults/ceilings and queue capacities
- migrate untouched v1 default policy
- exclude generated output and shipped runtime paths from snapshots
- report snapshot limit errors with the offending path
This commit is contained in:
xiaohan.yuan committed 2026-10-18 18:08:20 +08:00
1 parent c7d6c558e6
commit b2d37472f0
9 files changed
+115 -28

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ export async function authorizeSandboxWrite(policy: SandboxEffectivePolicy, desc
assertActive(context.signal)
await assertPolicyCurrent(policy)
if (writebackRequired && policy.writeback === 'deny') throw new Error('沙盒策略禁止写回工作区')
if (pending >= 64) throw new Error('沙盒待审批请求过多')
if (pending >= 128) throw new Error('沙盒待审批请求过多')
const expiresAt = Date.now() + duration
const started = performance.now()
const expired = (): boolean => Date.now() >= expiresAt || performance.now() - started >= duration
+2 -2
View File
@@ -3,7 +3,7 @@ import { assertActive } from './policy'
interface Waiting { workspace: string; start(): void; cancel(): void }
const queue: Waiting[] = [], busy = new Set<string>()
const maximumActive = 4, maximumQueued = 32
const maximumActive = 4, maximumQueued = 64
function drain(): void {
for (let index = 0; index < queue.length && busy.size < maximumActive;) {
const item = queue[index]
@@ -15,7 +15,7 @@ function drain(): void {
export async function withSandboxExecution<T>(root: string, signal: AbortSignal | undefined, operation: (canonicalRoot: string) => Promise<T>): Promise<T> {
assertActive(signal)
const real = await fs.realpath(root), workspace = process.platform === 'win32' ? real.toLowerCase() : real
if (queue.length >= maximumQueued || queue.filter(item => item.workspace === workspace).length >= 8) throw new Error('沙盒执行队列已满,请稍后重试')
if (queue.length >= maximumQueued || queue.filter(item => item.workspace === workspace).length >= 12) throw new Error('沙盒执行队列已满,请稍后重试')
return new Promise<T>((done, reject) => {
const item: Waiting = {
workspace,
+9 -2
View File
@@ -4,6 +4,7 @@ import type { SandboxEffectivePolicy } from '@shared/sandbox'
import { sandboxConfiguration } from './configuration'
import { assertActive, within } from './policy'
import { assertPolicyCurrent, sandboxPathDenied } from './policy-store'
import { SANDBOX_EXCLUDED_DIRECTORIES, SANDBOX_EXCLUDED_ROOT_PATHS } from './workspace-files'
export class SandboxReadError extends Error {
constructor(readonly code: string, message: string) { super(message) }
@@ -13,7 +14,13 @@ export interface NativeReadResult {
data?: string; entries?: Array<{ name: string; kind: 'file' | 'directory' | 'link' }>
}
const identityPattern = /^[a-f0-9]{16}:[a-f0-9]{32}$/
const excluded = new Set(['.tmp', 'node_modules', '.venv', 'venv', '__pycache__'])
function excluded(path: string): boolean {
const normalized = path.replaceAll('\\', '/').toLowerCase(),
parts = normalized.split('/'),
inExcludedDirectory = parts.some(part => SANDBOX_EXCLUDED_DIRECTORIES.has(part)),
inExcludedRoot = [...SANDBOX_EXCLUDED_ROOT_PATHS].some(root => normalized === root || normalized.startsWith(`${root}/`))
return inExcludedDirectory || inExcludedRoot
}
/** Every read reopens and pins the root/ancestors/target in native code; root identity is fixed for this Job. */
export class NativeWorkspaceReader {
@@ -27,7 +34,7 @@ export class NativeWorkspaceReader {
private allowed(path: string): boolean {
const absolute = resolve(this.policy.workspaceRoot, path)
return within(this.policy.workspaceRoot, absolute) && !sandboxPathDenied(this.policy, absolute)
&& !relative(this.policy.workspaceRoot, absolute).split(/[\\/]/).some(part => excluded.has(part.toLowerCase()))
&& !excluded(relative(this.policy.workspaceRoot, absolute))
&& !within(resolve(sandboxConfiguration().stateRoot), absolute)
}
async read(operation: 'read' | 'stat' | 'list', input: unknown, signal?: AbortSignal): Promise<NativeReadResult> {
+56 -4
View File
@@ -5,8 +5,16 @@ import type { SandboxEffectivePolicy, SandboxLimits, SandboxPolicyDocument, Sand
import { sandboxConfiguration } from './configuration'
import { protectedPath } from './policy'
export const SANDBOX_LIMITS: Readonly<SandboxLimits> = Object.freeze({ files: 10_000, bytes: 128 * 1024 * 1024, fileBytes: 8 * 1024 * 1024, timeoutMs: 120_000 })
const defaultRevision = 'sandbox-policy-v1-default'
/**
* Development defaults are intended for real source repositories, while the
* ceiling below remains finite so a malformed or hostile workspace cannot
* consume unbounded disk or CPU. A policy may lower either budget, but never
* raise it above the ceiling.
*/
export const SANDBOX_LIMITS: Readonly<SandboxLimits> = Object.freeze({ files: 50_000, bytes: 2 * 1024 * 1024 * 1024, fileBytes: 256 * 1024 * 1024, timeoutMs: 10 * 60_000 })
export const SANDBOX_LIMIT_CEILINGS: Readonly<SandboxLimits> = Object.freeze({ files: 200_000, bytes: 8 * 1024 * 1024 * 1024, fileBytes: 512 * 1024 * 1024, timeoutMs: 10 * 60_000 })
const defaultRevision = 'sandbox-policy-v2-development'
const legacyDefaultRevision = 'sandbox-policy-v1-default'
const maximumBytes = 256 * 1024
const keys = ['commandsEnabled', 'writeback', 'limits', 'deniedPaths', 'maxChanges']
const limitsKeys = ['files', 'bytes', 'fileBytes', 'timeoutMs'] as const
@@ -40,7 +48,7 @@ export function validatePolicyOverride(input: unknown): SandboxPolicyOverride {
result.limits = {}
for (const key of limitsKeys) if (Object.hasOwn(limits, key)) {
const limit = limits[key]
const ceiling = key === 'timeoutMs' ? 600_000 : SANDBOX_LIMITS[key]
const ceiling = SANDBOX_LIMIT_CEILINGS[key]
if (!Number.isInteger(limit) || Number(limit) < (key === 'timeoutMs' ? 100 : 1) || Number(limit) > ceiling) throw new Error(`沙盒资源上限无效:${key}`)
result.limits[key] = Number(limit)
}
@@ -50,7 +58,7 @@ export function validatePolicyOverride(input: unknown): SandboxPolicyOverride {
result.deniedPaths = [...new Set(value.deniedPaths.map(deniedPath))]
}
if (Object.hasOwn(value, 'maxChanges')) {
if (!Number.isInteger(value.maxChanges) || Number(value.maxChanges) < 1 || Number(value.maxChanges) > SANDBOX_LIMITS.files) throw new Error('沙盒变更项数上限无效')
if (!Number.isInteger(value.maxChanges) || Number(value.maxChanges) < 1 || Number(value.maxChanges) > SANDBOX_LIMIT_CEILINGS.files) throw new Error('沙盒变更项数上限无效')
result.maxChanges = Number(value.maxChanges)
}
return result
@@ -96,6 +104,50 @@ export async function readSandboxPolicy(): Promise<SandboxPolicyDocument> {
}
return result
}
/**
* v1 stored the old low defaults as an explicit complete document after any
* security preference was toggled. Promote only an untouched complete legacy
* default. A smaller limit, disabled commands, denied paths, or writeback
* restriction remains an intentional user choice.
*/
export async function migrateLegacyDefaultPolicy(): Promise<boolean> {
const path = join(sandboxConfiguration().stateRoot, 'policy.json')
const bytes = await regularControlFile(path)
if (!bytes) return false
const value = object(JSON.parse(bytes.toString('utf8')))
if (value.version !== 1 || value.revision !== legacyDefaultRevision || !Number.isSafeInteger(value.updatedAt)) return false
const legacyDefaults = object(value.defaults)
const legacy = { files: 10_000, bytes: 128 * 1024 * 1024, fileBytes: 8 * 1024 * 1024, timeoutMs: 120_000 }
const limits = object(legacyDefaults.limits)
const workspaces = object(value.workspaces)
const untouched = legacyDefaults.commandsEnabled === true && legacyDefaults.writeback === 'ask'
&& Array.isArray(legacyDefaults.deniedPaths) && legacyDefaults.deniedPaths.length === 0
&& legacyDefaults.maxChanges === legacy.files
&& limitsKeys.every(key => limits[key] === legacy[key])
&& Object.keys(workspaces).length === 0
if (!untouched) return false
const state = resolve(sandboxConfiguration().stateRoot)
const lockPath = join(state, 'policy-migration.lock'), temporary = join(state, `policy-${randomUUID()}.migrated.tmp`)
await fs.mkdir(state, { recursive: true, mode: 0o700 })
const lock = await fs.open(lockPath, 'wx', 0o600).catch(error => {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('沙盒策略正在被其他维护进程迁移;请稍后重试')
throw error
})
try {
if (!(await regularControlFile(path))?.equals(bytes)) return false
const migrated = defaults()
migrated.revision = randomUUID()
migrated.updatedAt = Date.now()
const output = await fs.open(temporary, 'wx', 0o600)
try { await output.writeFile(JSON.stringify(migrated, null, 2) + '\n'); await output.sync() } finally { await output.close() }
await fs.rename(temporary, path)
return true
} finally {
await lock.close()
await fs.unlink(temporary).catch(() => undefined)
await fs.unlink(lockPath).catch(() => undefined)
}
}
export async function effectiveSandboxPolicy(rootInput: string): Promise<SandboxEffectivePolicy> {
const root = await fs.realpath(rootInput), doc = await readSandboxPolicy()
const override = doc.workspaces[workspaceKey(root)]?.rules ?? {}
+3 -2
View File
@@ -162,7 +162,8 @@ async function executeIsolatedCommand(command: string, context: SandboxCommandCo
draft = await PersistentWorkspace.open(policy, context.sessionId!, workspaceIdentity)
await draft.synchronize()
}
const initial = draft ? await draft.checkoutInto(stage, id) : await snapshot(workspace, join(stage, 'workspace'), policy, context.signal)
const initial = draft ? await draft.checkoutInto(stage, id)
: await snapshot(workspace, join(stage, 'workspace'), policy, context.signal)
const toolchains = await stageSandboxToolchains(stage, context.runtimes ?? [], context.signal)
const dependencies = await stageSandboxDependencies(workspace, stage, context.dependencies ?? [], toolchains, context.signal)
if (toolchains.some(tool => tool.kind === 'git')) {
@@ -198,7 +199,7 @@ async function executeIsolatedCommand(command: string, context: SandboxCommandCo
await fs.writeFile(join(stage, 'git-arguments'), context.gitArguments.map(arg => Buffer.from(arg).toString('hex')).join('\n') + '\n', { flag: 'wx' })
}
const timeout = Math.min(context.timeoutMs ?? policy.limits.timeoutMs, policy.limits.timeoutMs)
if (!Number.isInteger(timeout) || timeout < 100 || timeout > 600_000) throw new Error('沙盒超时参数无效')
if (!Number.isInteger(timeout) || timeout < 100 || timeout > 10 * 60_000) throw new Error('沙盒超时参数无效')
await assertPolicyCurrent(policy)
const native = await runNative(binary, profile, stage, join(directory, 'result.json'), timeout, policy, context.signal, context.stdioProbe, context.readBridge ? { owner: { ...identity, sandboxRunId: id } } : undefined)
await assertPolicyCurrent(policy)
+27 -11
View File
@@ -8,10 +8,26 @@ import { assertActive, resolveSandboxPath, within } from './policy'
import { sandboxPathDenied } from './policy-store'
export type Snapshot = Map<string, { hash: string; size: number }>
const excludedDirectories = new Set(['.git', '.ycode', '.tmp', 'node_modules', '.venv', 'venv', '__pycache__', 'out', 'dist', 'release'])
/** Generated output and app-shipped runtimes never belong to a project draft. */
export const SANDBOX_EXCLUDED_DIRECTORIES = new Set([
'.git', '.ycode', '.workbuddy', '.tmp', '.tmp-profile', 'node_modules', '.venv', 'venv', '__pycache__',
'out', 'dist', 'release', 'coverage', '.next', '.nuxt', '.turbo'
])
/** These are application resources when YCode develops its own checkout. */
export const SANDBOX_EXCLUDED_ROOT_PATHS = new Set([
'resources/omp', 'resources/sandbox/win32-x64', 'resources/model-metadata'
])
function isExcluded(file: string): boolean {
const normalized = file.replaceAll('\\', '/').toLowerCase()
if (SANDBOX_EXCLUDED_DIRECTORIES.has(normalized)) return true
return [...SANDBOX_EXCLUDED_ROOT_PATHS].some(path => normalized === path || normalized.startsWith(`${path}/`))
}
export async function regularBytes(file: string, fileLimit: number): Promise<Buffer> {
const before = await fs.lstat(file)
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > fileLimit) throw new Error('快照包含链接、特殊文件或超过策略上限的文件')
if (!before.isFile()) throw new Error(`快照包含特殊文件:${file}`)
if (before.isSymbolicLink()) throw new Error(`快照包含符号链接:${file}`)
if (before.nlink !== 1) throw new Error(`快照包含多重硬链接:${file}`)
if (before.size > fileLimit) throw new Error(`文件超过沙盒单文件上限:${file}(${before.size} 字节,上限 ${fileLimit} 字节)`)
const handle = await fs.open(file, constants.O_RDONLY)
try {
const opened = await handle.stat()
@@ -30,22 +46,22 @@ export async function snapshot(source: string, target: string | undefined, polic
async function walk(directory: string): Promise<void> {
for await (const entry of await fs.opendir(directory)) {
assertActive(signal)
if (++entries > policy.limits.files) throw new Error('工作区超过沙盒快照的总项数上限')
const sourcePath = join(directory, entry.name), file = relative(source, sourcePath)
if (within(stateRoot, sourcePath) || sandboxPathDenied(policy, sourcePath) || entry.isSymbolicLink() || entry.isDirectory() && excludedDirectories.has(entry.name.toLowerCase())) { excluded++; continue }
if (within(stateRoot, sourcePath) || sandboxPathDenied(policy, sourcePath) || entry.isSymbolicLink() || isExcluded(file)) { excluded++; continue }
if (++entries > policy.limits.files) throw new Error(`工作区超过沙盒快照的总项数上限(${policy.limits.files})`)
await resolveSandboxPath(source, file)
if (entry.isDirectory()) { directories.add(file); if (target) await fs.mkdir(join(target, file), { recursive: true }); await walk(sourcePath) }
else if (entry.isFile()) {
if (files.size >= policy.limits.files) throw new Error('工作区超过沙盒快照的文件数量上限')
if (files.size >= policy.limits.files) throw new Error(`工作区超过沙盒快照的文件数量上限(${policy.limits.files})`)
const bytes = await regularBytes(sourcePath, policy.limits.fileBytes)
totalBytes += bytes.length
if (totalBytes > policy.limits.bytes) throw new Error('工作区超过沙盒快照的容量上限')
if (totalBytes > policy.limits.bytes) throw new Error(`工作区超过沙盒快照的容量上限:${file} 使快照达到 ${totalBytes} 字节,上限 ${policy.limits.bytes} 字节`)
if (target) {
await fs.mkdir(dirname(join(target, file)), { recursive: true })
await fs.writeFile(join(target, file), bytes, { flag: 'wx' })
}
files.set(file, { hash: digest(bytes), size: bytes.length })
} else throw new Error('工作区包含不支持的特殊文件')
} else throw new Error(`工作区包含不支持的特殊文件:${file}`)
}
}
await walk(source)
@@ -58,19 +74,19 @@ export async function stageChanges(stageRoot: string, before: Snapshot, beforeDi
async function walk(directory: string): Promise<void> {
for await (const entry of await fs.opendir(directory)) {
assertActive(signal)
if (++entries > policy.limits.files) throw new Error('沙盒产物超过总项数上限')
if (++entries > policy.limits.files) throw new Error(`沙盒产物超过总项数上限(${policy.limits.files})`)
const absolute = join(directory, entry.name), file = relative(stageRoot, absolute)
if (nodeDependencies && file === 'node_modules') continue
if (sandboxPathDenied(policy, join(policy.workspaceRoot, file)) || entry.isSymbolicLink()) throw new Error('沙盒产生了受保护路径或链接;未写回工作区')
await resolveSandboxPath(stageRoot, file)
if (entry.isDirectory()) { directories.add(file); await walk(absolute) }
else if (entry.isFile()) {
if (after.size >= policy.limits.files) throw new Error('沙盒产物超过文件数量上限')
if (after.size >= policy.limits.files) throw new Error(`沙盒产物超过文件数量上限(${policy.limits.files})`)
const bytes = await regularBytes(absolute, policy.limits.fileBytes)
totalBytes += bytes.length
if (totalBytes > policy.limits.bytes) throw new Error('沙盒产物超过容量上限')
if (totalBytes > policy.limits.bytes) throw new Error(`沙盒产物超过容量上限:${file} 使产物达到 ${totalBytes} 字节,上限 ${policy.limits.bytes} 字节`)
after.set(file, bytes)
} else throw new Error('沙盒产生了不支持的特殊文件')
} else throw new Error(`沙盒产生了不支持的特殊文件:${file}`)
}
}
await walk(stageRoot)
+9 -4
View File
@@ -7,7 +7,7 @@ import { sandboxConfiguration } from './configuration'
import { assertActive, protectedPath, within } from './policy'
import { assertPolicyCurrent, sandboxPathDenied } from './policy-store'
import { digest, type DirectoryChange, type FileChange } from './transactions'
import { regularBytes, snapshot, stageChanges, type Snapshot } from './workspace-files'
import { regularBytes, SANDBOX_EXCLUDED_DIRECTORIES, SANDBOX_EXCLUDED_ROOT_PATHS, snapshot, stageChanges, type Snapshot } from './workspace-files'
import { verifiedBinary } from './native-component'
import { NativeWorkspaceReader } from './filesystem-reader'
import { releaseRunLease } from './maintenance'
@@ -30,7 +30,12 @@ const same = (a?: WorkspaceEntry, b?: WorkspaceEntry): boolean => a?.kind === b?
const order = (tree: WorkspaceEntry[]): WorkspaceEntry[] => tree.sort((a, b) => a.path.localeCompare(b.path, 'en'))
const index = (tree: WorkspaceEntry[]): Map<string, WorkspaceEntry> => new Map(tree.map(entry => [key(entry.path), entry]))
const depth = (path: string): number => path.split('/').length
const excluded = new Set(['node_modules', '.tmp', '.venv', 'venv', '__pycache__', 'out', 'dist', 'release'])
const excluded = SANDBOX_EXCLUDED_DIRECTORIES
function excludedEntry(path: string): boolean {
const normalized = path.replaceAll('\\', '/').toLowerCase()
return normalized.split('/').some(part => excluded.has(part))
|| [...SANDBOX_EXCLUDED_ROOT_PATHS].some(root => normalized === root || normalized.startsWith(`${root}/`))
}
export function workspaceKey(workspace: string, sessionId: string): string {
if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 200 || /[\x00-\x1f]/.test(sessionId)) throw new Error('长期工作区需要有效的宿主会话身份')
@@ -81,7 +86,7 @@ function validateTree(tree: WorkspaceEntry[], policy: SandboxEffectivePolicy): v
let bytes = 0; const seen = new Map<string, WorkspaceEntry>()
for (const entry of tree) {
if (!entry || typeof entry.path !== 'string' || !entry.path || entry.path.length > 1024 || isAbsolute(entry.path) || entry.path.includes('\\')
|| entry.path.split('/').some(part => !part || part === '.' || part === '..' || excluded.has(part.toLowerCase())) || protectedPath(entry.path)
|| entry.path.split('/').some(part => !part || part === '.' || part === '..') || excludedEntry(entry.path) || protectedPath(entry.path)
|| sandboxPathDenied(policy, join(policy.workspaceRoot, entry.path)) || seen.has(key(entry.path)) || !['file', 'directory'].includes(entry.kind)) throw new Error('长期工作区包含无效、重复或被策略拒绝的路径')
if (entry.kind === 'file') {
if (!hash.test(entry.hash ?? '') || !Number.isSafeInteger(entry.size) || entry.size! < 0 || entry.size! > policy.limits.fileBytes) throw new Error('长期工作区文件身份或容量无效')
@@ -190,7 +195,7 @@ export class PersistentWorkspace {
const value = JSON.parse(bytes.toString('utf8')) as WorkspaceRevision
if (value.version !== 1 || value.id !== identity.id || key(value.workspaceRoot) !== key(this.policy.workspaceRoot) || value.sessionId !== this.sessionId || !fileId.test(value.workspaceIdentity)
|| !fileId.test(value.physicalIdentity) || !hash.test(value.policyFingerprint) || !Number.isSafeInteger(value.createdAt)
|| !Array.isArray(value.base) || !Array.isArray(value.tree) || value.base.length > 10000 || value.tree.length > 10000 || !Number.isSafeInteger(value.excluded) || value.excluded < 0) throw new Error('长期工作区版本归属无效')
|| !Array.isArray(value.base) || !Array.isArray(value.tree) || value.base.length > this.policy.limits.files || value.tree.length > this.policy.limits.files || !Number.isSafeInteger(value.excluded) || value.excluded < 0) throw new Error('长期工作区版本归属无效')
this.revision = value
}
assertUsable(): void {