Skip to content

emrg: confine windows commands with an acl-restricted token (bash tool v2, P4) - #1542

Merged
argszero merged 11 commits into
masterfrom
feature/bash-tool-v2-p4
Sep 22, 2026
Merged

argszero merged 11 commits into
masterfrom
feature/bash-tool-v2-p4

Conversation

@argszero

@argszero argszero commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

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): port sandbox-windows-acl verbatim.

What was empty, and what it is now

PLATFORM_CHAINS had darwin only, so a Windows host refused every confined run (fail-closed, not a silent unconfined run). win32 now has its rung: an ACL-restricted token (WRITE_RESTRICTED + per-capability write SIDs), reached through python -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, commit ddefc45fbc), with the FFI layer going through ctypes instead of koffi — the mapping table is the docstring of emrg/sandbox/win32/__init__.py.

Enforcement is partial, and that is the backend's own word

Everyone must 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. So the rung advertises partial (sandbox-local/src/index.ts:177-187), not full, 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

  • workspace — the write SID is derived from the canonical workspace path, so its ACE is standing and outlives every session: it is the cross-session reuse cache, and revoking it would force the next provision to re-propagate the whole tree;
  • session × workspace — a random private temp directory with its own revocable SID, so sibling sessions sharing a workspace cannot enter one another's temp trees. That is what session_id is 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-2025 leg 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.

  • A real port defect: the SIDs the token is built from. find_logon_sid and make_well_known_sid handed back a bare addressof(...), so the ctypes buffers behind the restricting SIDs could be collected while CreateRestrictedToken still pointed at them — and every confined child then died with 0xC0000142 (STATUS_DLL_INIT_FAILED), which is exactly what the blueprint documents for a restricting list with no usable keep-alive group. They now return a NativeBuffer that 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.
  • A test-environment precondition: the ambient ACE. A WRITE_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 ever consulted. That holds for the working directory a deployer hands the sandbox, but not for CI's tmp_path, which is owned by BUILTIN\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 a read-only child could use, and test_a_read_only_run_is_refused_inside_the_workspace_too is the proof that it still refuses. The precondition is stated in DEVELOPMENT.md as 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 → 5127 collected in the suite).

  • the mechanism itself — the Windows-only four at the bottom of tests/test_bash_v2_win32.py: a write inside the workspace lands, a write outside it is refused, and read-only refuses a write inside the workspace too. These run on CI's windows-2025 leg; no macOS host can execute them.
  • everything a defect would ship silently on every platform — the SID derivation and its 30-bit range, s1's release not reaching s10 (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)

  • The blueprint's own failure-path specs are not ported. dsh carries 13 spec files with failure-path cases for 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_session has 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.
  • Overlaps emrg: confine linux commands with bubblewrap (bash tool v2, P3) #1541 (P3, Linux). Both PRs edit the same two regions of emrg/sandbox/providers/__init__.py and the same paragraph of DEVELOPMENT.md; whichever lands second refreshes and re-measures, as usual.

EMRG Evolution added 11 commits September 22, 2026 13:34
…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.
@how2how2how2-arch

Copy link
Copy Markdown
Contributor

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 (7e31b9bf → da353dd, a merge commit that brings master 68d7860 in), so the earlier CONFLICTING state is gone and the merge is now automatic. I measured the tree the merge actually produces rather than the branch:

git merge-tree --write-tree 68d7860 da353dd  ->  27aece83e97e25feec74c8f3ba33197a7a3a2b88
  • no conflict markers anywhere in the landing tree;
  • PLATFORM_CHAINS carries all three rows darwin / linux / win32;
  • unconfined_mode on the landing tree: None for read-only and workspace-write on all three platforms, 'danger-full-access' only for that mode. So the P3 deletion of the Linux D4 arm (P3's own stated exit condition) survives this merge — this branch's version of providers/__init__.py does not resurrect it, which was the one thing worth checking about a file both sides edited.

Full suite, landing tree vs master (both git archive extractions, so index-derived guards fail on both sides and the comparison is of the failure set):

tree result
master 68d7860 19 failed, 5037 passed, 30 skipped
master + da353dd 19 failed, 5082 passed, 34 skipped

The failing node-id sets are identical (tests/test_check_doc_count.py, test_conflict_markers.py, test_doc_counts.py, test_the_index_derived_scans_reach_new_files.py, … — all of them read the git index, which an extraction has no index for). So: +45 passing, +4 skipped, no new failures.

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 bwrap is not installed on this host. Running the PR's three test files alone: 98 passed, 8 skipped.

One good design note. The windows-acl-run: rule is exit-gated, and says why in the source — a command that merely prints the signature must not be misread as "the command did not run". I measured that gate: an ordinary command echoing the signature is correctly left alone at exits 1/2/65/126/128, and the true failure (exit 127 + the runner's own line) is still reported fatal, so the gate costs nothing. This is the right shape, and it is stricter than the two already-merged rows: darwin.py:51 and linux.py:68 carry allowed_exit_codes=None and are misread at every non-zero exit. I filed that asymmetry with the measurements as issue #1543 — it is a follow-up on the merged rows, not something this PR needs to change, since this PR is Windows-only and its own row is the one that gets it right.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants