Repository navigation
emrg: a registry key grant is enforced on Windows through its own DACL applier - #2498
Conversation
…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.
|
Read the failing leg to a cause, since
That test pins the behaviour this PR replaces: on Windows a registry grant used to be refused, so Two things I did not find, in case they are useful:
For the record on the |
…e/sandbox-registry-enforcement
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
left a comment
There was a problem hiding this comment.
✅ 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
left a comment
There was a problem hiding this comment.
✅ 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 acrosshas_exact_grant+merge_entry. The earlier defect is precisely the one the new guard names: a bareintoutliving its buffer, which its consumers read as anACLStructureand 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
left a comment
There was a problem hiding this comment.
✅ 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 treebecfac8ff121,suite OK: 5682 passed, 36 skipped in 239.60s.check-merge-landing-diff.py 2498→ the landing changeswin32/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.
* 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>
Closes #2497
A grant has been typed as
directory/file/registrysince Stage 1, but no dialect enforced the third kind:ENFORCED_KINDSlistedregistryfor no platform family, sonamed_grantsraisedGrantKindNotEnforcedand every consumer refused a key by name. On Windows that left a tool whose state is a key with no remedy at all —lark-clikeeps its user-identity token underHKCU\Software\LarkCli\keychainand 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.pyedits 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 byGetNamedSecurityInfoW/SetNamedSecurityInfoW, while a key is addressed by an opened handle and its DACL byRegGetKeySecurity/RegSetKeySecurity— so the merged ACL has to be assembled into a self-relative descriptor throughInitializeSecurityDescriptor→SetSecurityDescriptorDacl→MakeSelfRelativeSD. What genuinely is shared is imported, not copied: theEXPLICIT_ACCESS_Wpacking, the ACE merge, the exact-ACE check and the per-object lock all come fromacl.py.has_exact_grantgained amaskkeyword (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:
S-1-4-x-y-2, beside the temp form's-1), so a key and a path spelling the same string —HKCU\Software\Xis a legal file name — cannot be satisfied by one another's ACE;The wiring lands with it, because either half alone is a lie:
ENFORCED_KINDSgainsregistryforwin32only; the manifest carries the key and its parent (the parent so the first creation of the subkey works, the key so an existingkeychainis reached — inheritance applies at creation, andRegSetKeySecurityon a parent does not walk the keys already beneath it); and the provider renders a key as--extra-keybeside--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
from emrg.client.app import run_clientandpython -m emrg --helpboth clean.git diff --quietcheck: win32 stops enforcingregistry; the key SID loses its-2domain;split_keyaccepts a bare hive; the provider renders a key as a path grant; a manifest key row is re-typed as a directory.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_grantred onwindows-2025withDID NOT RAISE GrantKindNotEnforced— and that red was right, because oneassertion was measuring two different questions.
writable_rootsis the path projection: readers that can only write paths ask it what the policygrants. It already dropped a registry grant, but it built its list from
granted_grants, whichrefuses 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.raiseswas green for a reason itsown 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:
emrg/sandbox/roots.py::_composed) builds the grants from the mode's sourcesplus the named ones;
granted_grantspasses the enforcement-checked list andwritable_rootspasses the display list before applying its kind filter. One composition, so the two readers still
cannot disagree about which grants exist.
that question was never the projection's; asking it there is precisely what made the projection's
guarantee unreachable.
file_refusalused to turn the projection'sGrantKindNotEnforcedinto the sentence a callershows; it now asks
named_grantsfor 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" whilesaying nothing at all.
Three mutation arms, each caught by the node that names it, each restored with a clean tree:
test_the_path_projection_has_no_key_to_a_registry_granttest_every_dialect_refuses_a_registry_grant_rather_than_losing_ittest_the_path_projection_has_no_key_to_a_registry_grantWhat 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/MakeSelfRelativeSDround trip), and the exact-ACEmaskcheck against a real key ACL. Theprojection'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
39814577992came backtestpass 3m22s,test-windowsfailure 4m40s —1 failed / 5208 passed / 502 skipped— and the failure was a death, not an assertion: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 2498passes on the landing tree here(
5675 passed, 36 skipped), so the fault is this head's own and specific to Windows — the leg thatexecutes 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.
RegGetKeySecuritywrites the descriptor into a buffer the caller supplies, and the DACL is a pointerinto that buffer.
read_current_key_daclbuilt that buffer as a local and returnedint(dacl), sothe buffer was collectable at
return— while the docstring asserted the opposite ("refers into thatbuffer, 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_grantwalks the pointer asan ACL header, and
SetEntriesInAclWcopies every ACE that header claims — so on a freed block anallocator's bookkeeping is read as
AclSize/AceCount, the walk runs off the end of the block, andthe 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.pyalready had. That module'sCurrentDaclcarries the descriptorallocation and the ACL pointer together; the registry module had diverged from it by returning a bare
integer.
KeyDaclis that object in the registry's idiom,read_current_key_daclreturns it, and bothcallers 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:
int(dacl)againAttributeError: 'int' object has no attribute 'buffer'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_clientandpython -m emrg --helpboth clean.
What this does not claim
CI on this head is pending, not green. The fix is pushed as
c563bfd0and its run has notconcluded; 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/MakeSelfRelativeSDrununder this code; the next run there is the first one carrying the fix.
The
maskplumbing is measured on the Windows leg only. No test off Windows driveshas_exact_grantwith 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 makeslark-cliwork 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.