Skip to content

emrg: sandbox grants are typed, and the defaults arrive without a command - #2493

Merged
argszero merged 2 commits into
masterfrom
feature/sandbox-typed-grants
Oct 22, 2026
Merged

argszero merged 2 commits into
masterfrom
feature/sandbox-typed-grants

Conversation

@argszero

@argszero argszero commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Closes #2492

What changed

A grant is a (kind, target, source) triple. emrg/sandbox/grants.py holds the model — directory, file and registry kinds, session and default sources — and emrg/sandbox/roots.py keeps the single derivation every dialect reads:

reader the question it answers
stored_grants(policy) what this session names, typed and with provenance, at any tier
named_grants(policy) what a dialect may act on — refuses an unenforceable kind by name
granted_grants(policy) the enforceable surface one confined execution may write under
writable_roots(policy) the paths among those, for readers that can hold only paths

The kind is written once, by whoever names the grant. providers/darwin.py used to choose subpath (a whole tree) or literal (this name) with os.path.isdir at profile-build time, so a name the host granted as one file silently widened to everything beneath it the day that name became a directory. It reads the recorded kind now. The fence, the bubblewrap mounts and the Windows DACL path read the same derivation, and Session stores the typed list in meta.json (with the path projection beside it, from the same producer) so a restart no longer re-decides a grant's meaning from the disk.

A default manifest supplies what nobody typed. DEFAULT_MANIFEST names the state directories a tool needs writable, per platform; default_grants() is total — an entry whose directory this host does not have is simply absent, never an error. Provenance is what keeps this on the right side of host decision D5 (the deployment constant that was deleted): /sandbox list shows every entry with its source, one /sandbox remove withdraws the default for that session, and naming it again makes it the host's own. A default never widens a tier that grants nothing, because granted_grants returns [] for read-only without consulting the manifest at all.

registry is modelled and refused by name. ENFORCED_KINDS says which kinds each platform's dialects implement, and registry is in none of them: named_grants raises GrantKindNotEnforced, the fence answers the same sentence as text, and the profile builders let it propagate so a confined command fails closed rather than running under a profile that quietly omitted a grant. emrg/sandbox/win32/ applies DACLs to paths; a key needs its own enforcement, which is Stage 2.

Both clients show it. The daemon's sandbox_roots frame and resume_result snapshot carry grants (typed, with source) beside roots (the path projection existing readers speak). The TUI lists every grant with its kind and provenance; the GUI's dialog renders the same rows, labels a default, and its remove button sends that target — so a default is visible and withdrawable from either entry point.

The two decisions the issue asked for explicitly

Staging. This is the model half; registry enforcement in emrg/sandbox/win32/ is Stage 2. The issue's registry acceptance ("the default registry key is writable under workspace-write on the windows-2025 leg") is therefore not claimed here — the opposite is: no platform enforces that kind, test_no_platform_enforces_a_registry_key_yet pins it, and the Stage-2 change is the one that edits that test on purpose.

Platforms. No macOS pytest leg was added. macOS appears only in build-release.yml, which runs no suite, and one more runner on every PR is a cost this change does not need to pay: the Darwin profile builder is a pure function of the policy, so it is exercised on both existing legs (test_a_recorded_kind_wins_over_the_disk builds a real Seatbelt profile string), and what is left unmeasured is sandbox-exec itself enforcing it — which was already unmeasured before this change. The macOS acceptance case is not claimed. That claim was first written while the guard was red on the windows-2025 leg; the paragraph below says what it measured before the repair and what it measures now.

Verification

  • uv run pytest tests/ -q — 5647 passed, 34 skipped on this tree.
  • cd emrg/gui/renderer && npm run typecheck && npm test — 734 passed (48 files).
  • uv run python -c "from emrg.client.app import run_client" and uv run python -m emrg --help — both fine.
  • scripts/check-node-test-count.py — OK: Agent.md documents 734 renderer + 168 GUI tests; the per-file breakdown was synced too, and tests/test_doc_counts.py passes.

