Repository navigation
emrg: sandbox grants are typed, and the defaults arrive without a command - #2493
Conversation
…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.
|
One cosmetic nit, left as a note rather than a head move.
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
left a comment
There was a problem hiding this comment.
✅ 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_disknow take their clause from the provider's ownsbpl_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.platformbranch was introduced. These two tests are the ones that must run onwindows-2025, and gating them would have turned the PR's Stage-1 acceptance into an empty claim. workspace_root="/tmp"becamestr(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.isdirin_root_grant→test_a_recorded_kind_wins_over_the_diskred; - fixing
_root_grantto always spellsubpath→ 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
left a comment
There was a problem hiding this comment.
✅ 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
left a comment
There was a problem hiding this comment.
✅ 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.
Closes #2492
What changed
A grant is a
(kind, target, source)triple.emrg/sandbox/grants.pyholds the model —directory,fileandregistrykinds,sessionanddefaultsources — andemrg/sandbox/roots.pykeeps the single derivation every dialect reads:stored_grants(policy)named_grants(policy)granted_grants(policy)writable_roots(policy)The kind is written once, by whoever names the grant.
providers/darwin.pyused to choosesubpath(a whole tree) orliteral(this name) withos.path.isdirat 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, andSessionstores the typed list inmeta.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_MANIFESTnames 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 listshows every entry with its source, one/sandbox removewithdraws the default for that session, and naming it again makes it the host's own. A default never widens a tier that grants nothing, becausegranted_grantsreturns[]forread-onlywithout consulting the manifest at all.registryis modelled and refused by name.ENFORCED_KINDSsays which kinds each platform's dialects implement, andregistryis in none of them:named_grantsraisesGrantKindNotEnforced, 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_rootsframe andresume_resultsnapshot carrygrants(typed, withsource) besideroots(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 underworkspace-writeon thewindows-2025leg") is therefore not claimed here — the opposite is: no platform enforces that kind,test_no_platform_enforces_a_registry_key_yetpins 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_diskbuilds a real Seatbelt profile string), and what is left unmeasured issandbox-execitself 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 thewindows-2025leg; 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"anduv 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, andtests/test_doc_counts.pypasses.New guards (
tests/test_sandbox_grants.py, plus additions totests/test_sandbox_extra_roots.pyand 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 atread-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.pypins the deployment manifest empty for the whole suite (the host's real~/.lark-cliis not a test input); the test that measures the shipped table takes a snapshot of it at import, past that fixture.The
windows-2025leg was red on the first head, and the defect was in the guards.12ace6b0reported
2 failed, 5179 passed, 499 skippedthere, both intests/test_sandbox_grants.py:test_a_recorded_kind_wins_over_the_diskbuilt its expected clause by interpolating the raw path, so it looked for the unescaped spelling while the profile carries whatsbpl_stringproduces. 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_grantpassedworkspace_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 ownsbpl_string, and the registry test takes thetmp_pathfixture 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:
Falsewith the grant visibly present and the new oneTrue— the same comparison, on this host.-k: restoringos.path.isdirin_root_grant, and deleting its kind branch so every grant is spelledsubpath. The guard reddens under both, and under the first both directions were read separately —literalpresentFalse,subpathabsentFalse— 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.