diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f9fa6e8bb2..a5f89567f5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1100,6 +1100,26 @@ export interface Config { Source: [`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts) +## `@deepseek-ai/dsh-pwsh-sandbox` + +Requires: `subprocess` · `sandbox` · `sandboxPolicy` + +```ts config-catalog +/** + * Plugin config: the local executor's knobs, verbatim. The sandbox policy — + * the default mode and fallback `workspace-write` root — is NOT here: it lives + * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves + * each calling session's mode and cwd for every enforcing capability. The + * runner choice is likewise the `ctx.sandbox` provider's config, not this + * executor's. + */ +export type Config = LocalConfig +``` + +Depends on: [`LocalConfig`](#deepseek-aidsh-pwsh-local) + +Source: [`packages/bash/pwsh-sandbox/src/index.ts:39`](../packages/bash/pwsh-sandbox/src/index.ts) + ## `@deepseek-ai/dsh-repeat-tool-guard` ```ts config-catalog @@ -1175,7 +1195,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:24`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:28`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` @@ -2611,6 +2631,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)) - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) +- `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) - `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/sdk-client/src/index.ts`](../packages/sdk/sdk-client/src/index.ts)) diff --git a/packages/bash/pwsh-sandbox/README.i18n.yaml b/packages/bash/pwsh-sandbox/README.i18n.yaml index 5de8fc8e5f..f8100bf8a0 100644 --- a/packages/bash/pwsh-sandbox/README.i18n.yaml +++ b/packages/bash/pwsh-sandbox/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/pwsh-sandbox/README.md -README.md: 222c61176b71042bfcb539a839507a425a1fb9e5 -README.zh.md: 02326bc36c9a7e1fe41817b9ab782e31a6d0b9b9 +README.md: bd506d011fa6167ddf7d6fe0565e475979ad0ec2 +README.zh.md: e9aa380302037be3c9dd07331035544299bf3bec diff --git a/packages/bash/pwsh-sandbox/README.md b/packages/bash/pwsh-sandbox/README.md index 222c61176b..bd506d011f 100644 --- a/packages/bash/pwsh-sandbox/README.md +++ b/packages/bash/pwsh-sandbox/README.md @@ -15,9 +15,19 @@ The executor inherits [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/)'s process ### Confinement works, denial surfaces as command failure -The model sees the confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool. +#### What the model sees -## Known Limitations +The confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool. + +#### Token effect + +No model-visible text beyond the command's stderr and the tool layer's standard denial surface. + +#### KV Cache effect + +None directly; the denial surface belongs to the tool layer. + +## Known Limitations and Deferred Work - **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`. - **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`). This is a deliberate backend-defined choice, the same decision Landlock makes (`readWrite: ['/tmp', ...]`): the seam's "backend-defined temp area" permits it, and the escape probe in `tests/acl.e2e.ts` lives outside the temp tree for exactly that reason. A per-run private temp (bwrap's `--tmpfs /tmp` semantics) would additionally need an environment-block rewrite in the runner; it is an optional future hardening, not a correctness gap. diff --git a/packages/bash/pwsh-sandbox/README.zh.md b/packages/bash/pwsh-sandbox/README.zh.md index 02326bc36c..e9aa380302 100644 --- a/packages/bash/pwsh-sandbox/README.zh.md +++ b/packages/bash/pwsh-sandbox/README.zh.md @@ -15,9 +15,19 @@ ### 隔离生效,拒绝以命令失败呈现 -模型看到受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。 +#### 模型看到什么 -## 已知限制 +受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。 + +#### Token 影响 + +除命令 stderr 与工具层标准拒绝面外,无额外模型可见文本。 + +#### KV Cache 影响 + +无直接影响;拒绝呈现面属于工具层。 + +## 已知限制与后续工作 - **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。 - **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`)。这是有意为之的后端自定义选择,与 Landlock 的决策(`readWrite: ['/tmp', ...]`)同类:seam 的 "backend-defined temp area" 词汇表允许它,`tests/acl.e2e.ts` 的逃逸探针也正是因此位于 temp 树之外。按运行创建私有临时目录(bwrap `--tmpfs /tmp` 的语义)还需 runner 改写环境块——这是可选的进一步加固,而非正确性缺口。 diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index d1ed2c0d43..42b5579325 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 34a9e7160bae3af93da17cae254202bc1b555967 -README.zh.md: 0ecfd146a2ef500aa34fe7b6314926a8daf802ab +README.md: 7896764867a0266bbd1e26210a2c7cccbcd09d62 +README.zh.md: 384bc5015167dfa8e4f650c0fcd22e32a62c0ab2 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 34a9e7160b..7896764867 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), built as the preparation layer for a Windows `SandboxProvider` (`workspace-write` / `read-only` modes). Linux/macOS backends live in [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/). +Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only this sandbox instance has added to the workspace and temp directories' DACLs. Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. @@ -11,6 +11,8 @@ Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED ```ts import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' +const workspaceRoot = process.cwd() + const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted @@ -56,9 +58,16 @@ The koffi struct definitions assert their sizes against the probe at module load - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. - **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). A defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +## Model Experience + +Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md), and their tools, which render this backend's enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection. + +#### KV Cache effect + +None directly; the denial surface belongs to the tool layer. + ## Known Limitations and Deferred Work -- **No `SandboxProvider` wiring yet** — this package is the primitives layer; the `ctx.sandbox.confine()` integration (spawn-side token application plus the `denialSignatures`/`runnerFailureRules` contract) is the next step and cannot reuse the argv-wrapping style of `dsh-sandbox-local` because the restricted token must be applied at `CreateProcess` time. - **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root. - **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. -- **Read-side confinement, network policy, and job-object kill-on-close are out of scope** for this layer and belong to the future provider design. +- **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 0ecfd146a2..384bc50151 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 Windows 端 `SandboxProvider`(`workspace-write` / `read-only` 模式)实装的准备层。Linux/macOS 后端见 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/)。 +面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 档(`workspace-write` / `read-only` 模式)挂载;同一包还携带 Linux/macOS 后端。 一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 只被本沙盒实例加到工作区与临时目录的 DACL 上。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 @@ -11,13 +11,15 @@ ```ts import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' +const workspaceRoot = process.cwd() + const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) -await sandbox.init() // 任何 Win32 调用失败都会抛错——绝不降级为无沙盒运行 +await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // 回收所有挂起的授权;逐项报告清理失败 +sandbox.dispose() // revokes all standing grants; reports every cleanup failure ``` 本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 @@ -56,9 +58,16 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 - **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +## 模型体验 + +经 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具间接生效:它们渲染本后端的强制完整性与拒绝事实(受限 stderr 由工具层按 `denialSignatures` 分类),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 + +#### KV Cache 影响 + +无直接影响;拒绝呈现面属于工具层。 + ## 已知限制与后续工作 -- **尚未接入 `SandboxProvider`** —— 本包是原语层;`ctx.sandbox.confine()` 的集成(在 spawn 侧应用受限令牌,并补齐 `denialSignatures`/`runnerFailureRules` 契约)是下一步。该集成不能沿用 `dsh-sandbox-local` 的 argv 包装风格,因为受限令牌必须在 `CreateProcess` 时生效。 - **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例。 - **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 -- **读侧隔离、网络策略、job-object 关闭即杀** 超出本层范围,留给未来的 provider 设计。 +- **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index 58020c4470..44020ae0c3 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -35,6 +35,9 @@ function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: numbe * orphan SID on `path`, inheriting to subcontainers and objects. The directory * must be owned by the caller (owner implicit WRITE_DAC) — same 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. */ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { const newAclSlot = allocPtrSlot() @@ -63,6 +66,10 @@ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): * descriptor allocation — only the descriptor may be LocalFree'd, and it must * not be freed before SetEntriesInAclW has consumed the ACL. Freeing the ACL * pointer itself corrupts the heap (verified the hard way). + * @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. + * @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 { const ownerSlot = allocPtrSlot() diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index a2fefecdbb..6d778f7b11 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -14,9 +14,14 @@ import * as abi from './win32-abi.ts' /** Branded koffi 3 native pointer. Koffi 3 pointers are BigInt values; the brand keeps them out of numeric contexts. */ declare const nativePtr: unique symbol +/** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */ export type NativePtr = bigint & { readonly [nativePtr]: true } -/** True for NULL pointers, however koffi returns them (null or 0n). */ +/** + * True for NULL pointers, however koffi returns them (null or 0n). + * @param value - a pointer as koffi may hand it back (pointer, null, or 0n). + * @returns a type guard narrowing to the NULL shapes. + */ export function isNullPtr(value: NativePtr | null | undefined): value is null | undefined { return value === null || value === undefined || (value as bigint) === 0n } @@ -40,6 +45,7 @@ export interface ProcessInfoOutput { dwThreadId: number } +/** The lazy koffi binding table: every Win32 call the ACL backend uses, signature-verified against the real headers. */ export interface Win32Bindings { // ---- process / token handles -------------------------------------------- openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr @@ -111,6 +117,7 @@ export interface Win32Bindings { const PVOID: Ptr = koffi.pointer('void') const PPVOID: Ptr = koffi.pointer(PVOID) +/** koffi STARTUPINFOW layout; its size is asserted against abi.STARTUPINFOW_SIZE at load. */ export const STARTUPINFOW = koffi.struct('STARTUPINFOW', { cb: 'uint32', lpReserved: 'str16', @@ -132,6 +139,7 @@ export const STARTUPINFOW = koffi.struct('STARTUPINFOW', { hStdError: PVOID, }) +/** koffi PROCESS_INFORMATION layout; its size is asserted against abi.PROCESS_INFORMATION_SIZE at load. */ export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', { hProcess: PVOID, hThread: PVOID, @@ -146,78 +154,127 @@ if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) { throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says ${abi.PROCESS_INFORMATION_SIZE}`) } -/** Allocate one pointer-sized slot (for `T **` out-parameters). */ +/** + * Allocate one pointer-sized slot (for `T **` out-parameters). + * @returns the allocated slot pointer. + */ export function allocPtrSlot(): NativePtr { const value: unknown = koffi.alloc(PVOID, 1) return value as NativePtr } -/** Allocate one uint32 slot. */ +/** + * Allocate one uint32 slot. + * @returns the allocated slot pointer. + */ export function allocUint32(): NativePtr { const value: unknown = koffi.alloc('uint32', 1) return value as NativePtr } -/** Write a uint32 value into a slot pointer. */ +/** + * Write a uint32 value into a slot pointer. + * @param slot - the slot allocated by {@link allocUint32}. + * @param value - the uint32 to encode. + */ export function encodeUint32(slot: NativePtr, value: number): void { koffi.encode(slot, 'uint32', value) } -/** Decode the pointer stored in a pointer-sized slot (NULL becomes null). */ +/** + * Decode the pointer stored in a pointer-sized slot (NULL becomes null). + * @param slot - the pointer-sized slot holding the out-parameter value. + * @returns the decoded pointer, or null for NULL. + */ export function decodePtr(slot: NativePtr): NativePtr | null { const value: unknown = koffi.decode(slot, PVOID) if (isNullPtr(value as NativePtr | null | undefined)) return null return value as NativePtr } -/** Decode a uint32 at a slot pointer. */ +/** + * Decode a uint32 at a slot pointer. + * @param slot - the uint32 slot holding the out-parameter value. + * @returns the decoded uint32. + */ export function decodeUint32(slot: NativePtr): number { const value: unknown = koffi.decode(slot, 'uint32') return value as number } -/** Decode a UTF-16 string at a pointer. */ +/** + * Decode a UTF-16 string at a pointer. + * @param ptr - pointer to the NUL-terminated UTF-16 string. + * @returns the decoded string. + */ export function decodeStr16(ptr: NativePtr): string { const value: unknown = koffi.decode(ptr, 'str16') return value as string } -/** Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). */ +/** + * Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). + * @param ptr - the koffi pointer. + * @returns the pointer's numeric address. + */ export function ptrAddress(ptr: NativePtr): bigint { return koffi.address(ptr) } -/** Allocate a raw byte block (used for SID copies and variable-length arrays). */ +/** + * Allocate a raw byte block (used for SID copies and variable-length arrays). + * @param length - the block size in bytes. + * @returns the allocated block pointer. + */ export function allocBytes(length: number): NativePtr { const value: unknown = koffi.alloc('uint8', length) return value as NativePtr } -/** Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries). */ +/** + * Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries). + * @param buffer - the buffer holding the pointer value. + * @param offset - byte offset of the pointer inside the buffer. + * @returns the decoded pointer, or null for NULL. + */ export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null { const value: unknown = koffi.decode(buffer, offset, PVOID) if (isNullPtr(value as NativePtr | null | undefined)) return null return value as NativePtr } -/** Allocate a zeroed STARTUPINFOW. */ +/** + * Allocate a zeroed STARTUPINFOW. + * @returns the allocated struct pointer. + */ export function allocStartupInfo(): NativePtr { const value: unknown = koffi.alloc(STARTUPINFOW, 1) return value as NativePtr } -/** Write the stdio-relevant fields into a zeroed STARTUPINFOW (others stay default-initialized). */ +/** + * Write the stdio-relevant fields into a zeroed STARTUPINFOW (others stay default-initialized). + * @param startupInfo - the allocated STARTUPINFOW to encode into. + * @param fields - the field subset to write. + */ export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInput): void { koffi.encode(startupInfo, STARTUPINFOW, fields) } -/** Allocate a zeroed PROCESS_INFORMATION. */ +/** + * Allocate a zeroed PROCESS_INFORMATION. + * @returns the allocated struct pointer. + */ export function allocProcessInfo(): NativePtr { const value: unknown = koffi.alloc(PROCESS_INFORMATION, 1) return value as NativePtr } -/** Decode a PROCESS_INFORMATION after CreateProcessAsUserW. */ +/** + * Decode a PROCESS_INFORMATION after CreateProcessAsUserW. + * @param processInfo - the PROCESS_INFORMATION filled by the spawn call. + * @returns the decoded handle/id fields. + */ export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput { const value: unknown = koffi.decode(processInfo, PROCESS_INFORMATION) return value as ProcessInfoOutput @@ -276,12 +333,20 @@ function bindings(): Win32Bindings { return cached } -/** Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). */ +/** + * Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). + * @returns the cached binding table. + */ export function win32(): Promise { return Promise.resolve(bindings()) } -/** Turn a Win32 error code into readable text via FormatMessageW. */ +/** + * Turn a Win32 error code into readable text via FormatMessageW. + * @param api - the binding table. + * @param win32Code - the error code to format. + * @returns the formatted message text, or '' when formatting fails. + */ export function errorText(api: Win32Bindings, win32Code: number): string { const buffer = Buffer.alloc(1024) const length = api.formatMessageW( @@ -295,13 +360,24 @@ export function errorText(api: Win32Bindings, win32Code: number): string { /** * Throw a Win32Error for a BOOL-style API failure. MUST be called immediately * after the failed call so GetLastError is not clobbered by other Win32 calls. + * @param api - the binding table. + * @param name - the failed API's name for the error message. + * @param detail - optional detail overriding the formatted system message. + * @returns never — always throws. */ export function throwLastError(api: Win32Bindings, name: string, detail?: string): never { const win32Code = api.getLastError() throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) } -/** Throw a Win32Error for an HRESULT-style API return value (the value IS the error code). */ +/** + * Throw a Win32Error for an HRESULT-style API return value (the value IS the error code). + * @param api - the binding table. + * @param name - the failed API's name for the error message. + * @param win32Code - the API's returned error code. + * @param detail - optional detail overriding the formatted system message. + * @returns never — always throws. + */ export function throwWin32(api: Win32Bindings, name: string, win32Code: number, detail?: string): never { throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) } diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 77655881ca..f597ebbc72 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -38,6 +38,7 @@ import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' export { Win32Error } from './errors.ts' +/** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */ export interface AclSandboxOptions { /** Directories the confined child may write into (must exist and be caller-owned). */ writableDirs: readonly string[] @@ -51,6 +52,7 @@ export interface AclSandboxOptions { writeSid?: string } +/** Per-spawn options: the program, its argv/cwd, and the stdio shape. */ export interface AclSandboxSpawnOptions { /** Program to run (resolved via PATH search when unqualified, like CreateProcess). */ command: string @@ -67,12 +69,14 @@ export interface AclSandboxSpawnOptions { stdio?: 'pipe' | 'inherit' } +/** A settled confined child: captured stdio and the exit code. */ export interface AclSandboxChildResult { stdout: Buffer stderr: Buffer exitCode: number } +/** A running confined child: its pid and a settlement promise. */ export interface AclSandboxChild { /** Child process id. */ pid: number @@ -98,7 +102,9 @@ function getTempPath(api: Win32Bindings): string { * failure. */ export class AclSandbox { + /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] + /** The orphan SID string whose ACEs form the write allowlist. */ readonly writeSid: string private readonly tempDirOption: string | null | undefined private tempDirResolved: string | null | undefined @@ -199,6 +205,8 @@ export class AclSandbox { * placed in a kill-on-close job (dies with the caller). Call dispose() only * after all children have exited — revoking grants under a live child * removes its remaining write allowance. + * @param options - the program, argv/cwd, and stdio shape. + * @returns the running child. */ spawn(options: AclSandboxSpawnOptions): AclSandboxChild { const api = this.api diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index 58571257a1..d9c7c70564 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -17,6 +17,8 @@ import * as abi from './win32-abi.ts' * Quote one argument per the CommandLineToArgvW parsing rules (backslash * escaping only before quotes; a trailing backslash before the closing quote * is doubled). + * @param argument - one argv entry to quote. + * @returns the quoted entry (bare when quoting is unnecessary). */ export function quoteArg(argument: string): string { if (argument === '') return '""' @@ -37,7 +39,12 @@ export function quoteArg(argument: string): string { return quoted + '"' } -/** Build the single command line CreateProcess parses from program + argv. */ +/** + * Build the single command line CreateProcess parses from program + argv. + * @param program - the executable (argv[0]). + * @param args - the remaining argv entries. + * @returns the joined, quoted command line. + */ export function buildCommandLine(program: string, args: readonly string[]): string { return [program, ...args].map(quoteArg).join(' ') } @@ -63,6 +70,7 @@ function setInheritable(api: Win32Bindings, handle: NativePtr, label: string): v } } +/** A confined child spawned with piped stdio: process handle plus the pipe read ends to drain. */ export interface SpawnedNative { pid: number process: NativePtr @@ -74,6 +82,10 @@ export interface SpawnedNative { * Create a process under the restricted token with piped stdio. The child's * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends * are returned for draining. + * @param api - the binding table. + * @param token - the restricted token the child runs under. + * @param options - command, args, and working directory. + * @returns the spawned child's handles. */ export function spawnSandboxed( api: Win32Bindings, @@ -133,7 +145,12 @@ export function spawnSandboxed( } } -/** Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling. */ +/** + * Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling. + * @param api - the binding table. + * @param handle - the pipe read end to drain (closed when done). + * @returns the complete pipe contents. + */ export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise { const chunks: Buffer[] = [] for (;;) { @@ -167,6 +184,9 @@ export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise< * the child has already exited, so this wait returns immediately. Calling it * earlier would block the event loop and starve the drains (the pipe-buffer * deadlock the POC comments warn about). + * @param api - the binding table. + * @param process - the child process handle (closed when done). + * @returns the child's exit code. */ export function waitForExit(api: Win32Bindings, process: NativePtr): number { const waitResult = api.waitForSingleObject(process, abi.INFINITE) @@ -197,6 +217,7 @@ function createKillOnCloseJob(api: Win32Bindings): NativePtr { return job } +/** A confined child spawned with inherited stdio: process handle plus its kill-on-close job. */ export interface SpawnedInherited { pid: number process: NativePtr @@ -217,6 +238,10 @@ export interface SpawnedInherited { * STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles * ("The handle is invalid", verified the hard way). The child starts * suspended so it can be assigned to a kill-on-close job before it runs. + * @param api - the binding table. + * @param token - the restricted token the child runs under. + * @param options - command, args, and working directory. + * @returns the spawned child's handles and job. */ export function spawnSandboxedInherited( api: Win32Bindings, diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index e1c0f2dc2a..569c428551 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -16,6 +16,8 @@ import * as abi from './win32-abi.ts' * CreateRestrictedToken requires (the POC's OpenProcessToken call; the token * handle is obtained through a real OpenProcess handle because the * GetCurrentProcess() pseudo-handle is not addressable through koffi). + * @param api - the binding table. + * @returns the opened token handle. */ export function openCurrentProcessToken(api: Win32Bindings): NativePtr { const processHandle = api.openProcess(abi.PROCESS_QUERY_INFORMATION, 0, process.pid) @@ -42,6 +44,9 @@ export function openCurrentProcessToken(api: Win32Bindings): NativePtr { * Find and copy the token's logon session SID (S-1-5-5-x-y, attribute * SE_GROUP_LOGON_ID). The restricted token needs it for WinSta0/desktop and * other per-logon objects; the POC extracts it the same way. + * @param api - the binding table. + * @param token - the token whose groups are scanned. + * @returns a copied logon SID (thrown when the token carries none). */ export function findLogonSid(api: Win32Bindings, token: NativePtr): NativePtr { const neededSlot = allocUint32() @@ -70,7 +75,12 @@ export function findLogonSid(api: Win32Bindings, token: NativePtr): NativePtr { throw new Error(`CreateRestrictedToken prerequisite failed: no logon SID found among ${groupCount} token groups`) } -/** Create one well-known SID (68-byte buffer) and assert its validity. */ +/** + * Create one well-known SID (68-byte buffer) and assert its validity. + * @param api - the binding table. + * @param type - the WELL_KNOWN_SID_TYPE to create. + * @returns the created SID pointer. + */ export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr { const sid = allocBytes(abi.SECURITY_MAX_SID_SIZE) const sizeSlot = allocUint32() @@ -91,6 +101,7 @@ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { return buffer } +/** The well-known SIDs packed into every restricted token's restricting list. */ export interface RestrictingSidSet { world: NativePtr authUser: NativePtr @@ -105,6 +116,12 @@ export interface RestrictingSidSet { * write SID that forms the write allowlist. S-1-2-1 (console logon) is * intentionally absent: see win32-abi.ts for the verified failure modes. * FAILS CLOSED: any failure throws — never spawn unrestricted. + * @param api - the binding table. + * @param currentToken - the process token to restrict. + * @param logonSid - the copied logon session SID. + * @param writeSid - the orphan SID forming the write allowlist. + * @param known - the well-known SIDs entering the restricting list. + * @returns the restricted token handle. */ export function createRestrictedToken( api: Win32Bindings, diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 97e5322bbf..7696605c4e 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -26,101 +26,161 @@ // ---- winnt.h --------------------------------------------------------------- // TOKEN_* access rights (winnt.h lines ~3928) +/** TOKEN_ASSIGN_PRIMARY: required to create a process with the token (CreateProcessAsUser). */ export const TOKEN_ASSIGN_PRIMARY = 0x0001 +/** TOKEN_DUPLICATE: required to duplicate a token (DuplicateTokenEx). */ export const TOKEN_DUPLICATE = 0x0002 +/** TOKEN_QUERY: required to read token information (GetTokenInformation). */ export const TOKEN_QUERY = 0x0008 +/** TOKEN_ADJUST_DEFAULT: required to change a token's default DACL. */ export const TOKEN_ADJUST_DEFAULT = 0x0080 // SID_AND_ATTRIBUTES.Attributes flags (winnt.h lines ~3446) +/** + * SE_GROUP_LOGON_ID: marks a token group SID as the logon SID (compared with + * `>>> 0` — the flag's high bit makes it negative as a signed 32-bit number). + */ export const SE_GROUP_LOGON_ID = 0xC0000000 // Generic file access (winnt.h lines ~5893-5913): // FILE_GENERIC_WRITE = STANDARD_RIGHTS_WRITE | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES // | FILE_WRITE_EA | FILE_APPEND_DATA | SYNCHRONIZE +/** STANDARD_RIGHTS_WRITE (== READ_CONTROL): the standard-rights component of generic write access. */ export const STANDARD_RIGHTS_WRITE = 0x00020000 // == READ_CONTROL +/** FILE_GENERIC_WRITE: every file-write permission bit plus SYNCHRONIZE. */ export const FILE_GENERIC_WRITE = 0x00120116 // What the POC grants: FILE_GENERIC_WRITE minus READ_CONTROL; displays as // "Write" in Explorer/icacls (windows-acl-restrict-poc.cpp line 16). +/** + * GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL — the write-access mask + * the orphan-SID ACEs grant (displays as "Write" in Explorer/icacls). + */ export const GRANT_MASK = FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE // 0x00100116 // CreateRestrictedToken flags (winnt.h lines ~4284) +/** DISABLE_MAX_PRIVILEGE: strip the token's maximum-privilege elevation so the confined child cannot escalate. */ export const DISABLE_MAX_PRIVILEGE = 0x1 +/** LUA_TOKEN: produce a limited-user (filtered admin) token. */ export const LUA_TOKEN = 0x4 +/** WRITE_RESTRICTED: intersect write access with the restricting SIDs' ACL grants — the sandbox's core mechanism. */ export const WRITE_RESTRICTED = 0x8 // WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407) +/** WinWorldSid: S-1-1-0 (Everyone). */ export const WinWorldSid = 1 +/** WinLocalSid: S-1-2-0 (LOCAL); CreateWellKnownSid(WinLocalSid) fails with ERROR_INVALID_PARAMETER on Windows 11 build 26200. */ export const WinLocalSid = 2 +/** WinInteractiveSid: S-1-5-4 (INTERACTIVE). */ export const WinInteractiveSid = 11 +/** WinAuthenticatedUserSid: S-1-5-11 (Authenticated Users). */ export const WinAuthenticatedUserSid = 17 // TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2) +/** TokenGroups: GetTokenInformation class returning the token's group SIDs. */ export const TokenGroups = 2 // SECURITY_INFORMATION (winnt.h line ~4293) +/** DACL_SECURITY_INFORMATION: read/write only the DACL of a security descriptor. */ export const DACL_SECURITY_INFORMATION = 0x00000004 // PROCESS access rights (winnt.h lines ~4364) +/** PROCESS_QUERY_INFORMATION: read exit status and times of a process handle. */ export const PROCESS_QUERY_INFORMATION = 0x0400 // ---- accctrl.h ------------------------------------------------------------- // SE_OBJECT_TYPE (accctrl.h line ~22: SE_UNKNOWN_OBJECT_TYPE=0, SE_FILE_OBJECT=1) +/** SE_FILE_OBJECT: the trustee path names a filesystem object. */ export const SE_FILE_OBJECT = 1 // TRUSTEE_FORM / TRUSTEE_TYPE (accctrl.h lines ~38-55): both enums start at 0 +/** TRUSTEE_IS_UNKNOWN: TRUSTEE_TYPE unknown (TrusteeForm carries the shape). */ export const TRUSTEE_IS_UNKNOWN = 0 +/** TRUSTEE_IS_SID: TRUSTEE_FORM — Trustee.ptstrName is a SID pointer. */ export const TRUSTEE_IS_SID = 0 +/** NO_MULTIPLE_TRUSTEE: Trustee.pMultipleTrustee is null. */ export const NO_MULTIPLE_TRUSTEE = 0 // ACCESS_MODE (accctrl.h line ~127: NOT_USED_ACCESS=0, GRANT_ACCESS=1, REVOKE_ACCESS=4) +/** GRANT_ACCESS: SetEntriesInAclW adds the entry as an allow ACE. */ export const GRANT_ACCESS = 1 +/** REVOKE_ACCESS: SetEntriesInAclW removes the matching allow ACE. */ export const REVOKE_ACCESS = 4 // grfInheritance (accctrl.h lines ~137-142) +/** + * SUB_CONTAINERS_AND_OBJECTS_INHERIT: the ACE applies to the directory, its + * subdirectories, and files (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE). + */ export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 // == OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE // ---- winbase.h ------------------------------------------------------------- +/** + * STARTF_USESTDHANDLES: STARTUPINFOW dwFlags — the child uses the hStd* + * handles, required because Node clears stdio inheritability at startup. + */ export const STARTF_USESTDHANDLES = 0x00000100 +/** HANDLE_FLAG_INHERIT: SetHandleInformation flag re-enabling handle inheritance for the spawned child's stdio handles. */ export const HANDLE_FLAG_INHERIT = 0x1 +/** INFINITE: never-timeout wait value. */ export const INFINITE = 0xFFFFFFFF +/** MAX_PATH: legacy path length bound. */ export const MAX_PATH = 260 // winbase.h line ~410: the confined child starts suspended so the runner can // assign it to the kill-on-close job before any of its code runs. +/** CREATE_SUSPENDED: create the child with its primary thread suspended until ResumeThread. */ export const CREATE_SUSPENDED = 0x4 // winbase.h lines ~497-499: GetStdHandle selectors. +/** STD_INPUT_HANDLE: GetStdHandle selector for the standard input. */ export const STD_INPUT_HANDLE = -10 +/** STD_OUTPUT_HANDLE: GetStdHandle selector for the standard output. */ export const STD_OUTPUT_HANDLE = -11 +/** STD_ERROR_HANDLE: GetStdHandle selector for the standard error. */ export const STD_ERROR_HANDLE = -12 // FormatMessageW flags (winbase.h lines ~1446-1469) +/** FORMAT_MESSAGE_FROM_SYSTEM: format the message from the system message table. */ export const FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000 +/** FORMAT_MESSAGE_IGNORE_INSERTS: skip insert-sequence substitution. */ export const FORMAT_MESSAGE_IGNORE_INSERTS = 0x00000200 // ---- error codes ----------------------------------------------------------- +/** ERROR_SUCCESS: the operation succeeded. */ export const ERROR_SUCCESS = 0 +/** ERROR_INSUFFICIENT_BUFFER: a size-probe call succeeded but needs a larger buffer. */ export const ERROR_INSUFFICIENT_BUFFER = 122 +/** ERROR_BROKEN_PIPE: the pipe's other end has closed. */ export const ERROR_BROKEN_PIPE = 109 +/** ERROR_NO_DATA: the pipe is being closed. */ export const ERROR_NO_DATA = 232 // ---- job object (winnt.h lines ~4859-4866, ~5138, ~5190-5199) -------------- // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last // job handle closes — the orphan-child backstop for the runner design. +/** JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last job handle closes — the orphan-child backstop. */ export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 // JOBOBJECTINFOCLASS: JobObjectBasicAccountingInformation=1, ..., ExtendedLimit=9. +/** JobObjectExtendedLimitInformation: JOBOBJECTINFOCLASS for the extended limit structure. */ export const JobObjectExtendedLimitInformation = 9 // sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. +/** sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. */ export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 // LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION // (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + PerJobUserTimeLimit@8), // verified by abi-probe. +/** + * LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION + * (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + + * PerJobUserTimeLimit@8), verified by abi-probe. + */ export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 // ---- ABI layout, verified by verify/abi-probe.cpp (x64) -------------------- +/** SECURITY_MAX_SID_SIZE: maximum SID byte size. */ export const SECURITY_MAX_SID_SIZE = 68 /** SID_AND_ATTRIBUTES stride: { PSID Sid @0 (8); DWORD Attributes @8 (4) } + pad. */ export const SID_AND_ATTRIBUTES_SIZE = 16 @@ -132,5 +192,7 @@ export const EXPLICIT_ACCESS_W_SIZE = 48 export const TRUSTEE_W_OFFSET = 16 /** ptstrName offset inside TRUSTEE_W (=> 40 inside EXPLICIT_ACCESS_W). */ export const TRUSTEE_W_PTSTRNAME_OFFSET = 24 +/** sizeof(STARTUPINFOW), verified by abi-probe. */ export const STARTUPINFOW_SIZE = 104 +/** sizeof(PROCESS_INFORMATION), verified by abi-probe. */ export const PROCESS_INFORMATION_SIZE = 24 diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 53491b89fb..663501c763 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -97,6 +97,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/subprocess/subprocess': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' }, 'packages/subprocess/subprocess-local': { kind: 'indirect', reason: 'The spawn backend delegates model rendering to consumer seams such as the bash executor family.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, + 'packages/sandbox/sandbox-windows-acl': { kind: 'indirect', reason: 'The provider backend delegates model rendering to the bash/pwsh sandbox executors and their tools.' }, 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, 'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, 'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },