docs(sandbox): align capability SID terminology

This commit is contained in:
Tianyi Cui
2026-08-10 17:58:00 +08:00
parent ba63251c48
commit 6f21d00169
12 changed files with 32 additions and 32 deletions
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-sandbox-windows-acl",
"description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with orphan-SID write allowlist) for the DeepSeek Harness sandbox seam",
"description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam",
"version": "0.0.1",
"private": true,
"type": "module",
+10 -10
View File
@@ -1,5 +1,5 @@
/**
* ACL editing helpers: grant/revoke the orphan write SID on a directory via
* ACL editing helpers: grant/revoke a capability SID on a directory via
* SetEntriesInAclW + SetNamedSecurityInfoW (the same calls the POC uses, with
* the failure handling the POC lacks). Every API call is checked and every
* failure is reported with the API name, the exact Win32 code, the formatted
@@ -39,7 +39,7 @@ export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions
entry.writeUInt32LE(abi.NO_MULTIPLE_TRUSTEE, 24) // Trustee.MultipleTrusteeOperation
entry.writeUInt32LE(abi.TRUSTEE_IS_SID, 28) // Trustee.TrusteeForm
entry.writeUInt32LE(abi.TRUSTEE_IS_UNKNOWN, 32) // Trustee.TrusteeType
entry.writeBigUInt64LE(ptrAddress(sidPtr), 40) // Trustee.ptstrName = the orphan SID
entry.writeBigUInt64LE(ptrAddress(sidPtr), 40) // Trustee.ptstrName = the capability SID
return entry
}
@@ -181,16 +181,16 @@ function mergeAndApply(
/**
* True when the explicit DACL already carries the EXACT write grant this
* module would add (Allow ACE, OI|CI inheritance, {@link abi.GRANT_MASK}, the
* orphan SID). Every field is read through koffi.decode at pointer offsets —
* capability SID). Every field is read through koffi.decode at pointer offsets —
* no memcpy, no pointer arithmetic. The ACE's SID is INLINE (embedded in the
* ACE after the 4-byte mask — there is no pointer to read; reading one
* yields garbage addresses and crashed EqualSid, verified by gdb), so it is
* compared field-by-field against the orphan SID through bounded offset
* compared field-by-field against the capability SID through bounded offset
* reads ({@link sameSidAt}). A malformed header reads as "no exact grant"
* so the caller falls back to the merge-apply path, which owns the robust
* failure handling.
* @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}).
* @param sidPtr - the orphan write SID to match.
* @param sidPtr - the capability SID to match.
* @returns whether the exact grant ACE is already present.
*/
function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean {
@@ -213,7 +213,7 @@ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean {
}
/**
* Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the orphan SID
* Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID
* on `path`, inheriting to subcontainers and objects. Idempotent: when the
* directory's current explicit DACL already carries the exact ACE (the
* per-session grant surviving from a previous server lifetime), the
@@ -226,7 +226,7 @@ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean {
* precondition as the POC.
* @param api - the binding table.
* @param path - the directory whose DACL gains the grant (the workspace or temp root).
* @param sidPtr - the orphan write SID the ACE names.
* @param sidPtr - the capability SID the ACE names.
*/
export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void {
withPathLock(api, path, () => {
@@ -244,15 +244,15 @@ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr):
}
/**
* Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS
* Remove every ACE for the capability SID from the directory DACL (REVOKE_ACCESS
* merge — other entries are preserved). Returns whether an ACE removal was
* attempted (false when the directory carries no DACL at all).
*
* Runs under the per-path lock (the whole get-merge-set sequence); the
* descriptor/ACL allocation contract lives on {@link readCurrentDacl}.
* @param api - the binding table.
* @param path - the directory whose DACL loses the orphan-SID ACEs.
* @param sidPtr - the orphan write SID whose ACEs are removed.
* @param path - the directory whose DACL loses the capability-SID ACEs.
* @param sidPtr - the capability SID whose ACEs are removed.
* @returns whether an ACE removal was attempted (false when the directory carries no DACL at all).
*/
export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean {
@@ -42,7 +42,7 @@ export class AclWriteGrant {
/**
* Parse the SID string and open the binding table (lazily, once per
* server). Fail-closed: any failure throws — nothing is granted yet.
* @param writeSid - the orphan write SID string (`S-1-4-x-y`).
* @param writeSid - the workspace (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string.
* @param api - optional already-resolved bindings (tests).
* @returns the ready grant (no ACEs yet).
*/
@@ -17,8 +17,8 @@
* - workspace-write: the workspace and temp directories carry distinct
* capability-SID Write grants; other ACL-addressable writes are denied
* except for the documented Everyone and hard-link boundaries.
* - read-only: no orphan-SID grants; the restricting list carries no orphan
* SID, so a standing grant ACE from an earlier
* - read-only: no capability-SID grants; the restricting list carries no
* capability SID, so a standing grant ACE from an earlier
* workspace-write period stays inert. BOTH modes drop Authenticated Users
* (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the
* Public tree writes are denied); the two lists share the keep-alive group
@@ -63,7 +63,7 @@ export const FILE_DELETE_CHILD = 0x0040
// security boundary).
/**
* GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL plus DELETE and
* FILE_DELETE_CHILD — the write+delete access mask the orphan-SID ACEs grant
* FILE_DELETE_CHILD — the write+delete access mask the capability-SID ACEs grant
* (displays as "Modify" in Explorer/icacls). WRITE_DAC/WRITE_OWNER are
* deliberately excluded: they would let the confined child take ownership or
* rewrite DACLs.
@@ -28,7 +28,7 @@ import { createHash } from 'node:crypto'
/**
* Derive the workspace's write SID (`S-1-4-x-y`; subauthorities 30-bit,
* matching the orphan shape the token and ACE layers already carry).
* matching the workspace-capability shape the token and ACE layers carry).
* @param workspaceRoot - the canonical workspace path.
* @returns the SDDL string form.
*/
@@ -120,7 +120,7 @@ describe.skipIf(!isWin32)('ACL editing', () => {
const api = await win32()
const dir = scratch()
const usersSid = sidFromString(api, 'S-1-5-32-545')
const orphanSid = sidFromString(api, 'S-1-4-4242-1')
const capabilitySid = sidFromString(api, 'S-1-4-4242-1')
try {
// Install one explicit ACE (Users + benign read mask) with the
// package's own bindings, exactly like a pre-existing explicit DACL
@@ -137,37 +137,37 @@ describe.skipIf(!isWin32)('ACL editing', () => {
expect(applyResult, `SetNamedSecurityInfoW setup (${applyResult})`).toBe(abi.ERROR_SUCCESS)
expect(isNullPtr(freed)).toBe(true)
grantWrite(api, dir, orphanSid)
revokeWrite(api, dir, orphanSid)
grantWrite(api, dir, capabilitySid)
revokeWrite(api, dir, capabilitySid)
const aces = readDirectAces(api, dir)
expect(aces.some(ace => ace.sid === 'S-1-5-32-545')).toBe(true) // explicit ACE preserved
expect(aces.some(ace => ace.sid === 'S-1-4-4242-1')).toBe(false) // orphan grant fully removed
} finally {
if (!isNullPtr(usersSid)) api.localFree(usersSid)
if (!isNullPtr(orphanSid)) api.localFree(orphanSid)
if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid)
}
})
it('grantWrite is idempotent: a second grant over the standing exact ACE skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => {
const api = await win32()
const dir = scratch()
const orphanSid = sidFromString(api, 'S-1-4-4242-2')
const capabilitySid = sidFromString(api, 'S-1-4-4242-2')
const apply = vi.spyOn(api, 'setNamedSecurityInfoW')
try {
grantWrite(api, dir, orphanSid)
grantWrite(api, dir, capabilitySid)
expect(apply).toHaveBeenCalledTimes(1)
// The exact ACE now stands (the per-session grant surviving from a
// previous server lifetime): the second grant is a DACL read only.
grantWrite(api, dir, orphanSid)
grantWrite(api, dir, capabilitySid)
expect(apply).toHaveBeenCalledTimes(1)
const aces = readDirectAces(api, dir)
expect(aces.filter(ace => ace.sid === 'S-1-4-4242-2')).toHaveLength(1)
revokeWrite(api, dir, orphanSid)
revokeWrite(api, dir, capabilitySid)
expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-2')).toBe(false)
} finally {
apply.mockRestore()
if (!isNullPtr(orphanSid)) api.localFree(orphanSid)
if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid)
}
})
@@ -330,7 +330,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
// The reported defect: a session that materialized its grant in
// workspace-write keeps the ACE standing for the server lifetime. After
// switching to read-only, the restricted token's read-only list must carry NO
// orphan SID — the standing ACE stays but the pass-2 check cannot use
// capability SID — the standing ACE stays but the pass-2 check cannot use
// it, so the workspace write is denied (previously it LEAKED). The
// switch back reuses the SAME standing ACE: the re-upgrade write lands
// without any re-grant.
@@ -1,7 +1,7 @@
/**
* workspaceWriteSid tests: the per-workspace write identity is deterministic
* (the same canonical path always derives the same SID — the property the
* cross-session grant reuse rests on), orphan-shaped, distinct across
* cross-session grant reuse rests on), capability-shaped, distinct across
* workspaces, and byte-sensitive (the canonical path is the caller's
* contract; an alias spelling derives a second identity, self-healing at
* the cost of one extra tree propagation).
@@ -12,7 +12,7 @@ import { describe, expect, it } from 'vitest'
import { tempWriteSid, workspaceWriteSid } from '../src/index.ts'
describe('workspaceWriteSid', () => {
it('derives a stable orphan-shaped SID per workspace path', () => {
it('derives a stable capability-shaped SID per workspace path', () => {
const first = workspaceWriteSid('C:\\Users\\agent\\repo')
const second = workspaceWriteSid('C:\\Users\\agent\\repo')
expect(first).toBe(second)