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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
/**
|
||||
* The windows-acl per-session grant: the DURABLE record (session-log event
|
||||
* fold/provision) plus the SERVER-LIFETIME ACE materialization
|
||||
* ({@link AclWriteGrant}), exercised through the REAL
|
||||
* LocalSandboxProvider.confine() with a real session store. The Win32 surface
|
||||
* is mocked at the package boundary (`@deepseek-ai/dsh-sandbox-windows-acl`),
|
||||
* so these assertions run in every CI lane that runs sandbox-local's suites;
|
||||
* the real-FFI grant behavior is pinned in @deepseek-ai/dsh-sandbox-windows-
|
||||
* acl's own tests on win32 hosts.
|
||||
* windows-acl per-session grant: the DURABLE record (log-event fold/provision
|
||||
* with ownership binding + tamper validation) plus the SERVER-LIFETIME ACE
|
||||
* materialization, through the REAL LocalSandboxProvider.confine() with a
|
||||
* real session store. Win32 surface mocked at the package boundary; the
|
||||
* real-FFI grant behavior lives in sandbox-windows-acl's win32 tests.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { basename, join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SessionId, SessionStore } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { sessionTempDir } from '../src/acl-session.ts'
|
||||
|
||||
@@ -52,7 +49,7 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
|
||||
})
|
||||
|
||||
/** One provisioned record event, shaped like the live log's envelope. */
|
||||
function recordEvent(record: { writeSid: string; workspace: string; tempDir: string }): SessionEvent {
|
||||
function recordEvent(record: { writeSid: string; sessionId: SessionIdType; workspace: string; tempDir: string }): SessionEvent {
|
||||
return { type: 'sandbox/acl-session', seq: 0, time: 0, data: record }
|
||||
}
|
||||
|
||||
@@ -70,6 +67,11 @@ function workspaceRoot(): string {
|
||||
return mkdtempSync(join(tmpdir(), 'dsh-acl-session-ws-'))
|
||||
}
|
||||
|
||||
/** A well-shaped private temp path under the host temp root (never created). */
|
||||
function shapedTempPath(): string {
|
||||
return join(tmpdir(), `dsh-${'ab'.repeat(8)}`)
|
||||
}
|
||||
|
||||
describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
const scratch: string[] = []
|
||||
|
||||
@@ -83,38 +85,30 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
it('workspace-write: first confine provisions the record, materializes the grant ONCE, and passes --write-sid + the private temp dir', async () => {
|
||||
it('workspace-write: first confine provisions the record and materializes the grant ONCE (--write-sid + the private temp dir)', async () => {
|
||||
try {
|
||||
const { ctx, sandbox, fiber } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const privateTemp = sessionTempDir('sess-1')
|
||||
scratch.push(privateTemp)
|
||||
const session = ctx.sessions.create(SessionId('sess-1'), { meta: { cwd: ws } })
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-1' }
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') }
|
||||
|
||||
const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
||||
expect(confined.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', privateTemp,
|
||||
'--mode', 'workspace-write',
|
||||
'--write-sid', 'S-1-4-42-42',
|
||||
'--',
|
||||
'pwsh', '/Command', 'x',
|
||||
])
|
||||
expect(confined.argv).toContain('--write-sid')
|
||||
expect(confined.argv).toContain('S-1-4-42-42')
|
||||
expect(confined.argv).toContain('workspace-write')
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false })
|
||||
expect(existsSync(privateTemp)).toBe(true) // the private temp subdir was created
|
||||
const tempDir = (session.events.at(-1)!.data as { tempDir: string }).tempDir
|
||||
scratch.push(tempDir)
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, tempDir], disposed: false })
|
||||
expect(existsSync(tempDir)).toBe(true) // created exclusively
|
||||
expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1)
|
||||
|
||||
// Reuse: the SECOND confine is the map hit — no new grant, no new event.
|
||||
const second = sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
||||
expect(second.argv).toEqual(confined.argv)
|
||||
// Reuse: the second confine is the map hit.
|
||||
sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(session.events).toHaveLength(1)
|
||||
|
||||
// Provider dispose revokes the standing grant.
|
||||
await fiber.dispose()
|
||||
expect(mockState.grants[0]!.disposed).toBe(true)
|
||||
} finally {
|
||||
@@ -122,15 +116,60 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('read-only: the record still rides along (--write-sid, one event) but NOTHING is materialized and the ambient temp root is passed', async () => {
|
||||
it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the same SID, the downgrade keeps the standing grant', async () => {
|
||||
try {
|
||||
const { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const session = ctx.sessions.create(SessionId('sess-switch'), { meta: { cwd: ws } })
|
||||
const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-switch') }
|
||||
const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-switch') }
|
||||
|
||||
// read-only first: record rides along, nothing materialized, ambient temp.
|
||||
const confinedRo = sandbox.confine(['true'], readOnly)
|
||||
expect(confinedRo.argv).toContain('--write-sid')
|
||||
expect(confinedRo.argv).toContain(tmpdir())
|
||||
expect(mockState.grants).toHaveLength(0)
|
||||
const record = session.events.filter(event => event.type === 'sandbox/acl-session')[0]!.data as { tempDir: string }
|
||||
scratch.push(record.tempDir)
|
||||
expect(existsSync(record.tempDir)).toBe(false)
|
||||
|
||||
// Upgrade: first workspace-write materializes with the same SID.
|
||||
const upgraded = sandbox.confine(['true'], workspaceWrite)
|
||||
expect(upgraded.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', record.tempDir,
|
||||
'--mode', 'workspace-write',
|
||||
'--write-sid', 'S-1-4-42-42',
|
||||
'--',
|
||||
'true',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, record.tempDir], disposed: false })
|
||||
expect(existsSync(record.tempDir)).toBe(true)
|
||||
|
||||
// Reuse: map hit.
|
||||
sandbox.confine(['true'], workspaceWrite)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
|
||||
// Downgrade: standing grant KEPT (inert under read-only, free re-upgrade).
|
||||
sandbox.confine(['true'], readOnly)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]!.disposed).toBe(false)
|
||||
expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('read-only: the record rides along (--write-sid, one event) but NOTHING is materialized and the ambient temp root is passed', async () => {
|
||||
try {
|
||||
const { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const privateTemp = sessionTempDir('sess-ro')
|
||||
scratch.push(privateTemp)
|
||||
const session = ctx.sessions.create(SessionId('sess-ro'), { meta: { cwd: ws } })
|
||||
const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-ro' }
|
||||
const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-ro') }
|
||||
|
||||
const confined = sandbox.confine(['true'], policy)
|
||||
expect(confined.argv).toEqual([
|
||||
@@ -143,69 +182,6 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
'true',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(0)
|
||||
expect(existsSync(privateTemp)).toBe(false) // no private temp dir under read-only
|
||||
expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the same SID, and the downgrade keeps the standing grant (no revoke, no re-grant)', async () => {
|
||||
try {
|
||||
const { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const privateTemp = sessionTempDir('sess-switch')
|
||||
scratch.push(privateTemp)
|
||||
const session = ctx.sessions.create(SessionId('sess-switch'), { meta: { cwd: ws } })
|
||||
const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-switch' }
|
||||
const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-switch' }
|
||||
|
||||
// Read-only first: the record still rides along (--write-sid, one
|
||||
// event) but NOTHING is materialized and the ambient temp root is
|
||||
// passed — the map stays empty, so the later upgrade must materialize.
|
||||
const confinedRo = sandbox.confine(['true'], readOnly)
|
||||
expect(confinedRo.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', tmpdir(),
|
||||
'--mode', 'read-only',
|
||||
'--write-sid', 'S-1-4-42-42',
|
||||
'--',
|
||||
'true',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(0)
|
||||
expect(existsSync(privateTemp)).toBe(false)
|
||||
|
||||
// Upgrade: the FIRST workspace-write confine materializes the grant
|
||||
// (the map was empty — read-only never wrote it) with the SAME SID
|
||||
// and the private temp dir, so the upgrade path cannot dead-end.
|
||||
const upgraded = sandbox.confine(['true'], workspaceWrite)
|
||||
expect(upgraded.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', privateTemp,
|
||||
'--mode', 'workspace-write',
|
||||
'--write-sid', 'S-1-4-42-42',
|
||||
'--',
|
||||
'true',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false })
|
||||
expect(existsSync(privateTemp)).toBe(true)
|
||||
|
||||
// Reuse: the second workspace-write call is the map hit.
|
||||
sandbox.confine(['true'], workspaceWrite)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
|
||||
// Downgrade: the standing grant is KEPT — never revoked, never
|
||||
// re-granted. The read-only restricted token's list carries no
|
||||
// orphan SID (pinned by the windows-acl runner regression), so the
|
||||
// ACE is inert under read-only while the map hit keeps the
|
||||
// re-upgrade free of eager propagation.
|
||||
sandbox.confine(['true'], readOnly)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]!.disposed).toBe(false)
|
||||
expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1)
|
||||
} finally {
|
||||
cleanup()
|
||||
@@ -216,21 +192,20 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
try {
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const record = { writeSid: 'S-1-4-77-1', workspace: ws, tempDir: sessionTempDir('resumed') }
|
||||
scratch.push(record.tempDir)
|
||||
const tempDir = shapedTempPath()
|
||||
const record = { writeSid: 'S-1-4-77-1', sessionId: SessionId('resumed'), workspace: ws, tempDir }
|
||||
scratch.push(tempDir)
|
||||
|
||||
const first = await setup()
|
||||
const session = first.ctx.sessions.create(SessionId('resumed'), { seed: [recordEvent(record)], meta: { cwd: ws } })
|
||||
// The constructor appends the `session/end-seed` marker, so the log is
|
||||
// the seed plus that marker — exactly one acl record among them.
|
||||
expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1)
|
||||
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'resumed' }
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') }
|
||||
const confined = first.sandbox.confine(['true'], policy)
|
||||
expect(confined.argv).toContain('S-1-4-77-1')
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, record.tempDir] })
|
||||
// Replay IS the state: the seeded record satisfies the fold, nothing appended.
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, tempDir] })
|
||||
// Replay IS the state: nothing appended.
|
||||
expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1)
|
||||
expect(session.events).toHaveLength(2)
|
||||
} finally {
|
||||
@@ -238,14 +213,93 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('fork: a child seeded with the PARENT\'s events ignores the parent record and provisions a fresh identity (sessionId binding)', async () => {
|
||||
try {
|
||||
const { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const parentTemp = shapedTempPath()
|
||||
const parentRecord = { writeSid: 'S-1-4-77-9', sessionId: SessionId('parent'), workspace: ws, tempDir: parentTemp }
|
||||
scratch.push(parentTemp)
|
||||
// SessionStore.fork copies the parent's events verbatim — the child must NOT inherit the record.
|
||||
const child = ctx.sessions.create(SessionId('child'), { seed: [recordEvent(parentRecord)], meta: { cwd: ws } })
|
||||
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') }
|
||||
sandbox.confine(['true'], policy)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42' }) // fresh, NOT the parent's
|
||||
expect(child.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(2) // parent's copied + child's fresh
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud on a matching-but-tampered record: a non-orphan write SID or a foreign temp path never materializes', async () => {
|
||||
try {
|
||||
const { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
|
||||
// writeSid = Everyone: would widen the grant to every token.
|
||||
const everyone = { writeSid: 'S-1-1-0', sessionId: SessionId('tampered-sid'), workspace: ws, tempDir: shapedTempPath() }
|
||||
ctx.sessions.create(SessionId('tampered-sid'), { seed: [recordEvent(everyone)], meta: { cwd: ws } })
|
||||
const sidPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-sid') }
|
||||
expect(() => sandbox.confine(['true'], sidPolicy)).toThrow(/malformed write SID/)
|
||||
expect(mockState.grants).toHaveLength(0)
|
||||
|
||||
// tempDir outside the host temp root.
|
||||
const foreignTemp = { writeSid: 'S-1-4-42-7', sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' }
|
||||
ctx.sessions.create(SessionId('tampered-temp'), { seed: [recordEvent(foreignTemp)], meta: { cwd: ws } })
|
||||
const tempPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-temp') }
|
||||
expect(() => sandbox.confine(['true'], tempPolicy)).toThrow(/outside the host temp root/)
|
||||
expect(mockState.grants).toHaveLength(0)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving grants', async () => {
|
||||
try {
|
||||
const { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
|
||||
// Pre-existing entry: exclusive mkdir throws EEXIST instead of adopting it.
|
||||
const preexisting = shapedTempPath()
|
||||
mkdirSync(preexisting)
|
||||
scratch.push(preexisting)
|
||||
const preRecord = { writeSid: 'S-1-4-42-8', sessionId: SessionId('preexisting'), workspace: ws, tempDir: preexisting }
|
||||
ctx.sessions.create(SessionId('preexisting'), { seed: [recordEvent(preRecord)], meta: { cwd: ws } })
|
||||
const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') }
|
||||
expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]!.disposed).toBe(true) // self-revoked
|
||||
|
||||
// Reparse point: same EEXIST (exclusive mkdir never follows links).
|
||||
const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-'))
|
||||
scratch.push(target)
|
||||
const linkPath = shapedTempPath().replace(/abab$/, 'cdcd') // distinct well-shaped name
|
||||
symlinkSync(target, linkPath)
|
||||
scratch.push(linkPath)
|
||||
const linkRecord = { writeSid: 'S-1-4-42-9', sessionId: SessionId('reparse'), workspace: ws, tempDir: linkPath }
|
||||
ctx.sessions.create(SessionId('reparse'), { seed: [recordEvent(linkRecord)], meta: { cwd: ws } })
|
||||
const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') }
|
||||
expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[1]!.disposed).toBe(true)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud when the durable record\'s workspace does not match the resolved policy root', async () => {
|
||||
try {
|
||||
const { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const mismatched = { writeSid: 'S-1-4-77-2', workspace: '/somewhere-else', tempDir: join(tmpdir(), 'dsh-x') }
|
||||
const mismatched = { writeSid: 'S-1-4-77-2', sessionId: SessionId('stale'), workspace: '/somewhere-else', tempDir: shapedTempPath() }
|
||||
ctx.sessions.create(SessionId('stale'), { seed: [recordEvent(mismatched)], meta: { cwd: ws } })
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'stale' }
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('stale') }
|
||||
expect(() => sandbox.confine(['true'], policy)).toThrow(/does not match the resolved policy root/)
|
||||
expect(mockState.grants).toHaveLength(0)
|
||||
} finally {
|
||||
@@ -259,7 +313,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
await bare.plugin(LocalSandboxProvider, {})
|
||||
const sandbox = bare.sandbox as LocalSandboxProvider
|
||||
sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] }
|
||||
const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws', sessionId: 'sess-none' }
|
||||
const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws', sessionId: SessionId('sess-none') }
|
||||
expect(() => sandbox.confine(['true'], policy)).toThrow(/requires the session store/)
|
||||
|
||||
const { sandbox: withStore } = await setup()
|
||||
@@ -274,17 +328,17 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
const { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } })
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-add-fail' }
|
||||
const session = ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } })
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') }
|
||||
|
||||
// add() throws on the FIRST path: the cleanup dispose() runs and the
|
||||
// original error propagates unchanged.
|
||||
// add() throws on the FIRST path: cleanup dispose() runs, original error propagates.
|
||||
mockState.addFailure = new Error('grant exploded')
|
||||
expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded')
|
||||
scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]!.disposed).toBe(true)
|
||||
|
||||
// add() AND dispose() both throw: both surface as an AggregateError.
|
||||
// add() AND dispose() both throw: AggregateError.
|
||||
mockState.grants = []
|
||||
mockState.addFailure = new Error('grant exploded again')
|
||||
mockState.disposeFailure = new Error('cleanup exploded')
|
||||
@@ -308,7 +362,6 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
'pwsh', '/Command', 'x',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(0)
|
||||
// Disposing a provider with no grants is a no-op (the empty-map guard).
|
||||
await fiber.dispose()
|
||||
} finally {
|
||||
cleanup()
|
||||
@@ -320,9 +373,10 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
const { ctx, sandbox, fiber } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
ctx.sessions.create(SessionId('sess-dispose'), { meta: { cwd: ws } })
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-dispose' }
|
||||
const session = ctx.sessions.create(SessionId('sess-dispose'), { meta: { cwd: ws } })
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') }
|
||||
sandbox.confine(['true'], policy)
|
||||
scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
|
||||
mockState.disposeFailure = new Error('revoke exploded')
|
||||
@@ -334,4 +388,12 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('sessionTempDir names are random and well-shaped (unpredictable, never derivable from the session id)', () => {
|
||||
const a = basename(sessionTempDir())
|
||||
const b = basename(sessionTempDir())
|
||||
expect(a).not.toBe(b)
|
||||
expect(a).toMatch(/^dsh-[0-9a-f]{16}$/)
|
||||
expect(b).toMatch(/^dsh-[0-9a-f]{16}$/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -383,7 +383,7 @@ describe('the windows-acl probe (runner invocation contract)', () => {
|
||||
expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true'])
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied'])
|
||||
expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }])
|
||||
expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }])
|
||||
})
|
||||
|
||||
it('reads a failing probe as unusable and walks to the next rung', async () => {
|
||||
|
||||
Reference in New Issue
Block a user