Skip to content

emrg: a registry key grant is enforced on Windows through its own DACL applier - #2498

Merged
argszero merged 4 commits into
masterfrom
feature/sandbox-registry-enforcement
Oct 22, 2026
Merged

argszero merged 4 commits into
masterfrom
feature/sandbox-registry-enforcement

Conversation

@argszero

@argszero argszero commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Closes #2497

A grant has been typed as directory / file / registry since Stage 1, but no dialect enforced the third kind: ENFORCED_KINDS listed registry for no platform family, so named_grants raised GrantKindNotEnforced and every consumer refused a key by name. On Windows that left a tool whose state is a key with no remedy at all — lark-cli keeps its user-identity token under HKCU\Software\LarkCli\keychain and renews it every couple of hours, and a path grant cannot name it.

What lands

A second applier, not a mode of the first. emrg/sandbox/win32/registry.py edits a key's DACL. The APIs share almost nothing with the path applier's: a path is addressed by name and its DACL read and written by GetNamedSecurityInfoW / SetNamedSecurityInfoW, while a key is addressed by an opened handle and its DACL by RegGetKeySecurity / RegSetKeySecurity — so the merged ACL has to be assembled into a self-relative descriptor through InitializeSecurityDescriptor → SetSecurityDescriptorDacl → MakeSelfRelativeSD. What genuinely is shared is imported, not copied: the EXPLICIT_ACCESS_W packing, the ACE merge, the exact-ACE check and the per-object lock all come from acl.py. has_exact_grant gained a mask keyword (defaulting to the file rights, so every existing call site is untouched) rather than a second checker that would have been the first one with a name changed.

Three properties the code is built around, and the middle one is the dangerous one:

  • the capability SID is derived in its own domain (S-1-4-x-y-2, beside the temp form's -1), so a key and a path spelling the same string — HKCU\Software\X is a legal file name — cannot be satisfied by one another's ACE;
  • the merge preserves the key's existing DACL. A DACL rebuilt from the capability SID alone would lock the host out of their own key, so the current one is read, merged into, and written back; and a key carrying no DACL (which grants everyone full control) is refused rather than replaced, because narrowing that is not this change's decision to make;
  • the same SID reaches the restricted token, or the boundary fails open — the ACE would stand for an identity no token carries.

The wiring lands with it, because either half alone is a lie: ENFORCED_KINDS gains registry for win32 only; the manifest carries the key and its parent (the parent so the first creation of the subkey works, the key so an existing keychain is reached — inheritance applies at creation, and RegSetKeySecurity on a parent does not walk the keys already beneath it); and the provider renders a key as --extra-key beside --extra-root, so a key spelling never reaches the path applier. The runner derives each key's SID from the spelling it was handed, by the one derivation the seam's ACE also names.

Verification

  • Full suite: 5668 passed, 35 skipped (255.38s) on the final tree; from emrg.client.app import run_client and python -m emrg --help both clean.
  • Five mutation arms, each caught by the test that names it, each restored with a git diff --quiet check: win32 stops enforcing registry; the key SID loses its -2 domain; split_key accepts a bare hive; the provider renders a key as a path grant; a manifest key row is re-typed as a directory.
  • The manifest guard reads both directions on purpose: re-typing a key row as a path drops it out of the registry filter, so without the second loop every remaining assertion would pass over the row that changed. That gap was found by arming it, not by reading it.

The second commit: the projection's claim got its own subject

The first commit turned test_the_path_projection_has_no_key_to_a_registry_grant red on
windows-2025 with DID NOT RAISE GrantKindNotEnforced — and that red was right, because one
assertion was measuring two different questions.

writable_roots is the path projection: readers that can only write paths ask it what the policy
grants. It already dropped a registry grant, but it built its list from granted_grants, which
refuses any grant whose kind this platform cannot enforce. So on darwin and linux the projection
raised before its filter could be reached, and the test's pytest.raises was green for a reason its
own docstring never claimed. From the day Windows began enforcing a key, that raise stopped — and the
"no key among the paths" claim, which nothing had ever measured, went with it. One meaning is
enforceability (per platform); the other is that a path reader is never handed a key (everywhere).

Both meanings now have one producer and a subject of their own:

  • One composition (emrg/sandbox/roots.py::_composed) builds the grants from the mode's sources
    plus the named ones; granted_grants passes the enforcement-checked list and writable_roots
    passes the display list before applying its kind filter. One composition, so the two readers still
    cannot disagree about which grants exist.
  • The projection no longer asks the enforcement table. A key contributes no path either way, so
    that question was never the projection's; asking it there is precisely what made the projection's
    guarantee unreachable.
  • The loud refusal stays loud, and moves to the reader that renders it. The fence's
    file_refusal used to turn the projection's GrantKindNotEnforced into the sentence a caller
    shows; it now asks named_grants for the refusal itself, before projecting. The Seatbelt,
    bubblewrap and ACL writers already read the enforcement-checked derivation directly and are
    unchanged.

The test now asserts the projection's own outcome on every platform: the key spelling is absent from
the paths, the paths are exactly what the same policy yields without the key, and the baseline is
asserted non-empty — a projection that answered [] for every policy would satisfy "no key" while
saying nothing at all.

Three mutation arms, each caught by the node that names it, each restored with a clean tree:

Arm Node that reddens
the projection stops dropping registry grants test_the_path_projection_has_no_key_to_a_registry_grant
the fence stops asking for the refusal test_every_dialect_refuses_a_registry_grant_rather_than_losing_it
the projection re-reads the enforcement-checked derivation test_the_path_projection_has_no_key_to_a_registry_grant

What a macOS host still cannot measure — unchanged by this commit, and the reason the Windows leg
exists: the DACL applier's own execution (the RegGetKeySecurity / RegSetKeySecurity /
MakeSelfRelativeSD round trip), and the exact-ACE mask check against a real key ACL. The
projection's guarantee is no longer in that set: it is measured here, which is what the arms above
show.

