# dsh-acl-sandbox-patch 中文说明:[README.md](README.md) A **temporary fix plugin** for the DeepSeek Harness (DSH) 0.1.7 Windows ACL sandbox failing on **non-system-drive workspaces** (D:/E:/F:) — restores `workspace-write` command execution **without touching any system ACL**. ## What it fixes Since 0.1.7, every `workspace-write` command must materialize an ACL grant on the workspace before spawn. `@deepseek-ai/dsh-sandbox-windows-acl` applies three edits in **one** `SetNamedSecurityInfoW` call: a capability-SID Allow ACE, a world-SID `FILE_DELETE_CHILD` Deny ACE, and a **Low mandatory integrity label**. Writing the label requires the caller to hold **`WRITE_OWNER`** on the directory — but: - Directories created on a non-system drive default to `Authenticated Users: Modify`, with **no FullControl for the creating user**; - DSH runs under a UAC-filtered token where `BUILTIN\Administrators` is **deny-only**, so the group's FullControl is unusable; - Owner-implicit rights cover only `READ_CONTROL` + `WRITE_DAC`, **not `WRITE_OWNER`**. The grant therefore returns `ERROR_ACCESS_DENIED (5)`, the sandbox fails closed, and the command never spawns: ``` SetNamedSecurityInfoW failed (Win32 5): grantWrite() ``` **Not affected**: the `read-only` policy (no writable root is granted, this call is never reached), and workspaces under `%USERPROFILE%` (user profiles grant FullControl to the user SID natively). Upstream reports (the 0.1.7-rc.2 sandbox package is byte-identical to rc.1 — still unfixed): - [Discussion #7771 — workspace owned by BUILTIN\Administrators](https://github.com/deepseek-ai/deepseek-harness/discussions/7771) - [Discussion #7720 — the other half of the trigger](https://github.com/deepseek-ai/deepseek-harness/discussions/7720) - [Discussion #7804 — detailed reproduction report](https://github.com/deepseek-ai/deepseek-harness/discussions/7804) ## Requirements (minimum supported version) | Item | Requirement | |---|---| | OS | **Windows** (NTFS; the plugin no-ops on other platforms) | | **DSH minimum version** | **`>= 0.1.7-rc.1`** — the release that introduced `@deepseek-ai/dsh-sandbox-windows-acl` and the bug. DSH ≤ 0.1.5 has no ACL sandbox and does not need this plugin | | Verified on | 0.1.7-rc.1 / 0.1.7-rc.2 (sandbox packages byte-identical) | | Policy | Only `workspace-write` needs it (`read-only` / `danger-full-access` are unaffected) | Declared in `engines.dsh` as `>=0.1.7-rc.1`. Once upstream fixes the bug (discussions closed or sandbox package upgraded), the strict path simply succeeds and the plugin degrades to a no-op — uninstall at that point. ## How it works Only the sandbox's own FFI calls are wrapped, via two mutually-backing injection paths: | Path | Covers | Mechanism | |---|---|---| | A. koffi hook | Every node child process (sandbox runner, probes) | Hooks `Module._load` to intercept `require('koffi')`, wraps the `.func()` of the object returned by `koffi.load()`, and returns wrapped functions for dsh-win32-process's 4-argument call form (C-declaration single-string form passes through untouched). Preloaded via `NODE_OPTIONS=--require=lib/preload.cjs`, ahead of the main module graph | | B. api-table rewrite | The DSH server process (owns the workspace standing grant) | Resolves the sandbox package, creates a throwaway `AclWriteGrant` to materialize the process-wide shared binding table, replaces the target properties on it, then disposes. Independent of binding order | The wrappers (`lib/preload.cjs`): 1. **`SetNamedSecurityInfoW`**: tries the full, original apply (label included) first; **only on Win32 5** degrades to a **DACL-only** retry (strips `LABEL_SECURITY_INFORMATION`, SACL passed as NULL). A DACL write needs only the owner-implicit `WRITE_DAC`, which succeeds. 2. **`SetTokenInformation(TokenIntegrityLevel=25)`**: skips lowering the child token to Low when either of two criteria hit — a workspace with no Low label cannot be written by a Low child (no-write-up). Criterion 1: state file (plugin-directory `.degraded.state` primary path) / environment variable / in-process flag. Criterion 2: the runner reads `--workspace` from its own argv and directly probes whether that directory carries a Low label (absent ⇒ degraded environment). Criterion 2 depends on no file or environment — it is the final fallback. 3. **`GetNamedSecurityInfoW`**: once degraded, queries containing the LABEL bit receive a synthesized "already carries the exact Low label" SACL (isomorphic to `hasExactLabel`'s field-by-field comparison), restoring the idempotent-skip. In a healthy environment the behavior is identical to stock (full grant, normal integrity lowering); only environments that genuinely cannot apply the label degrade. ## Security boundary (the honest part) Retained: - The `WRITE_RESTRICTED` token (dual-list access check: normal SIDs + restricting SIDs) - Capability-SID Allow ACE — only the workspace and the session's private temp are writable - World-SID `FILE_DELETE_CHILD` Deny — delete authority converges on the capability ACE - Fail-closed: every other Win32 failure still throws, the sandbox is never removed Lost (only in environments that could not apply the Low label anyway): - The Low mandatory-integrity defense-in-depth layer (children are no longer forced to Low integrity) Never present in the first place (unchanged from stock): read confinement, network policy, process visibility. ## Install ``` dsh plugin --profile web add github:masknull/dsh-acl-sandbox-patch ``` The command links the package, records it as a dependency, and — because the package declares `dsh.bundle` — appends it to the profile's bundle list. **Restart DSH** for the new bundle layer to take effect. The startup log should show: ``` [dsh-acl-sandbox-patch] active — Windows ACL sandbox Low-label / integrity-lowering patch installed (upstream Discussions #7771/#7720/#7804) ``` Run shell commands in a new session — no per-command approval, no more Win32 5. > Optionally pin a commit for supply-chain hygiene: `github:masknull/dsh-acl-sandbox-patch#`. ## Verification The quickest check after install: `whoami` / `echo hi` in a new session, with output and no approval prompt; then try overwriting a file and creating a file inside the sandbox — both should just work. **Measured results** (a non-system-drive workspace with 44,251 files, DSH 0.1.7-rc.2, v0.1.2): | Scenario | Result | |---|---| | Unpatched | Every command fails before spawn: `SetNamedSecurityInfoW failed (Win32 5): grantWrite()` | | Ordinary `pwsh` after patching | Returns directly, no per-command approval | | First `workspace-write` command after a DSH restart | ~20 s (one-time full-tree DACL propagation — inherent cost) | | Subsequent commands in the same process | ~1.7 s | | Commands in a new session | Under 2 s (the seam's per-provider reuse cache) | | **In-sandbox token integrity (key metric of v0.1.2)** | **Medium** (Low in 0.1.1 and earlier — all in-sandbox file writes denied by no-write-up) | | **In-sandbox file operations (create/overwrite/delete)** | **All normal** (all Access denied in 0.1.1 and earlier) | | Server-side degraded state file | Both the plugin-directory `.degraded.state` and the tmpdir path written correctly | | Runner decision path (state file + direct label probing) | Verified item by item | | `write` tool overwriting an existing file | Normal (v1's `type specifier` error fixed) | ## Rollback ``` dsh plugin --profile web remove dsh-acl-sandbox-patch ``` Then restart DSH. The plugin has **no persistent side effects**: no system ACL changes, no leftover files (`NODE_OPTIONS` is set in-process only and inherited by children; state files live in the plugin directory / tmpdir, are cleared at every boot and created at runtime), and no background jobs. ## Known limitations - **Windows only**; no-op elsewhere. - Requires a DSH restart to take effect (the preload needs process startup). - **Sessions established before a restart may keep a pre-restart orphan runner process** (legacy behavior: Low token, no state-file awareness) — this is DSH restarting without reaping old runners, not a plugin defect; **a new session restores normal behavior**. - **Slow first command after a restart**: in degraded mode the exact-ACE skip never matches (the Low label is never present), so the first `workspace-write` command after each DSH restart performs one full-tree DACL propagation (measured ~20 s on a 44k-file tree — inherent to the sandbox mechanism). Subsequent commands and new sessions hit the reuse cache, under 2 s. - Uninstall once upstream fixes the issue (corresponding discussion closed or sandbox package upgraded) to restore stock behavior. ## Changelog ### 0.1.2 (current) 1. **State file primary path moved into the plugin directory** (`.degraded.state`, zero environment dependence): 0.1.1 placed it in `os.tmpdir()`, but the sandbox runner chain rewrites `TMP/TEMP` into the session-private directory, so the runner's `os.tmpdir()` pointed elsewhere and the state file was never found — children stayed Low. The primary path is now `__dirname/../.degraded.state` (written by the server, read by the runner, path-constant); the tmpdir path is kept as a compatibility fallback. 2. **Direct workspace-label probing (criterion 2, self-contained)**: the runner takes `--workspace` from its own argv and reads whether that directory carries a Low mandatory label — present ⇒ strict environment (lower integrity normally); absent ⇒ degraded environment (skip lowering). It depends on no file or environment variable and is the final fallback. ### 0.1.1 1. **koffi `lib.func` calling-form misjudgment**: koffi has two calling forms — 4-argument (dsh-win32-process's `bind`) and a C-declaration single string (dsh-fs-local etc., containing `(`). v1 always forwarded as the 4-argument form, so the declaration string was rejected by koffi's type parser (`Unexpected character '(' in type specifier`) and **every "overwrite existing file" write failed** (new-file writes don't take that path, hence asymptomatic). Now only the 4-argument form is recognized; everything else passes through untouched. 2. **Degraded-flag cross-process propagation (first cut)**: v1 propagated "degraded" via environment variables only. The subprocess runner is a session-scoped long-lived process that often starts before the server's first grant, so it missed the flag and lowered children to Low integrity. Introduced the state-file mechanism (0.1.2 then fixed its path and added direct label probing). ### 0.1.0 First usable version: two injection paths (koffi hook + api-table rewrite), strict-first degradation, environment-variable propagation. ## Files | File | Role | |---|---| | `lib/index.js` | Plugin entry (host half): in-process install + instance identity + state cleanup + `NODE_OPTIONS` injection | | `lib/preload.cjs` | Patch core (CJS, zero dependencies): koffi hook + api-table wrapping + degraded decision + state file | | `cordis.patch.yml` | Profile-tree insert entry (install mechanism same as official plugins) | | `.degraded.state` | Degraded state file (written/cleared by the server at runtime; git-ignored) | ## License [MIT](LICENSE)