diff --git a/emrg/sandbox/fence.py b/emrg/sandbox/fence.py index 4e8a4822..2894fb7d 100644 --- a/emrg/sandbox/fence.py +++ b/emrg/sandbox/fence.py @@ -52,7 +52,7 @@ from emrg.sandbox.grants import GrantKindNotEnforced from emrg.sandbox.policy import DANGER_FULL_ACCESS, SandboxPolicy -from emrg.sandbox.roots import canonical_path, writable_roots +from emrg.sandbox.roots import canonical_path, named_grants, writable_roots from emrg.tools.file_policy import protected_paths @@ -91,7 +91,13 @@ def file_refusal(file_path: str, policy: SandboxPolicy) -> str | None: # ``emrg/tools/file_policy.py`` because the legacy scanner reads it too. return f"{policy.mode} sandbox: blocked write to protected daemon file {file_path!r}" try: - roots = writable_roots(policy) + # The enforcement question is asked here rather than left to + # ``writable_roots``, which projects paths and so has no answer for a + # kind that contributes none. Asking it through the projection made it + # refuse an *enforceability* problem, and the two became inseparable the + # moment Windows began enforcing a key (PR #2498): the raise stopped, and + # with it both the refusal and the projection's own guarantee. + named_grants(policy) except GrantKindNotEnforced as refused: # A grant this platform cannot enforce is refused rather than dropped # (issue #2492, requirement 5). The fence answers in text because its @@ -99,6 +105,7 @@ def file_refusal(file_path: str, policy: SandboxPolicy) -> str | None: # same exception propagate instead, so a confined command fails closed # rather than running with a boundary nobody applied. return f"{policy.mode} sandbox: {refused.reason}" + roots = writable_roots(policy) if any(_is_within_root(target, root) for root in roots): return None granted = ( diff --git a/emrg/sandbox/grants.py b/emrg/sandbox/grants.py index 1944b62c..86831a6d 100644 --- a/emrg/sandbox/grants.py +++ b/emrg/sandbox/grants.py @@ -69,15 +69,18 @@ #: The kinds **this platform's dialects implement**, per platform family. #: -#: ``registry`` is deliberately in none of them: the kind is modelled and -#: :func:`enforcement_refusal` names the reason, but the code that applies a DACL -#: to a key does not exist yet (issue #2492 Stage 2). A set rather than a comment -#: because two callers read it — the refusal sentence, and the guard that keeps -#: :data:`DEFAULT_MANIFEST` from claiming a kind its platform cannot honour. +#: ``registry`` is enforced on ``win32`` alone: a key's DACL is read and rewritten +#: through ``RegGetKeySecurity`` / ``RegSetKeySecurity`` +#: (:mod:`emrg.sandbox.win32.registry`). The other three families name no +#: registry API at all, so for them the kind stays modelled and unenforced, and +#: :func:`enforcement_refusal` says so by name (issue #2492 Stage 2). A set +#: rather than a comment because two callers read it — the refusal sentence, and +#: the guard that keeps :data:`DEFAULT_MANIFEST` from claiming a kind its +#: platform cannot honour. ENFORCED_KINDS: dict[str, tuple[str, ...]] = { "darwin": (GRANT_DIRECTORY, GRANT_FILE), "linux": (GRANT_DIRECTORY, GRANT_FILE), - "win32": (GRANT_DIRECTORY, GRANT_FILE), + "win32": (GRANT_DIRECTORY, GRANT_FILE, GRANT_REGISTRY), "other": (GRANT_DIRECTORY, GRANT_FILE), } @@ -90,13 +93,12 @@ #: Two properties this table owes, both asserted by tests rather than trusted: #: #: * **a row's kind is one its platform can enforce** — :data:`ENFORCED_KINDS` is -#: the reader, so the Windows registry keys the rant also names are absent -#: here until Stage 2 can enforce them. A manifest promising a grant the run -#: does not have would be the lying boundary this module refuses elsewhere; -#: * **the table is data, not a code path** — an entry whose directory does not -#: exist on this host is simply absent from the derivation -#: (:func:`default_grants`), so a deployment that has never run ``lark-cli`` -#: is not an error and not a grant. +#: the reader, so a row is only written for a kind that platform's dialect +#: implements. That is what the Windows registry rows below are held to, and +#: why no ``darwin``/``linux`` row names one; +#: * **the table is data, not a code path** — an entry whose object does not exist +#: on this host is simply absent from the derivation (:func:`default_grants`), so +#: a deployment that has never run ``lark-cli`` is not an error and not a grant. #: #: ``%APPDATA%`` is expanded with :func:`os.path.expandvars`, which understands #: the ``%NAME%`` form on Windows only; on POSIX the two darwin/linux rows carry @@ -113,6 +115,16 @@ "win32": ( (GRANT_DIRECTORY, "~/.lark-cli"), (GRANT_DIRECTORY, "%APPDATA%/Microsoft/Protect"), + # Two rows for one tool, and both are load-bearing: the **parent** is what + # lets the confined child create ``keychain`` in the first place (the + # capability ACE is inheritable), while the **key** itself is what reaches + # a ``keychain`` that already exists — inheritance applies when a key is + # created, and ``RegSetKeySecurity`` on the parent does not walk the keys + # already beneath it. A deployment where ``keychain`` is absent loses only + # the second row (:func:`default_grants` is total over a key this host does + # not have) and still gets the first. + (GRANT_REGISTRY, "HKCU\\Software\\LarkCli"), + (GRANT_REGISTRY, "HKCU\\Software\\LarkCli\\keychain"), ), } @@ -292,10 +304,18 @@ def default_grants(platform: str | None = None) -> list[RootGrant]: out: list[RootGrant] = [] for kind, spelling in DEFAULT_MANIFEST.get(family, ()): target = os.path.expandvars(os.path.expanduser(spelling)) - try: - exists = os.path.isdir(target) if kind == GRANT_DIRECTORY else os.path.exists(target) - except OSError: # pragma: no cover - both calls swallow their own errors - exists = False + if kind == GRANT_REGISTRY: + # Imported here rather than at module scope: the registry predicate is + # Windows-only machinery, and this derivation runs on every platform + # for every family. Off Windows it answers False without touching it. + from emrg.sandbox.win32.registry import key_exists # noqa: PLC0415 + + exists = key_exists(target) + else: + try: + exists = os.path.isdir(target) if kind == GRANT_DIRECTORY else os.path.exists(target) + except OSError: # pragma: no cover - both calls swallow their own errors + exists = False if not exists: continue out.append(RootGrant(kind=kind, target=target, source=SOURCE_DEFAULT)) diff --git a/emrg/sandbox/providers/win32.py b/emrg/sandbox/providers/win32.py index 99471558..27adbf2a 100644 --- a/emrg/sandbox/providers/win32.py +++ b/emrg/sandbox/providers/win32.py @@ -44,6 +44,7 @@ from dataclasses import dataclass from emrg.sandbox.contract import Runner, RunnerFailureRule +from emrg.sandbox.grants import GRANT_REGISTRY from emrg.sandbox.policy import SandboxPolicy from emrg.sandbox.roots import canonical_path, named_grants from emrg.sandbox.win32.ffi import Win32Bindings, win32 @@ -387,10 +388,12 @@ def runner_argv(policy: SandboxPolicy) -> list[str]: "--temp", tempfile.gettempdir(), "--mode", policy.mode, ] - extra_roots = _extra_root_spellings(policy) + path_spellings, key_spellings = _extra_grant_spellings(policy) extra_flags: list[str] = [] - for root in extra_roots: + for root in path_spellings: extra_flags += ["--extra-root", root] + for key in key_spellings: + extra_flags += ["--extra-key", key] if policy.session_id is None: return [ *runner_invocation(), @@ -399,7 +402,7 @@ def runner_argv(policy: SandboxPolicy) -> list[str]: "--mode", policy.mode, *extra_flags, ] - capability = STORE.materialize(policy.session_id, workspace, extra_roots=extra_roots) + capability = STORE.materialize(policy.session_id, workspace, extra_roots=path_spellings) return [ *runner_invocation(), "--workspace", workspace, @@ -411,8 +414,8 @@ def runner_argv(policy: SandboxPolicy) -> list[str]: ] -def _extra_root_spellings(policy: SandboxPolicy) -> tuple[str, ...]: - """The enforceable extra grants' paths — the manifest's and the session's. +def _extra_grant_spellings(policy: SandboxPolicy) -> tuple[tuple[str, ...], tuple[str, ...]]: + """The enforceable extra grants' spellings, split by object kind. Read from the shared derivation (:func:`~emrg.sandbox.roots.named_grants`) rather than off ``policy.extra_grants``, because a grant the host never typed @@ -420,16 +423,42 @@ def _extra_root_spellings(policy: SandboxPolicy) -> tuple[str, ...]: manifest is for (issue #2492). Canonicalization and de-duplication happen in that derivation, once, for every dialect. - A **registry** grant never reaches this function: no platform enforces that - kind yet, and the derivation raises - :class:`~emrg.sandbox.grants.GrantKindNotEnforced` rather than handing a key - spelling to a path consumer. On Windows that is the loop Stage 2 closes — the - key needs a DACL of its own, not an ``--extra-root`` flag. + The split is the whole point of the pair: a path grant becomes an + ``--extra-root`` and a key grant becomes an ``--extra-key``, because this + backend applies a DACL to a *path* with ``SetNamedSecurityInfoW`` and to a + *key* with ``RegSetKeySecurity``. Handing a key spelling to the path half + would grant a filesystem object nobody named. + + :param policy: the resolved per-call policy. + :returns: the path spellings and the key spellings. + """ + named = named_grants(policy) + return ( + tuple(grant.target for grant in named if grant.kind != GRANT_REGISTRY), + tuple(grant.target for grant in named if grant.kind == GRANT_REGISTRY), + ) + + +def _extra_root_spellings(policy: SandboxPolicy) -> tuple[str, ...]: + """The enforceable extra grants' paths — the manifest's and the session's. + + :param policy: the resolved per-call policy. + :returns: the canonical path spellings. + """ + return _extra_grant_spellings(policy)[0] + + +def _extra_key_spellings(policy: SandboxPolicy) -> tuple[str, ...]: + """The enforceable extra grants' registry keys — the manifest's and the session's. + + One producer for the key half too, for the reason the path half has one: the + ACE the seam applies and the SID the runner puts in the token are derived from + this same list, so a key cannot end up granted on one side only. :param policy: the resolved per-call policy. - :returns: the canonical spellings. + :returns: the canonical key spellings. """ - return tuple(grant.target for grant in named_grants(policy)) + return _extra_grant_spellings(policy)[1] def release_session(session_id: str) -> None: diff --git a/emrg/sandbox/roots.py b/emrg/sandbox/roots.py index 233e1ade..0105a4bd 100644 --- a/emrg/sandbox/roots.py +++ b/emrg/sandbox/roots.py @@ -14,7 +14,7 @@ import os import tempfile -from typing import TYPE_CHECKING, NamedTuple +from typing import TYPE_CHECKING, Callable, NamedTuple from emrg.sandbox.grants import ( GRANT_DIRECTORY, @@ -192,19 +192,58 @@ def granted_grants(policy: SandboxPolicy) -> list[RootGrant]: """ if policy.mode != "workspace-write": return [] - return _dedupe([*_mode_grants(policy), *named_grants(policy)]) + return _composed(policy, named_grants) + + +def _composed( + policy: SandboxPolicy, named: Callable[[SandboxPolicy], list[RootGrant]] +) -> list[RootGrant]: + """One composition of a policy's grants: the mode's sources, then the named ones. + + The two named-list policies differ in exactly one respect — whether the + enforcement table is consulted (:func:`named_grants` asks it, + :func:`stored_grants` does not) — and both reach this same composition, so + the readers of either cannot disagree about which grants exist: "the shell + can write where ``write`` cannot" needs one derivation, not one call site. + + ``read-only`` (and ``danger-full-access``, which never asks) grant nothing: + the early return is what makes requirement 4 of issue #2492 true by + construction rather than by a check — a default manifest cannot widen a tier + that grants none, because this function never consults one for that tier. + + :param policy: the file-effect policy to derive the grant list from. + :param named: which named-list policy to append — the enforcement-checked + one, or the display one the path projection reads. + :returns: the canonical, deduplicated grants; empty for every mode but + ``workspace-write``. + """ + if policy.mode != "workspace-write": + return [] + return _dedupe([*_mode_grants(policy), *named(policy)]) def writable_roots(policy: SandboxPolicy) -> list[str]: - """The **paths** among :func:`granted_grants` — the projection path-readers use. + """The **paths** a policy grants — the projection path-readers use. - A derived view, not a second allow-list: it is built from - :func:`granted_grants` and can therefore never disagree with it. It exists + A derived view, not a second allow-list: it is built by the same + :func:`_composed` composition :func:`granted_grants` reads, and applies one + filter to it, so it can never disagree with that derivation. It exists because several readers need nothing but paths — the fence's containment test, the confined cache relocation, and the providers' own spellings — and because a registry grant has no path to contribute, so a path-only reader must not be handed one. + **It does not ask the enforcement table**, and that is the point of the + split. Whether this platform can enforce a registry key is a question about + enforcement; a key contributes no path either way. Asking it here is what + once made this projection's own guarantee unmeasurable: while no platform + enforced a key the projection refused before it could be asked, and the day + one did — Windows, PR #2498 — the refusal vanished and so did the "no key + among the paths" claim, leaving a green reading about nothing. The loud + answer to a grant this platform cannot enforce is :func:`named_grants`'s, + and every reader that *acts* on grants reads that one: the profile builders + directly, and the fence by asking for the refusal itself. + ``read-only`` still answers ``[]``, which is the property the host's rule rests on: at a tier that grants nothing, nothing is writable however the session or the manifest is configured. @@ -212,7 +251,11 @@ def writable_roots(policy: SandboxPolicy) -> list[str]: :param policy: the file-effect policy to derive the allow-list from. :returns: the canonical writable paths. """ - return [grant.target for grant in granted_grants(policy) if grant.kind != GRANT_REGISTRY] + return [ + grant.target + for grant in _composed(policy, stored_grants) + if grant.kind != GRANT_REGISTRY + ] #: The shared POSIX temp root — one spelling, named once, read through diff --git a/emrg/sandbox/win32/abi.py b/emrg/sandbox/win32/abi.py index be25b2cb..c25b3d11 100644 --- a/emrg/sandbox/win32/abi.py +++ b/emrg/sandbox/win32/abi.py @@ -96,6 +96,51 @@ #: Maximum SID allocation size in bytes. SECURITY_MAX_SID_SIZE = 68 +# --- registry keys (issue #2492 Stage 2) ----------------------------------- + +#: Predefined ``HKEY_CURRENT_USER`` handle. +HKEY_CURRENT_USER = 0x80000001 +#: Predefined ``HKEY_LOCAL_MACHINE`` handle. +HKEY_LOCAL_MACHINE = 0x80000002 +#: Standard right: read an object's DACL. +READ_CONTROL = 0x00020000 +#: Standard right: replace an object's DACL. +WRITE_DAC = 0x00040000 +#: Key access needed to read a key's DACL and rewrite it — and nothing else. +#: +#: ``RegOpenKeyExW`` wants the rights to be asked for up front, so the applier +#: asks for exactly the two the edit needs: with these, a key whose DACL the +#: caller may not rewrite fails at the open, before any merge is attempted. +KEY_SECURITY_ACCESS = READ_CONTROL | WRITE_DAC +#: Key right: read a key's values. +KEY_QUERY_VALUE = 0x0001 +#: Key right: write a key's values. +KEY_SET_VALUE = 0x0002 +#: Key right: create a subkey. +KEY_CREATE_SUB_KEY = 0x0004 +#: Key right: enumerate a key's subkeys. +KEY_ENUMERATE_SUB_KEYS = 0x0008 +#: Key right: receive change notifications for a key. +KEY_NOTIFY = 0x0010 +#: Capability-SID access mask granting read, value writes and subkey creation. +#: +#: The registry's counterpart of :data:`GRANT_MASK`, and it excludes the standard +#: rights for the same reason: ``WRITE_DAC`` and ``WRITE_OWNER`` would let a +#: confined child rewrite the key's DACL or take its ownership and walk out of the +#: allowlist it was handed. ``READ_CONTROL`` is excluded with them — the mask +#: grants what a tool does to a key's *contents*, never what it does to the key's +#: own security descriptor. +REGISTRY_GRANT_MASK = ( + KEY_QUERY_VALUE | KEY_SET_VALUE | KEY_CREATE_SUB_KEY | KEY_ENUMERATE_SUB_KEYS | KEY_NOTIFY +) +#: ``SECURITY_DESCRIPTOR_REVISION``: the only revision ``InitializeSecurityDescriptor`` accepts. +SECURITY_DESCRIPTOR_REVISION = 1 +#: The bytes an absolute ``SECURITY_DESCRIPTOR`` occupies. +#: +#: Named by the SDK for exactly this use — the buffer an absolute descriptor is +#: built in before ``MakeSelfRelativeSD`` measures the form the API wants. +SECURITY_DESCRIPTOR_MIN_LENGTH = 20 + # --- process creation (Win32 process owner, win32-process/src/abi.ts) ------- #: STARTUPINFO.dwFlags: the std handles in the struct are meaningful. diff --git a/emrg/sandbox/win32/acl.py b/emrg/sandbox/win32/acl.py index 89319be9..d56401ff 100644 --- a/emrg/sandbox/win32/acl.py +++ b/emrg/sandbox/win32/acl.py @@ -278,12 +278,14 @@ def merge_and_apply( throw_last_error(api, "LocalFree", f"{label}({path}) new ACL") -def has_exact_grant(api: Win32Bindings, old_acl: int, sid_ptr: int) -> bool: +def has_exact_grant( + api: Win32Bindings, old_acl: int, sid_ptr: int, mask: int = abi.GRANT_MASK +) -> bool: """Whether the DACL already carries the exact write grant this module adds. Every field is read through bounded offset reads (Allow ACE, ``OI|CI`` - inheritance, :data:`~emrg.sandbox.win32.abi.GRANT_MASK`, the capability - SID) — no pointer arithmetic, no ``memcpy``. The ACE's SID is **inline** + inheritance, the access mask, the capability SID) — no pointer arithmetic, + no ``memcpy``. The ACE's SID is **inline** (embedded after the 4-byte mask), so there is no pointer to follow: it is read byte-wise against the ACE's own size. A malformed header reads as "no exact grant", which sends the caller down the merge path — the path that @@ -292,6 +294,10 @@ def has_exact_grant(api: Win32Bindings, old_acl: int, sid_ptr: int) -> bool: :param api: the binding table. :param old_acl: the current explicit DACL pointer. :param sid_ptr: the capability SID to match. + :param mask: the access mask the grant's ACE carries — the file rights by + default, and :data:`~emrg.sandbox.win32.abi.REGISTRY_GRANT_MASK` for a + registry key. A second checker was the alternative and would have been + this one with one name changed. :returns: whether the exact grant ACE is already present. """ header = ACLStructure.from_address(old_acl) @@ -307,11 +313,11 @@ def has_exact_grant(api: Win32Bindings, old_acl: int, sid_ptr: int) -> bool: ace_size = int(ctypes.c_uint16.from_address(old_acl + offset + 2).value) if ace_size < 8 or offset + ace_size > acl_size: return False # implausible: fall back to the merge path - mask = int(ctypes.c_uint32.from_address(old_acl + offset + 4).value) + ace_mask = int(ctypes.c_uint32.from_address(old_acl + offset + 4).value) exact = ( ace_type == abi.ACCESS_ALLOWED_ACE_TYPE and ace_flags == abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT - and mask == abi.GRANT_MASK + and ace_mask == mask ) if exact: inline = _inline_sid_bytes(old_acl + offset + 8, ace_size - 8) diff --git a/emrg/sandbox/win32/ffi.py b/emrg/sandbox/win32/ffi.py index 6f7d0636..45ab8882 100644 --- a/emrg/sandbox/win32/ffi.py +++ b/emrg/sandbox/win32/ffi.py @@ -353,6 +353,49 @@ def _declare(self) -> None: c.POINTER(c.c_void_p), c.POINTER(c.c_void_p), ] + # -- a registry key's DACL (issue #2492 Stage 2) --------------------- + # + # ``LSTATUS`` is a 32-bit signed ``LONG``, not a ``DWORD``: a negative + # status declared unsigned would read as a huge success code and the + # caller's ``!= ERROR_SUCCESS`` check would still fire, but the code it + # printed would be a number the system's message table has no text for. + + a32.RegOpenKeyExW.restype = wintypes.LONG + a32.RegOpenKeyExW.argtypes = [ + c.c_void_p, wintypes.LPCWSTR, wintypes.DWORD, c.c_int, + c.POINTER(c.c_void_p), + ] + + a32.RegCloseKey.restype = wintypes.LONG + a32.RegCloseKey.argtypes = [c.c_void_p] + + a32.RegGetKeySecurity.restype = wintypes.LONG + a32.RegGetKeySecurity.argtypes = [ + c.c_void_p, wintypes.DWORD, c.c_void_p, c.POINTER(wintypes.DWORD), + ] + + a32.RegSetKeySecurity.restype = wintypes.LONG + a32.RegSetKeySecurity.argtypes = [c.c_void_p, wintypes.DWORD, c.c_void_p] + + a32.GetSecurityDescriptorDacl.restype = wintypes.BOOL + a32.GetSecurityDescriptorDacl.argtypes = [ + c.c_void_p, c.POINTER(wintypes.BOOL), c.POINTER(c.c_void_p), + c.POINTER(wintypes.BOOL), + ] + + a32.InitializeSecurityDescriptor.restype = wintypes.BOOL + a32.InitializeSecurityDescriptor.argtypes = [c.c_void_p, wintypes.DWORD] + + a32.SetSecurityDescriptorDacl.restype = wintypes.BOOL + a32.SetSecurityDescriptorDacl.argtypes = [ + c.c_void_p, wintypes.BOOL, c.c_void_p, wintypes.BOOL, + ] + + a32.MakeSelfRelativeSD.restype = wintypes.BOOL + a32.MakeSelfRelativeSD.argtypes = [ + c.c_void_p, c.c_void_p, c.POINTER(wintypes.DWORD), + ] + # -- the primitive helpers every call site shares ------------------------ def get_last_error(self) -> int: diff --git a/emrg/sandbox/win32/registry.py b/emrg/sandbox/win32/registry.py new file mode 100644 index 00000000..8105df0b --- /dev/null +++ b/emrg/sandbox/win32/registry.py @@ -0,0 +1,471 @@ +"""DACL editing for a registry key: grant and revoke a capability SID on a key. + +The registry counterpart of :mod:`emrg.sandbox.win32.acl`, and deliberately a +second module rather than a second mode of that one: the two edit one kind of +thing — a security descriptor's DACL — through APIs that have almost nothing in +common. A path is addressed by name, and its DACL is read and written by +``GetNamedSecurityInfoW`` / ``SetNamedSecurityInfoW``, which hand back a +self-relative descriptor and take one back. A key is addressed by an opened +handle, and its descriptor is read and written by ``RegGetKeySecurity`` / +``RegSetKeySecurity`` — so the merged ACL has to be assembled into a self-relative +descriptor here, through ``InitializeSecurityDescriptor`` → +``SetSecurityDescriptorDacl`` → ``MakeSelfRelativeSD``. One module with two +bodies would be one function with two of everything; the parts that genuinely +are shared — the ``EXPLICIT_ACCESS_W`` packing, the ACE merge, the exact-ACE +check, the per-object lock — are imported from ``acl.py``, not copied. + +Three contracts, the first two carried over from that module: + +* the ACL pointer returned by ``SetEntriesInAclW`` is its **own** allocation and + must be freed, while a descriptor's DACL points *into* the descriptor and must + not be; +* the exact-ACE check keeps provisioning idempotent: without it every provision + re-merges an identical ACE, which re-propagates it across the key's subkeys; +* **the merge preserves what is already there.** The DACL a key carries is where + the user's own access comes from. The ACE added here is an addition, never a + replacement — a rebuild built from the capability SID alone would lock the host + out of their own key. So the current DACL is read, merged into, and written + back; and a key whose DACL is **absent** (a NULL DACL, which grants everyone + full control) is refused rather than replaced, because narrowing that is not + this module's decision to make. + +The key's spelling is parsed once (:func:`split_key`) and its capability SID is +derived from that same spelling (:func:`~emrg.sandbox.win32.sid.registry_write_sid`) +by both halves of the grant — the ACE applied here and the SID the runner puts in +the restricted token — so the two cannot disagree about which key was granted. +""" + +from __future__ import annotations + +import ctypes +from ctypes import wintypes + +from emrg.sandbox.win32 import abi +from emrg.sandbox.win32.acl import ( + build_explicit_access, + has_exact_grant, + with_path_lock, +) +from emrg.sandbox.win32.ffi import ( + Win32Bindings, + alloc_ptr_slot, + decode_ptr, + is_null_ptr, + local_free, + throw_last_error, + throw_win32, +) + +#: The predefined roots a grant may name, by their accepted spellings. +#: +#: Only the two hives a per-session grant can mean anything under: a grant names +#: what one confined process may write, which is the current user's own state +#: (``HKCU``) or a machine-wide key that is already writable (``HKLM``). ``HKU``, +#: ``HKCR`` and ``HKCC`` are views onto the same two, and accepting a third +#: spelling of one place would let two grants of one key derive two SIDs. +KEY_ROOTS: dict[str, int] = { + "HKCU": abi.HKEY_CURRENT_USER, + "HKEY_CURRENT_USER": abi.HKEY_CURRENT_USER, + "HKLM": abi.HKEY_LOCAL_MACHINE, + "HKEY_LOCAL_MACHINE": abi.HKEY_LOCAL_MACHINE, +} + + +class RegistryKeyError(ValueError): + """A grant's target is not a registry key this module can address.""" + + +class KeyDaclRefused(RuntimeError): + """A key's DACL is not one this module will edit.""" + + +class KeyDacl: + """A key's current DACL pointer and the buffer that owns it. + + ``RegGetKeySecurity`` writes the descriptor into a buffer the caller supplies, + and the DACL is a pointer *into* that buffer — so the two travel together or + the pointer is meaningless. It is meaningless in a way that does not raise: + the pointer's next consumers are :func:`~emrg.sandbox.win32.acl.has_exact_grant`, + which walks it as an ACL header, and ``SetEntriesInAclW``, which copies every + ACE it is told is there. On a collected buffer those read an allocator's + bookkeeping as ``AclSize``/``AceCount``, so the walk runs off the end of a + freed block and the copy can overrun the new ACL: the heap is corrupted and + some later call is the one that dies. + + This is :class:`~emrg.sandbox.win32.acl.CurrentDacl` in the registry's idiom, + and the two files carry one each because nothing else keeps a descriptor + alive: the answer holds the buffer, so a caller that holds the answer holds + the memory its pointer addresses. + """ + + __slots__ = ("_buffer", "pointer") + + def __init__(self, pointer: int, buffer: ctypes.Array) -> None: + self._buffer = buffer + self.pointer = pointer + + @property + def buffer(self) -> ctypes.Array: + """The descriptor buffer :attr:`pointer` addresses. + + Exposed so a test can assert the pointer lies inside it, and for nothing + else: the buffer is owned here and is not the caller's to free or resize. + + :returns: the buffer this answer keeps alive. + """ + return self._buffer + + +def split_key(key: str) -> tuple[int, str]: + """Split a key's full spelling into its predefined root and its subkey path. + + Pure, and measured on every platform: the parsing is the half of this module + that says which key a grant named, so it is the half a POSIX leg can hold to + its rule. + + A **bare hive** is refused. ``HKCU`` alone names the whole user hive, and a + grant that reaches every key the user owns is a widening nobody asked for — + the same reason a path grant names the directory rather than the volume. + + :param key: the full spelling, e.g. ``HKCU\\Software\\LarkCli\\keychain``. + :returns: the predefined ``HKEY`` value and the subkey path. + :raises RegistryKeyError: when the spelling names no key this module can + address, with the reason in the message. + """ + if not isinstance(key, str): + raise RegistryKeyError(f"a registry grant needs a key spelling, not {type(key).__name__}") + spelling = key.strip() + if not spelling: + raise RegistryKeyError("a registry grant needs a key spelling, not an empty string") + head, separator, tail = spelling.partition("\\") + if not separator: + raise RegistryKeyError( + f"registry key {spelling!r} names a whole hive; a grant names the key it needs, " + "e.g. HKCU\\Software\\Vendor\\Component" + ) + root = KEY_ROOTS.get(head.upper()) + if root is None: + raise RegistryKeyError( + f"registry key {spelling!r} starts with {head!r}, which is not one of " + f"{', '.join(sorted(KEY_ROOTS))}" + ) + subkey = tail.strip("\\") + if not subkey: + raise RegistryKeyError( + f"registry key {spelling!r} names a whole hive; a grant names the key it needs, " + "e.g. HKCU\\Software\\Vendor\\Component" + ) + return root, subkey + + +def key_exists(key: str) -> bool: + """Whether this host has the key. + + The registry counterpart of ``os.path.isdir`` in the manifest's totality rule: + a default grant naming a key nobody created is **absent**, never an error, so + a host that has never run the tool that owns the key is not a broken boundary. + + Answers ``False`` off Windows, where ``winreg`` does not exist — which is what + lets the one derivation read every platform's manifest row on every platform. + + :param key: the key's full spelling. + :returns: whether the key is present and readable. + """ + try: + root, subkey = split_key(key) + except RegistryKeyError: + return False + try: + import winreg # noqa: PLC0415 - Windows-only stdlib, imported where it exists + except ImportError: # pragma: no cover - the POSIX legs + return False + hive = winreg.HKEY_CURRENT_USER if root == abi.HKEY_CURRENT_USER else winreg.HKEY_LOCAL_MACHINE + try: + with winreg.OpenKey(hive, subkey, 0, winreg.KEY_READ): + return True + except OSError: + return False + + +def open_key(api: Win32Bindings, key: str) -> int: + """Open a key for the two rights a DACL edit needs, and nothing else. + + Asking for exactly ``READ_CONTROL | WRITE_DAC`` means a key the caller may not + re-DACL fails here, at the open, instead of half way through a merge. + + :param api: the binding table. + :param key: the key's full spelling. + :returns: the key handle. + :raises RegistryKeyError: when the spelling is not one this module addresses. + :raises Win32Error: when the open failed. + """ + root, subkey = split_key(key) + handle_slot = alloc_ptr_slot() + status = int(api.advapi32.RegOpenKeyExW( + ctypes.c_void_p(root), + subkey, + 0, + abi.KEY_SECURITY_ACCESS, + ctypes.byref(handle_slot), + )) + if status != abi.ERROR_SUCCESS: + throw_win32(api, "RegOpenKeyExW", status, key) + handle = decode_ptr(handle_slot) + if is_null_ptr(handle): + throw_win32(api, "RegOpenKeyExW", api.get_last_error(), f"{key}: null key handle") + return int(handle) + + +def close_key(api: Win32Bindings, handle: int, key: str) -> None: + """Close one key handle, reporting a failure. + + :param api: the binding table. + :param handle: the handle :func:`open_key` returned. + :param key: the key's full spelling, for the error message. + :raises Win32Error: when ``RegCloseKey`` reported a failure. + """ + status = int(api.advapi32.RegCloseKey(ctypes.c_void_p(handle))) + if status != abi.ERROR_SUCCESS: + throw_win32(api, "RegCloseKey", status, key) + + +def _close_key_best_effort(api: Win32Bindings, handle: int) -> None: + """Close a handle on a path that is already failing. + + A cleanup failure must not mask the action's own error — the rule + ``acl.py``'s lock states — so this call's status is dropped and only the + original exception travels. + + :param api: the binding table. + :param handle: the handle to close. + """ + try: + api.advapi32.RegCloseKey(ctypes.c_void_p(handle)) + except BaseException: # noqa: BLE001 - the caller is already raising + pass + + +def read_current_key_dacl(api: Win32Bindings, key: str, handle: int) -> KeyDacl | None: + """Read the key's current explicit DACL, with the buffer that owns it. + + The descriptor is read into a buffer this module owns, so nothing here is + freed with ``LocalFree``: the DACL pointer refers *into* that buffer, and the + two are therefore handed back together — as a :class:`KeyDacl`, never as a + bare pointer, because a pointer alone outlives nothing and its consumers + would read freed memory instead of raising. + + :param api: the binding table. + :param key: the key's full spelling, for error messages. + :param handle: the open key handle. + :returns: the DACL pointer with the buffer that owns it, or ``None`` when the + key carries no DACL. + :raises Win32Error: when a call failed. + :raises KeyDaclRefused: when the key carries a NULL DACL, which grants + everyone full control; replacing it would narrow access the host has + today, and that is not this module's decision. + """ + size = wintypes.DWORD(0) + probe = int(api.advapi32.RegGetKeySecurity( + ctypes.c_void_p(handle), abi.DACL_SECURITY_INFORMATION, None, ctypes.byref(size), + )) + if probe != abi.ERROR_INSUFFICIENT_BUFFER: + throw_win32(api, "RegGetKeySecurity", probe, f"{key} (size probe)") + if size.value == 0: # pragma: no cover - the API never answers zero here + throw_win32( + api, "RegGetKeySecurity", abi.ERROR_INSUFFICIENT_BUFFER, f"{key}: empty descriptor" + ) + buffer = ctypes.create_string_buffer(size.value) + read = int(api.advapi32.RegGetKeySecurity( + ctypes.c_void_p(handle), + abi.DACL_SECURITY_INFORMATION, + ctypes.cast(buffer, ctypes.c_void_p), + ctypes.byref(size), + )) + if read != abi.ERROR_SUCCESS: + throw_win32(api, "RegGetKeySecurity", read, key) + present = wintypes.BOOL() + dacl_slot = alloc_ptr_slot() + defaulted = wintypes.BOOL() + if int(api.advapi32.GetSecurityDescriptorDacl( + ctypes.cast(buffer, ctypes.c_void_p), + ctypes.byref(present), + ctypes.byref(dacl_slot), + ctypes.byref(defaulted), + )) == 0: + throw_last_error(api, "GetSecurityDescriptorDacl", key) + if not present.value: + raise KeyDaclRefused( + f"registry key {key} carries no DACL, which grants every principal full control; " + "writing one here would narrow access this module did not grant and cannot restore" + ) + dacl = decode_ptr(dacl_slot) + if is_null_ptr(dacl): + return None + return KeyDacl(int(dacl), buffer) + + +def merge_entry(api: Win32Bindings, entry, old_acl: int | None, label: str) -> int: + """Merge one ``EXPLICIT_ACCESS_W`` into a DACL and return the new ACL. + + The registry half of ``acl.py``'s ``merge_and_apply``: ``old_acl`` ``None`` + builds a DACL from scratch, and the result is the caller's to free. + + :param api: the binding table. + :param entry: the packed entry to merge. + :param old_acl: the current DACL pointer, or ``None``. + :param label: the caller's name for error details. + :returns: the merged ACL pointer. + :raises Win32Error: when the merge failed. + """ + new_acl_slot = alloc_ptr_slot() + merged = int(api.advapi32.SetEntriesInAclW( + 1, + ctypes.byref(entry), + ctypes.c_void_p(old_acl or 0), + ctypes.byref(new_acl_slot), + )) + if merged != abi.ERROR_SUCCESS: + throw_win32(api, "SetEntriesInAclW", merged, label) + new_acl = decode_ptr(new_acl_slot) + if is_null_ptr(new_acl): + throw_win32(api, "SetEntriesInAclW", api.get_last_error(), f"{label}: null new ACL") + return int(new_acl) + + +def apply_dacl(api: Win32Bindings, key: str, handle: int, new_acl: int) -> None: + """Install ``new_acl`` as the key's DACL. + + ``RegSetKeySecurity`` takes a **self-relative** descriptor, while an ACL is + installed on an *absolute* one — so the merged ACL is put into a fresh + absolute descriptor, and ``MakeSelfRelativeSD`` produces the form the API + wants. Building a fresh descriptor rather than editing the one read earlier is + what keeps this call honest: the only field carried across is the DACL this + module just merged, and the previous descriptor's own memory is never mutated. + + :param api: the binding table. + :param key: the key's full spelling, for error messages. + :param handle: the open key handle. + :param new_acl: the merged ACL pointer. + :raises Win32Error: when any call failed. + """ + descriptor = ctypes.create_string_buffer(abi.SECURITY_DESCRIPTOR_MIN_LENGTH) + absolute = ctypes.cast(descriptor, ctypes.c_void_p) + if int(api.advapi32.InitializeSecurityDescriptor( + absolute, abi.SECURITY_DESCRIPTOR_REVISION + )) == 0: + throw_last_error(api, "InitializeSecurityDescriptor", key) + if int(api.advapi32.SetSecurityDescriptorDacl( + absolute, True, ctypes.c_void_p(new_acl), False + )) == 0: + throw_last_error(api, "SetSecurityDescriptorDacl", key) + + # The measuring call fails on purpose: a NULL buffer is answered with + # ERROR_INSUFFICIENT_BUFFER and the size the relative form needs. + size = wintypes.DWORD(0) + if int(api.advapi32.MakeSelfRelativeSD(absolute, None, ctypes.byref(size))) == 0: + code = api.get_last_error() + if code != abi.ERROR_INSUFFICIENT_BUFFER: + throw_win32(api, "MakeSelfRelativeSD", code, f"{key} (size probe)") + if size.value == 0: # pragma: no cover - the API never answers zero here + throw_win32( + api, "MakeSelfRelativeSD", abi.ERROR_INSUFFICIENT_BUFFER, f"{key}: empty measurement" + ) + relative = ctypes.create_string_buffer(size.value) + if int(api.advapi32.MakeSelfRelativeSD( + absolute, ctypes.cast(relative, ctypes.c_void_p), ctypes.byref(size) + )) == 0: + throw_last_error(api, "MakeSelfRelativeSD", key) + + status = int(api.advapi32.RegSetKeySecurity( + ctypes.c_void_p(handle), abi.DACL_SECURITY_INFORMATION, ctypes.cast(relative, ctypes.c_void_p) + )) + if status != abi.ERROR_SUCCESS: + throw_win32(api, "RegSetKeySecurity", status, key) + + +def _free_new_acl(api: Win32Bindings, new_acl: int, label: str) -> None: + """Free the ACL ``SetEntriesInAclW`` allocated. + + :param api: the binding table. + :param new_acl: the ACL pointer. + :param label: the caller's name for error details. + :raises Win32Error: when ``LocalFree`` reported a failure. + """ + freed = local_free(api, new_acl) + if not is_null_ptr(freed): + throw_last_error(api, "LocalFree", f"{label} new ACL") + + +def grant_key_write(api: Win32Bindings, key: str, sid_ptr: int) -> None: + """Grant :data:`~emrg.sandbox.win32.abi.REGISTRY_GRANT_MASK` to a capability SID on a key. + + Idempotent: a key whose DACL already carries the exact ACE (one surviving from + an earlier provision) skips the write entirely — otherwise every provision + re-propagates an identical inheritable ACE across the key's subkeys. + Otherwise read-merge-write, so the key's pre-existing ACEs survive. Runs under + the per-object lock, keyed by the key's own spelling. + + :param api: the binding table. + :param key: the key whose DACL gains the grant. + :param sid_ptr: the capability SID the ACE names. + :raises RegistryKeyError: when the spelling is not one this module addresses. + :raises KeyDaclRefused: when the key carries a NULL DACL. + :raises Win32Error: when any call failed. + """ + with with_path_lock(api, key): + handle = open_key(api, key) + try: + # ``dacl`` is bound, not just read: its buffer owns the pointer that + # ``has_exact_grant`` walks and ``merge_entry`` copies ACEs out of, + # so letting it go out of scope here would free the memory under both. + dacl = read_current_key_dacl(api, key, handle) + old_acl = dacl.pointer if dacl is not None else None + if old_acl is not None and has_exact_grant( + api, old_acl, sid_ptr, mask=abi.REGISTRY_GRANT_MASK + ): + close_key(api, handle, key) + return + entry = build_explicit_access(sid_ptr, abi.GRANT_ACCESS, abi.REGISTRY_GRANT_MASK) + new_acl = merge_entry(api, entry, old_acl, f"grantKeyWrite({key})") + except BaseException: + _close_key_best_effort(api, handle) + raise + try: + apply_dacl(api, key, handle, new_acl) + finally: + _free_new_acl(api, new_acl, f"grantKeyWrite({key})") + close_key(api, handle, key) + + +def revoke_key_write(api: Win32Bindings, key: str, sid_ptr: int) -> bool: + """Remove every ACE naming a capability SID from a key's DACL. + + Other entries are preserved: the merge is a ``REVOKE_ACCESS`` of one trustee. + + :param api: the binding table. + :param key: the key whose DACL loses the capability SID's ACEs. + :param sid_ptr: the capability SID whose ACEs are removed. + :returns: whether a removal was attempted (``False`` when the key carries no + explicit DACL — the NULL-DACL case is refused, not treated as empty). + :raises RegistryKeyError: when the spelling is not one this module addresses. + :raises KeyDaclRefused: when the key carries a NULL DACL. + :raises Win32Error: when any call failed. + """ + with with_path_lock(api, key): + handle = open_key(api, key) + try: + dacl = read_current_key_dacl(api, key, handle) + if dacl is None: + close_key(api, handle, key) + return False + entry = build_explicit_access(sid_ptr, abi.REVOKE_ACCESS, 0) + new_acl = merge_entry(api, entry, dacl.pointer, f"revokeKeyWrite({key})") + except BaseException: + _close_key_best_effort(api, handle) + raise + try: + apply_dacl(api, key, handle, new_acl) + finally: + _free_new_acl(api, new_acl, f"revokeKeyWrite({key})") + close_key(api, handle, key) + return True diff --git a/emrg/sandbox/win32/runner.py b/emrg/sandbox/win32/runner.py index 5b78b948..e99bba93 100644 --- a/emrg/sandbox/win32/runner.py +++ b/emrg/sandbox/win32/runner.py @@ -14,7 +14,7 @@ python -m emrg.sandbox.win32.runner --workspace --temp --mode [--write-sid --temp-write-sid ] - [--extra-root ]… -- + [--extra-root ]… [--extra-key ]… -- ``--extra-root`` carries the host-named writable roots of the policy (rant ``2026-10-09T09:43:39`` §7). It is repeatable, may name a directory or a single @@ -23,6 +23,14 @@ token is built with cannot name two different paths. Under ``read-only`` the flag is refused outright — that tier grants no root at all. +``--extra-key`` is the same contract for a **registry key** (issue #2492 Stage +2): repeatable, never carrying a SID, and each key's capability SID derived here +from the spelling this process was handed, by the one derivation the seam's ACE +also names. It is a flag of its own rather than a spelling of ``--extra-root`` +because a key's DACL is edited through a different API on a different object +type, and a path consumer handed a key spelling would grant something else +entirely. ``read-only`` refuses it for the same reason it refuses a root. + ``--write-sid`` + ``--temp-write-sid`` are the seam's grant contract: the **caller** has already materialized distinct workspace and private-temp ACEs and owns their revocation, so the runner neither grants nor revokes them @@ -59,9 +67,11 @@ from dataclasses import dataclass from emrg.sandbox.win32.ffi import Win32Bindings, set_environment_variable, win32 +from emrg.sandbox.win32.registry import key_exists from emrg.sandbox.win32.sandbox import AclSandbox from emrg.sandbox.win32.sid import ( assert_temp_root_outside_workspace, + registry_write_sid, root_write_sid, temp_write_sid, workspace_write_sid, @@ -137,6 +147,7 @@ class ParsedArgs: write_sid: str | None temp_write_sid: str | None extra_roots: list[str] + extra_keys: list[str] command: str args: list[str] @@ -159,6 +170,7 @@ def parse_args(raw: list[str]) -> ParsedArgs: write_sid: str | None = None temp_write_sid_value: str | None = None extra_roots: list[str] = [] + extra_keys: list[str] = [] index = 0 while index < len(raw): token = raw[index] @@ -182,6 +194,8 @@ def parse_args(raw: list[str]) -> ParsedArgs: temp_write_sid_value = value elif token == "--extra-root": extra_roots.append(value) + elif token == "--extra-key": + extra_keys.append(value) else: fail(f"unknown argument: {token}") if workspace is None: @@ -200,6 +214,7 @@ def parse_args(raw: list[str]) -> ParsedArgs: write_sid=write_sid, temp_write_sid=temp_write_sid_value, extra_roots=extra_roots, + extra_keys=extra_keys, command=argv[0], args=list(argv[1:]), ) @@ -301,6 +316,9 @@ def _build_sandbox(parsed: ParsedArgs) -> tuple[AclSandbox, str | None]: # too rather than only the seam's: an argv carrying an extra root under # read-only is a caller that believes it granted something. fail("read-only does not accept --extra-root") + if parsed.mode == "read-only" and parsed.extra_keys: + # The same belief, one object kind over. + fail("read-only does not accept --extra-key") if parsed.mode == "workspace-write" and (parsed.write_sid is None) != (parsed.temp_write_sid is None): fail("workspace-write requires --write-sid and --temp-write-sid together") assert_temp_root_outside_workspace(parsed.workspace, parsed.temp) @@ -316,6 +334,17 @@ def _build_sandbox(parsed: ParsedArgs) -> tuple[AclSandbox, str | None]: fail(f"--extra-root does not exist: {root}") extra_grants.append((root, root_write_sid(root))) + # The same construction one object kind over: each key's SID is derived here + # from the spelling this process was handed, by the one derivation the seam's + # ACE also names. A key is required to exist for the same reason a root is — + # there is no DACL to merge into on a key that is not there, and inventing one + # would mean creating a key as a side effect of confining a command. + extra_key_grants: list[tuple[str, str]] = [] + for key in parsed.extra_keys: + if not key_exists(key): + fail(f"--extra-key does not exist: {key}") + extra_key_grants.append((key, registry_write_sid(key))) + write_sid: str | None = None private_temp: str | None = None private_temp_sid: str | None = None @@ -340,6 +369,7 @@ def _build_sandbox(parsed: ParsedArgs) -> tuple[AclSandbox, str | None]: write_sid=write_sid, temp_write_sid=private_temp_sid, extra_grants=extra_grants, + extra_key_grants=extra_key_grants, manage_dacls=not seam_managed, ) return sandbox, owned_temp diff --git a/emrg/sandbox/win32/sandbox.py b/emrg/sandbox/win32/sandbox.py index e549d0b0..9a8a789a 100644 --- a/emrg/sandbox/win32/sandbox.py +++ b/emrg/sandbox/win32/sandbox.py @@ -34,6 +34,7 @@ throw_last_error, win32, ) +from emrg.sandbox.win32.registry import grant_key_write, split_key from emrg.sandbox.win32.sid import assert_private_temp_disjoint from emrg.sandbox.win32.spawn import RestrictedChild, spawn_restricted from emrg.sandbox.win32.token import ( @@ -107,6 +108,16 @@ class AclSandbox: as well as a directory: Windows ACLs are per-object, and the single-file case is the one half of item 7 the Windows leg has not yet measured (the seam's ``GrantStore`` word is the standing-ACE cache for both). + :param extra_key_grants: the host-named extra writable **registry keys**, each + paired with the capability SID derived from its own spelling (issue #2492 + Stage 2). Same one-SID-per-object rule as an extra root, and the same + standing lifetime, for the same reason: the SID is a pure function of the + spelling, so the ACE is a cache and the *policy* is what grants the key per + call — a call that stops passing ``--extra-key`` builds a token without that + SID and the write is refused again, with no DACL teardown on a hot path. + A key carries no existence precondition here: the caller + (``runner.py``) has already required it, and a key is not something this + class may create. :param manage_dacls: whether this instance owns its DACL grants. ``False`` means the caller (the seam's grant store) already materialized them, so ``init``/``dispose`` neither grant nor revoke. @@ -121,6 +132,7 @@ def __init__( write_sid: str | None = None, temp_write_sid: str | None = None, extra_grants: "list[tuple[str, str]] | tuple[tuple[str, str], ...]" = (), + extra_key_grants: "list[tuple[str, str]] | tuple[tuple[str, str], ...]" = (), manage_dacls: bool = True, ) -> None: self.mode = mode @@ -148,13 +160,27 @@ def __init__( resolved_extras.append((absolute, sid)) self.extra_grants = resolved_extras + # A key is validated by its spelling, not by the disk: there is no + # ``os.path.exists`` for it, and creating one here would be a confinement + # mechanism writing the host's registry. ``split_key`` refuses a bare hive + # and an unknown root, which is the half of the check this class owns; the + # key's presence is the runner's (``--extra-key does not exist``). + resolved_keys: list[tuple[str, str]] = [] + for key, sid in extra_key_grants: + split_key(key) + resolved_keys.append((key, sid)) + self.extra_key_grants = resolved_keys + if mode == "workspace-write" and write_sid is None: raise ValueError( "AclSandbox workspace-write requires a write SID — derive it from the workspace " "via workspace_write_sid()" ) if mode == "read-only" and ( - write_sid is not None or temp_write_sid is not None or resolved_extras + write_sid is not None + or temp_write_sid is not None + or resolved_extras + or resolved_keys ): raise ValueError("AclSandbox read-only does not accept write SIDs") if write_sid is not None and temp_write_sid == write_sid: @@ -171,6 +197,17 @@ def __init__( f"{sid} ({path})" ) named.add(sid) + # A key's SID is derived in its own domain, so a collision with a path + # should be impossible — which is exactly why it is checked: an identity + # that "cannot" collide and silently does would widen one grant into + # another, and the check is one comparison. + for key, sid in resolved_keys: + if sid in named: + raise ValueError( + f"AclSandbox extra key SID must be distinct from every other write SID: " + f"{sid} ({key})" + ) + named.add(sid) if temp_dir is None and temp_write_sid is not None: raise ValueError("AclSandbox temp write SID requires a temp directory") if mode == "workspace-write" and temp_dir is not None and temp_write_sid is None: @@ -199,6 +236,9 @@ def __init__( #: token points at them for the child's whole life — so they are freed on #: the same two exits and by the same helper. self._extra_sid_ptrs: list[tuple[str, int]] = [] + #: The host-named keys' parsed SIDs, each beside the key it was derived + #: from. The same kind of OS allocation on the same two exits. + self._extra_key_sid_ptrs: list[tuple[str, int]] = [] @property def temp_dir(self) -> str | None: @@ -234,6 +274,8 @@ def init(self, api: Win32Bindings | None = None) -> None: ) for path, sid in self.extra_grants: self._extra_sid_ptrs.append((path, _parse_sid(bindings, sid))) + for key, sid in self.extra_key_grants: + self._extra_key_sid_ptrs.append((key, _parse_sid(bindings, sid))) if self._temp_dir is not None: if not os.path.isdir(self._temp_dir): raise ValueError( @@ -260,6 +302,13 @@ def init(self, api: Win32Bindings | None = None) -> None: # propagation on a hot path and buy nothing. for path, sid_ptr in self._extra_sid_ptrs: grant_write(bindings, path, sid_ptr) + # A key's ACE is applied through a different API on a different + # object type (``RegSetKeySecurity``) and is standing on the same + # terms as the roots': the SID is a pure function of the spelling, + # so the ACE is a cache and the policy withdraws the grant by not + # passing the key on the next call. + for key, sid_ptr in self._extra_key_sid_ptrs: + grant_key_write(bindings, key, sid_ptr) if self._temp_dir is not None and self._temp_write_sid_ptr is not None: # Recorded before the grant: grant_write can throw after a # successful apply, and the fail-closed path must still @@ -277,6 +326,7 @@ def init(self, api: Win32Bindings | None = None) -> None: self._write_sid_ptr, self._temp_write_sid_ptr, *(sid_ptr for _, sid_ptr in self._extra_sid_ptrs), + *(sid_ptr for _, sid_ptr in self._extra_key_sid_ptrs), ) if sid is not None ] @@ -388,9 +438,12 @@ def dispose(self) -> None: _free_sid_best_effort(api, self._temp_write_sid_ptr, "temp write SID", failures) for path, sid_ptr in self._extra_sid_ptrs: _free_sid_best_effort(api, sid_ptr, f"extra root write SID ({path})", failures) + for key, sid_ptr in self._extra_key_sid_ptrs: + _free_sid_best_effort(api, sid_ptr, f"extra key write SID ({key})", failures) self._write_sid_ptr = None self._temp_write_sid_ptr = None self._extra_sid_ptrs = [] + self._extra_key_sid_ptrs = [] if self._token is not None: try: if int(api.kernel32.CloseHandle(ctypes.c_void_p(self._token))) == 0: diff --git a/emrg/sandbox/win32/sid.py b/emrg/sandbox/win32/sid.py index 4c645225..a2bd2642 100644 --- a/emrg/sandbox/win32/sid.py +++ b/emrg/sandbox/win32/sid.py @@ -92,6 +92,30 @@ def temp_write_sid(temp_dir: str) -> str: return f"S-1-4-{first}-{second}-1" +def registry_write_sid(key: str) -> str: + """Derive one registry key's capability SID (``S-1-4-x-y-2``). + + Domain-separated from the path identities by a fixed third sub-authority, the + way :func:`temp_write_sid` is: a registry key and a file path can spell the + same string, and one derivation would then hand one object the other's + identity. Two sub-authorities now mean "a filesystem path", ``-1`` a private + temp directory, ``-2`` a registry key. + + The spelling is lowercased because the registry namespace is + case-insensitive: ``HKCU\\Software\\LarkCli`` and ``HKCU\\software\\larkcli`` + are one key, so they must derive one SID. Unlike a path there is no + ``realpath`` to converge the spellings — the canonicalization *is* this + lowercasing, and both halves of the grant (the ACE the seam applies and the + SID the runner puts in the token) call this one function, so they cannot + disagree about which key was granted. + + :param key: the key's full spelling, e.g. ``HKCU\\Software\\LarkCli``. + :returns: the SDDL string form. + """ + first, second = _derive(b"registry\0" + key.lower().encode("utf-8")) + return f"S-1-4-{first}-{second}-2" + + def contains_directory(root: str, candidate: str) -> bool: """Whether ``root`` is the same canonical directory as ``candidate`` or contains it. diff --git a/tests/test_bash_v2_win32.py b/tests/test_bash_v2_win32.py index a98ff4d3..a28d34ed 100644 --- a/tests/test_bash_v2_win32.py +++ b/tests/test_bash_v2_win32.py @@ -26,12 +26,19 @@ import pytest -from emrg.sandbox.grants import RootGrant +from emrg.sandbox import grants as grants_mod +from emrg.sandbox.grants import GRANT_DIRECTORY, GRANT_REGISTRY, SOURCE_SESSION, RootGrant from emrg.sandbox.policy import SandboxPolicy from emrg.sandbox.providers import win32 as provider from emrg.sandbox.roots import canonical_path -from emrg.sandbox.win32 import ffi +from emrg.sandbox.win32 import abi, ffi from emrg.sandbox.win32.ffi import SIDAndAttributes +from emrg.sandbox.win32.registry import ( + RegistryKeyError, + key_exists, + read_current_key_dacl, + split_key, +) from emrg.sandbox.win32.runner import ( RUNNER_FAILURE_EXIT, RUNNER_SIGNATURE, @@ -52,6 +59,7 @@ assert_private_temp_disjoint, assert_temp_root_outside_workspace, contains_directory, + registry_write_sid, root_write_sid, temp_write_sid, workspace_write_sid, @@ -62,6 +70,7 @@ #: domain-separating ``-1``. WORKSPACE_SID_SHAPE = re.compile(r"^S-1-4-(\d+)-(\d+)$") TEMP_SID_SHAPE = re.compile(r"^S-1-4-(\d+)-(\d+)-1$") +REGISTRY_SID_SHAPE = re.compile(r"^S-1-4-(\d+)-(\d+)-2$") #: The sub-authority ceiling the derivation reduces into (30-bit). SUB_AUTHORITY_CEILING = 2**30 - 1 @@ -2282,3 +2291,283 @@ def test_init_owns_every_sid_the_restricted_token_is_pointed_at(tmp_path, monkey assert len(api.kernel32.closed) == 2, "both the current and the restricted token are closed" + + +# ── a registry key's capability identity (issue #2492 Stage 2) ──────────── + + +def test_the_registry_sid_is_its_own_domain_so_a_key_and_a_path_never_collide(): + """A key and a path spelling the same string must not share one identity. + + ``HKCU\\Software\\X`` is a perfectly legal file name, so one derivation for + both would let a key's ACE answer a path's grant — and this stack fails open + when the token carries a SID no ACE names, or an ACE stands for an object the + token never asked for. The third sub-authority is fixed at ``-2`` beside the + temp form's ``-1``, and the seed is domain-separated as well. + """ + spelling = "HKCU\\Software\\LarkCli" + sid = registry_write_sid(spelling) + + assert REGISTRY_SID_SHAPE.match(sid), sid + assert sid != workspace_write_sid(spelling) + assert sid != temp_write_sid(spelling) + assert registry_write_sid(spelling) == registry_write_sid(spelling.lower()), ( + "the registry namespace is case-insensitive: two spellings of one key are one key, " + "so they must derive one identity" + ) + assert registry_write_sid(spelling) != registry_write_sid(spelling + "\\keychain") + + +def test_split_key_reads_both_roots_and_refuses_what_it_cannot_address(): + """The half of the applier every platform can measure, on its own terms. + + A bare hive is refused because it names every key the user owns — a widening + nobody asked for, and the registry counterpart of granting a volume instead of + a directory. An unknown root is refused because it names nothing this module + can open. + """ + assert split_key("HKCU\\Software\\X") == (abi.HKEY_CURRENT_USER, "Software\\X") + assert split_key("hkey_current_user\\Software\\X") == ( + abi.HKEY_CURRENT_USER, + "Software\\X", + ) + assert split_key("HKLM\\SOFTWARE\\X") == (abi.HKEY_LOCAL_MACHINE, "SOFTWARE\\X") + assert split_key("HKEY_LOCAL_MACHINE\\SOFTWARE\\X")[0] == abi.HKEY_LOCAL_MACHINE + assert split_key("HKCU\\Software\\X\\") == (abi.HKEY_CURRENT_USER, "Software\\X"), ( + "a trailing separator is a spelling, not a different key" + ) + + for refused in ("HKCU", "HKCU\\", "", " ", "HKCR\\X", "Software\\X", "HKCU/X", None): + with pytest.raises(RegistryKeyError): + split_key(refused) + + +def test_key_exists_answers_false_where_there_is_no_registry(): + """The predicate that keeps the manifest total, measured on a host without one. + + ``winreg`` does not exist off Windows, so the answer is ``False`` — which is + exactly what lets one derivation read every platform's manifest row on every + platform instead of inventing an environment to avoid reading it. + """ + if sys.platform == "win32": + pytest.skip("on Windows the answer is this runner's own registry, not a constant") + assert key_exists("HKCU\\Software\\LarkCli") is False + assert key_exists("not-a-key-at-all") is False + + +class _FakeRegistryAclApi: + """The three calls ``read_current_key_dacl`` makes, over a buffer it fills. + + ``RegGetKeySecurity`` writes a descriptor into the buffer the caller supplies + and ``GetSecurityDescriptorDacl`` reports a DACL that points *into* it — the + shape the real API has, and the reason the pointer and the buffer have to + travel together. The bytes are arbitrary: what is measured through this fake + is ownership, never the descriptor's contents. + """ + + def __init__(self) -> None: + self.advapi32 = self + self.blob = b"REGKEY-DESCRIPTOR-BYTES" + self.filled: list[int] = [] + + @staticmethod + def _address(pointer) -> int: + if isinstance(pointer, int): + return pointer + return int(ctypes.cast(pointer, ctypes.c_void_p).value or 0) + + def RegGetKeySecurity(self, handle, info, descriptor, size_ref): + if descriptor is None: + size_ref._obj.value = len(self.blob) + return abi.ERROR_INSUFFICIENT_BUFFER + address = self._address(descriptor) + ctypes.memmove(address, self.blob, len(self.blob)) + self.filled.append(address) + return abi.ERROR_SUCCESS + + def GetSecurityDescriptorDacl(self, descriptor, present_ref, dacl_ref, defaulted_ref): + present_ref._obj.value = 1 + dacl_ref._obj.value = self._address(descriptor) + defaulted_ref._obj.value = 0 + return 1 + + +def test_a_key_dacl_answer_owns_the_buffer_its_pointer_addresses(): + """The ownership the merge depends on, measured where no registry exists. + + ``RegGetKeySecurity`` fills a buffer the caller supplies and the DACL points + into it, so a pointer handed back on its own stays valid only until that + buffer is collected. Its next consumers walk the pointer as an ACL header and + copy every ACE it claims, so on a collected buffer they read an allocator's + bookkeeping as ``AclSize``/``AceCount`` and can write past the new ACL — the + heap is corrupted, and something unrelated dies later with no assertion + firing. So the answer carries the buffer, and what is asserted here is that + the pointer addresses the buffer it carries, and that the buffer kept is the + one the API filled. + """ + api = _FakeRegistryAclApi() + dacl = read_current_key_dacl(api, "HKCU\\Software\\X", 0x4B1) + + assert dacl is not None, "the fake reports a present DACL" + assert api.filled, "the reader must pass its own buffer to the API, not a scratch one" + start = ctypes.addressof(dacl.buffer) + assert start <= dacl.pointer < start + len(dacl.buffer), ( + "the DACL pointer must address the buffer the answer keeps alive" + ) + assert bytes(dacl.buffer)[: len(api.blob)] == api.blob, ( + "the buffer kept must be the one the API filled, not a copy of it" + ) + + +@needs_windows +def test_a_key_grant_is_applied_checkable_and_reversible_on_a_real_key(): + """The boundary itself, on a throwaway key under ``HKCU``. + + Three properties, and the middle one is what a careless rewrite breaks: + + * the key's DACL gains the capability ACE (:func:`has_exact_grant` answers for + the *registry* mask, not the file one); + * the **host's own access survives the merge** — a DACL rebuilt from the + capability SID alone would lock the user out of their own key, which is why + this asserts the ambient open still works rather than only that the ACE is + there; + * a revoke takes ours away and leaves theirs. + + The key is created for this test and deleted at the end, so nothing it writes + outlives it. + """ + import uuid + import winreg + + from emrg.sandbox.win32.acl import has_exact_grant + from emrg.sandbox.win32.grants import AclWriteGrant + from emrg.sandbox.win32.registry import ( + close_key, + grant_key_write, + open_key, + read_current_key_dacl, + revoke_key_write, + ) + + name = f"EMRGTest-{uuid.uuid4().hex}" + subkey = f"Software\\{name}" + key = f"HKCU\\{subkey}" + winreg.CreateKey(winreg.HKEY_CURRENT_USER, subkey).Close() + api = ffi.win32() + grant = AclWriteGrant.create(registry_write_sid(key)) + try: + def current_acl(): + """Read the key's DACL through the production reader, keeping its buffer. + + The answer is returned whole rather than reduced to its pointer: the + buffer it carries is what makes that pointer addressable, and this test + reads through it after the handle is closed. + """ + handle = open_key(api, key) + try: + return read_current_key_dacl(api, key, handle) + finally: + close_key(api, handle, key) + + before = current_acl() + assert before is not None, "a key created here carries a DACL" + assert not has_exact_grant(api, before.pointer, grant.sid_ptr, mask=abi.REGISTRY_GRANT_MASK), ( + "the capability ACE must not be there before the grant — otherwise nothing below measures it" + ) + + grant_key_write(api, key, grant.sid_ptr) + after = current_acl() + assert has_exact_grant(api, after.pointer, grant.sid_ptr, mask=abi.REGISTRY_GRANT_MASK), ( + "the key's DACL must carry the capability ACE after the grant" + ) + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, subkey, 0, winreg.KEY_READ | winreg.KEY_WRITE + ): + pass # the host's own access survived: the merge added an ACE + + revoke_key_write(api, key, grant.sid_ptr) + revoked = current_acl() + assert not has_exact_grant( + api, revoked.pointer, grant.sid_ptr, mask=abi.REGISTRY_GRANT_MASK + ) + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, subkey, 0, winreg.KEY_READ | winreg.KEY_WRITE + ): + pass # and the revoke took only the capability ACE + finally: + grant.dispose() + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, subkey) + + +@pytest.fixture +def enforces_registry(monkeypatch): + """Pin this host's enforcement table to claim the registry kind. + + The provider under test is the Windows dialect, and the leg this runs on is + not necessarily Windows — so the **precondition** is pinned rather than the + subject faked: what is measured is the provider's rendering, not this + platform's capability. ``named_grants`` reads the table at call time, so a + patch of the module's own table is what it sees. On the Windows leg the entry + is already there and the fixture changes nothing. + """ + family = grants_mod.platform_family() + table = dict(grants_mod.ENFORCED_KINDS) + if GRANT_REGISTRY not in table[family]: + table[family] = (*table[family], GRANT_REGISTRY) + monkeypatch.setattr(grants_mod, "ENFORCED_KINDS", table) + + +def test_a_registry_grant_reaches_the_runner_as_its_own_flag(tmp_path, enforces_registry): + """The wiring, measured on every platform: a key becomes ``--extra-key``. + + Two halves of one property. The policy renders a key grant as its **own** flag + — never as ``--extra-root``, which the path applier would apply to a + filesystem object nobody named — and the runner's parser reads it back into + the list its SIDs are derived from. Either half alone proves nothing: a flag + nobody parses confines nothing, and a parser nobody feeds is dead code. + """ + workspace = tmp_path / "ws" + workspace.mkdir() + key = "HKCU\\Software\\LarkCli" + policy = SandboxPolicy( + mode="workspace-write", + workspace_root=str(workspace), + extra_grants=(RootGrant(kind=GRANT_REGISTRY, target=key, source=SOURCE_SESSION),), + ) + + argv = provider.runner_argv(policy) + + assert argv[argv.index("--extra-key") + 1] == key + assert "--extra-root" not in argv, "a key must never be rendered as a path grant" + + parsed = parse_args([ + "--workspace", str(workspace), + "--temp", str(tmp_path), + "--mode", "workspace-write", + "--extra-key", key, + "--", "cmd", + ]) + assert parsed.extra_keys == [key] + assert parsed.extra_roots == [] + + +def test_a_key_grant_and_a_path_grant_travel_on_separate_flags(tmp_path, enforces_registry): + """One policy, both kinds: neither may be dropped, and neither may swap flags.""" + workspace = tmp_path / "ws" + workspace.mkdir() + root = tmp_path / "root" + root.mkdir() + key = "HKCU\\Software\\LarkCli" + policy = SandboxPolicy( + mode="workspace-write", + workspace_root=str(workspace), + extra_grants=( + RootGrant(kind=GRANT_DIRECTORY, target=str(root), source=SOURCE_SESSION), + RootGrant(kind=GRANT_REGISTRY, target=key, source=SOURCE_SESSION), + ), + ) + + argv = provider.runner_argv(policy) + + assert argv[argv.index("--extra-root") + 1] == canonical_path(str(root)) + assert argv[argv.index("--extra-key") + 1] == key diff --git a/tests/test_sandbox_grants.py b/tests/test_sandbox_grants.py index 77c9b02c..ae3857ea 100644 --- a/tests/test_sandbox_grants.py +++ b/tests/test_sandbox_grants.py @@ -28,6 +28,7 @@ from __future__ import annotations import json +import sys import pytest @@ -60,6 +61,7 @@ stored_grants, writable_roots, ) +from emrg.sandbox.win32.registry import RegistryKeyError, split_key from emrg.session import Session #: The shipped manifest, captured at import — before the autouse fixture can pin @@ -145,6 +147,23 @@ def test_the_path_projection_has_no_key_to_a_registry_grant(tmp_path): only paths. Handing a key spelling to one of them would grant something else entirely — the identity bug requirement 1 of issue #2492 names. + **Two meanings used to share this one assertion, and the second is not this + test's.** Whether a *key can be enforced* on the platform at hand is + ``test_a_registry_grant_is_either_enforced_here_or_refused_by_name``'s + question, answered from ``enforced_kinds()``; whether a *path reader* may + ever be handed a key is this test's, and the answer is **none anywhere**. + Reading the first through ``pytest.raises(GrantKindNotEnforced)`` around the + projection meant the claim held only while no platform enforced a key: the + day one did, the raise stopped and the projection's own guarantee went + unmeasured — measured as a red `test-windows` leg on PR #2498, where the + raise simply no longer happens. + + So the claim is stated as what the projection does with the key — it is + absent, and the paths are exactly the ones the same policy yields without it + — and the baseline is asserted non-empty, because a projection that answered + ``[]`` for every policy would satisfy "no key" while telling the truth about + nothing. + The workspace root is the fixture's own directory, not a literal ``/tmp``: the policy refuses a root that is not absolute *in the execution world*, and ``/tmp`` is not one on a Windows host — where this test then failed on the setup rather @@ -157,12 +176,19 @@ def test_the_path_projection_has_no_key_to_a_registry_grant(tmp_path): workspace_root=str(tmp_path), extra_grants=(grant,), ) + without_key = resolve_policy(mode="workspace-write", workspace_root=str(tmp_path)) assert canonical_target(grant) == "HKCU\\Software\\LarkCli", ( "realpath over a key spelling would rewrite the key into a path" ) - with pytest.raises(GrantKindNotEnforced): - writable_roots(policy) + paths = writable_roots(policy) + assert paths, "the projection has no subject if it answers no path at all" + assert "HKCU\\Software\\LarkCli" not in paths, ( + "a key spelling reached a reader that can only write paths" + ) + assert paths == writable_roots(without_key), ( + "the key grant changed the paths, so it is not merely absent from them" + ) # ── §2 the manifest supplies grants nobody typed ─────────────────────────── @@ -291,31 +317,83 @@ def test_the_manifest_grants_nothing_at_read_only(pin_default_manifest, tmp_path # ── §5 a kind no platform enforces is refused by name ───────────────────── -def test_no_platform_enforces_a_registry_key_yet(): - """The staging boundary, pinned: the kind exists, the enforcement does not. +def test_one_platform_enforces_a_registry_key_and_the_other_three_refuse_it(): + """The staging boundary has moved, and it is now asymmetric on purpose. - ``emrg/sandbox/win32/`` applies DACLs to paths. A key needs its own - enforcement, and it is Stage 2 of issue #2492 — so this asserts the *absence* - on every platform rather than trusting a comment, and the Stage-2 change is - the one that edits this test on purpose. + A registry key's DACL is read and rewritten through ``RegGetKeySecurity`` / + ``RegSetKeySecurity``, which exist on Windows and nowhere else. So the kind is + enforceable on exactly one family, and the other three must keep saying so + rather than dropping the grant — a platform that cannot enforce a kind and + silently accepts it is the lying boundary this module refuses everywhere else. """ - for family in ("darwin", "linux", "win32", "other"): + assert GRANT_REGISTRY in enforced_kinds("win32") + for family in ("darwin", "linux", "other"): assert GRANT_REGISTRY not in enforced_kinds(family), family -def test_a_registry_grant_is_refused_by_name_and_not_dropped(tmp_path): +def test_the_windows_registry_rows_name_keys_the_applier_can_address(): + """The shipped table's registry rows, held to the parser that has to act on them. + + A row naming a spelling ``split_key`` refuses would be a default grant that can + never be applied — the lying manifest this module refuses elsewhere, one layer + down. Comparing the two halves is what keeps the table and the applier in step; + nothing here opens a key, so it measures on every platform. + """ + rows = _SHIPPED_MANIFEST["win32"] + keys = [row for row in rows if row[0] == GRANT_REGISTRY] + assert len(keys) >= 2, ( + f"the Windows manifest ships {len(keys)} registry row(s) — the parent and the key " + "are both load-bearing, so fewer than two means nothing was measured" + ) + for kind, spelling in keys: + _root, subkey = split_key(spelling) + assert subkey, spelling + assert kind in _SHIPPED_ENFORCED["win32"], (kind, spelling) + # The other direction, and it is what makes this a guard rather than a tally: + # re-typing a key row as a path drops it out of `keys`, so every assertion + # above would pass over the row that changed. A path row must therefore *not* + # carry a key's spelling. + for kind, spelling in rows: + if kind == GRANT_REGISTRY: + continue + with pytest.raises(RegistryKeyError): + split_key(spelling) + + +def test_a_registry_row_this_host_does_not_have_is_absent_not_an_error(): + """The manifest's totality covers a key, not only a directory. + + ``lark-cli`` may never have run here, and on a host with no registry it cannot + have — so the row is *absent* from the derivation, never a failure, and the + directory rows beside it are unaffected. + """ + if sys.platform == "win32": + pytest.skip("on Windows the answer depends on this runner's own registry") + kinds = [grant.kind for grant in default_grants("win32")] + assert GRANT_REGISTRY not in kinds, kinds + + +def test_a_registry_grant_is_either_enforced_here_or_refused_by_name(tmp_path): """Requirement 5: neither silently ignored nor reported as granted. Dropping it would let the run claim a boundary it does not have; passing the - key to a path consumer would grant something else. So the derivation raises, - and the sentence names the kind and the platform's set. + key to a path consumer would grant something else. So the derivation either + hands the grant on — on the one platform that can enforce a key — or raises, + and the sentence names the kind, the target and what this platform can do. + The test asks ``enforced_kinds()`` rather than ``sys.platform``, so one body + measures both answers without asserting which platform it runs on. """ + grant = RootGrant(kind=GRANT_REGISTRY, target="HKCU\\Software\\X") policy = resolve_policy( mode="workspace-write", workspace_root=str(tmp_path), - extra_grants=(RootGrant(kind=GRANT_REGISTRY, target="HKCU\\Software\\X"),), + extra_grants=(grant,), ) + if GRANT_REGISTRY in enforced_kinds(): + assert named_grants(policy) == [grant], "an enforceable key grant must survive typed" + return + with pytest.raises(GrantKindNotEnforced) as refused: named_grants(policy) @@ -330,7 +408,17 @@ def test_every_dialect_refuses_a_registry_grant_rather_than_losing_it(tmp_path): The fence answers in text (its callers render a sentence to the model) while the profile builders let the exception out, so a confined command fails closed instead of running under a profile that quietly omitted a grant. + + Scoped to the platforms that do not enforce the kind: on Windows a registry + grant *is* enforceable, and Seatbelt and bubblewrap are not the dialects a run + there reaches — the chain picks a provider by platform — so the refusal they + own has nothing to answer. """ + if GRANT_REGISTRY in enforced_kinds(): + pytest.skip( + "this platform enforces a registry key; the Seatbelt and bubblewrap " + "builders are not the dialects a run here reaches" + ) policy = resolve_policy( mode="workspace-write", workspace_root=str(tmp_path),