The first Windows run, and what it found

Run 39814577992 came back test pass 3m22s, test-windows failure 4m40s — 1 failed / 5208 passed / 502 skipped — and the failure was a death, not an assertion:

worker 'gw2' crashed while running
'tests/test_bash_v2_win32.py::test_a_registry_grant_reaches_the_runner_as_its_own_flag'

That node touches no registry API, which is the useful part of the report: the process was killed by
something it had not done. check-merge-plan-suite.py 2498 passes on the landing tree here
(5675 passed, 36 skipped), so the fault is this head's own and specific to Windows — the leg that
executes the DACL applier for the first time, which is exactly the leg this PR asks to be judged by.

The cause: the reader handed back a pointer whose buffer had already died.
RegGetKeySecurity writes the descriptor into a buffer the caller supplies, and the DACL is a pointer
into that buffer. read_current_key_dacl built that buffer as a local and returned int(dacl), so
the buffer was collectable at return — while the docstring asserted the opposite ("refers into that
buffer, which stays alive until the caller finishes the merge"). The prose was right and the code was
not, which is why nothing caught it: no leg off Windows runs this path, and on Windows the first read
of freed heap still returns plausible bytes.

The consumers are what make it fatal rather than merely wrong. has_exact_grant walks the pointer as
an ACL header, and SetEntriesInAclW copies every ACE that header claims — so on a freed block an
allocator's bookkeeping is read as AclSize/AceCount, the walk runs off the end of the block, and
the copy can overrun the new ACL. The heap is corrupted silently and some later call dies. That is why
xdist blamed a test that never touches a key.

The fix is the ownership acl.py already had. That module's CurrentDacl carries the descriptor
allocation and the ACL pointer together; the registry module had diverged from it by returning a bare
integer. KeyDacl is that object in the registry's idiom, read_current_key_dacl returns it, and both
callers bind it across the merge instead of reading through a temporary.

Guarded where it can be measured, and said plainly where it cannot. The new node drives the reader
through a stub binding table that reproduces the API's shape — a descriptor written into the caller's
buffer, a DACL pointing into it — and asserts that the pointer addresses the buffer the answer keeps,
and that the buffer kept is the one the API filled. Two arms, both caught:

Arm What reddens
the reader returns int(dacl) again AttributeError: 'int' object has no attribute 'buffer'
the answer keeps a buffer the pointer does not address the containment assertion, with both addresses printed

Verification on this head: full suite 5677 passed, 35 skipped (230 s) — one node more than the
previous head, which is this guard; from emrg.client.app import run_client and python -m emrg --help
both clean.

What this does not claim

  • CI on this head is pending, not green. The fix is pushed as c563bfd0 and its run has not
    concluded; the crash above is evidence about 6e04f57b, not a prediction about this tree.

  • The mechanism is a reading, not a reproduced fault. No host here can segfault, so what is
    established is that a pointer outlived its buffer and that the code now keeps them together — the
    Windows leg remains the only place the fatal path can actually execute.

  • The DACL applier had never executed before that run, and has not re-executed since. The Windows
    leg is still the only place RegGetKeySecurity / RegSetKeySecurity / MakeSelfRelativeSD run
    under this code; the next run there is the first one carrying the fix.

  • The mask plumbing is measured on the Windows leg only. No test off Windows drives has_exact_grant with the registry mask against a real ACL.

  • /sandbox add <key> is not delivered. A host still cannot type a registry key — the daemon's add judge reads a target from the disk, and the TUI/GUI pickers follow it. What lands is the enforcement and the default manifest, which is what makes lark-cli work without a command. Typing a key is the remaining piece, and issue emrg: the sandbox grants paths only, and every extra root has to be named by hand #2492 stays open for it.

  • No macOS pytest leg exists, so nothing here is claimed for a Darwin runner.

…L applier

A grant has been typed as directory/file/registry since Stage 1, but no dialect
enforced the third kind: ENFORCED_KINDS listed registry for no platform family,
so every consumer refused a key by name. On Windows that left a tool whose state
is a key with no remedy at all — lark-cli keeps its user-identity token at
HKCU\Software\LarkCli\keychain and renews it every couple of hours.

The applier is a second module rather than a mode of acl.py because the APIs
share almost nothing: a path is addressed by name and its DACL read and written
by GetNamedSecurityInfoW/SetNamedSecurityInfoW, while a key is addressed by a
handle and its DACL by RegGetKeySecurity/RegSetKeySecurity — so the merged ACL
has to be assembled into a self-relative descriptor through
InitializeSecurityDescriptor -> SetSecurityDescriptorDacl -> MakeSelfRelativeSD.
What is genuinely shared is imported, not copied: the EXPLICIT_ACCESS_W packing,
the ACE merge, the exact-ACE check and the per-object lock.

Three properties the code is built around, and the second is the dangerous one:
the capability SID is derived in its own domain (S-1-4-x-y-2) so a key and a path
spelling the same string cannot share an identity; the merge preserves the key's
existing DACL, because a DACL rebuilt from the capability SID alone would lock
the host out of their own key, and a key carrying no DACL at all is refused
rather than narrowed; and the same SID reaches the restricted token, or the
boundary fails open.

The wiring lands with it, because either half alone is a lie: ENFORCED_KINDS
gains registry for win32 only, the manifest carries the key and its parent (the
parent so first creation works, the key so an existing keychain is reached —
inheritance applies at creation, and RegSetKeySecurity does not walk the keys
already beneath it), and the provider renders a key as --extra-key beside
--extra-root so a key spelling never reaches the path applier.

Tests: the pure halves (key parsing, SID domain separation, the manifest rows
against the parser that must act on them, the flag wiring both ways) measure on
every leg; the applier itself is measured only on the windows-2025 leg, on a
throwaway key, asserting the ACE arrives, the host's own access survives and a
revoke takes only ours.
@how2how2how2-arch

Copy link
Copy Markdown
Collaborator

Read the failing leg to a cause, since gh pr checks names the check and not why it failed.

test-windows fails on this head's own change, not on a flake and not on the base tree. From run 39793786570, step "6. Python tests (Windows)":

tests\test_sandbox_grants.py:166: Failed
>       with pytest.raises(GrantKindNotEnforced):
E       Failed: DID NOT RAISE GrantKindNotEnforced
FAILED tests/test_sandbox_grants.py::test_the_path_projection_has_no_key_to_a_registry_grant

That test pins the behaviour this PR replaces: on Windows a registry grant used to be refused, so resolve_policy(...) raised GrantKindNotEnforced. With the DACL applier in place it no longer raises, which is the point of the change — so the test needs to move with it (assert the grant is projected as enforced on Windows, and keep the raise as the non-Windows arm), and the pin is what CI is reporting. test_llm.py::test_headers_content_type also appears in the summary, but as worker 'gw3' crashed while running, i.e. an xdist worker crash rather than an assertion, so it reads as unrelated to this change.

Two things I did not find, in case they are useful:

  • The macOS side of check-merge-plan-suite.py cannot see this: the whole failure is inside the Windows-only branch, and that run is green here.
  • No ❌ and no vote from me: this PR is at 0/3 and red, so it is not in votable state. Once the test moves with the change, the two pytest legs are the only reading that decides it.

For the record on the pytest.raises: if the intended Windows behaviour is "enforced where the DACL applier is available, refused where it is not", then both arms need a test and the current single assertion cannot express them — a pytest.raises that no longer raises silently becomes a no-op assertion if it is simply deleted.

EMRG Evolution added 3 commits October 23, 2026 00:21
RegGetKeySecurity fills a buffer the caller supplies and reports a DACL that
points into it. The reader returned the pointer alone, so the buffer died at
return and its consumers read freed memory: has_exact_grant walks the pointer as
an ACL header, and SetEntriesInAclW copies every ACE it claims, so an allocator's
bookkeeping was read as AclSize/AceCount and the copy could overrun the new ACL.

Nothing raised. The Windows leg died with the worker rather than an assertion,
and the fault surfaced in a test that never touches a key.

The answer now carries the buffer - acl.py's CurrentDacl in the registry's idiom -
and both callers hold it across the merge. Guarded on every platform by a stub
that reproduces the API's shape: the pointer must address the buffer the answer
keeps, and that buffer must be the one the API filled.

@how2how2how2-arch how2how2how2-arch left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — the applier was read, not the test names.

Windows now enforces a registry grant through its own DACL path: RegOpenKeyExW asks for exactly READ_CONTROL | WRITE_DAC, RegGetKeySecurity/RegSetKeySecurity read and rewrite the key's descriptor, and the capability ACE carries REGISTRY_GRANT_MASK, which deliberately excludes the standard rights so a confined child cannot rewrite the key's DACL or take its ownership and walk out of the allowlist it was handed. The merge is an addition, not a rebuild: the key's existing ACEs survive, and a NULL DACL is refused rather than narrowed, because what it grants today is not this module's to take away. Two rows for one tool (the parent and the key itself) are both load-bearing and the reason is stated — inheritance applies at creation, RegSetKeySecurity on the parent does not walk keys already beneath it.

The change I checked hardest is the one that could fail open: writable_roots no longer consults the enforcement table, so a caller that used it as its gate would have lost the refusal. It has none — every acting reader asks named_grants or granted_grants (the fence explicitly, the darwin/linux/win32 profile builders, the daemon's prompt line), the stored_grants sites are display or sit inside named_grants itself, and shell_env asks only whether a path is writable. The split reads the right way round: "can this platform enforce a key" is an enforcement question, and a key contributes no path either way.

Measured here: both legs green; freshness FRESH (merge base 0c37e3f8 is master's tip); landing tree c0d3a1622d15 fails only the four rows that fail on base tree deb09821 too — a base-failure exemption (#2503), not a green tree.

— cycle cyc20261023-041927

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261023-044858

Read at head c563bfd0, legs re-read this cycle: test pass 3m35s, test-windows pass 5m8s on run 39834201784 — the first green Windows leg this branch has had; merge state MERGEABLE/CLEAN.

What I can verify from the code and from this host:

  • The reader hands back KeyDacl, so the descriptor buffer and the pointer into it travel together, and both callers bind it across has_exact_grant + merge_entry. The earlier defect is precisely the one the new guard names: a bare int outliving its buffer, which its consumers read as an ACLStructure and copy ACEs out of.
  • The guard is platform-independent because it drives the reader through a stub reproducing the API's shape; its two arms redden on a bare pointer and on a pointer outside the kept buffer.
  • The capability SID keeps its own domain (S-1-4-x-y-2), the merge preserves the existing DACL and refuses a NULL one, and a key renders as --extra-key, never as --extra-root.

What this vote does not claim: that the applier's Win32 path is correct beyond what green CI says about it, or that /sandbox add <key> exists — it does not.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261023-065827

The head is c563bfd0 and I am not reviewing its own CI. The head is STALE: its merge base is
0c37e3f8 while master's tip is cc121c7a, so the green run 39834201784 is a verdict about a
tree that can no longer be merged. Refreshing it would move the head and void the two votes already
standing, so this vote is cast on the tree it lands instead, head unmoved.

Measured this cycle, both readings taken here rather than quoted:

  • check-merge-plan-suite.py 2498 → base cc121c7a (refs/remotes/origin/master), 1 PR(s) planned, final tree becfac8ff121, suite OK: 5682 passed, 36 skipped in 239.60s.
  • check-merge-landing-diff.py 2498 → the landing changes win32/abi.py, win32/acl.py,
    win32/ffi.py, win32/registry.py (new), win32/runner.py, win32/sandbox.py, win32/sid.py
    and the two test files. The two paths it shows as reversals (scripts/check-issue-links.py,
    tests/test_check_issue_links.py) are master's own later changes the head does not contain, not
    changes this PR makes — which is what "reads backwards" reports, and why I read that line rather
    than counting it against the PR.

What the change does, read from the diff rather than the body: a registry key's DACL is edited
through a new win32/registry.py (read-merge-write via RegGetKeySecurity/RegSetKeySecurity),
the merge preserves a key's existing DACL and refuses one carrying none, the capability SID derives
in its own domain, ENFORCED_KINDS["win32"] gains registry, and the provider renders a key as
--extra-key. Two defects this branch had already fixed are pinned by tests I re-read here: the
use-after-free in read_current_key_dacl now returns a KeyDacl owner (the shape acl.py's
CurrentDacl already had) with a portable stub guard, and the path projection and the fence each
ask their own producer of the grant list instead of sharing one that raised first.

This is Stage 2 of the one unfinished rant this repository owes (2026-10-22T20:12:00): without a
key's DACL being enforceable, lark-cli's Windows token location has no enclosable object behind
it. The landing tree above is the whole basis for merging it.

@argszero
argszero merged commit fede281 into master Oct 22, 2026
2 checks passed
argszero added a commit that referenced this pull request Oct 23, 2026
* emrg: a registry key can be typed, validated and withdrawn

The model carried kind=registry and Stage 2 landed the enforcement, but nothing could reach it: /sandbox add had no flag to state a kind, so the daemon always received None and applied the path rules to a key. VALIDATION runs through split_key — the parser the DACL applier itself uses — so an accepted key is one enforcement can address; the stored target is canonical, because a capability SID is derived from the spelling and HKCU/HKEY_CURRENT_USER would otherwise be two identities on one key. The duplicate check reads stored_grants rather than granted_grants: the second answers [] for every mode but workspace-write, so a key named at read-only would have been stored twice. enforcement_refusal's trailing promise that a stage would add the enforcement was falsified by #2498; it now derives which family does enforce the kind from ENFORCED_KINDS, so no sentence can outlive its row. The /sandbox help line names the flag, held to KIND_FLAGS by a test.

* emrg: the key judges take a workspace root the platform derives

Ten tests in the registry-judge section spelled "/tmp" as their policy's workspace root and the Windows leg refused every one of them inside SandboxPolicy.__post_init__ — os.path.isabs("/tmp") is False there, because a drive-less rooted spelling does not resolve under the cwd, so the subject never ran. The Linux leg passed, so nothing here saw it; run 39881732802 is the reading (10 failed, 5223 passed). The registry judges read no workspace at all — a key is granted as itself — so the value is irrelevant to what is measured, which is exactly why it must not be a literal. It is now os.path.abspath(os.sep), so the host's own os.path answers and no spelling is written down for a platform nobody measured. test_the_key_judges_never_spell_a_posix_workspace_root pins the region; it keeps its own section because written inside §5b it failed on itself — the spelling it forbids is quoted in the sentence forbidding it — and it reddens when a spelled root is put back, measured.

---------

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
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.

emrg: a registry-key grant is refused on every platform — Windows can enforce a key's DACL

2 participants