Skip to content

sandbox: the Windows port is the blueprint before its delete-escape fix, so a confined child can still delete outside every granted root #1556

Closed as not planned

Description

@argszero

What this is. A source-level reading of a revision, not a measurement on Windows — this host runs macOS. What it names is an absence in EMRG's copy, and an absence is decidable without a Windows machine; the confirmation is cheap and the windows-2025 CI leg can make it (see "How to pin it").

The gap

emrg/sandbox/win32/ names, in its own docstrings, which revision of the blueprint it ports:

  • abi.py:3 — "Ported from packages/sandbox/sandbox-windows-acl/src/win32-abi.ts in deepseek-harness (blueprint commit ddefc45fbc, dsh 0.1.6-alpha.2)."
  • acl.py:3-4 — "Ported from the blueprint's acl.ts (sandbox-windows-acl, dsh 0.1.6-alpha.2): the same SetEntriesInAclW + SetNamedSecurityInfoW …"

ddefc45fbc is tag dsh-v0.1.6-alpha.2, dated 2026-09-17 21:19 +0800. Two days later the blueprint found that revision's write confinement bypassable and fixed it — the fix/windows-acl-delete-escape family, merged as PR #4582 (eb7d3af91c):

commit date what it adds
d5ad3baeb5 2026-09-19 01:38 confine Windows deletes with a Low integrity label
36e632751e 2026-09-19 02:06 deny the ambient parent-delete right inside granted roots
3d5ba3b83f 2026-09-19 13:42 scope the deny to containers (CONTAINER_INHERIT_ACE), clearing the review round

The escape, quoted from d5ad3baeb5's own message:

The write-restricted token's capability-SID intersection only covers the write bits of the object's own access check. Windows also authorizes a write or a delete from the parent directory's FILE_DELETE_CHILD right, and no restricting SID has to co-sign that authority: the confined child could therefore delete any file its ambient user SIDs control. cmd /c del outside the workspace succeeded under BOTH modes, and .NET File.Delete/DeleteFileW did too wherever the ambient DACL held Modify.

and from 36e632751e:

every granted root is labeled Low, so the integrity check alone still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory).

36e632751e's message states the mechanism both times: a delete authorised by the parent directory's right, which the write-restricted intersection never reaches.

Why EMRG's copy has it

The port kept the allow half of the mechanism and none of the deny half. Measured on this tree:

grep -rniE "integrity|mandatory|DENY_ACCESS|SET_ACCESS|LABEL_SECURITY|ACCESS_DENIED|no-write-up" emrg/sandbox/
-> 0 matches

The only ACE vocabulary in emrg/sandbox/win32/abi.py is GRANT_ACCESS = 1 (:61), REVOKE_ACCESS = 4 (:63) and ACCESS_ALLOWED_ACE_TYPE = 0 (:91); acl.py builds allow ACEs and merges them through SetEntriesInAclW + SetNamedSecurityInfoW; nothing lowers the token's integrity level or writes a mandatory label. Each of the blueprint's three commits adds exactly one of those mechanisms, so the capability-SID intersection is the only confinement here — and the paragraph quoted above says what that leaves open.

GRANT_MASK (abi.py:35) grants DELETE | FILE_DELETE_CHILD inside a root, which is correct and is the counterpart of the missing deny: with only the grant, a delete is authorised from the parent's right as well and no restricting SID co-signs it.

So this is not a port bug in the ordinary sense — the port is faithful to a revision known to be broken. It is the "the change was written, the consumer inventory was not" class this repository has met before, one level out: the blueprint's own later history is a source this port has never re-read. The design document's annotation for this rung ("Windows ACL 照抄移植(无实机 → 标注未验证)") was honest, and this issue is what the annotation was standing in for.

How to pin it (CI can, so "no Windows machine" no longer blocks it)

.github/workflows/test.yml:78 runs pytest on windows-2025 on every PR, and tests/test_bash_v2_win32.py already has the two shapes this needs — test_a_workspace_write_run_inherits_the_capability_and_nothing_outside_it (:809) and test_a_read_only_run_is_refused_inside_the_workspace_too (:843). What is missing is a delete path:

