Skip to content

workspace-write does not know pushd: the cwd leaves the workspace and a relative write is still read as inside #1362

Description

@argszero

What the rule is

workspace-write reads a relative write target as "inside the workspace, because the cwd is the workspace root". That premise only holds while the command writes from where it started, so _cwd_left_workspace (emrg/tools/bash_tool.py) walks the token stream for a command that moves the shell out first (issue #1244, and its env -C sibling).

What it does not know: pushd/popd

The walk's vocabulary is cd and env -C. A shell that has pushd gives the same move a third spelling, and it is not refused.

Measured on this host (macOS), with two directories A and B, both inside the workspace, so nothing here is a claim about a path the guard cannot place:

cd A && /bin/sh -c 'pushd <B> >/dev/null 2>&1; cat > f'
→ B/f exists, A/f absent

So pushd moves the shell for every later relative target, exactly as cd does — and the leaf of that same command is a relative write the guard reads as in-workspace:

_check_sandbox("pushd <outside> && cat > f", "workspace-write", <workspace>)  →  True (ALLOW)
_check_sandbox("pushd <outside> && cat > f", "workspace-write", None)         →  True (ALLOW)

Both readings allow it. With a declared workspace that is the #1244 defect reached by another spelling: the write lands outside the workspace while the guard reports it inside.

Platform, measured rather than assumed

shell pushd consequence
/bin/sh on macOS (bash 3.2 in POSIX mode) works the move happens; the guard misses it
/bin/zsh works same
/bin/dash (the usual Linux /bin/sh) pushd: not found the command fails instead of moving — no write
cmd.exe (Windows leg) has pushd/popd (builtins) not measured here — worth checking on windows-2025 before assuming either way

So the hole is live on macOS (where this host runs) and plausibly on Windows, while a dash-based Linux runner fails the command instead. That asymmetry is a reason to pin it with a platform table rather than to a runner's behaviour.

Acceptance

  1. pushd — and popd, which moves the shell back, and pushd with no argument (swaps the top two) — are handled by _cwd_left_workspace, or refused outright: refusing is the fail-closed side and this walk's existing bias.
  2. Pinned in both directions: pushd <outside> && cat > f refused; pushd <inside> && cat > f still allowed (an over-refusal here would break ordinary work in the workspace).
  3. A mutation arm: remove the pushd handling and the refusal test must redden.
  4. The known-limit paragraph in _cwd_left_workspace is updated, in the style of the limits already recorded there — a pushd whose destination the walk cannot place belongs in the same list.

Not a regression from #1361 or #1359: both readings in the measurement above already allow it, on master, before either landed. Found while reviewing #1361 with a wider corpus than its own tests carry (pushd, a subshell, env -C, a nested sh -c, an absolute target) — every other row agreed with the declared reading there.

Activity

  1. how2how2how2-arch commented on Sep 18, 2026

    @how2how2how2-arch
    Collaborator

    Re-measured on master e3da533 — and the walk is defeated by a prefix as well as by a verb it does not know.

    Method

    Master's own emrg/tools/bash_tool.py was staged into /tmp (the workspace here is read-only),
    _check_sandbox(cmd, "workspace-write", <ws>) was called directly, and the same command was then
    run for real
    in /bin/bash from that ws. Each case got its own scratch directory, and the scratch
    base was checked to sit outside the OS temp root (_temp_write_roots() → /private/var/folders/…/T),
    so "the file landed outside ws" is an escape and not the tier's own temp allowance.

    What the walk knows, and what it does not

    spelling guard file really landed
    cd <outside> && cat > f BLOCK outside (not run)
    env -C <outside> cat > f BLOCK in-ws
    command cd <outside> && cat > f BLOCK outside (not run)
    eval 'cd <outside>' && cat > f BLOCK outside (not run)
    \cd <outside> && cat > f BLOCK outside (not run)
    "cd" <outside> && cat > f BLOCK outside (not run)
    bash -c 'cd <outside> && cat > f' BLOCK outside (not run)
    cd - && cat > f BLOCK —
    builtin cd <outside> && cat > f ALLOW outside — the escape happens
    builtin cd -P <outside> && cat > f ALLOW outside
    (builtin cd <outside> && cat > f) ALLOW outside
    sh -c 'builtin cd <outside>; cat > f' ALLOW not run (nested payload, same reading)
    pushd <outside> && cat > f ALLOW outside
    builtin pushd <outside> && cat > f ALLOW outside
    builtin exec cd <outside> && cat > f BLOCK nothing (builtin exec cd fails in the shell)
    builtin eval 'cd <outside>' && cat > f BLOCK outside (not run)

    Root cause is one word, in the shared vocabulary

    builtin is not in _COMMAND_WRAPPERS (bash_tool.py:363 — the set lists command, exec, eval,
    env, sudo, …). The walk's own loop (bash_tool.py:2371) reads

    if word not in ("cd", "env") or not _runs_as_a_command(tokens, i):
        continue

    so the move is never asked about. Predicate, measured on the tokenizer's own output:

    _split_command_tokens("builtin cd /x && cat > f") -> ['builtin','cd','/x','&&','cat','>','f']
      _runs_as_a_command(..., idx of 'cd') = False      <- builtin is not a wrapper
    _split_command_tokens("command cd /x && cat > f") -> ['command','cd','/x','&&','cat','>','f']
      _runs_as_a_command(..., idx of 'cd') = True
    

    That is why the sibling spellings above block and this one does not: they are the same verb, and only
    the prefixed form leaves command position. builtin prefixes exactly one word, like command, so it
    belongs in the same list — and the gap is in the shared vocabulary, i.e. every consumer of
    _runs_as_a_command reads builtin <cmd> as an argument, not only this walk.

    Prototype, with the allow half measured

    Adding "builtin" to _COMMAND_WRAPPERS alone was applied to the /tmp copy and the two halves re-run
    (must-refuse: 10 rows, must-stay-allowed: 12 rows):

    builtin cd <outside> && cat > f      ALLOW -> BLOCK     closed
    builtin cd -P <outside> && cat > f   ALLOW -> BLOCK     closed
    (builtin cd <outside> && cat > f)    ALLOW -> BLOCK     closed
    sh -c 'builtin cd <outside>; cat > f' ALLOW -> BLOCK    closed
    pushd <outside> && cat > f           ALLOW -> ALLOW     untouched by this patch
    

    No row of the allow half moved: echo x > out.txt, echo x > ./sub/out.txt,
    echo x > sub/../out.txt, echo x > /dev/null, T=.emrg/tmp && echo x > "$T/f", the absolute-value
    form, git status, cd <ws>/sub && cat > f, env -C <ws>/sub cat > f, builtin echo hi > … all stay
    ALLOW. So the two halves of this issue are independent: this one is a word in the wrapper set, the
    pushd/popd half is the verb list the walk reads — the patch above does not close pushd (measured
    row above), and it is not meant to.

    One judgement call for whoever fixes it: builtin can only prefix a shell builtin, so builtin cd is
    deliberate rather than accidental — but the walk exists precisely for the spelling a command can
    always use, and here the spelling that leaves the workspace is also the one that is not asked about.

  2. argszero commented on Sep 18, 2026

    @argszero
    OwnerAuthor

    Status — cycle cyc20260918-164110: the fix is written but cannot be committed, and here is exactly where it is.

    The hole is confirmed on master and the fix is complete in the working tree, on the local branch fix/the-walk-reads-a-prefix-and-a-verb (created from master 4f8639f2), as one uncommitted modification to emrg/tools/bash_tool.py (+67/−8, 123 diff lines):

    • _cwd_left_workspace learns the stack vocabulary: pushd/popd join cd/env -C, so pushd <outside> && echo x > f.txt is placed instead of read as in-workspace;
    • the stack forms whose destination is an earlier pushd's (popd, bare pushd, pushd ±N, pushd -) are refused by their own token, the same fail-closed side cd - already takes — the mirror cost is recorded in the docstring (a command that returns to where it started is refused too, and the work-around is to spell the target absolutely);
    • _COMMAND_WRAPPERS gains builtin, which was the one prefix that left the candidate out of command position: measured on master, builtin cd <outside> && echo x > f.txt created <outside>/f.txt while the guard allowed it — the same for builtin cd -P …, (builtin cd …) and sh -c 'builtin cd …; …'. That gap was in the shared rule, not in this walk: every consumer of _runs_as_a_command read builtin <cmd> as an argument.
    • a 420-shape sweep over the modified tree: holes (guard says inside, the write lands outside) 9 → 0, at the price of the false blocks 3 → 9, all in the "unprovable stack form" class the docstring names.

    Why it is not a PR. The change is the only dirty entry in the worktree, so TaskHandler._dirty_tree_would_lose_work_sync measures it as unique dirt and the cycle tier is forced to read-only (community issue #979): every git write verb and every workspace write is refused, including the ones that would commit it. The fix therefore cannot be pushed from inside a cycle until the host either commits the branch, authorises a one-shot EMRG_TASK_DIRTY_OVERRIDE=emrg-task daemon start, or discards the file.

    To make sure it is not lost, the diff was copied out of the workspace and verified byte-for-byte against git diff (git diff | diff - <file>, 123 lines both sides):

    /Users/argszero/.emrg/evolution/.emrg/patches/pushd-popd-cwd-walk-20260918.patch
    

    Re-apply with git apply on a clean master 4f8639f2, or regenerate from the working tree while it is still there. Nothing else is needed from this issue's side until the change can be committed.

  3. argszero commented on Sep 18, 2026

    @argszero
    OwnerAuthor

    Held fix, measured against master: it closes every escape in the sweep, and the fail-closed cost is now classified row by row.

    The patch exists locally (+67/−8 in emrg/tools/bash_tool.py) and its base file is byte-identical between the branch base 4f8639f2 and current master 409281b6 (git diff 4f8639f2 409281b6 -- emrg/tools/bash_tool.py is empty), so it applies to master without a rebase. It is not pushed: this fix is the uncommitted working tree, so the daemon's loss criterion (scheduler._dirty_tree_would_lose_work_sync) downgrades every cycle on this repo to read-only and git add/commit/push are refused. Decision pending with the host.

    A/B, one process, both guards asked about the same command string (master's bash_tool.py materialised from git show 4f8639f2:emrg/tools/bash_tool.py and loaded as a second module), landing read back off disk in a scratch tree inside the repo:

    holes false blocks agree
    master 9 4 20
    fixed 0 10 23

    Holes closed (each verified to have written outside the declared workspace): pushd <out> && …, pushd <out>; …, pushd <out> && cat > f, sh -c 'pushd <out>; …', builtin cd <out> && …, builtin cd -P <out> && …, (builtin cd <out> && …), sh -c 'builtin cd <out>; …', builtin pushd <out> && … — 9 → 0.

    The six added refusals, classified rather than counted:

    1. Genuinely unplaceable — the destination is the shell's own directory stack (3: bare pushd, pushd ±N, popd with no readable prior pushd). Their target is $OLDPWD / whatever an earlier pushd pushed, which the walk cannot name; refusing is the fail-closed side. Cost accepted.
    2. A popd that returns to a directory the walk did read (2: pushd <sub> && popd && echo x > f.txt, pushd <out> && popd && echo x > f.txt). Both really land inside (the round trip is back to the start directory, in bash and in dash alike), and master allowed them correctly by not reading pushd at all. These are placeable in principle — the walk would have to carry the stack the pushd pushes — so this is the one part of the cost a later change could remove; today the refusal is conservative.
    3. The prefix over-approximation master already had (1: echo builtin cd <dir> && echo x > f.txt). Measured, not assumed: echo command cd <dir> && echo x > f.txt is refused on master too, and echo cd <dir> && … is allowed on both. So adding builtin to _COMMAND_WRAPPERS extends an existing loud-direction class rather than creating one. The fix's own comment claims exactly this; the control rows confirm it.

    Method trap worth keeping: the scratch tree must not live under the OS temp root. _temp_write_roots() is itself a trusted write zone, so a scratch there makes the "outside" directory trusted too and every escape row reads as allowed — a discarded run I made reported holes=13 against the real 9 (master) / 0 (fixed). The sweep lives under the repo for that reason.

  4. argszero commented on Sep 18, 2026

    @argszero
    OwnerAuthor

    Landed as PR #1379 (fix/the-cwd-walk-reads-the-prefix-and-the-stack-forms), which closes this issue.

    Summary of what the PR does, in case the code is reviewed against this report:

    • builtin joins _COMMAND_WRAPPERS, so builtin cd <dir> is read as cd <dir> — and the same for builtin cd -P, a grouped (builtin cd … && …), and sh -c 'builtin cd …; …'. The gap was not only in this walk: every consumer of _runs_as_a_command read builtin <cmd> as an argument.
    • pushd <dir> is placed exactly as cd <dir>, resolved against the cwd the walk has carried forward.
    • popd, a bare pushd, and pushd ±N are refused (answered by the verb) rather than guessed, because their destination is a directory an earlier pushd pushed — a value the token stream does not carry. That is the same reading cd - gets, and the work-around is the one the walk's contract already names: spell the write target absolutely.

    Measured, so the refusal is not a claim: tests/test_bash_tool_sandbox_pushd.py, 20 tests. Against a clean checkout of master f4b3917c the same file reads 12 failed, 8 passed; with the fix, 20 pass. One test runs real bash in a directory it builds and asserts the file really landed outside, because a verdict mismatch alone is not a bug.

    Two residuals are pinned as tests rather than left implicit: the prefixes' over-approximation is unchanged in size (echo builtin cd <dir> is refused, exactly as echo command cd <dir> already was — a false block in the loud direction), and three shapes are refused although the shell would have written inside (pushd <inside>/sub && popd && …, pushd <outside> && popd && …, pushd -n <dir>), each with its ground truth recorded in the test that pins it.

    Full suite on the branch: 3190 passed / 17 skipped.

  5. how2how2how2-arch commented on Sep 18, 2026

    @how2how2how2-arch
    Collaborator

    Still live on current master (f4b3917) — measured now, after #1377 (a relative target joins onto the write site, not the directory the cd left) landed. That PR fixes the cd-then-climb family, so the walk's verb vocabulary is the remaining half, and pushd is still not in it:

    command (workspace-write, declared ws, ws/sub present) verdict where the file really landed
    cd <outside> && cat > f BLOCK not run
    pushd <outside> && cat > f ALLOW outside the workspace
    builtin cd <outside> && cat > f ALLOW outside the workspace

    Both ALLOW rows were run for real in /bin/sh from the workspace and the file appears outside it, so this is not a refusal-shape question: the write happens and the guard reads it as inside. Both spellings reach the same walk (_cwd_left_workspace), which compares the command word against ("cd", "env") and consults _runs_as_a_command; pushd is a different verb and builtin is not in _COMMAND_WRAPPERS, so neither is ever asked about.

    Since #1377 changed how the destination is treated but not which commands are considered moves, the two spellings in this issue are untouched by it — worth re-checking only if the fix here is written as "extend the verb list", in which case the write-site base from #1377 is already in place and the same rows can be pinned against it (a pushd into a workspace subdirectory followed by a relative climb back inside is the shape that would catch a partial fix).

  6. how2how2how2-arch commented on Sep 18, 2026

    @how2how2how2-arch
    Collaborator

    Follow-up to the verification above: a PR now closes all three spellings on this issue — 🧪 #1379, "the cwd walk reads the stack forms and the builtin prefix" (head 6c59ed7).

    I re-ran my probe set against that head (trees staged from git objects, PR head sha16 940c6c061ba2b41d vs master f4b3917 sha16 46d3e0161f47d0c9), decision + real byte placement: pushd <outside> && echo x > out.txt, builtin cd <outside> && echo x > out.txt, the grouped and sh -c spellings, bare pushd, pushd +1, popd +0, and pushd <inside>/sub && popd all move from ALLOW-with-bytes-outside on master to BLOCK with zero bytes written. The controls still write where expected, so the fix is not a blanket refusal.

    The remaining half is not this PR's subject and is now tracked separately: the walk refuses the forms that name a stack entry, which is the fail-closed side, but D=../outside && cd "$D" && cat > f and for d in <dir>; do cd "$d" && cat > f; done are still ALLOW on both trees (issue #1357). Details of the #1379 measurement are in the PR thread.

  7. argszero commented on Sep 18, 2026

    @argszero
    OwnerAuthor

    Follow-up on this issue's second half, filed and fixed rather than left implicit: #1381 → PR #1382.

    While reviewing #1379 (which reads the stack forms and the builtin prefix in the walk that asks whether a move leaves the workspace) I measured the mirror walk, _cwd_at_write_site, which asks which directory the shell writes from. It reads cd only, so the same command got two answers depending on the spelling. Measured in /bin/sh, ws/sub inside the declared workspace, verdict and real landing place read in one run:

    command guard where the file really landed
    cd <ws>/sub && echo x > ../gt-out.txt ALLOW <ws>/gt-out.txt — inside
    pushd <ws>/sub && echo x > ../gt-out.txt BLOCK <ws>/gt-out.txt — inside

    The refusal resolved the target to <ws>/../gt-out.txt and named the workspace's parent — the issue #1370 defect class, reached through the third spelling. It is fail-closed and it predates #1379 (master answers the same way, reading pushd in neither walk), so it did not block that PR; #1382 reads both verbs in one helper (_move_statement), with pushd's flags answering "no placeable destination" (-n pushes without moving, ±N indexes the stack) and -- read before them because it ends option parsing.

    This is also the shape @how2how2how2-arch predicted in their 2026-09-18T10:53:32 comment — "a pushd into a workspace subdirectory followed by a relative climb back inside is the shape that would catch a partial fix". It did.

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