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:
1 parent
c7d6c558e6
commit
b2d37472f0
9 files changed
+115
-28
No files matched your search
@@ -111,7 +111,7 @@ try {
|
|||||||
await update({ commandsEnabled: true, limits: { fileBytes: 4 } })
|
await update({ commandsEnabled: true, limits: { fileBytes: 4 } })
|
||||||
await assert.rejects(executeSandboxCommand('echo unsafe>size.txt', context), /上限/)
|
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))
|
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)
|
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)
|
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)
|
const operation = await sandboxOperation(result.data.runId)
|
||||||
|
|||||||
@@ -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))
|
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)
|
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))
|
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)
|
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)
|
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))
|
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' })
|
tool = await executeTool('execute_command', { command: 'echo explicit-native>explicit-mode.txt', workspace: 'persistent' }, { ...context, permissionMode: 'full-access' })
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function authorizeSandboxWrite(policy: SandboxEffectivePolicy, desc
|
|||||||
assertActive(context.signal)
|
assertActive(context.signal)
|
||||||
await assertPolicyCurrent(policy)
|
await assertPolicyCurrent(policy)
|
||||||
if (writebackRequired && policy.writeback === 'deny') throw new Error('沙盒策略禁止写回工作区')
|
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 expiresAt = Date.now() + duration
|
||||||
const started = performance.now()
|
const started = performance.now()
|
||||||
const expired = (): boolean => Date.now() >= expiresAt || performance.now() - started >= duration
|
const expired = (): boolean => Date.now() >= expiresAt || performance.now() - started >= duration
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { assertActive } from './policy'
|
|||||||
|
|
||||||
interface Waiting { workspace: string; start(): void; cancel(): void }
|
interface Waiting { workspace: string; start(): void; cancel(): void }
|
||||||
const queue: Waiting[] = [], busy = new Set<string>()
|
const queue: Waiting[] = [], busy = new Set<string>()
|
||||||
const maximumActive = 4, maximumQueued = 32
|
const maximumActive = 4, maximumQueued = 64
|
||||||
function drain(): void {
|
function drain(): void {
|
||||||
for (let index = 0; index < queue.length && busy.size < maximumActive;) {
|
for (let index = 0; index < queue.length && busy.size < maximumActive;) {
|
||||||
const item = queue[index]
|
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> {
|
export async function withSandboxExecution<T>(root: string, signal: AbortSignal | undefined, operation: (canonicalRoot: string) => Promise<T>): Promise<T> {
|
||||||
assertActive(signal)
|
assertActive(signal)
|
||||||
const real = await fs.realpath(root), workspace = process.platform === 'win32' ? real.toLowerCase() : real
|
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) => {
|
return new Promise<T>((done, reject) => {
|
||||||
const item: Waiting = {
|
const item: Waiting = {
|
||||||
workspace,
|
workspace,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { SandboxEffectivePolicy } from '@shared/sandbox'
|
|||||||
import { sandboxConfiguration } from './configuration'
|
import { sandboxConfiguration } from './configuration'
|
||||||
import { assertActive, within } from './policy'
|
import { assertActive, within } from './policy'
|
||||||
import { assertPolicyCurrent, sandboxPathDenied } from './policy-store'
|
import { assertPolicyCurrent, sandboxPathDenied } from './policy-store'
|
||||||
|
import { SANDBOX_EXCLUDED_DIRECTORIES, SANDBOX_EXCLUDED_ROOT_PATHS } from './workspace-files'
|
||||||
|
|
||||||
export class SandboxReadError extends Error {
|
export class SandboxReadError extends Error {
|
||||||
constructor(readonly code: string, message: string) { super(message) }
|
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' }>
|
data?: string; entries?: Array<{ name: string; kind: 'file' | 'directory' | 'link' }>
|
||||||
}
|
}
|
||||||
const identityPattern = /^[a-f0-9]{16}:[a-f0-9]{32}$/
|
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. */
|
/** Every read reopens and pins the root/ancestors/target in native code; root identity is fixed for this Job. */
|
||||||
export class NativeWorkspaceReader {
|
export class NativeWorkspaceReader {
|
||||||
@@ -27,7 +34,7 @@ export class NativeWorkspaceReader {
|
|||||||
private allowed(path: string): boolean {
|
private allowed(path: string): boolean {
|
||||||
const absolute = resolve(this.policy.workspaceRoot, path)
|
const absolute = resolve(this.policy.workspaceRoot, path)
|
||||||
return within(this.policy.workspaceRoot, absolute) && !sandboxPathDenied(this.policy, absolute)
|
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)
|
&& !within(resolve(sandboxConfiguration().stateRoot), absolute)
|
||||||
}
|
}
|
||||||
async read(operation: 'read' | 'stat' | 'list', input: unknown, signal?: AbortSignal): Promise<NativeReadResult> {
|
async read(operation: 'read' | 'stat' | 'list', input: unknown, signal?: AbortSignal): Promise<NativeReadResult> {
|
||||||
|
|||||||
@@ -5,8 +5,16 @@ import type { SandboxEffectivePolicy, SandboxLimits, SandboxPolicyDocument, Sand
|
|||||||
import { sandboxConfiguration } from './configuration'
|
import { sandboxConfiguration } from './configuration'
|
||||||
import { protectedPath } from './policy'
|
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 maximumBytes = 256 * 1024
|
||||||
const keys = ['commandsEnabled', 'writeback', 'limits', 'deniedPaths', 'maxChanges']
|
const keys = ['commandsEnabled', 'writeback', 'limits', 'deniedPaths', 'maxChanges']
|
||||||
const limitsKeys = ['files', 'bytes', 'fileBytes', 'timeoutMs'] as const
|
const limitsKeys = ['files', 'bytes', 'fileBytes', 'timeoutMs'] as const
|
||||||
@@ -40,7 +48,7 @@ export function validatePolicyOverride(input: unknown): SandboxPolicyOverride {
|
|||||||
result.limits = {}
|
result.limits = {}
|
||||||
for (const key of limitsKeys) if (Object.hasOwn(limits, key)) {
|
for (const key of limitsKeys) if (Object.hasOwn(limits, key)) {
|
||||||
const limit = 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}`)
|
if (!Number.isInteger(limit) || Number(limit) < (key === 'timeoutMs' ? 100 : 1) || Number(limit) > ceiling) throw new Error(`沙盒资源上限无效:${key}`)
|
||||||
result.limits[key] = Number(limit)
|
result.limits[key] = Number(limit)
|
||||||
}
|
}
|
||||||
@@ -50,7 +58,7 @@ export function validatePolicyOverride(input: unknown): SandboxPolicyOverride {
|
|||||||
result.deniedPaths = [...new Set(value.deniedPaths.map(deniedPath))]
|
result.deniedPaths = [...new Set(value.deniedPaths.map(deniedPath))]
|
||||||
}
|
}
|
||||||
if (Object.hasOwn(value, 'maxChanges')) {
|
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)
|
result.maxChanges = Number(value.maxChanges)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -96,6 +104,50 @@ export async function readSandboxPolicy(): Promise<SandboxPolicyDocument> {
|
|||||||
}
|
}
|
||||||
return result
|
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> {
|
export async function effectiveSandboxPolicy(rootInput: string): Promise<SandboxEffectivePolicy> {
|
||||||
const root = await fs.realpath(rootInput), doc = await readSandboxPolicy()
|
const root = await fs.realpath(rootInput), doc = await readSandboxPolicy()
|
||||||
const override = doc.workspaces[workspaceKey(root)]?.rules ?? {}
|
const override = doc.workspaces[workspaceKey(root)]?.rules ?? {}
|
||||||
|
|||||||
@@ -162,7 +162,8 @@ async function executeIsolatedCommand(command: string, context: SandboxCommandCo
|
|||||||
draft = await PersistentWorkspace.open(policy, context.sessionId!, workspaceIdentity)
|
draft = await PersistentWorkspace.open(policy, context.sessionId!, workspaceIdentity)
|
||||||
await draft.synchronize()
|
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 toolchains = await stageSandboxToolchains(stage, context.runtimes ?? [], context.signal)
|
||||||
const dependencies = await stageSandboxDependencies(workspace, stage, context.dependencies ?? [], toolchains, context.signal)
|
const dependencies = await stageSandboxDependencies(workspace, stage, context.dependencies ?? [], toolchains, context.signal)
|
||||||
if (toolchains.some(tool => tool.kind === 'git')) {
|
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' })
|
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)
|
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)
|
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)
|
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)
|
await assertPolicyCurrent(policy)
|
||||||
|
|||||||
@@ -8,10 +8,26 @@ import { assertActive, resolveSandboxPath, within } from './policy'
|
|||||||
import { sandboxPathDenied } from './policy-store'
|
import { sandboxPathDenied } from './policy-store'
|
||||||
|
|
||||||
export type Snapshot = Map<string, { hash: string; size: number }>
|
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> {
|
export async function regularBytes(file: string, fileLimit: number): Promise<Buffer> {
|
||||||
const before = await fs.lstat(file)
|
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)
|
const handle = await fs.open(file, constants.O_RDONLY)
|
||||||
try {
|
try {
|
||||||
const opened = await handle.stat()
|
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> {
|
async function walk(directory: string): Promise<void> {
|
||||||
for await (const entry of await fs.opendir(directory)) {
|
for await (const entry of await fs.opendir(directory)) {
|
||||||
assertActive(signal)
|
assertActive(signal)
|
||||||
if (++entries > policy.limits.files) throw new Error('工作区超过沙盒快照的总项数上限')
|
|
||||||
const sourcePath = join(directory, entry.name), file = relative(source, sourcePath)
|
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)
|
await resolveSandboxPath(source, file)
|
||||||
if (entry.isDirectory()) { directories.add(file); if (target) await fs.mkdir(join(target, file), { recursive: true }); await walk(sourcePath) }
|
if (entry.isDirectory()) { directories.add(file); if (target) await fs.mkdir(join(target, file), { recursive: true }); await walk(sourcePath) }
|
||||||
else if (entry.isFile()) {
|
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)
|
const bytes = await regularBytes(sourcePath, policy.limits.fileBytes)
|
||||||
totalBytes += bytes.length
|
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) {
|
if (target) {
|
||||||
await fs.mkdir(dirname(join(target, file)), { recursive: true })
|
await fs.mkdir(dirname(join(target, file)), { recursive: true })
|
||||||
await fs.writeFile(join(target, file), bytes, { flag: 'wx' })
|
await fs.writeFile(join(target, file), bytes, { flag: 'wx' })
|
||||||
}
|
}
|
||||||
files.set(file, { hash: digest(bytes), size: bytes.length })
|
files.set(file, { hash: digest(bytes), size: bytes.length })
|
||||||
} else throw new Error('工作区包含不支持的特殊文件')
|
} else throw new Error(`工作区包含不支持的特殊文件:${file}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await walk(source)
|
await walk(source)
|
||||||
@@ -58,19 +74,19 @@ export async function stageChanges(stageRoot: string, before: Snapshot, beforeDi
|
|||||||
async function walk(directory: string): Promise<void> {
|
async function walk(directory: string): Promise<void> {
|
||||||
for await (const entry of await fs.opendir(directory)) {
|
for await (const entry of await fs.opendir(directory)) {
|
||||||
assertActive(signal)
|
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)
|
const absolute = join(directory, entry.name), file = relative(stageRoot, absolute)
|
||||||
if (nodeDependencies && file === 'node_modules') continue
|
if (nodeDependencies && file === 'node_modules') continue
|
||||||
if (sandboxPathDenied(policy, join(policy.workspaceRoot, file)) || entry.isSymbolicLink()) throw new Error('沙盒产生了受保护路径或链接;未写回工作区')
|
if (sandboxPathDenied(policy, join(policy.workspaceRoot, file)) || entry.isSymbolicLink()) throw new Error('沙盒产生了受保护路径或链接;未写回工作区')
|
||||||
await resolveSandboxPath(stageRoot, file)
|
await resolveSandboxPath(stageRoot, file)
|
||||||
if (entry.isDirectory()) { directories.add(file); await walk(absolute) }
|
if (entry.isDirectory()) { directories.add(file); await walk(absolute) }
|
||||||
else if (entry.isFile()) {
|
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)
|
const bytes = await regularBytes(absolute, policy.limits.fileBytes)
|
||||||
totalBytes += bytes.length
|
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)
|
after.set(file, bytes)
|
||||||
} else throw new Error('沙盒产生了不支持的特殊文件')
|
} else throw new Error(`沙盒产生了不支持的特殊文件:${file}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await walk(stageRoot)
|
await walk(stageRoot)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { sandboxConfiguration } from './configuration'
|
|||||||
import { assertActive, protectedPath, within } from './policy'
|
import { assertActive, protectedPath, within } from './policy'
|
||||||
import { assertPolicyCurrent, sandboxPathDenied } from './policy-store'
|
import { assertPolicyCurrent, sandboxPathDenied } from './policy-store'
|
||||||
import { digest, type DirectoryChange, type FileChange } from './transactions'
|
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 { verifiedBinary } from './native-component'
|
||||||
import { NativeWorkspaceReader } from './filesystem-reader'
|
import { NativeWorkspaceReader } from './filesystem-reader'
|
||||||
import { releaseRunLease } from './maintenance'
|
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 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 index = (tree: WorkspaceEntry[]): Map<string, WorkspaceEntry> => new Map(tree.map(entry => [key(entry.path), entry]))
|
||||||
const depth = (path: string): number => path.split('/').length
|
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 {
|
export function workspaceKey(workspace: string, sessionId: string): string {
|
||||||
if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 200 || /[\x00-\x1f]/.test(sessionId)) throw new Error('长期工作区需要有效的宿主会话身份')
|
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>()
|
let bytes = 0; const seen = new Map<string, WorkspaceEntry>()
|
||||||
for (const entry of tree) {
|
for (const entry of tree) {
|
||||||
if (!entry || typeof entry.path !== 'string' || !entry.path || entry.path.length > 1024 || isAbsolute(entry.path) || entry.path.includes('\\')
|
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('长期工作区包含无效、重复或被策略拒绝的路径')
|
|| 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 (entry.kind === 'file') {
|
||||||
if (!hash.test(entry.hash ?? '') || !Number.isSafeInteger(entry.size) || entry.size! < 0 || entry.size! > policy.limits.fileBytes) throw new Error('长期工作区文件身份或容量无效')
|
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
|
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)
|
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)
|
|| !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
|
this.revision = value
|
||||||
}
|
}
|
||||||
assertUsable(): void {
|
assertUsable(): void {
|
||||||
|
|||||||
Reference in new issue
Block a user