fix(sandbox): harden the per-session record and the ACL runner failure paths (review round v6)

Durable record: bound to the owning session id and validated at the fold (orphan-SID shape, temp path inside the host temp root) — a fork's copied parent record no longer provisions the child, and a tampered record fails loud. Private temp dir: random unguessable name persisted in the record, created exclusively (pre-existing entries and reparse points fail EEXIST). Persistence: a fresh provision kicks an immediate flush (no write-behind debounce), narrowing the crash window to the flush latency — documented as the one self-healing gap. Runner-failure rules: exit-gated on 127 so a confined command that prints the signature on a non-127 exit is never misclassified. Spawn: AssignProcessToJobObject failure terminates the suspended child (no hanging orphans). SandboxExecutionPolicy.sessionId is the branded SessionId. Boundary docs: qualifying clause on the absolutist sentences, NULL-DACL Known Limitation, 'full' scoped to the supported NTFS surface, CLM gate comment.
This commit is contained in:
Huanqi Cao
2026-08-08 23:23:38 +08:00
parent 441927c526
commit 6478da61e3
27 changed files with 472 additions and 278 deletions

View File

@@ -8,18 +8,20 @@
* ({@link AclWriteGrant} materialization, revoked on dispose); the record
* survives restarts so a resumed session reuses the SAME SID — re-granting
* idempotently merges into (or skips) the standing ACEs instead of leaking a
* fresh dead SID's ACEs per restart. A fork gets a new session id and thus a
* fresh record; the record's workspace must match the session's immutable
* cwd (asserted by the provider).
* fresh dead SID's ACEs per restart. The record is BOUND to its owning
* session id, so a fork (which copies the parent's events, record included)
* never inherits the parent's identity — it provisions a fresh one. The
* record's payload is durable input and is validated at the fold (orphan-SID
* shape, well-formed temp path); a matching-but-tampered record fails loud.
*
* @module dsh-sandbox-local/acl-session
*/
import { createHash } from 'node:crypto'
import { randomBytes } from 'node:crypto'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { randomWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
@@ -27,13 +29,15 @@ declare module '@deepseek-ai/dsh-session' {
* The session's windows-acl write identity was provisioned — log-only
* (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`):
* durable and replayable, never in the model transcript. The LAST such
* event is the session's record ({@link sessionAclRecord}); the
* provider appends exactly one on the session's first Windows confined
* execution.
* event owned by the session is its record ({@link sessionAclRecord});
* the provider appends exactly one on the session's first Windows
* confined execution.
*/
'sandbox/acl-session': {
/** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */
writeSid: string
/** The owning session — the binding a fork's copied event cannot satisfy. */
sessionId: SessionId
/** The workspace root the grant applies to (the session's immutable cwd, as resolved). */
workspace: string
/** The session's private temp subdirectory under the host temp root. */
@@ -46,48 +50,73 @@ declare module '@deepseek-ai/dsh-session' {
export interface AclSessionRecord {
/** The orphan write SID whose ACEs form the session's write allowlist. */
writeSid: string
/** The owning session id (binds the record against fork inheritance). */
sessionId: SessionId
/** The workspace root the record was provisioned for. */
workspace: string
/** The session's private temp subdirectory. */
tempDir: string
}
/** Orphan shape `S-1-4-x-y` — a replayed `Everyone` SID would widen the grant to every token. */
const ORPHAN_SID_PATTERN = /^S-1-4-\d+-\d+$/u
/**
* The session's windows-acl record: the last `sandbox/acl-session` event in
* the log, or undefined when the session has none (never confined on
* Windows). The pure fold — resume needs no catch-up machinery because
* replaying the log IS the state.
* @param events - session events in log order (other event types are skipped).
* @returns the last provisioned record, or undefined without one.
* The session's record: the last `sandbox/acl-session` event owned by it, or
* undefined (never confined / a fork). Durable-input validation: tampered
* SID or temp path fails loud. @param events/@param sessionId/@returns as
* below.
* @param events - session events (other types skipped).
* @param sessionId - owning session (fork binding).
* @returns the last owned record, or undefined without one.
*/
export function sessionAclRecord(events: readonly SessionEvent[]): AclSessionRecord | undefined {
export function sessionAclRecord(events: readonly SessionEvent[], sessionId: SessionId): AclSessionRecord | undefined {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index] as SessionEvent
if (event.type === 'sandbox/acl-session') return event.data
if (event.type !== 'sandbox/acl-session') continue
const data = event.data
// Fork copies the parent's record: skip non-owned records (fork mints fresh).
if (data.sessionId !== sessionId) continue
if (typeof data.writeSid !== 'string' || !ORPHAN_SID_PATTERN.test(data.writeSid)) {
throw new Error(
`sandbox-local: session "${sessionId}" acl record carries a malformed write SID ${JSON.stringify(data.writeSid)} `
+ '(expected the orphan shape S-1-4-x-y)',
)
}
if (typeof data.workspace !== 'string' || data.workspace.length === 0) {
throw new Error(`sandbox-local: session "${sessionId}" acl record carries an empty workspace`)
}
if (typeof data.tempDir !== 'string' || dirname(data.tempDir) !== tmpdir()) {
throw new Error(
`sandbox-local: session "${sessionId}" acl record carries a temp path outside the host temp root: ${JSON.stringify(data.tempDir)}`,
)
}
return data
}
return undefined
}
/**
* The session's private temp subdirectory: `<tmpdir>\dsh-<first 12 hex of
* sha256(session id)>`. Deterministic from the session id, so it converges
* across server restarts (the same SID re-grants the same directory) and OS
* temp hygiene may reclaim it — deliberately no GC here.
* @param sessionId - the session identity.
* The session's private temp subdirectory name: `<tmpdir>\dsh-<16 random hex>`.
* The name is RANDOM and persisted in the record — convergence across server
* restarts comes from the record (the same SID re-grants the same directory),
* not from any derivation an attacker (who knows the session id through
* `DSH_SESSION_ID`) could predict and pre-place. The provider creates it
* exclusively and rejects reparse points; OS temp hygiene may reclaim it —
* deliberately no GC here.
* @returns the private temp subdirectory path.
*/
export function sessionTempDir(sessionId: string): string {
const digest = createHash('sha256').update(sessionId).digest('hex').slice(0, 12)
return join(tmpdir(), `dsh-${digest}`)
export function sessionTempDir(): string {
return join(tmpdir(), `dsh-${randomBytes(8).toString('hex')}`)
}
/**
* Provision the record for a session that has none (its first Windows
* confined execution): a fresh write SID plus the private temp
* subdirectory, appended as exactly one log-only `sandbox/acl-session`
* event — the provision IS its event, nothing mutates record state out of
* band. Fork (new session id) provisions a fresh record; resume replays the
* stored one.
* confined execution): a fresh write SID plus the private temp subdirectory,
* appended as exactly one log-only `sandbox/acl-session` event — the
* provision IS its event, nothing mutates record state out of band. Fork
* (whose copied parent record is not its own) provisions a fresh record;
* resume replays the stored one.
* @param session - the session the record belongs to.
* @param workspaceRoot - the resolved policy root (the session's immutable cwd).
* @returns the provisioned record.
@@ -95,8 +124,9 @@ export function sessionTempDir(sessionId: string): string {
export function provisionAclSession(session: Session, workspaceRoot: string): AclSessionRecord {
const record: AclSessionRecord = {
writeSid: randomWriteSid(),
sessionId: session.id,
workspace: workspaceRoot,
tempDir: sessionTempDir(session.id),
tempDir: sessionTempDir(),
}
session.append('sandbox/acl-session', record)
return record

View File

@@ -29,7 +29,7 @@ import z from 'schemastery'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AclWriteGrant } from '@deepseek-ai/dsh-sandbox-windows-acl'
import { provisionAclSession, sessionAclRecord } from './acl-session.ts'
import type { AclSessionRecord } from './acl-session.ts'
@@ -163,8 +163,12 @@ const STATIC_ENFORCEMENT: Record<SelectedRunner['runner'], SandboxEnforcement> =
bwrap: 'full',
landlock: 'full',
seatbelt: 'full',
// The restricted token intersects every write access by construction, so
// the ACL runner governs every promised file effect — full enforcement.
// 'full' is the SUPPORTED-SURFACE promise: on NTFS both restricting lists
// close every ambient write (INTERACTIVE/LOCAL and Authenticated Users are
// absent from both — pinned by the runner's Public-probe and CIM-denial
// regressions). FAT-class (non-ACL) targets are declared unsupported
// (warn-only) in the backend README — outside the promise, not an
// exception to it.
'windows-acl': 'full',
}
@@ -194,13 +198,19 @@ const DENIAL_SIGNATURES = {
runnerCommand: ['read-only file system', 'permission denied'],
} as const satisfies Record<SelectedRunner['runner'] | 'runnerCommand', readonly string[]>
/** The windows-acl runner's documented failure exit (its own RUNNER_FAILURE_EXIT contract, distinct from Landlock's 125). */
const WINDOWS_ACL_RUNNER_FAILURE_EXIT = 127
/**
* Runner-owned fatal diagnostics. Landlock has a versioned exit-125 plus
* fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit
* 1 but its public contract does not reserve that status, while sandbox-exec
* publishes no launcher-failure status; those backends remain signature-only.
* The windows-acl runner prints `windows-acl-run: <detail>` on every
* runner-side failure and exits 127. Keep the Landlock tuple aligned with the
* runner-side failure and exits 127 — the rule is exit-gated on that status
* so a confined command that merely PRINTS the signature (or a runner
* cleanup failure reported on a non-zero child exit) is never misclassified
* as "the command did not run". Keep the Landlock tuple aligned with the
* assembled snapshot fixture at
* `examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts`.
*/
@@ -212,7 +222,7 @@ const RUNNER_FAILURE_RULES = {
informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`],
}],
seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }],
'windows-acl': [{ fatalSignatures: ['windows-acl-run: '] }],
'windows-acl': [{ allowedExitCodes: [WINDOWS_ACL_RUNNER_FAILURE_EXIT], fatalSignatures: ['windows-acl-run: '] }],
} as const satisfies Record<SelectedRunner['runner'], readonly RunnerFailureRule[]>
/**
@@ -319,48 +329,63 @@ export class LocalSandboxProvider extends SandboxProvider {
* the session log (provisioned on first use), its ACEs materialized once
* per server lifetime, and the runner receives `--write-sid` plus the
* session's PRIVATE temp subdirectory — it grants nothing and revokes
* nothing. Agentless calls (no session) pass no SID: the runner
* self-manages per-call grants on the ambient temp root.
* nothing. A fresh provision kicks an IMMEDIATE persistence flush right
* after the append (no write-behind debounce delay), narrowing the
* crash-and-lose-record window to the flush latency itself — the residual
* is documented in the README (the spawn seams are synchronous, so no
* await barrier exists between record and ACEs). Agentless calls (no
* session) pass no SID: the runner self-manages per-call grants on the
* ambient temp root.
* @param policy - the resolved per-call policy.
* @returns the runner invocation.
*/
private windowsAclRunnerArgv(policy: SandboxPolicy): string[] {
const sessionId = policy.sessionId
const record = sessionId === undefined ? undefined : this.aclSessionRecord(sessionId, policy.workspaceRoot)
if (record !== undefined) this.materializeAclGrant(record, policy.mode)
if (sessionId === undefined) {
return [
...this.windowsAclRunnerInvocation(),
'--workspace', policy.workspaceRoot,
'--temp', tmpdir(),
'--mode', policy.mode,
]
}
const record = this.aclSessionRecord(sessionId, policy.workspaceRoot)
this.materializeAclGrant(record, policy.mode)
return [
...this.windowsAclRunnerInvocation(),
'--workspace', policy.workspaceRoot,
// Workspace-write sessions confine their temp writes to the PRIVATE
// per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only
// and agentless runs pass the ambient temp root — the runner validates
// it exists but grants nothing (or self-manages, agentless only).
'--temp', policy.mode === 'workspace-write' && record !== undefined ? record.tempDir : tmpdir(),
// runs pass the ambient temp root — the runner validates it exists
// but grants nothing.
'--temp', policy.mode === 'workspace-write' ? record.tempDir : tmpdir(),
'--mode', policy.mode,
...record === undefined ? [] : ['--write-sid', record.writeSid],
'--write-sid', record.writeSid,
]
}
/**
* Fold (or provision) the calling session's durable windows-acl record.
* The provision appends exactly one log-only `sandbox/acl-session` event
* to the session log; the record's workspace must equal the policy root —
* both derive from the session's immutable cwd, so a mismatch is a
* corrupted composition and fails loud.
* to the session log and kicks an immediate persistence flush (the
* write-behind coordinator's bounded window would otherwise delay the
* record's durability past its ACE materialization); the record's
* workspace must equal the policy root — both derive from the session's
* immutable cwd, so a mismatch is a corrupted composition and fails loud.
* @param sessionId - the policy's calling-session identity.
* @param workspaceRoot - the resolved policy root.
* @returns the session's record.
*/
private aclSessionRecord(sessionId: string, workspaceRoot: string): AclSessionRecord {
private aclSessionRecord(sessionId: SessionId, workspaceRoot: string): AclSessionRecord {
const store = this.ctx.get('sessions')
if (store === undefined) {
throw new Error('sandbox-local: per-session windows-acl confinement requires the session store (ctx.sessions)')
}
const session = store.get(SessionId(sessionId))
const session = store.get(sessionId)
if (session === undefined) {
throw new Error(`sandbox-local: windows-acl policy carries session "${sessionId}" but ctx.sessions has no such session`)
}
const existing = sessionAclRecord(session.events)
const existing = sessionAclRecord(session.events, sessionId)
if (existing !== undefined) {
if (existing.workspace !== workspaceRoot) {
throw new Error(
@@ -370,20 +395,30 @@ export class LocalSandboxProvider extends SandboxProvider {
}
return existing
}
return provisionAclSession(session, workspaceRoot)
const record = provisionAclSession(session, workspaceRoot)
// Immediate durability kick: the append is write-behind (bounded
// coordinator window); flush now so the record is durable as close to
// its ACE materialization as the synchronous confine seam allows. The
// residual window (a crash inside the flush latency) can strand inert
// orphan-SID ACEs — documented in the README.
void store.flush(session)
return record
}
/**
* Materialize the record's ACEs once per server lifetime: lazily at the
* session's first confined execution, reused for every later call (the map
* hit is the whole call). Workspace-write grants the workspace root and
* the private temp subdirectory (created here); read-only materializes
* NOTHING — its token alone restricts every write, and a standing grant
* from an earlier workspace-write period is KEPT through a downgrade
* (never revoked): the read-only restricted token carries no orphan SID
* (the read-only list), so the ACE is inert there, while the map hit keeps the
* re-upgrade free of re-propagation. Fail-closed: a half-materialized
* grant is revoked before the error propagates.
* the private temp subdirectory — created here EXCLUSIVELY (the name is
* random and unguessable, a pre-existing entry throws EEXIST, and a
* reparse point is rejected, so the grant never lands on an
* attacker-placed object); read-only materializes NOTHING — its token
* alone restricts every write, and a standing grant from an earlier
* workspace-write period is KEPT through a downgrade (never revoked): the
* read-only restricted token carries no orphan SID (the read-only list),
* so the ACE is inert there, while the map hit keeps the re-upgrade free
* of re-propagation. Fail-closed: a half-materialized grant is revoked
* before the error propagates.
* @param record - the session's durable record.
* @param mode - the policy mode (grants exist only under workspace-write).
*/
@@ -391,7 +426,10 @@ export class LocalSandboxProvider extends SandboxProvider {
if (this.aclGrants.has(record.writeSid) || mode === 'read-only') return
const grant = AclWriteGrant.create(record.writeSid)
try {
mkdirSync(record.tempDir, { recursive: true })
// Exclusive creation (no `recursive`): a pre-existing entry OR a
// reparse point both fail EEXIST — the grant never lands on a foreign
// object.
mkdirSync(record.tempDir)
grant.add(record.workspace)
grant.add(record.tempDir)
} catch (error) {