New guards (tests/test_sandbox_grants.py, plus additions to tests/test_sandbox_extra_roots.py and the renderer's bridge/dialog tests): the recorded kind beating the disk, one path named as two kinds staying two grants, a default applying without a command, a withdrawn default no longer granted and the write actually refused afterwards, the manifest granting nothing at read-only, the refusal naming the kind instead of dropping it, the wire form round-tripping with a malformed entry narrowing, and both clients rendering provenance.

tests/conftest.py pins the deployment manifest empty for the whole suite (the host's real ~/.lark-cli is not a test input); the test that measures the shipped table takes a snapshot of it at import, past that fixture.

The windows-2025 leg was red on the first head, and the defect was in the guards. 12ace6b0
reported 2 failed, 5179 passed, 499 skipped there, both in tests/test_sandbox_grants.py:

  • test_a_recorded_kind_wins_over_the_disk built its expected clause by interpolating the raw path, so it looked for the unescaped spelling while the profile carries what sbpl_string produces. On a host whose paths hold no backslash the two are equal — which is why a guard meant to be platform-independent was green here and red there. Its negative assertion failed the same way, and that one matters more: a spelling that can never match reports "absent" whatever the profile holds, so on that leg the direction that is supposed to catch a widening measured nothing.
  • test_the_path_projection_has_no_key_to_a_registry_grant passed workspace_root="/tmp", which is not absolute in the execution world on Windows, so it died in the policy constructor instead of measuring its claim.

Both are fixed in 06cbc46f: each clause now comes from the provider's own sbpl_string, and the registry test takes the tmp_path fixture so the root it runs under is not itself a platform statement. Neither test is skipped or gated by platform — they are the two that must run on that leg, and removing them would turn this PR's Stage-1 acceptance into an empty claim.

The repair is measured, not asserted:

  • Locally reproduced the Windows shape: a directory whose name carries a backslash makes the old spelling False with the grant visibly present and the new one True — the same comparison, on this host.
  • Two mutation arms, by node id, never -k: restoring os.path.isdir in _root_grant, and deleting its kind branch so every grant is spelled subpath. The guard reddens under both, and under the first both directions were read separately — literal present False, subpath absent False — so the negative assertion is shown to be a measurement rather than a string that could never match.

CI is pending on 06cbc46f — never read as green, and this cycle pushed that head, so it neither votes on it nor merges it.

…mand

The writable surface was list[str]: an object had a path and nothing else, so
its kind could not be stated and each enforcement dialect asked the disk for it
(seatbelt_profile_args chose subpath or literal with os.path.isdir at
profile-build time, so a name granted as one file widened to the tree beneath it
the day that name became a directory), and an object that is not a path could
not be named at all.

A grant is a (kind, target, source) triple now: emrg/sandbox/grants.py holds the
model, roots.py keeps the one derivation (stored_grants for display, named_grants
for what a dialect may act on, granted_grants for the enforceable surface and
writable_roots for readers that need only paths), and the kind is written once by
whoever names the grant instead of being re-decided per dialect.

A default manifest supplies the grants a tool needs so a host who types nothing
still has them writable; each entry is shown with its source and withdrawn by one
command, which is the line between this and the deployment constant host decision
D5 deleted. A registry grant is modelled but no platform enforces that kind yet,
so the derivation refuses it by name rather than dropping it.
@argszero

Copy link
Copy Markdown
Owner Author

One cosmetic nit, left as a note rather than a head move.

emrg/client/app.py builds the /sandbox list grant listing as "\n ".join(f"\n …"). Each element already carries its own leading newline and indent, so the separator adds a second one and two grants render with a bare line between them. Joining with "" fixes it.

This is not a correctness defect: every grant still shows its target, kind and source, a default is still visible, and a withdrawal still reads. No test pins that text. It is recorded here so whichever change next touches that listing folds it in, instead of opening a separate issue for one separator.

The head is unchanged.

…is one

Two tests in tests/test_sandbox_grants.py were red on the Windows leg, both in
the reading rather than in the grant:

- test_a_recorded_kind_wins_over_the_disk built its expected Seatbelt clause by
  interpolating the raw path, so it looked for `(literal "C:\Users\...")` while
  the profile carries the escaped spelling the provider produces. On a host whose
  paths have no backslashes the two spellings are equal, which is why the guard
  was green here and red there. Its negative assertion failed the same way, and
  for a second reason that matters more: a spelling that can never match answers
  "absent" whatever the profile holds, so that direction measured nothing at all
  on that leg. Both now take their clause from the provider's own sbpl_string.

- test_the_path_projection_has_no_key_to_a_registry_grant passed workspace_root
  "/tmp", which is not absolute in the execution world on Windows, so the test
  died in the policy constructor instead of measuring its claim. It now takes the
  tmp_path fixture: the claim is about a kind that is not a path, so the root it
  is made under must not itself be a platform statement.

The escaping fix reproduces the Windows shape on a POSIX host: a directory whose
name contains a backslash makes the old spelling false with the grant present and
the new one true. Both mutation arms redden the guard by node id — restoring
os.path.isdir in _root_grant, and deleting the kind branch so every grant is
spelled subpath — with the positive and the negative direction each read
separately.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

✅ LGTM — cycle cyc20261022-211211

Both legs are green on 06cbc46f: test 2m42s and test-windows 5m34s. The leg that was red on the previous head is the one I checked hardest, because a guard repaired until it passes on the leg it failed is exactly where a guard quietly loses its discriminating power.

Scope. git diff 12ace6b0 06cbc46f touches one file, tests/test_sandbox_grants.py, and no product code. That is the right shape: the failure was in the reading, not in the grant.

The three things I verified from the committed diff.

  • Both directions of test_a_recorded_kind_wins_over_the_disk now take their clause from the provider's own sbpl_string, so the expected string and the profile are escaped by one producer. The negative assertion used to compare an unescaped spelling against an escaped profile — a string that could never match, which answers "absent" whatever the profile holds, so the direction meant to catch a widening measured nothing on that leg.
  • No platform skip and no sys.platform branch was introduced. These two tests are the ones that must run on windows-2025, and gating them would have turned the PR's Stage-1 acceptance into an empty claim.
  • workspace_root="/tmp" became str(tmp_path) — an absolute execution-world path on every platform, which is what the policy constructor demands.

I ran the mutation arms myself rather than taking the PR's word for them, by node id and never -k:

  • restoring os.path.isdir in _root_grant → test_a_recorded_kind_wins_over_the_disk red;
  • fixing _root_grant to always spell subpath → red again;
  • and under the first arm, the two predicates read apart: (literal …) in profile → False, (subpath …) not in profile → False. Both directions redden, so the negative one is a measurement on this leg too.

The product file was restored byte-identically afterwards (sha256 compared against the backup) and git diff for emrg/sandbox/providers/darwin.py is empty.

On the merits. A grant is a (kind, target, source) triple, the kind is recorded once by whoever names it instead of being re-read from the disk at profile-build time, and one derivation feeds the Seatbelt profile, the fence, bubblewrap and the Windows DACL path. registry is modelled and refused by name everywhere until Stage 2 enforces it, and the PR says so rather than claiming the acceptance — the absence is pinned by a test, which is the honest form.

One vote from this cycle, so 1/3. I did not merge.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

✅ LGTM — cycle cyc20261022-211835

Second vote on this head (06cbc46f), taken from this cycle's own reading of the committed tree rather than from the previous cycle's note.

Both legs pass on this head: test 2m42s, test-windows 5m34s (run 39784357954). The Windows leg is the one that matters: the previous head was red there, and red inside the guards rather than inside the grant.

Read at the head rather than inferred: the expected Seatbelt clause is spelled by the provider's own sbpl_string (tests/test_sandbox_grants.py:109), both guards root themselves at tmp_path (:96, :157) instead of a POSIX /tmp that is not absolute in the execution world on Windows, and the file carries no skipif, no mark.skip and no sys.platform — so neither guard was dropped on a leg to make it pass, which is the failure the red leg invited. Both ran on this checkout: 2 passed.

The design half is what the issue asked for and what I am approving: grants are typed as a (kind, target, source) triple, one derivation feeds the Seatbelt profile, the fence, bubblewrap and the Windows DACL path, the kind is recorded once instead of re-read from the disk per dialect, Session persists the typed list so a restart does not re-decide a grant's meaning, and a default manifest applies without a command while showing its provenance in /sandbox list.

What this does not claim, and should not be read as claiming: registry grants are refused by name everywhere until Stage 2 enforces that kind in emrg/sandbox/win32/, and test_no_platform_enforces_a_registry_key_yet pins that gap; no macOS pytest leg exists, so the Darwin profile builder is a pure function exercised on both legs while sandbox-exec itself stays unmeasured.

2/3 after this vote. Two more cycles are still needed; this one does not merge it.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

✅ LGTM — cycle cyc20261022-212659

Third vote on this head (06cbc46f), from this cycle's own readings rather than the two that stand before it.

The landing is measured, not assumed. check-merge-freshness.py reports FRESH: the merge base 9f5ca50d is master's tip, so the green CI below still describes the tree a merge would produce, and no refresh is warranted — which matters, because a push here would void both standing votes.

Both legs pass on this head: test 2m42s, test-windows 5m34s (run 39784357954). The Windows leg is the one that had failed on the previous head, and it failed there inside the guards rather than inside the grant.

Ran here, on this checkout: tests/test_sandbox_grants.py, tests/test_sandbox_extra_roots.py, tests/test_sandbox_escalation.py, tests/test_session_sandbox.py → 137 passed, 1 skipped.

What the diff does, as I read it: grants become a (kind, target, source) triple with directory, file and registry kinds and session/default sources; one derivation (stored_grants / named_grants / granted_grants / writable_roots) is what the Seatbelt profile, the fence, bubblewrap and the Windows DACL path all read, so the dialects cannot drift apart; the kind is recorded once instead of re-read from the disk per dialect; Session persists the typed list so a restart does not re-decide a grant's meaning; and a default manifest applies with no command, shows its provenance in /sandbox list on both clients, and is withdrawn by one /sandbox remove.

Two limits, stated by the PR rather than found by me: registry grants are refused by name everywhere until Stage 2 enforces that kind in emrg/sandbox/win32/, and test_no_platform_enforces_a_registry_key_yet pins that gap; and there is no macOS pytest leg, so the Darwin profile builder is a pure function exercised on both legs while sandbox-exec itself stays unmeasured. Approving it does not claim either of those.

One cross-check the pair raises. This diff touches emrg/session.py, whose syntax tree is what #2494's one-producer guard reads. I checked the added lines for a new spelling of the session directory: 0 hits — so landing this cannot turn that guard red on the landed tree. check-merge-pairs.py measures both orders clean.

3/3 after this vote; the merge is this cycle's next act.

@argszero
argszero merged commit bcc6ac8 into master Oct 22, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

emrg: the sandbox grants paths only, and every extra root has to be named by hand

1 participant