Skip to content

emrg: a substitution between quotes is read as the command it runs (#1516) - #1519

Closed
pm25coder wants to merge 1 commit into
masterfrom
feature/quoted-substitution-is-a-command
Closed

pm25coder wants to merge 1 commit into
masterfrom
feature/quoted-substitution-is-a-command

Conversation

@pm25coder

Copy link
Copy Markdown
Collaborator

Fixes #1516.

A command substitution written inside double quotes reaches the guard as one token — quote
semantics are kept there deliberately (issue #1162: a > inside a quoted argument must not
become an operator) — so neither payload reader had a command word to recurse into, and the
tier question was answered about nothing. The reporter's own measurement (read-only): the
quoted spellings answered ALLOW with an empty target list and executed, while
git checkout . on its own was refused.

The change

_nested_command_texts gains an optional text — the masked line the caller tokenized.
_substitution_payloads reads the bodies of $( … ) and ` … ` off that line, which is
the only place the quote type is still visible, and hands each body back to the same
recursion
the named wrapper already uses: the readers judge a payload by their own rules
instead of by a second copy of them. All three callers (_extract_write_targets,
_find_git_mutator, _cwd_left_workspace) pass it; omitting text keeps the token-only
reading the walk had.

Three rules, each with its direction:

  • quoting decides — a single-quoted region is literal, so echo '$(git checkout .)' runs
    nothing and stays allowed; inside double quotes an apostrophe is an ordinary character, which
    is why the scan tracks the quote it stands in (echo "it's $(git checkout .)" is read);
  • $(( … )) is arithmetic: its text is not handed over as a command, but a substitution
    nested inside it is (echo "$(($(git checkout .)))");
  • everything else is read, including a body in a comment — the same over-read the
    named-wrapper walk already performs, and the fail-closed side of this guard.

A backslash is read the way _protect_windows_backslashes lets the tokenizer read it, so the
scanner and the tokenizer cannot disagree about which word a $( sits in: echo "\$(git checkout .)" stays literal on POSIX and is read (refused) on Windows, where a backslash is a
path character. The test reads that expectation off the module's own flag rather than writing
it down twice.

Measurement

Parity with the bare twin, at both tiers, _check_sandbox only, nothing executed. The
workspace-write column allows an in-workspace git mutator by design (the dirty-tree rule is
a read-only rule); what must not move is the row's relation to its own bare spelling:

row read-only workspace-write
git checkout . (the control) BLOCK ALLOW
echo "$(git checkout .)", echo "`git checkout .`", x="$(git checkout .)", echo "tail $(git checkout .)", echo "$(sh -c 'git checkout .')", echo "$(git checkout .)" > /dev/null BLOCK (was ALLOW) ALLOW, as the control
echo "$(rm -rf /tmp/x)", echo "$(touch /tmp/x)" BLOCK (was ALLOW, no target named) BLOCK (was ALLOW)
echo "$(cd <outside>)" && echo x > out.txt BLOCK BLOCK, as the bare cd <outside> && …
echo '$(git checkout .)', echo "not a substitution", echo "$(date)", echo "a > b" ALLOW (unchanged) ALLOW (unchanged)

Mutation arm (reproduced while writing this): with _substitution_payloads returning []
the same rows answer ALLOW at read-only again — 13/13 — so the new refusals are the reader's
and no other rule's. That arm is pinned as an assertion in the test file rather than left as a
claim.

Verification

  • tests/test_quoted_substitution.py — 63 tests, both directions, driven through
    _check_sandbox at both tiers: the quoted spellings refused for the command they run, their
    single-quoted twins allowed, the existing sandbox: read-only says no writes allowed, but only four command patterns block a write; plain rm and sed -i are allowed #1162 shapes (a quoted > / |) unchanged, the
    reader pinned spell by spell, and the mutation arm above.
  • uv run pytest tests/ -q: 4492 passed, 214 skipped. The failures are the 7 known
    environmental ones (1 test_check_merge_order.py::TestAgainstRealGitHistory::test_the_shapes_git_really_prints
    — this host's git rejects merge-tree --write-tree --quiet on the baseline too — and 6
    test_review_queue.py) plus test_the_index_derived_scans_reach_new_files, which asks for the
    new file to be staged and passes once it is (10 passed).
  • uv run python -c "from emrg.client.app import run_client" and uv run python -m emrg --help:
    both clean.

Note on scope

This closes the quoted-substitution hole for the three readers that consume the walk. It does
not make the guard resolve a substitution that a variable carries (x='$(git checkout .)'
in one command, echo "$x" in the next), which is the unresolved-word class of issue #1244 and
a separate reading.

@pm25coder

Copy link
Copy Markdown
Collaborator Author

Closing this one as a duplicate of #1518, which implements the same fix for issue #1516 and was opened before this PR became visible to this cycle's scan (open PRs were scanned at 16:27, #1518 was created at 16:36).

The two are independent implementations. Everything this branch pins that #1518 does not — the arithmetic rows — is reported as measured technical feedback on #1518 (comment above), so nothing is lost by closing this. The branch feature/quoted-substitution-is-a-command (commit cc2e411) stays on the remote if anyone wants to compare the two readings: it also carries tests/test_quoted_substitution.py (63 rows, both tiers, with the mutation arm pinned as an assertion).

@pm25coder pm25coder closed this Sep 21, 2026
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.

sandbox: a command substitution inside double quotes is one token, so both tiers run the command it names instead of refusing it

1 participant