grep -niE "\bdel\b|rmdir|Remove-Item|DeleteFile|unlink" tests/test_bash_v2_win32.py
-> 0 matches

The blueprint's own pin is runner.spec.ts (cmd, .NET, Remove-Item, libuv), so the port has a model to copy rather than a design to invent.

Suggested shape

  1. Port the three commits: the Low mandatory label applied in the same SetNamedSecurityInfoW call that adds the capability ACE, and the inheritable Deny Everyone FILE_DELETE_CHILD scoped with CONTAINER_INHERIT_ACE — 3d5ba3b83f measured that the unscoped deny reaches files, and 0x40 is a member of FILE_ALL_ACCESS, so every GENERIC_ALL open inside a granted root starts failing.
  2. Keep the residual cost visible rather than rediscovering it: a FullControl open of a directory inside a granted root stays denied by design (the deny contains a right the full-access mask contains). Its revoke path must keep the shared Low label while another capability grant stands on the same directory, so two grants on one path stay independently revocable.
  3. Pin per path on the windows-2025 leg: cmd /c del, .NET File.Delete, Remove-Item and a libuv unlink — each (a) outside every granted root under both modes, (b) inside a sibling granted root (another workspace, a sibling session's temp tree).
  4. Carry 3d5ba3b83f's other halves while porting: it also closed leaks on fail-closed paths (the label ACL on both SetEntriesInAclW early exits, the read descriptor when the label build fails, the parsed capability SID when either well-known-SID creation fails) and made the idempotent skip require the exact deny, so a root granted by an earlier build is upgraded on its next provision rather than skipped.

Related

Activity

  1. argszero commented on Sep 23, 2026

    @argszero
    OwnerAuthor

    Independent verification on this tree, plus one measurement about the fix's own provenance pin — from cycle cyc20260924-015321 (read-only tier, so the whole reading is source and grep; nothing executed).

    The absence claim reproduces, coordinates and all

    On HEAD 664ff8b (one commit behind master, #1555):

    grep -rniE 'integrity|mandatory|DENY_ACCESS|SET_ACCESS|LABEL_SECURITY|ACCESS_DENIED|no-write-up' emrg/sandbox/ -> 0
    emrg/sandbox/win32/abi.py:61,63,91 -> GRANT_ACCESS = 1, REVOKE_ACCESS = 4, ACCESS_ALLOWED_ACE_TYPE = 0 (and no DENY ACE type)
    emrg/sandbox/win32/abi.py:35 -> GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE
    grep -niE '\bdel\b|rmdir|Remove-Item|DeleteFile|unlink' tests/test_bash_v2_win32.py -> 0 (of 38 tests)

    So the grant half is present and the deny half is absent, exactly as filed, and both test coordinates you name exist with those names: test_a_workspace_write_run_inherits_the_capability_and_nothing_outside_it and test_a_read_only_run_is_refused_inside_the_workspace_too, each under @needs_windows.

    The revision pin is prose in 9 of the 10 modules, and names the commit in only 2

    This is the half the port will have to carry and no guard can check. Census over emrg/sandbox/win32/:

    init.py commit ddefc45fbc + dsh 0.1.6-alpha.2
    abi.py commit ddefc45fbc + dsh 0.1.6-alpha.2
    acl.py, ffi.py, grants.py, runner.py, sandbox.py, sid.py, token.py dsh 0.1.6-alpha.2 only
    spawn.py neither

    Six of those nine also state what the file is a port of (acl.ts, win32-process/src/ffi.ts, the chain table, ...), and emrg/sandbox/providers/win32.py adds the provider row with line ranges. Nothing reads any of it: there is no constant, no recorded datum, and no check that re-reads the blueprint — so 'this port is faithful to a revision known to be broken' is not detectable from the tree, which is why re-reading the blueprint's own later history was the only way to find the three commits at all.

    Two consequences for whoever ports them:

    1. The landing must update the pin in whichever of those modules it changes, or the port will state a revision it no longer matches — silently, since the pin is prose in a docstring.
    2. That is the same defect class one level out: the port has an inventory of its files, not of its revision. If step 4's 'carry the other halves' is taken, the cheapest carrier for it is a recorded pin a guard can compare against a re-read blueprint — the shape this repo already uses for rules it can mechanise.

    Scope note, because a port must not symmetrise the other two providers

    The parent-directory-right mechanism you quote is Windows-specific, and the tree says so in its own terms. darwin is a path rule — (deny file-write*) with /dev/null as the single literal allow (providers/darwin.py:77-78), i.e. an operation set over paths; linux is a mount namespace — --ro-bind / / then --bind , and providers/linux.py:29 states the refused file is simply absent from the namespace, which is a mechanism no ambient parent right can reach around. So whatever the Windows deny-half becomes, neither neighbour wants a copy of it.

    The one thing I could not settle here, and it is the question a port should ask first: whether seatbelt's file-write* operation set covers unlink (delete) as well as content writes. That decides whether the darwin provider's confinement is complete by coverage or complete by accident of the operation sets. It needs an execution, which this tier refuses — flagged rather than claimed.

  2. argszero commented on Sep 25, 2026

    @argszero
    OwnerAuthor

    The question this issue flagged as needing an execution is answered: file-write* covers removal, and it is now a test rather than a sentence.

    Re-measured this cycle on macOS 26.6.2 (build 25G83), against the production profile builder (seatbelt_profile_args), one spawn per row:

    profile command result
    read-only rm a file inside the workspace refused, Operation not permitted, file survives
    workspace-write rm a file outside the workspace refused, file survives
    read-only mv a file from the workspace to /tmp refused, source survives, destination absent
    read-only chmod 777 / >> append on a file inside refused
    workspace-write, root granted rm the same inside file succeeds, exit 0, stderr empty

    The fourth row is the control that makes the reading a coverage claim rather than a wording claim: the deciding variable is the grant, not the operation — the same binary, the same file, the same syscall, denied where nothing is granted and allowed inside the granted (subpath …).

    So the answer to the question as posed is complete by coverage, not by accident: file-write* is a wildcard over the file-write-* operation set, which includes the directory-entry operations (unlink, and the create half of a move) as well as content writes. That means the darwin row does not need the explicit deny-half the Windows port needs, and why not is now a property of the operation set rather than of the profile's phrasing — the neighbour backends stay unsymmetrised for the reason you already state (darwin is an operation set over paths, linux is a namespace where the file is absent).

    It is pinned, not merely recorded, by tests/test_bash_v2_boundary.py::test_deletion_is_governed_by_the_same_grant_as_a_write — four rows, the production builder, and the move row kept because a boundary that half-completed it (create refused, unlink already done) would lose the file rather than refuse the operation. Two mutation arms were confirmed to land before being believed: dropping (deny file-write*) fails the read-only row, and emptying roots fails the control row.

    The one consequence I would carry into the delete-escape work: on Windows the equivalent hole is real and needs a deny half, because there the ACL grant is per-object at creation and the parent-directory right is what the blueprint's later commits close — so nothing above transfers, but the test shape does (outside-the-grant removal, the half-completed move, and a granted control).

    PR #1614 carries the test. The measurement is a definition, not a change: behaviour is untouched.

    — cycle cyc20260925-152945

  3. argszero commented on Sep 25, 2026

    @argszero
    OwnerAuthor

    The other halves have coordinates: the blueprint's delete-escape fix is three commits past the port's pin, and two of them read as absent from this host.

    Taking this issue's own open item — the port is the blueprint before its delete-escape fix — I re-measured the port against the blueprint's later history, from a local clone (source reads and git log only; this cycle is on macOS, nothing executed).

    The pin, and what the pack gained after it. The port's modules name dsh 0.1.6-alpha.2 = ddefc45fbc as their source revision. Three commits touch packages/sandbox/sandbox-windows-acl/ after that revision, in this order:

    commit subject
    d5ad3baeb5 fix(sandbox): confine Windows deletes with a Low integrity label
    36e632751e fix(sandbox): deny the ambient parent-delete right inside granted roots
    3d5ba3b83f fix(sandbox): scope the delete deny to containers, clearing the review round

    acl.ts moves 154 lines in the first and 156 in the second, with ffi.ts, grant.ts, token.ts and win32-abi.ts alongside, plus the pack's own test file; the third is a review-round correction and adds the note .agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md. So "carry the other halves" is not one constant to copy: the fix has an ABI half, an ACL half and a token half, and one review round on top.

    Two of the three read as absent here, by census.

    • emrg/sandbox/win32/abi.py:61,63,91 — GRANT_ACCESS = 1, REVOKE_ACCESS = 4, ACCESS_ALLOWED_ACE_TYPE = 0, and no denied-ACE type at all. The blueprint's 36e632751e adds exactly that line, with its comment: ACCESS_DENIED_ACE_TYPE = 1 — "shares the allowed ACE's Mask/SID layout". Without it a deny ACE cannot be expressed in the port's own ABI, which is the mechanical reason the deny half has no site rather than merely no caller.
    • grep -rniE 'integrity|mandatory|LABEL_SECURITY|no-write-up' emrg/sandbox/ → 0. The Low-integrity-label half (d5ad3baeb5) has no site in the port either, and no module in the tree mentions the mechanism.

    What this does not say. The blueprint's local clone is at 477b4f4205 (dsh-0.1.7-rc.2) — it has moved twice since the pin, so "the blueprint" today names a tree two releases past what the port was taken from. That is a provenance choice rather than an error, and the pin is the right thing to move only in the landing that changes those modules (this issue's own note). And I have not run any of it: the defect itself is the report's Windows measurement, not mine — what is measured here is that the fix exists upstream and that its two mechanisms have no site in this tree.

    No PR accompanies this comment: with no Windows host the port cannot be verified where it matters, and landing a different ACL shape with a green macOS run is the failure this thread has been documenting.

    — cycle cyc20260925-160939

  4. argszero commented on Sep 25, 2026

    @argszero
    OwnerAuthor

    The fix family is now inside a tag — the re-port has a revision to pin, not just a commit family

    Measured this cycle in the blueprint checkout: the three commits this issue's table names are all ancestors of tag dsh-v0.1.7-rc.2 (477b4f4205, an ancestor of the release merge 787b746b80), not merely of a branch:

    git merge-base --is-ancestor d5ad3baeb5 dsh-v0.1.7-rc.2   # 2026-09-19  confine Windows deletes (Low label)
    git merge-base --is-ancestor 36e632751e dsh-v0.1.7-rc.2   #           deny the ambient parent-delete right
    git merge-base --is-ancestor 3d5ba3b83f dsh-v0.1.7-rc.2   #           scope the deny to containers
    

    Why it matters for this issue rather than being a footnote: the port's docstrings (abi.py:3, acl.py:3-4) name a blueprint commit (ddefc45fbc = dsh-v0.1.6-alpha.2), which is how the absence this issue describes stayed invisible — a commit comment does not tell a later reader that a newer revision exists. A tag does: the natural pin for the re-port is dsh-v0.1.7-rc.2, which carries the delete-escape family and everything the port already copies faithfully. Same prerelease class as the current pin, so the design's pin convention is unchanged.

    Scope of this reading. Source-level only (this host is macOS): ancestry of the three commits in the tag, and that the tag's sandbox package is the same package the port names. No Windows measurement, and nothing here changes the issue's suggested shape.

    Also worth a look while the revision is being chosen: the newest revision adds one precondition the pin did not have — a granted directory must also carry WRITE_OWNER, because the mandatory label lives in the SACL, so a directory whose DACL grants only Modify now fails the grant loudly instead of silently skipping the confinement (README.md, "Granted directories must be caller-owned and grant WRITE_OWNER"). That is a host-facing obligation, not just a port detail.

    — cycle cyc20260926-030230

  5. argszero commented on Sep 27, 2026

    @argszero
    OwnerAuthor

    What the merged referrer answered, and what it leaves — measured by cycle cyc20260926-220539 on master 4ca419f8, 2026-09-27.

    #1614 (4fbc4c05, merged 2026-09-25) references this issue and the link reading reports it as landed work carrying this number. It answered one half, and says so in its own body: "darwin: deletion is covered by file-write* — measured, not assumed (the open question in #1556)" — the macOS provider's ENFORCEMENT = "full" claim is now backed by a measurement that removal is inside file-write*, so a confined process cannot delete what it cannot write.

    The half this issue is about is unchanged. Both of this issue's own probes, re-run just now on 4ca419f8:

    $ grep -rniE "integrity|mandatory|DENY_ACCESS|SET_ACCESS|LABEL_SECURITY|ACCESS_DENIED|no-write-up" emrg/sandbox/
    (0 matches)
    
    $ grep -cE "\bdel\b|rmdir|Remove-Item|DeleteFile|unlink" tests/test_bash_v2_win32.py
    0
    

    Still zero: no Low mandatory label, no inheritable Deny Everyone FILE_DELETE_CHILD, no delete path in the win32 suite. So the port is still faithful to ddefc45fbc — the revision whose delete escape d5ad3baeb5 fixed — and a confined Windows child can still delete via the parent directory's FILE_DELETE_CHILD right.

    This issue therefore stays open, and the referrer's row should read as partially answered rather than landed. The remaining work is exactly the "Suggested shape" above, with the pin the issue names (a delete path in tests/test_bash_v2_win32.py, on the windows-2025 leg).

  6. argszero commented on Sep 28, 2026

    @argszero
    OwnerAuthor

    Closed — host decision, 2026-09-28: defer until a Windows-side report exists. The finding is valid; what is missing is a user on Windows, not evidence.

    Validated before closing (re-measured on 019572f5, all four claims hold):

    • the port names its source revision: emrg/sandbox/win32/abi.py:3-4 — blueprint ddefc45fbc (dsh 0.1.6-alpha.2), i.e. before the blueprint's own delete-escape fix;
    • the deny half is absent entirely: grep -rniE "integrity|mandatory|DENY_ACCESS|LABEL_SECURITY|no-write-up" emrg/sandbox/ → 0 matches; the only ACE vocabulary is GRANT_ACCESS (abi.py:61), REVOKE_ACCESS (:63), ACCESS_ALLOWED_ACE_TYPE (:91), and GRANT_MASK (:35) grants DELETE | FILE_DELETE_CHILD;
    • no delete path is tested: grep -niE "\bdel\b|rmdir|Remove-Item|DeleteFile|unlink" tests/test_bash_v2_win32.py → 0 matches;
    • the leg that could pin it exists: .github/workflows/test.yml:79 runs on windows-2025.

    The risk this leaves open, stated so it is not rediscovered as a surprise: at both confined Windows tiers, a command inside a granted root can delete files outside every granted root (and inside a sibling granted root), because a delete is authorised by the parent directory's FILE_DELETE_CHILD right and no restricting SID has to co-sign it. macOS and Linux are unaffected.

    Why no successor issue: the host's instruction is to wait for a Windows-side report rather than to carry a fix-up issue with no one to verify it. The material a future report needs is preserved here — the three blueprint commits to port (the Low mandatory label applied in the same SetNamedSecurityInfoW call as the capability ACE; the inheritable Deny Everyone FILE_DELETE_CHILD scoped with CONTAINER_INHERIT_ACE; the fail-closed leak halves and the exact-deny idempotence skip), the residual cost that stays by design (FullControl open of a directory inside a granted root), and the blueprint's own pin to copy rather than invent (runner.spec.ts: cmd, .NET, Remove-Item, libuv, each inside and outside a granted root).

    If a Windows host reports it, reopen this issue — the measurement and the port plan are in its body, and nothing has changed under it since.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions