From e35f65f2032e319d3a8bca0ffd841a0e21be2e27 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 23 Oct 2026 09:02:29 +0800 Subject: [PATCH 1/2] emrg: a registry key can be typed, validated and withdrawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/client/app.py | 10 +- emrg/client/widgets.py | 3 +- emrg/sandbox/grants.py | 54 ++++++++- emrg/sandbox/roots.py | 110 +++++++++++++---- emrg/sandbox/win32/registry.py | 31 +++++ emrg/server/daemon.py | 2 +- tests/test_bash_v2_win32.py | 35 ++++++ tests/test_sandbox_extra_roots.py | 189 ++++++++++++++++++++++++++---- tests/test_sandbox_grants.py | 67 ++++++++++- 9 files changed, 443 insertions(+), 58 deletions(-) diff --git a/emrg/client/app.py b/emrg/client/app.py index 835d9283..9044f856 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -25,6 +25,7 @@ from websockets.exceptions import ConnectionClosed from emrg.protocol import TaskResponse, ToolEnd, ToolStart, new_task_id from emrg.sandbox.escalation import APPROVAL_TIMEOUT_SECONDS +from emrg.sandbox.grants import split_kind_flag from emrg.sandbox.policy import SANDBOX_MODES from emrg.session import generate_session_id, session_dir # The credential rule is a stdlib-only leaf (`emrg/server/redaction.py`), deliberately @@ -3652,9 +3653,16 @@ def _is_image_token(s, i): sub = parts[1].strip() if len(parts) > 1 else "" rest = parts[2].strip() if len(parts) > 2 else "" if sub in ("add", "remove", "list"): + # The kind is *stated*, never inferred, and the mapping + # lives in `grants.split_kind_flag` so this client cannot + # spell a kind the model does not have (issue #2492 + # requirement 1). No flag means a path, which is what + # every command before `--registry` did. + kind, target = split_kind_flag(rest) await conn.send_command( "set_sandbox_roots", - session_id=session_id, cwd=cwd, op=sub, path=rest, + session_id=session_id, cwd=cwd, op=sub, path=target, + kind=kind, ) status.update(center=f"sandbox roots: {sub}...") elif sub: diff --git a/emrg/client/widgets.py b/emrg/client/widgets.py index 4bbbe3ea..00e38056 100644 --- a/emrg/client/widgets.py +++ b/emrg/client/widgets.py @@ -1037,7 +1037,8 @@ def render(self, ctx): "/rant": "Send feedback to the evolution system [/rant | /rant @ ]", "/model": "Switch LLM model [/model | /model ]", "/sandbox": "Set this session's sandbox tier, or its extra writable roots " - "[/sandbox | /sandbox | /sandbox add|remove|list ]", + "[/sandbox | /sandbox | /sandbox add|remove|list ] " + "[/sandbox add|remove --registry names a registry key]", "/trigger": "List or manually trigger scheduled tasks [/trigger | /trigger ]", "/task-session": "Open a scheduled task's session (no args = interactive picker)", "/skills": "List loaded skills (user + project)", diff --git a/emrg/sandbox/grants.py b/emrg/sandbox/grants.py index 86831a6d..d7180f83 100644 --- a/emrg/sandbox/grants.py +++ b/emrg/sandbox/grants.py @@ -41,10 +41,11 @@ :func:`enforcement_refusal` produces the one sentence a caller shows when a kind is not there, and every dialect refuses such a grant **by name** rather than dropping it — dropping it would report a boundary the run does not have, and -passing it on as a path would grant something else entirely. Today ``registry`` -is in no platform's set: the model carries the kind, the writer refuses it with -that sentence, and the enforcement itself — a DACL on a registry key, in -``emrg/sandbox/win32/`` — is Stage 2 of issue #2492. +passing it on as a path would grant something else entirely. ``registry`` is in +``win32``'s set alone — a key's DACL is read and rewritten through +``RegGetKeySecurity`` / ``RegSetKeySecurity`` in ``emrg/sandbox/win32/registry.py`` +— so the other three families refuse such a grant with that sentence, while a +Windows host may name a key. """ from __future__ import annotations @@ -67,13 +68,42 @@ SOURCE_DEFAULT = "default" GRANT_SOURCES: tuple[str, ...] = (SOURCE_SESSION, SOURCE_DEFAULT) +#: The flag a host types to **state** an object's kind, by the kind it names. +#: +#: One entry today, and a mapping rather than a bare string because it is the one +#: producer of the answer: the client parses the typed command with +#: :func:`split_kind_flag` and the daemon judges the kind it carries, so the two +#: cannot spell one kind two ways. Stating the kind is the whole point — reading +#: it off the disk is the defect issue #2492 exists to remove, and a key has +#: nothing on a POSIX disk to read it off. +KIND_FLAGS: dict[str, str] = {"--registry": GRANT_REGISTRY} + + +def split_kind_flag(argument: str) -> tuple[str | None, str]: + """Split one ``/sandbox add|remove`` argument into its stated kind and target. + + The flag is only the **first** word: everything after it is the target, + whitespace included, because a path may legitimately contain spaces and a + leading ``--`` never can. An argument that does not open with a known flag is + a target with no stated kind — the caller then applies the path rules, which + is what every command before this one did. + + :param argument: the argument as typed, after the subcommand. + :returns: ``(kind, target)``; ``kind`` is ``None`` when none was stated. + """ + words = argument.split(None, 1) + if words and words[0].lower() in KIND_FLAGS: + return KIND_FLAGS[words[0].lower()], (words[1].strip() if len(words) > 1 else "") + return None, argument + #: The kinds **this platform's dialects implement**, per platform family. #: #: ``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 +#: :func:`enforcement_refusal` says so by name — deriving from this table which +#: family *does* enforce it, so no sentence can outlive the row it describes. 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. @@ -175,11 +205,23 @@ def enforcement_refusal(kind: str, target: str = "", platform: str | None = None # full of backslashes, and `repr` doubles every one of them, so the sentence a # host reads would name a key that looks nothing like the one they typed. named = f" '{target}'" if target else "" + # Where the kind *is* enforced, read off the table rather than written as + # prose. The sentence this replaces closed by promising a future stage would + # ship the enforcement — and #2498 shipped it, so the sentence went on + # telling a Windows host that a key could not be applied while + # `ENFORCED_KINDS["win32"]` said otherwise. A derived clause cannot drift + # from the table it is derived from. + enforcing = sorted(f for f, kinds in ENFORCED_KINDS.items() if kind in kinds) + elsewhere = ( + f" ({kind} grants are enforceable on {', '.join(enforcing)})" + if enforcing + else f" (no platform enforces a {kind} grant yet)" + ) return ( f"a {kind} grant{named} cannot be enforced on {platform_family(platform)} — this " f"platform enforces {', '.join(allowed)}. Dropping it would report a boundary the " f"run does not have, and honouring it as a path would grant a different object, so " - f"it is refused instead (issue #2492 Stage 2 adds {kind} enforcement)" + f"it is refused instead{elsewhere}" ) diff --git a/emrg/sandbox/roots.py b/emrg/sandbox/roots.py index 0105a4bd..582da04f 100644 --- a/emrg/sandbox/roots.py +++ b/emrg/sandbox/roots.py @@ -410,6 +410,72 @@ def _absolute_root_spelling(path: str) -> tuple[str, str | None]: return canonical_path(expanded), None +def judge_registry_key( + key: str, policy: SandboxPolicy, platform: str | None = None +) -> ExtraRootVerdict: + """Judge one host-named registry key — the refusal, the duplicate, or the grant. + + A third judge beside :func:`judge_extra_root` and :func:`judge_root_removal`, + for the reason those two are separate functions: a key answers a different + question. None of the six granting rules has a meaning for one — a key is not + the filesystem root, is not the home directory, has no protected daemon state + file beneath it and nothing to ``stat`` — while two questions are its own: + *is the spelling one key* and *can this platform apply it at all*. + + **Validation and enforcement are kept apart here**, in that order. The + spelling is checked first, because a malformed key is malformed on every + platform, and it is checked with + :func:`~emrg.sandbox.win32.registry.split_key` — the parser the DACL applier + itself uses — so a key this judge accepts is one that path can address, and no + second parser with a second wording exists. Only then does the platform + answer, through :func:`~emrg.sandbox.grants.enforcement_refusal`, which stays + the one producer of that sentence. + + The stored target is the **canonical** spelling + (:func:`~emrg.sandbox.win32.registry.canonical_key_spelling`), because a + capability SID is derived from the spelling: ``HKCU`` and + ``HKEY_CURRENT_USER`` are both accepted and would otherwise be two identities + on one key. Naming a key twice is therefore recognised by a case-insensitive + comparison, since the registry namespace is case-insensitive. + + That comparison reads :func:`stored_grants` rather than + :func:`granted_grants`, and the difference is the tier rather than the + enforcement: the second reader answers ``[]`` for every mode but + ``workspace-write``, so a key named at ``read-only`` would not be found and + would be stored twice — the same defect the path judge's own second loop + exists to prevent. A key is granted as itself, with no containment test to + run, so "is it on the list" is the whole question and the mode-independent + list is the whole answer. + + :param key: the key as the host typed it. + :param policy: the session's policy **including its stored grants**, so a key + already granted is reported rather than stored twice. + :param platform: the platform to judge; defaults to ``sys.platform``. It is a + parameter for the same reason :func:`enforcement_refusal` has one — the + spelling rules are ordinary Python, so a POSIX leg holds them to their + rule without a Windows host. + :returns: the verdict — the canonical spelling, and either a refusal or the + typed grant to store. + """ + from emrg.sandbox.win32.registry import RegistryKeyError, canonical_key_spelling + + try: + canonical = canonical_key_spelling(key.strip()) + except RegistryKeyError as exc: + return ExtraRootVerdict("", refusal=str(exc)) + refusal = enforcement_refusal(GRANT_REGISTRY, canonical, platform) + if refusal is not None: + return ExtraRootVerdict("", refusal=refusal) + for grant in stored_grants(policy): + if grant.kind == GRANT_REGISTRY and grant.target.lower() == canonical.lower(): + return ExtraRootVerdict(canonical, notice=( + f"{canonical!r} is already granted for this session — a registry namespace " + "is case-insensitive and both accepted spellings of a root name one key, so " + "this is the grant you already have" + ), store=False) + return ExtraRootVerdict(canonical, grant=RootGrant(GRANT_REGISTRY, canonical)) + + def judge_extra_root( path: str, policy: SandboxPolicy, kind: str | None = None ) -> ExtraRootVerdict: @@ -490,21 +556,7 @@ def judge_extra_root( f"unknown grant kind {kind!r} — expected one of {', '.join(GRANT_KINDS)}" )) if kind == GRANT_REGISTRY: - # Terminal in this stage, and the sentence says why rather than pretending - # the key was judged: no platform enforces a registry grant yet - # (``grants.ENFORCED_KINDS``), so there is no key spelling to validate - # against. Stage 2 of issue #2492 replaces this branch with the key's own - # rules *and* the DACL that applies it — landing only one of the two would - # be a stored grant that no dialect acts on, which is the silent ignore - # requirement 5 forbids. - refusal = enforcement_refusal(GRANT_REGISTRY, path.strip()) - if refusal is None: # pragma: no cover - no platform reaches this in Stage 1 - refusal = ( - "a registry grant is enforced on this platform but its key spelling is not " - "validated yet — issue #2492 Stage 2 lands the validation and the enforcement " - "together" - ) - return ExtraRootVerdict("", refusal=refusal) + return judge_registry_key(path, policy) canonical, refusal = _absolute_root_spelling(path) if refusal: @@ -583,7 +635,9 @@ def judge_extra_root( return ExtraRootVerdict(canonical, grant=RootGrant.from_path(canonical)) -def judge_root_removal(path: str, policy: SandboxPolicy) -> ExtraRootVerdict: +def judge_root_removal( + path: str, policy: SandboxPolicy, kind: str | None = None +) -> ExtraRootVerdict: """Judge one ``/sandbox remove`` — the canonical spelling, or why it cannot be read. Removal asks a different question from :func:`judge_extra_root`, and this is @@ -603,12 +657,28 @@ def judge_root_removal(path: str, policy: SandboxPolicy) -> ExtraRootVerdict: not grant this" — a path that is simply not on the list is answered with the list as it stands, which is what "removal is idempotent" means. - :param path: the path as the host typed it. - :param policy: unused — carried so the two judges are called alike, and so a - later removal rule has somewhere to read the session's state from. - :returns: the verdict — canonical spelling, and a refusal only for a path + **A key is withdrawn by its own spelling rule and no other.** ``kind`` is the + kind the host stated (``/sandbox remove --registry``); when it names a + registry key the target is canonicalized the way the grant was stored, and + none of the path rules run — there is no absolute path to require and no + disk to stat. A grant that can be added must be withdrawable, so this branch + can refuse only an unreadable *spelling*, never the policy. + + :param path: the path, or key, as the host typed it. + :param policy: unused by the path rules — carried so the two judges are called + alike, and so a later removal rule has somewhere to read the session's + state from. + :param kind: the kind the host stated, or ``None`` for a path. + :returns: the verdict — canonical spelling, and a refusal only for an argument that could not be read. """ + if kind == GRANT_REGISTRY: + from emrg.sandbox.win32.registry import RegistryKeyError, canonical_key_spelling + + try: + return ExtraRootVerdict(canonical_key_spelling(path.strip())) + except RegistryKeyError as exc: + return ExtraRootVerdict("", refusal=str(exc)) canonical, refusal = _absolute_root_spelling(path) if refusal: return ExtraRootVerdict(canonical, refusal=refusal) diff --git a/emrg/sandbox/win32/registry.py b/emrg/sandbox/win32/registry.py index 8105df0b..40c20bf7 100644 --- a/emrg/sandbox/win32/registry.py +++ b/emrg/sandbox/win32/registry.py @@ -158,6 +158,37 @@ def split_key(key: str) -> tuple[int, str]: return root, subkey +#: The one spelling each root is **stored** as, by the ``HKEY`` value it resolves to. +#: +#: :data:`KEY_ROOTS` accepts two spellings per hive because a host may type +#: either, and this is the second half of that decision: a capability SID is +#: derived from the spelling (:func:`~emrg.sandbox.win32.sid.registry_write_sid`), +#: so two spellings of one key would be two identities on one key — the same +#: failure the ``KEY_ROOTS`` note names for a *third* hive spelling, one level in. +CANONICAL_ROOT_NAMES: dict[int, str] = { + abi.HKEY_CURRENT_USER: "HKCU", + abi.HKEY_LOCAL_MACHINE: "HKLM", +} + + +def canonical_key_spelling(key: str) -> str: + """The one spelling a key grant is stored as: the short root, then the subkey. + + The registry namespace is case-insensitive and + :func:`~emrg.sandbox.win32.sid.registry_write_sid` lowercases what it derives, + so the subkey keeps the case the host typed — it is display. What has to + converge is the **root**, where ``HKCU`` and ``HKEY_CURRENT_USER`` are two + accepted spellings of one hive and would otherwise derive two SIDs for one + key. + + :param key: the key's full spelling, in any accepted form. + :returns: the canonical spelling, e.g. ``HKCU\\Software\\LarkCli``. + :raises RegistryKeyError: when the spelling names no key (:func:`split_key`). + """ + root, subkey = split_key(key) + return f"{CANONICAL_ROOT_NAMES[root]}\\{subkey}" + + def key_exists(key: str) -> bool: """Whether this host has the key. diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 75ad50a2..3b5522cd 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -6972,7 +6972,7 @@ async def _handle_set_sandbox_roots( # root whose path was deleted impossible to withdraw (measured # 2026-10-09 on head `4eb69bab`). verdict = ( - judge_root_removal(path, policy) if op == "remove" + judge_root_removal(path, policy, kind) if op == "remove" else judge_extra_root(path, policy, kind) ) if verdict.refusal: diff --git a/tests/test_bash_v2_win32.py b/tests/test_bash_v2_win32.py index a28d34ed..09c45a50 100644 --- a/tests/test_bash_v2_win32.py +++ b/tests/test_bash_v2_win32.py @@ -35,6 +35,7 @@ from emrg.sandbox.win32.ffi import SIDAndAttributes from emrg.sandbox.win32.registry import ( RegistryKeyError, + canonical_key_spelling, key_exists, read_current_key_dacl, split_key, @@ -76,6 +77,40 @@ SUB_AUTHORITY_CEILING = 2**30 - 1 +# ── the canonical key spelling ─────────────────────────────────────────── + + +def test_a_key_has_one_canonical_spelling_so_one_hive_is_one_identity(): + """``HKCU`` and ``HKEY_CURRENT_USER`` are one hive, so they must be one key. + + ``split_key`` accepts both spellings on purpose — a host may type either — and + ``registry_write_sid`` derives the capability SID from the spelling it is + handed. Left alone, those two facts put **two identities on one key**: two + ACEs where one was asked for, and a restricted token carrying a SID the other + half of the grant never derives. The convergence has to happen where the grant + is stored, which is what ``canonical_key_spelling`` is. + """ + # The hazard is real: the raw spellings derive two different SIDs. + assert registry_write_sid("HKCU\\Software\\LarkCli") != registry_write_sid( + "HKEY_CURRENT_USER\\Software\\LarkCli" + ) + # …and canonicalizing is what removes it, for both accepted roots. + assert canonical_key_spelling("HKCU\\Software\\LarkCli") == "HKCU\\Software\\LarkCli" + assert ( + canonical_key_spelling("HKEY_CURRENT_USER\\Software\\LarkCli") + == "HKCU\\Software\\LarkCli" + ) + assert canonical_key_spelling("hkey_local_machine\\Software\\X") == "HKLM\\Software\\X" + # The subkey's case is display, not identity — one derivation lowercases it. + assert ( + canonical_key_spelling("HKCU\\Software\\LarkCli").lower() + == canonical_key_spelling("HKCU\\software\\larkcli").lower() + ) + # A spelling the parser refuses is refused here too, with the parser's reason. + with pytest.raises(RegistryKeyError, match="whole hive"): + canonical_key_spelling("HKCU") + + # ── capability SIDs ─────────────────────────────────────────────────────── diff --git a/tests/test_sandbox_extra_roots.py b/tests/test_sandbox_extra_roots.py index 97b0202e..a6a3f3da 100644 --- a/tests/test_sandbox_extra_roots.py +++ b/tests/test_sandbox_extra_roots.py @@ -43,12 +43,14 @@ from emrg.protocol import TaskRequest from emrg.sandbox import roots as roots_mod from emrg.sandbox.fence import file_refusal +from emrg.sandbox.grants import GRANT_REGISTRY, RootGrant from emrg.sandbox.policy import SandboxPolicy, resolve_policy from emrg.sandbox.providers.darwin import seatbelt_profile_args from emrg.sandbox.providers.linux import BWRAP_BIN, bwrap_profile_args, runner_argv from emrg.sandbox.roots import ( canonical_path, judge_extra_root, + judge_registry_key, judge_root_removal, writable_roots, ) @@ -543,6 +545,130 @@ def test_a_session_that_named_nothing_injects_an_empty_list(tmp_path): assert args["writable_roots"] == [] +# ── §5b a registry key is judged by its own rules ───────────────────────── + + +def test_a_key_is_validated_by_the_parser_the_applier_uses(): + """A key the judge accepts is a key the DACL path can address — same parser. + + Two questions are a key's own and this is the first: *is the spelling one + key*. It is answered by ``registry.split_key``, the parser + ``apply_dacl`` itself calls, so the judge cannot accept a spelling the + enforcement then fails to resolve. The stored target is the canonical form, + because the capability SID is derived from the spelling. + """ + verdict = judge_registry_key( + "HKEY_CURRENT_USER\\Software\\LarkCli\\keychain", _ws_policy("/tmp"), platform="win32" + ) + assert verdict.refusal is None, verdict + assert verdict.grant == RootGrant(GRANT_REGISTRY, "HKCU\\Software\\LarkCli\\keychain") + assert verdict.canonical == "HKCU\\Software\\LarkCli\\keychain" + assert verdict.store is True + + +@pytest.mark.parametrize("bad, phrase", [ + ("", "empty string"), + ("HKCU", "whole hive"), + ("HKU\\Software\\X", "not one of"), + ("Software\\LarkCli", "not one of"), +]) +def test_a_key_that_names_no_key_is_refused_with_the_parsers_own_reason(bad, phrase): + """One parser, one wording: the refusal a host reads is `split_key`'s.""" + verdict = judge_registry_key(bad, _ws_policy("/tmp"), platform="win32") + assert verdict.refusal and phrase in verdict.refusal, verdict + # ``store`` is deliberately not asserted: :class:`ExtraRootVerdict` documents + # it as the caller's instruction for the *accepted* outcomes — "False only + # for a path already covered — a refusal never reaches this field" — so it + # keeps its default ``True`` here and reading it would be reading noise. + assert verdict.grant is None, verdict + + +def test_the_spelling_is_judged_before_the_platform(): + """A malformed key is malformed everywhere, so that is the sentence that wins. + + On a platform that cannot apply a key's DACL, "this key does not exist" is + the more useful answer than "this platform has no registry" — the host would + otherwise fix the platform question and meet the spelling one afterwards. + """ + verdict = judge_registry_key("HKCU", _ws_policy("/tmp"), platform="darwin") + assert verdict.refusal and "whole hive" in verdict.refusal, verdict + + +def test_a_platform_that_cannot_apply_a_key_refuses_it_with_the_platform_sentence(): + """The refusal names the platform's limit, and never a stage that has landed.""" + verdict = judge_registry_key( + "HKCU\\Software\\LarkCli", _ws_policy("/tmp"), platform="darwin" + ) + assert verdict.refusal, verdict + assert "cannot be enforced on" in verdict.refusal + # The sentence this replaces claimed the enforcement had not shipped, which + # `ENFORCED_KINDS["win32"]` had already falsified by the time a Windows host + # could reach it. + assert "not validated yet" not in verdict.refusal + assert verdict.grant is None + + +def test_naming_a_key_twice_reports_the_grant_that_is_stored(): + """A duplicate is answered, not stored twice — and case is not a difference. + + The registry namespace is case-insensitive, so ``hkcu\\software\\x`` and + ``HKCU\\Software\\X`` are one key with one identity; storing both would put + two grants on the session's list for one ACE. + """ + policy = resolve_policy( + mode="workspace-write", + workspace_root="/tmp", + extra_grants=[RootGrant(GRANT_REGISTRY, "HKCU\\Software\\LarkCli")], + ) + verdict = judge_registry_key( + "HKEY_CURRENT_USER\\software\\larkcli", policy, platform="win32" + ) + assert verdict.refusal is None, verdict + assert verdict.store is False + assert verdict.grant is None + assert verdict.notice and "already granted" in verdict.notice + + +def test_a_key_named_twice_at_read_only_is_not_stored_twice(): + """The duplicate check reads the mode-independent list, so the tier cannot hide it. + + ``granted_grants`` answers ``[]`` for every mode but ``workspace-write``, so a + duplicate check built on it finds nothing at ``read-only`` and stores the same + key a second time — one key, two grants, two capability SIDs derived from one + spelling. The session's list follows the session and not the tier, exactly as + it does for a path, so the reader that does not consult the tier is the one + that answers "is this already on the list". + """ + policy = resolve_policy( + mode="read-only", + workspace_root="/tmp", + extra_grants=[RootGrant(GRANT_REGISTRY, "HKCU\\Software\\LarkCli")], + ) + verdict = judge_registry_key( + "HKEY_CURRENT_USER\\Software\\LarkCli", policy, platform="win32" + ) + assert verdict.refusal is None, verdict + assert verdict.store is False, "a key already on the list must not be stored again" + assert verdict.grant is None, verdict + assert verdict.notice and "already granted" in verdict.notice, verdict + + +def test_a_key_is_withdrawn_by_the_flag_that_added_it(): + """An added grant must be withdrawable, so removal has the key's own branch. + + The path rules are skipped entirely: a key is not absolute, and there is + nothing on a disk to stat. Only the spelling can be refused here — never the + policy, because a withdrawal is not a grant. + """ + verdict = judge_root_removal( + "HKEY_CURRENT_USER\\Software\\LarkCli", _ws_policy("/tmp"), GRANT_REGISTRY + ) + assert verdict.refusal is None, verdict + assert verdict.canonical == "HKCU\\Software\\LarkCli" + refused = judge_root_removal("HKCU", _ws_policy("/tmp"), GRANT_REGISTRY) + assert refused.refusal and "whole hive" in refused.refusal + + # ── §6 the daemon's command ─────────────────────────────────────────────── @@ -616,6 +742,43 @@ def test_a_refused_path_is_not_stored(outside): assert "sandbox_roots" not in _meta(session) +def test_a_typed_registry_kind_is_answered_without_a_stale_sentence(outside): + """The command carries a *stated* kind, and the daemon judges it as a key. + + The answer has two legitimate shapes — a platform whose dialects can apply a + key's DACL stores the typed grant, one that cannot says so with the platform + sentence — and this asserts the invariant that holds in both: the refusal is + never for a reason the model has moved past. Before this, the sentence told a + Windows host that the enforcement had not shipped, when it had. + """ + ws, _ = outside + session = Session.create_with_id("s_registry", ws) + server = _server() + + frames = _drive(server, { + "type": "set_sandbox_roots", + "session_id": session.session_id, + "cwd": str(ws), + "op": "add", + "path": "HKCU\\Software\\LarkCli", + "kind": "registry", + }) + + reply = [f for f in frames if f.get("type") == "sandbox_roots"][-1] + # Reloaded, not read off the object this test built: the daemon loads the + # session itself, so an assertion on ``session`` here would be about a copy + # nothing wrote to — it would hold whatever the daemon did. + stored = Session.load(session.session_id, ws).sandbox_grants + refused = reply.get("error") + if refused: + assert "cannot be enforced on" in refused, refused + assert "not validated yet" not in refused, refused + assert stored == [], stored + else: + assert [grant.kind for grant in stored] == [GRANT_REGISTRY], stored + assert stored[0].target == "HKCU\\Software\\LarkCli", stored + + def test_remove_withdraws_the_root_and_is_idempotent(outside): """Acceptance 4, through the command: removed is removed, and twice is not an error.""" ws, elsewhere = outside @@ -998,32 +1161,6 @@ def test_adding_a_withdrawn_default_back_makes_it_the_host_s(outside, pin_defaul }[canonical_path(str(elsewhere))] == "session" -def test_a_registry_add_is_refused_with_the_one_sentence(outside): - """Requirement 5 at the command: the kind is named, not ignored. - - Stage 1 stores no registry grant, because no dialect can enforce one yet - (``emrg/sandbox/grants.py``); the refusal is the same sentence - ``named_grants`` raises, so a host and a dialect cannot be told two different - things about one rule. - """ - ws, _elsewhere = outside - session = Session.create_with_id("s_registry", ws) - server = _server() - - frames = _drive(server, { - "type": "set_sandbox_roots", - "session_id": session.session_id, - "cwd": str(ws), - "op": "add", - "path": "HKCU\\Software\\LarkCli", - "kind": "registry", - }) - - reply = [f for f in frames if f.get("type") == "sandbox_roots"][-1] - assert "registry" in reply["error"] and "Stage 2" in reply["error"], reply - assert Session.load(session.session_id, ws).sandbox_grants == [] - - # ── §7 the model's view of the boundary ─────────────────────────────────── diff --git a/tests/test_sandbox_grants.py b/tests/test_sandbox_grants.py index ae3857ea..d90fcf28 100644 --- a/tests/test_sandbox_grants.py +++ b/tests/test_sandbox_grants.py @@ -11,7 +11,8 @@ The layout follows the issue's requirements: §1 the kind is explicit (§1), §2 the manifest supplies grants nobody typed (§2), §3 it is visible and withdrawable (§3), §4 it never widens a tier that grants nothing (§4), §5 a kind no platform -enforces is refused by name rather than dropped (§5). +enforces is refused by name rather than dropped (§5), §6 a host can state a kind +when they type one (§6). Two readings this file is written around: @@ -41,6 +42,7 @@ GRANT_FILE, GRANT_KINDS, GRANT_REGISTRY, + KIND_FLAGS, SOURCE_DEFAULT, SOURCE_SESSION, GrantKindNotEnforced, @@ -49,6 +51,7 @@ enforced_kinds, enforcement_refusal, manifest_targets, + split_kind_flag, ) from emrg.sandbox.policy import grants_from_arguments, resolve_policy from emrg.sandbox.providers.darwin import sbpl_string, seatbelt_profile_args @@ -399,7 +402,9 @@ def test_a_registry_grant_is_either_enforced_here_or_refused_by_name(tmp_path): assert "registry" in refused.value.reason assert "HKCU\\Software\\X" in refused.value.reason - assert "Stage 2" in refused.value.reason + # The sentence names where the kind *is* enforced, derived from the table, so + # it says "win32" rather than promising a stage that would add it. + assert "win32" in refused.value.reason, refused.value.reason def test_every_dialect_refuses_a_registry_grant_rather_than_losing_it(tmp_path): @@ -491,7 +496,7 @@ def test_the_shipped_manifest_only_promises_kinds_its_platform_enforces(): A manifest row promising a grant the run cannot enforce is the lying boundary this module refuses everywhere else — so the table is held to :data:`ENFORCED_KINDS`, which is why the Windows registry keys the issue also - names are absent from it until Stage 2 can enforce them. + names are absent from the families whose dialects cannot apply a key's DACL. """ assert _SHIPPED_MANIFEST, "the shipped manifest is empty — nothing was measured" for family, rows in _SHIPPED_MANIFEST.items(): @@ -529,3 +534,59 @@ def test_the_session_keeps_a_grant_s_typed_kind_across_a_reload(tmp_path): assert meta["sandbox_roots"] == [named.target], "the projection, from the same producer" assert reloaded.sandbox_grants == [named] assert reloaded.sandbox_roots == [named.target] + + +# ── §6 a host can state a kind when they type one ───────────────────────── + + +def test_the_flag_that_states_a_kind_has_one_producer(): + """The client and the daemon read one mapping, so neither can invent a kind. + + ``/sandbox add --registry HKCU\\Software\\X`` is the only way a key can be + named: the kind is *stated* by the host, never read off the disk (requirement + 1 of issue #2492 — a key has nothing on a POSIX disk to read it off). + """ + assert split_kind_flag("--registry HKCU\\Software\\LarkCli") == ( + GRANT_REGISTRY, + "HKCU\\Software\\LarkCli", + ) + # No flag means a path, and the argument comes back exactly as typed — this is + # what every command before `--registry` did, so it must not be reformatted. + assert split_kind_flag("~/notes") == (None, "~/notes") + assert split_kind_flag("") == (None, "") + # A target may contain spaces; only the first word is ever the flag. + assert split_kind_flag("--registry HKCU\\Software\\My Key") == ( + GRANT_REGISTRY, + "HKCU\\Software\\My Key", + ) + # The flag with nothing after it still states a kind — the judge refuses the + # empty spelling with the parser's own sentence rather than re-reading it as a + # path called "--registry". + assert split_kind_flag("--registry") == (GRANT_REGISTRY, "") + # …and a leading `--` that is not one of the known flags stays part of the + # target, so the mapping cannot absorb a path it does not name. + assert split_kind_flag("--not-a-flag x") == (None, "--not-a-flag x") + + +def test_every_stated_flag_is_named_in_the_command_help(): + """A flag nobody can read about is half a feature. + + The help line is the only place a host learns the syntax, and it is prose — + exactly the carrier that goes stale in silence. So it is held to the table + that produces the flags: a kind added to :data:`KIND_FLAGS` without a word in + ``_COMMAND_HELP`` fails here rather than shipping undiscoverable. + """ + from emrg.client.widgets import _COMMAND_HELP + + sandbox_help = _COMMAND_HELP["/sandbox"] + for flag, kind in KIND_FLAGS.items(): + assert flag in sandbox_help, ( + f"{kind} can be named with {flag}, but /sandbox's help never mentions it: " + f"{sandbox_help!r}" + ) + + +def test_every_stated_flag_names_a_kind_the_model_has(): + """A flag for a kind outside :data:`GRANT_KINDS` would be a second vocabulary.""" + assert set(KIND_FLAGS.values()) <= set(GRANT_KINDS) + assert all(flag.startswith("--") for flag in KIND_FLAGS) From 2f7e7b6e1442bdb8702dee7803dd765e85a26ef5 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 23 Oct 2026 09:18:04 +0800 Subject: [PATCH 2/2] emrg: the key judges take a workspace root the platform derives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/test_sandbox_extra_roots.py | 68 +++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/tests/test_sandbox_extra_roots.py b/tests/test_sandbox_extra_roots.py index a6a3f3da..65a2137b 100644 --- a/tests/test_sandbox_extra_roots.py +++ b/tests/test_sandbox_extra_roots.py @@ -154,6 +154,23 @@ def _ws_policy(workspace, extra=()) -> SandboxPolicy: return resolve_policy(mode="workspace-write", workspace_root=str(workspace), extra_roots=extra) +#: An absolute workspace root on **every** platform, derived rather than spelled. +#: +#: ``"/tmp"`` is not one: ``os.path.isabs("/tmp")`` is **False** on Windows, because +#: a drive-less rooted spelling does not resolve under the cwd, so ``SandboxPolicy`` +#: refuses it before any of this file's subject runs. The registry judges below never +#: read the workspace — a key is granted as itself — so the value only has to clear +#: that precondition, and it has to clear it on the platform the test runs on. The +#: Windows leg measured the difference: ten failures, run 39881732802, every one of +#: them ``workspace_root must be an absolute execution-world path (got '/tmp')`` +#: while the same tests passed on Linux. +#: +#: ``abspath`` is what makes it platform-honest: the host's own ``os.path`` answers, +#: so this is ``/`` here and ``C:\\`` on Windows, and no spelling is written down for +#: a platform nobody measured. +_ANY_WS = os.path.abspath(os.sep) + + # ── §1 what a path is judged to be ──────────────────────────────────────── @@ -558,7 +575,7 @@ def test_a_key_is_validated_by_the_parser_the_applier_uses(): because the capability SID is derived from the spelling. """ verdict = judge_registry_key( - "HKEY_CURRENT_USER\\Software\\LarkCli\\keychain", _ws_policy("/tmp"), platform="win32" + "HKEY_CURRENT_USER\\Software\\LarkCli\\keychain", _ws_policy(_ANY_WS), platform="win32" ) assert verdict.refusal is None, verdict assert verdict.grant == RootGrant(GRANT_REGISTRY, "HKCU\\Software\\LarkCli\\keychain") @@ -574,7 +591,7 @@ def test_a_key_is_validated_by_the_parser_the_applier_uses(): ]) def test_a_key_that_names_no_key_is_refused_with_the_parsers_own_reason(bad, phrase): """One parser, one wording: the refusal a host reads is `split_key`'s.""" - verdict = judge_registry_key(bad, _ws_policy("/tmp"), platform="win32") + verdict = judge_registry_key(bad, _ws_policy(_ANY_WS), platform="win32") assert verdict.refusal and phrase in verdict.refusal, verdict # ``store`` is deliberately not asserted: :class:`ExtraRootVerdict` documents # it as the caller's instruction for the *accepted* outcomes — "False only @@ -590,14 +607,14 @@ def test_the_spelling_is_judged_before_the_platform(): the more useful answer than "this platform has no registry" — the host would otherwise fix the platform question and meet the spelling one afterwards. """ - verdict = judge_registry_key("HKCU", _ws_policy("/tmp"), platform="darwin") + verdict = judge_registry_key("HKCU", _ws_policy(_ANY_WS), platform="darwin") assert verdict.refusal and "whole hive" in verdict.refusal, verdict def test_a_platform_that_cannot_apply_a_key_refuses_it_with_the_platform_sentence(): """The refusal names the platform's limit, and never a stage that has landed.""" verdict = judge_registry_key( - "HKCU\\Software\\LarkCli", _ws_policy("/tmp"), platform="darwin" + "HKCU\\Software\\LarkCli", _ws_policy(_ANY_WS), platform="darwin" ) assert verdict.refusal, verdict assert "cannot be enforced on" in verdict.refusal @@ -617,7 +634,7 @@ def test_naming_a_key_twice_reports_the_grant_that_is_stored(): """ policy = resolve_policy( mode="workspace-write", - workspace_root="/tmp", + workspace_root=_ANY_WS, extra_grants=[RootGrant(GRANT_REGISTRY, "HKCU\\Software\\LarkCli")], ) verdict = judge_registry_key( @@ -641,7 +658,7 @@ def test_a_key_named_twice_at_read_only_is_not_stored_twice(): """ policy = resolve_policy( mode="read-only", - workspace_root="/tmp", + workspace_root=_ANY_WS, extra_grants=[RootGrant(GRANT_REGISTRY, "HKCU\\Software\\LarkCli")], ) verdict = judge_registry_key( @@ -661,14 +678,49 @@ def test_a_key_is_withdrawn_by_the_flag_that_added_it(): policy, because a withdrawal is not a grant. """ verdict = judge_root_removal( - "HKEY_CURRENT_USER\\Software\\LarkCli", _ws_policy("/tmp"), GRANT_REGISTRY + "HKEY_CURRENT_USER\\Software\\LarkCli", _ws_policy(_ANY_WS), GRANT_REGISTRY ) assert verdict.refusal is None, verdict assert verdict.canonical == "HKCU\\Software\\LarkCli" - refused = judge_root_removal("HKCU", _ws_policy("/tmp"), GRANT_REGISTRY) + refused = judge_root_removal("HKCU", _ws_policy(_ANY_WS), GRANT_REGISTRY) assert refused.refusal and "whole hive" in refused.refusal +# ── §5c the region above reads its own precondition ─────────────────────── + + +def test_the_key_judges_never_spell_a_posix_workspace_root(): + """The registry judges read no workspace, so the root they are handed is derived. + + Ten tests in this section spelled ``"/tmp"`` as the workspace root, and the + Windows leg refused every one of them inside ``SandboxPolicy.__post_init__`` + before a line of the subject ran: ``os.path.isabs("/tmp")`` is **False** on + Windows, because a drive-less rooted spelling does not resolve under the cwd. + The Linux leg passed, so nothing here saw it. A key is granted as itself and + none of these judges reads the workspace, so the value is irrelevant to what is + measured — which is exactly why it must never be a literal. + + The region is bounded by the §5b and §5c markers rather than read to the end of + the file: a ``split(marker, 1)[0]`` bound widens to EOF, and §7's wire frame + legitimately carries a ``/tmp`` spelling this rule is not about. The guard keeps + its own section for a reason it measured: written inside §5b it failed on itself, + because the spelling it forbids is quoted in the sentence forbidding it. + """ + source = Path(__file__).read_text(encoding="utf-8") + start = source.index("# ── §5b a registry key is judged by its own rules") + end = source.index("# ── §5c the region above reads its own precondition") + region = source[start:end] + + assert "_ANY_WS" in region, "the region did not read — nothing was measured" + assert '"/tmp"' not in region, ( + "a registry-judge test spells a POSIX workspace root; os.path.isabs('/tmp') is " + "False on Windows, so SandboxPolicy refuses it before the subject runs" + ) + assert os.path.isabs(_ANY_WS), ( + f"the derived root must clear the predicate the policy applies, got {_ANY_WS!r}" + ) + + # ── §6 the daemon's command ───────────────────────────────────────────────