emrg: confine windows commands with an acl-restricted token (bash tool v2, P4) - #1542
Conversation
…l v2, P4) The Windows rung of the v2 chain table: `WRITE_RESTRICTED` plus per-capability write SIDs, ported file for file from deepseek-harness's `sandbox-windows-acl` (dsh 0.1.6-alpha.2, commit ddefc45fbc) through ctypes instead of koffi. `PLATFORM_CHAINS["win32"]` was empty, so a Windows host refused every confined run; it now has its rung, selected without a probe because a single candidate is not arbitrated. The backend's own honest claim travels with it: `enforcement = "partial"`, not "full". `Everyone` has to sit in both restricting lists for the restricted child to initialize at all, so an object granting `Everyone` write access stays writable, and an NTFS hard link can alias a granted file to a path outside the workspace. The result surface repeats that word to the model rather than promising an absolute boundary the mechanism cannot keep. Two grant lifetimes, deliberately different: the workspace write SID is derived from the canonical workspace path, so its ACE is standing and outlives every session (the cross-session reuse cache — revoking it would re-propagate the whole tree on the next provision), while each session × workspace pair gets a random private temp directory with its own revocable SID, so sibling sessions sharing a workspace cannot enter one another's temp trees. A temp root inside the workspace is refused before a directory or an ACE exists, because every child below it would inherit the standing workspace capability. Measured: 89 tests across the three v2 files, of which the mechanism itself is the Windows-only pair at the bottom of `tests/test_bash_v2_win32.py` (a write inside the workspace lands, a write outside it is refused) — the rest of that file pins what a defect would ship silently on every platform: the SID derivation and its 30-bit range, the two grant lifetimes including the `s1`-must-not-release-`s10` key encoding, every fail-closed cleanup path, the policy→argv rendering, and the runner's own argument contract.
The guard that requires a text-mode subprocess to name its codec caught the new Windows-only probe: a cp936/cp1252 host would decode the child's traceback with the locale codec and turn a refusal into a mojibake assertion or a UnicodeDecodeError. Found by CI's ubuntu leg, which runs that guard.
The first Windows CI run of P4's mechanism pair failed with an empty stderr and a workspace file that was never written, which is the same reading for a refused write, a child killed by the token, and a child that never started. The exit code is the one fact that survives dead stdio, so it is now asserted first and carried in every failure message, and the pair is preceded by two contract tests: a confined child runs to completion, and its stdio reaches the seam verbatim.
The Windows run reports exit 0xC0000142 (STATUS_DLL_INIT_FAILED) for a plain python child, with both streams empty. That is one fact short of a cause: the blueprint documents this exact code for a missing keep-alive group, for console flags it does not use, and for a child that cannot reach its desktop. This probe spawns non-Python children and the same children unconfined, and reports the whole table in one failure message. It is deleted as soon as the table is read.
…ool v2, P4) The Windows CI mechanism test found it: every confined child died STATUS_DLL_INIT_FAILED (0xC0000142), including `cmd` and `where`, while the same children ran bare. The cause is the port's one structural substitution — `ctypes` memory belongs to a Python object, where the blueprint's koffi allocation belongs to the caller. `find_logon_sid`/`make_well_known_sid` returned `addressof(buffer)` and the buffer died on return, so the restricting list handed to `CreateRestrictedToken` pointed into memory the interpreter had already given to the `SID_AND_ATTRIBUTES` array built from those very addresses: the keep-alive group was effectively absent, which is precisely the documented cause of 0xC0000142. Dispose then LocalFree'd those addresses — heap corruption on a second allocator's memory. Now the address and its owner travel together (`ffi.NativeBuffer`) and the sandbox holds the owners for the token's lifetime (`_owned_sids`), releasing them by dropping the reference. The default-DACL grant takes the Everyone SID's address instead of the handle. The regression tests run on macOS, because the property that failed is `ctypes`'s and not Windows': the two helpers hand back a buffer that owns the address, the memory survives a churn of same-size allocations, and after `init` every address in the restricting list is inside a buffer the sandbox still holds — with nothing ctypes-owned handed to `LocalFree`.
The SID-lifetime fix made every confined child start, and the workspace-write case now reports the one thing left: a real PermissionError writing a NEW file inside the granted workspace, while read-only correctly denies. The ported mechanism promises that write. This probe reports the workspace DACL's ACEs and their SID bytes next to the capability SIDs, the restricted token's default DACL (what a new object's own DACL is taken from), and the child's own winerror for a new file, an existing file, a new directory and cmd's redirection. It is deleted as soon as the table is read.
Round 4 showed the capability ACE landing exactly as designed — mask 0x110156, OI|CI, the workspace SID's own bytes — and still every write denied, including one to a file the parent had already created. Under WRITE_RESTRICTED Windows evaluates the token's own SIDs first and the restricting SIDs second, so the question is which check fails. This probe mints the token variants directly (same LUA flags with and without WRITE_RESTRICTED), reports the directory owner SIDs and the process user SID beside them, writes one file per variant, and then repeats the restricted case on a directory that also grants the caller's own SID. It is deleted as soon as the table is read.
…ary) Round 5 measured the cause: the workspace, tmp_path and the ambient temp root are all owned by Administrators with no inheritable ACE for a SID the filtered (LUA) token holds, so the token's own access check fails before the restricting check is even consulted — a plain LUA token cannot write into the workspace, while the ambient temp root it can reach. This round reports the ambient temp root's ACEs, then repeats the write under the same token variants on a directory that carries the caller's own ACE, and on a fresh directory carrying only that ACE, to establish which check each ACE satisfies. Deleted as soon as the table is read.
…as (bash tool v2, P4) The restricted child is checked against the token's own SIDs before the restricting list, so a workspace owned by another account is refused before the capability ACE is consulted. The CI temp root is owned by Administrators, which made the two file-effect tests measure the runner's filesystem instead of the boundary; they now grant the running user's own SID first, and the read-only test still proves that SID admits nothing on its own. The probes that established this are removed, and the helper's own SID lifetime is pinned by a fake-API test that runs off Windows.
P3 landed the linux row while P4 was in review, so both edits to PLATFORM_CHAINS and to the boundary paragraph of DEVELOPMENT.md conflicted. The merged table keeps all three rows and the paragraph now describes what each platform actually covers: macOS/Linux at the process boundary reporting full, Windows reporting partial with its caller-owned precondition spelled out.
|
I tested this PR (contributor, read-only — no vote) and found the new head landing clean. What I measured. The head moved while I was looking at it (
Full suite, landing tree vs master (both
The failing node-id sets are identical ( The skipped arms are honestly platform-scoped rather than blanket: 4 win32 boundary arms skip with "the ACL restricted-token boundary exists only on Windows", 4 linux arms skip because One good design note. The |
Bash tool v2, phase P4 — the Windows rung. Design
bash-tool-v2-design.md§5.6/§5.7, host decision D2 (2026-09-21 16:51): portsandbox-windows-aclverbatim.What was empty, and what it is now
PLATFORM_CHAINShaddarwinonly, so a Windows host refused every confined run (fail-closed, not a silent unconfined run).win32now has its rung: an ACL-restricted token (WRITE_RESTRICTED+ per-capability write SIDs), reached throughpython -m emrg.sandbox.win32.runner. One candidate, so it is selected without a probe — a single candidate has no alternative to arbitrate.The port is file-for-file from dsh
sandbox-windows-acl(dsh 0.1.6-alpha.2, commitddefc45fbc), with the FFI layer going throughctypesinstead ofkoffi— the mapping table is the docstring ofemrg/sandbox/win32/__init__.py.Enforcement is
partial, and that is the backend's own wordEveryonemust sit in both restricting lists for the restricted child to initialize at all, so an object grantingEveryonewrite access stays writable — and an NTFS hard link can alias a granted file to a path outside the workspace. So the rung advertisespartial(sandbox-local/src/index.ts:177-187), notfull, and the result surface repeats that word to the model. A test pins the word, because a mechanism that cannot keep the absolute promise must not have one spelled for it.Two grant lifetimes, deliberately different
session_idis for, and the temp root is refused if it lies inside the workspace: every child below such a parent would inherit the standing workspace capability.Two defects the Windows leg found
The
windows-2025leg is the only instrument that can run this boundary, and it was red for two reasons no macOS host could have shown. Both are fixed, and neither is visible in the diff as a change of mechanism.find_logon_sidandmake_well_known_sidhanded back a bareaddressof(...), so thectypesbuffers behind the restricting SIDs could be collected whileCreateRestrictedTokenstill pointed at them — and every confined child then died with0xC0000142(STATUS_DLL_INIT_FAILED), which is exactly what the blueprint documents for a restricting list with no usable keep-alive group. They now return aNativeBufferthat owns its array, and the sandbox holds those owners for the token's lifetime. The property is pinned on every platform by fakes (a churn of same-size allocations must not reach the SID bytes), and two mutation arms — a bare address, and keeping the address instead of the owner — each kill those tests.WRITE_RESTRICTEDchild is checked against the token's own SIDs before the restricting list, so a workspace owned by another account is refused before the capability ACE is ever consulted. That holds for the working directory a deployer hands the sandbox, but not for CI'stmp_path, which is owned byBUILTIN\Administrators— the two file-effect tests were measuring the runner's filesystem rather than the boundary. They now grant the running user's own SID first. This does not soften what they measure: that SID sits in no restricting list, so the capability ACE remains the only ACE aread-onlychild could use, andtest_a_read_only_run_is_refused_inside_the_workspace_toois the proof that it still refuses. The precondition is stated inDEVELOPMENT.mdas well, because a workspace handed over by another account is a product fact, not a detail of the test setup.The probes that established this were temporary by construction; their code is gone from the head, which is what a squash merge lands.
What is measured
98 tests across the three v2 files, green locally (
5061 → 5127collected in the suite).tests/test_bash_v2_win32.py: a write inside the workspace lands, a write outside it is refused, andread-onlyrefuses a write inside the workspace too. These run on CI'swindows-2025leg; no macOS host can execute them.s1's release not reachings10(the key is encoded, not concatenated), the standing/revocable split, every fail-closed cleanup path (including the aggregate that reports a failed revocation beside the failed apply), the policy→argv rendering, and the runner's own argument contract (malformed argv, contradictory grant shapes, missing directories checked before any Win32 call).Declared gaps (not silently absent)
ffi/acl/token/grant(injected-binding fakes). This PR pins the structure those specs protect — lifetimes, ordering, fail-closed cleanup — but not each individual Win32 call's failure branch. That is the next slice, and it is a test-only change because the injection seams already exist.providers.win32.release_sessionhas no caller yet. It revokes one session's revocable temp ACE; nothing in the daemon calls it, so on Windows a temp directory and its ACE live until the process exits. Bounded and low severity — after the runner exits no live token carries that SID, so the residue is disk space rather than privilege — but wiring it into session deletion needs the session-id identity mapping verified, which is why it is not guessed at here.emrg/sandbox/providers/__init__.pyand the same paragraph ofDEVELOPMENT.md; whichever lands second refreshes and re-measures, as usual.