docs(sandbox): align capability SID terminology
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user