From b2d37472f0d2a866dda4678a1d32bace000d9ff7 Mon Sep 17 00:00:00 2001 From: "xiaohan.yuan" Date: Sun, 18 Oct 2026 18:08:20 +0800 Subject: [PATCH] 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 --- scripts/sandbox-policy-regression.ts | 2 +- scripts/sandbox-workspace-regression.ts | 8 +++- src/main/sandbox/authorizations.ts | 2 +- src/main/sandbox/execution.ts | 4 +- src/main/sandbox/filesystem-reader.ts | 11 ++++- src/main/sandbox/policy-store.ts | 60 +++++++++++++++++++++++-- src/main/sandbox/service.ts | 5 ++- src/main/sandbox/workspace-files.ts | 38 +++++++++++----- src/main/sandbox/workspace.ts | 13 ++++-- 9 files changed, 115 insertions(+), 28 deletions(-) diff --git a/scripts/sandbox-policy-regression.ts b/scripts/sandbox-policy-regression.ts index b513f2e..63540dc 100644 --- a/scripts/sandbox-policy-regression.ts +++ b/scripts/sandbox-policy-regression.ts @@ -111,7 +111,7 @@ try { await update({ commandsEnabled: true, limits: { fileBytes: 4 } }) await assert.rejects(executeSandboxCommand('echo unsafe>size.txt', context), /上限/) check('configured snapshot byte limits are enforced before execution', !await fs.stat(join(workspace, 'size.txt')).catch(() => null)) - await update({ limits: { fileBytes: 8 * 1024 * 1024 } }) + await update({ limits: { fileBytes: 256 * 1024 * 1024 } }) result = await executeSandboxCommand('echo approved>sample.txt', context) check('native writeback records policy and authorization identities', result.ok && result.data.policyRevision === document.revision && !!result.data.authorizationId) const operation = await sandboxOperation(result.data.runId) diff --git a/scripts/sandbox-workspace-regression.ts b/scripts/sandbox-workspace-regression.ts index 81aeed7..cf4e286 100644 --- a/scripts/sandbox-workspace-regression.ts +++ b/scripts/sandbox-workspace-regression.ts @@ -106,8 +106,14 @@ try { check('physical tree replacement is rejected and explicit recovery restores a verified checkpoint', result.ok && result.data.stdout.includes('saved-before-failure') && !await fs.stat(join(workspace, 'root-replaced.txt')).catch(() => null)) let tool = await executeTool('execute_command', { command: 'echo tool-route>tool.txt', workspace: 'persistent' }, context) check('model-facing command selects the real persistent backend', tool.ok && (tool.data as { writeback?: string }).writeback === 'deferred' && !await fs.stat(join(workspace, 'tool.txt')).catch(() => null)) + tool = await executeTool('execute_command', { command: 'echo default-tool-route>default-tool.txt' }, context) + check('model-facing commands default to the retained draft instead of copying on every command', tool.ok + && (tool.data as { writeback?: string; workspace?: { mode?: string } }).writeback === 'deferred' + && (tool.data as { workspace?: { mode?: string } }).workspace?.mode === 'persistent' + && !await fs.stat(join(workspace, 'default-tool.txt')).catch(() => null)) tool = await executeTool('sandbox_workspace', { action: 'changes' }, context) - check('model-facing workspace tool inspects pending draft changes', tool.ok && (tool.data as { changes: Array<{ path: string }> }).changes.some(change => change.path === 'tool.txt')) + check('model-facing workspace tool inspects pending draft changes', tool.ok && (tool.data as { changes: Array<{ path: string }> }).changes.some(change => change.path === 'tool.txt') + && (tool.data as { changes: Array<{ path: string }> }).changes.some(change => change.path === 'default-tool.txt')) tool = await executeTool('sandbox_workspace', { action: 'status', sessionId: 'other-session', workspaceRoot: stateRoot }, context) check('workspace tool rejects caller-supplied ownership and path overrides', !tool.ok && /归属参数/.test(tool.content)) tool = await executeTool('execute_command', { command: 'echo explicit-native>explicit-mode.txt', workspace: 'persistent' }, { ...context, permissionMode: 'full-access' }) diff --git a/src/main/sandbox/authorizations.ts b/src/main/sandbox/authorizations.ts index ee56f44..9c2dc72 100644 --- a/src/main/sandbox/authorizations.ts +++ b/src/main/sandbox/authorizations.ts @@ -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 diff --git a/src/main/sandbox/execution.ts b/src/main/sandbox/execution.ts index b145404..a5335f5 100644 --- a/src/main/sandbox/execution.ts +++ b/src/main/sandbox/execution.ts @@ -3,7 +3,7 @@ import { assertActive } from './policy' interface Waiting { workspace: string; start(): void; cancel(): void } const queue: Waiting[] = [], busy = new Set() -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(root: string, signal: AbortSignal | undefined, operation: (canonicalRoot: string) => Promise): Promise { 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((done, reject) => { const item: Waiting = { workspace, diff --git a/src/main/sandbox/filesystem-reader.ts b/src/main/sandbox/filesystem-reader.ts index 13eebd4..a63fc4b 100644 --- a/src/main/sandbox/filesystem-reader.ts +++ b/src/main/sandbox/filesystem-reader.ts @@ -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 { diff --git a/src/main/sandbox/policy-store.ts b/src/main/sandbox/policy-store.ts index 62cd303..afa495a 100644 --- a/src/main/sandbox/policy-store.ts +++ b/src/main/sandbox/policy-store.ts @@ -5,8 +5,16 @@ import type { SandboxEffectivePolicy, SandboxLimits, SandboxPolicyDocument, Sand import { sandboxConfiguration } from './configuration' import { protectedPath } from './policy' -export const SANDBOX_LIMITS: Readonly = 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 = Object.freeze({ files: 50_000, bytes: 2 * 1024 * 1024 * 1024, fileBytes: 256 * 1024 * 1024, timeoutMs: 10 * 60_000 }) +export const SANDBOX_LIMIT_CEILINGS: Readonly = 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 { } 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 { + 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 { const root = await fs.realpath(rootInput), doc = await readSandboxPolicy() const override = doc.workspaces[workspaceKey(root)]?.rules ?? {} diff --git a/src/main/sandbox/service.ts b/src/main/sandbox/service.ts index 9aa73a1..6bf2fa5 100644 --- a/src/main/sandbox/service.ts +++ b/src/main/sandbox/service.ts @@ -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) diff --git a/src/main/sandbox/workspace-files.ts b/src/main/sandbox/workspace-files.ts index 3db56d6..92359cc 100644 --- a/src/main/sandbox/workspace-files.ts +++ b/src/main/sandbox/workspace-files.ts @@ -8,10 +8,26 @@ import { assertActive, resolveSandboxPath, within } from './policy' import { sandboxPathDenied } from './policy-store' export type Snapshot = Map -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 { 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 { 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 { 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) diff --git a/src/main/sandbox/workspace.ts b/src/main/sandbox/workspace.ts index 84f094c..89ed2e2 100644 --- a/src/main/sandbox/workspace.ts +++ b/src/main/sandbox/workspace.ts @@ -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 => 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() 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 {