feat(sandbox): per-session windows-acl write grant with dual-mode restricting lists and a private temp subdirectory

This commit is contained in:
Huanqi Cao
2026-08-08 17:29:43 +08:00
parent 91d3ed6c5a
commit abfb933620
40 changed files with 1239 additions and 118 deletions

View File

@@ -0,0 +1,103 @@
/**
* The windows-acl per-session write identity — the DURABLE half of the seam's
* per-session grant reuse. Each session owns exactly one record (one orphan
* write SID, one private temp subdirectory), stored as a log-only
* `sandbox/acl-session` event on the session log (the `sandbox/mode`
* precedent): replayable, never in the model transcript, and no external
* config store. The ACE half is server-lifetime state owned by the provider
* ({@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).
*
* @module dsh-sandbox-local/acl-session
*/
import { createHash } from 'node:crypto'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { randomWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* 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.
*/
'sandbox/acl-session': {
/** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */
writeSid: string
/** 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. */
tempDir: string
}
}
}
/** The durable per-session record carried by one `sandbox/acl-session` event. */
export interface AclSessionRecord {
/** The orphan write SID whose ACEs form the session's write allowlist. */
writeSid: string
/** The workspace root the record was provisioned for. */
workspace: string
/** The session's private temp subdirectory. */
tempDir: string
}
/**
* 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.
*/
export function sessionAclRecord(events: readonly SessionEvent[]): 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
}
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.
* @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}`)
}
/**
* 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.
* @param session - the session the record belongs to.
* @param workspaceRoot - the resolved policy root (the session's immutable cwd).
* @returns the provisioned record.
*/
export function provisionAclSession(session: Session, workspaceRoot: string): AclSessionRecord {
const record: AclSessionRecord = {
writeSid: randomWriteSid(),
workspace: workspaceRoot,
tempDir: sessionTempDir(session.id),
}
session.append('sandbox/acl-session', record)
return record
}

View File

@@ -4,11 +4,18 @@
* competing candidates once, and reports each wrap's enforcement and stderr
* classification facts. Missing or unusable confinement fails closed rather
* than returning the original argv.
*
* The windows-acl rung additionally owns the per-session write grant: one
* orphan write SID and one private temp subdirectory per session (durable
* record in the session log — see `./acl-session.ts`), ACEs materialized
* lazily at the session's first confined execution and held for the SERVER
* process's lifetime (revoked on dispose). The runner receives `--write-sid`
* and stops managing DACLs itself.
* @module @deepseek-ai/dsh-sandbox-local
*/
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { existsSync, mkdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import {
@@ -22,6 +29,10 @@ 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 { AclWriteGrant } from '@deepseek-ai/dsh-sandbox-windows-acl'
import { provisionAclSession, sessionAclRecord } from './acl-session.ts'
import type { AclSessionRecord } from './acl-session.ts'
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
/** Plugin config. All optional — `static Config` supplies the defaults. */
@@ -205,9 +216,10 @@ const RUNNER_FAILURE_RULES = {
} as const satisfies Record<SelectedRunner['runner'], readonly RunnerFailureRule[]>
/**
* Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless
* apart from the cached chain verdict — it spawns nothing but the one-time
* probes, so there is no disposal work beyond cordis' own.
* Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the
* chain verdict and, on the windows-acl rung, the per-session write grants
* ({@link AclWriteGrant}, one per session, revoked on provider dispose); the
* one-time probes spawn nothing else.
*/
export class LocalSandboxProvider extends SandboxProvider {
// Inline schema call: the config catalog walks `static Config` statically.
@@ -225,6 +237,12 @@ export class LocalSandboxProvider extends SandboxProvider {
private readonly probeTimeoutMs: number
/** Cached chain verdict; undefined until the first confined wrap needs it. */
private selectedRunner: SelectedRunner | 'unavailable' | undefined
/**
* Server-lifetime per-session write grants (windows-acl rung), keyed by the
* session's orphan write SID — the native half of the per-session reuse;
* the durable half lives in the session log (`./acl-session.ts`).
*/
private readonly aclGrants = new Map<string, AclWriteGrant>()
constructor(ctx: Context, config: Config) {
super(ctx)
@@ -246,6 +264,12 @@ export class LocalSandboxProvider extends SandboxProvider {
this.configuredRunnerFailureSignatures = runnerFailureSignatures
this.probeTimeoutMs = config.probeTimeoutMs as number
assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs)
// Standing ACL grants are revoked with the provider: a clean server
// shutdown leaves no orphan-SID ACEs behind (an unclean one leaves ACEs
// the session's durable record re-grants idempotently on resume).
ctx.effect(() => () => {
this.revokeAclGrants()
})
}
/**
@@ -284,18 +308,124 @@ export class LocalSandboxProvider extends SandboxProvider {
case 'bwrap': return ['bwrap', ...bwrapProfileArgs(policy)]
case 'landlock': return [this.landlockLauncher(), ...landlockProfileArgs(policy)]
case 'seatbelt': return [this.seatbeltExec(), ...seatbeltProfileArgs(policy)]
case 'windows-acl': return [
...this.windowsAclRunnerInvocation(),
'--workspace', policy.workspaceRoot,
// Explicit, never GetTempPathW-defaulted: the runner grants exactly
// this directory (workspace-write) or nothing (read-only).
'--temp', tmpdir(),
'--mode', policy.mode,
]
case 'windows-acl': return this.windowsAclRunnerArgv(policy)
default: return assertNever(runner)
}
}
/**
* The windows-acl runner argv for one policy. With a calling session
* (the policy's `sessionId`), the session's durable record is folded from
* 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.
* @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)
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(),
'--mode', policy.mode,
...record === undefined ? [] : ['--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.
* @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 {
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))
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)
if (existing !== undefined) {
if (existing.workspace !== workspaceRoot) {
throw new Error(
`sandbox-local: session "${sessionId}" acl record workspace ${JSON.stringify(existing.workspace)} `
+ `does not match the resolved policy root ${JSON.stringify(workspaceRoot)} (session cwd is immutable)`,
)
}
return existing
}
return provisionAclSession(session, workspaceRoot)
}
/**
* 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. 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).
*/
private materializeAclGrant(record: AclSessionRecord, mode: ConfinedSandboxMode): void {
if (this.aclGrants.has(record.writeSid) || mode === 'read-only') return
const grant = AclWriteGrant.create(record.writeSid)
try {
mkdirSync(record.tempDir, { recursive: true })
grant.add(record.workspace)
grant.add(record.tempDir)
} catch (error) {
// Revoke whatever stands and free the SID — never leave a half-grant
// behind a failed confine (the runner never runs).
try {
grant.dispose()
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl grant materialization failed and its cleanup also failed')
}
throw error
}
this.aclGrants.set(record.writeSid, grant)
}
/**
* Revoke every standing per-session grant and free every SID (provider
* dispose). Cleanup failures are reported, not thrown: cordis teardown
* must not be aborted by grant revocation, and the durable records make a
* missed revocation self-healing on the next resume.
*/
private revokeAclGrants(): void {
if (this.aclGrants.size === 0) return
const failures: unknown[] = []
for (const grant of this.aclGrants.values()) {
try {
grant.dispose()
} catch (error) {
failures.push(error)
}
}
this.aclGrants.clear()
if (failures.length > 0) {
this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`)
for (const error of failures) this.ctx.logger.warn(error)
}
}
/**
* Resolve which runner confines commands, once, for the provider's
* lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole