Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion emrg/client/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion emrg/client/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1037,7 +1037,8 @@ def render(self, ctx):
"/rant": "Send feedback to the evolution system [/rant | /rant @<project> <msg>]",
"/model": "Switch LLM model [/model | /model <name>]",
"/sandbox": "Set this session's sandbox tier, or its extra writable roots "
"[/sandbox | /sandbox <mode> | /sandbox add|remove|list <path>]",
"[/sandbox | /sandbox <mode> | /sandbox add|remove|list <path>] "
"[/sandbox add|remove --registry <key> names a registry key]",
"/trigger": "List or manually trigger scheduled tasks [/trigger | /trigger <name>]",
"/task-session": "Open a scheduled task's session (no args = interactive picker)",
"/skills": "List loaded skills (user + project)",
Expand Down
54 changes: 48 additions & 6 deletions emrg/sandbox/grants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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}"
)


Expand Down
110 changes: 90 additions & 20 deletions emrg/sandbox/roots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions emrg/sandbox/win32/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 35 additions & 0 deletions tests/test_bash_v2_win32.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")


# ── the ABI declarations ─────────────────────────────────────────────────


Expand Down
Loading
Loading