fix(source-control): make worktree_root handoff shell-safe for unset and special-char values - #1334
Conversation
…and special-char values
${user_config.worktree_root} substitution into skill content is raw text, not
shell-escaped, so neither quote style around an inline `--root
'${user_config.worktree_root}'` literal was fully safe: double-quoted broke on
an unset key, and the interim single-quoted fix (#898) broke on a configured
root containing a single quote, $, or a backtick.
worktree-create.sh gains an additive --root-file <path> flag that reads the
root from a file instead of a process argument. Both render sites
(context/create.md, SKILL.md) now write the substituted value to a temp file
via a quoted heredoc (fully literal, no expansion) and pass --root-file
instead of inlining the value in a --root shell literal. The existing
unset/unexpanded-token refuse (exit 3) is reused unchanged.
Adds worktree-create.test.sh coverage for the new flag (mutual exclusivity,
missing file, special-char root, empty content, unexpanded token) and a
create-action eval asserting the safe render shape.
Closes #965
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 8s —— View job PR Review:
|
| Case | Flag combo | Expected exit |
|---|---|---|
Both --root + --root-file |
mutual exclusivity | 2 |
--root-file missing file |
file existence | 2 |
Special-char root (', $, backtick) |
happy path | 0 |
| Empty file content | unset guard reuse | 3 |
Literal ${user_config.worktree_root} token |
unset guard reuse | 3 |
The TEST_TMPDIR_NATIVE / cygpath -m logic for the special-char fixture is thoughtful — the MSYS POSIX→Windows auto-conversion heuristic is documented to fail when the path carries metacharacter bytes, and anchoring to a drive-letter form is the right fix. The inline comment explains it clearly.
One untested edge: a file with no trailing newline (e.g. printf '%s' '/some/path' > "$root_file"). IFS= read -r root < "$root_file" exits with status 1 (EOF without newline) but still populates root correctly; the script does not check read's exit status, so this works. Not a bug, but worth a quick note in the comment or a test case for future readers.
Documentation and evals
SKILL.md — The updated headline rule precisely captures the constraint ("never inline it in a quoted --root argument — a value containing ', $, or a backtick would break the shell literal") and cross-references the render. ✅
Eval ID 9 — The expectations are concrete and testable: no inlined --root literal, heredoc present, exit-3 contract preserved. ✅
CHANGELOG.md — Entry is clear, cites both the original and interim issues (#898, #965), explains why both quote styles failed, and describes the contract-preserving behavior for the unset case. ✅
Overall
Approve with the trap observation noted. The fix is correct, the guard reuse for the unset case is elegant, the Windows fixture handling is careful, and the test suite covers all the meaningful branches. The temp file cleanup gap is minor and can be addressed in a follow-up if desired.
|
Claude finished @kyle-sexton's task in 2m 59s —— View job Security Review — PR #1334Tasks
OverviewThis PR replaces an unsafe inline shell-literal pattern (embedding a raw-substituted FindingsSUGGESTION · Low severity · CONFIRMEDHeredoc delimiter cat > "$root_file" <<'WT_ROOT_EOF'
${user_config.worktree_root}
WT_ROOT_EOFAfter plugin substitution, if The consequence is a confusing failure rather than a security bypass, but it is a real code edge case. Using a higher-entropy delimiter such as SUGGESTION · Low severity · CONFIRMEDTemp file persists when Claude halts on a non-zero helper exit before reaching root_file="$(mktemp)"
cat > "$root_file" <<'WT_ROOT_EOF'
${user_config.worktree_root}
WT_ROOT_EOF
bash "${CLAUDE_PLUGIN_ROOT}/scripts/worktree-create.sh" \
--name "<validated-name>" --root-file "$root_file"
rm -f "$root_file" # ← not reached if Claude stops on non-zero exit aboveThe skill correctly instructs Claude to STOP on any non-zero helper exit, but the cleanup line is after the invocation. When Claude stops early the temp file survives. Positive security notes
No critical or important security issues found. The two suggestions above are hygiene-level items; the core fix is mechanically correct and an unambiguous security improvement over the prior single-quoted inline pattern. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9e6189243a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…entirely A quoted heredoc suppresses expansion inside its body but cannot prevent delimiter collision: a worktree_root value carrying a line equal to the delimiter ends the heredoc early and the shell parses the remainder as commands. Both render sites now write the raw-substituted value with the Write tool -- a JSON string parameter no shell parses -- and the helper rejects a multi-line root file instead of silently taking its first line. Also drops the macOS-user-path example the machine-specific-paths gate flags and the mktemp-template token the comment-hygiene gate reads as a warning marker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012SV88ZgGDKdUKEPJaJtDpG
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1e77dcb242
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Conflicts composed rather than sided: - worktree-create.sh usage: main's --name branch-grammar paragraph kept alongside this branch's rewritten --root / --root-file text. - plugin.json / CHANGELOG.md: main's 0.26.9 line taken, this branch's entry renumbered to 0.26.10 on top of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012SV88ZgGDKdUKEPJaJtDpG
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
|
Warning Automated review did not complete — this is an infrastructure failure, not a review verdict. Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."
Re-running the job, or pushing a new commit, will retry the review. |
|
Warning Automated security review did not complete — this is an infrastructure failure, not a review verdict. Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."
Re-running the job, or pushing a new commit, will retry the review. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aa2c566892
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Three review findings on the Write-tool handoff: - The rendered block ended with `rm -rf`, so a helper refusal (exit 3) was reported as 0 and the next step's "stop on non-zero" never fired. Capture the status before cleanup and re-exit with it. - The worktree eval still demanded a quoted heredoc, so it would have failed the safe rendering and rewarded the delimiter-collision hole this change removes. - `--root-file` counted lines, which trims a trailing newline indistinguishably from a root whose own last byte is one. The file's bytes are now the root verbatim; any newline is a usage error. Verified empirically that the Write tool appends nothing, so the handoff produces no terminator to trim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012SV88ZgGDKdUKEPJaJtDpG
main released 0.26.10; this branch's entry moves to 0.26.11 above it and plugin.json follows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012SV88ZgGDKdUKEPJaJtDpG
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9890a31260
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… values Two more review findings on --root-file: - Command substitution drops NUL bytes, so `<root>-<NUL>suffix` collapsed to `<root>-suffix` and created a worktree at a path nobody supplied. The check now compares byte counts on the file itself, before the value can reach a shell variable that could never hold a NUL anyway. - The mutual-exclusion guard tested the resulting strings, so `--root ''` counted as absent and let --root-file quietly win. It now keys off whether each flag appeared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012SV88ZgGDKdUKEPJaJtDpG
main took 0.26.11; this branch's entry moves to 0.26.12 above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012SV88ZgGDKdUKEPJaJtDpG
|
Claude encountered an error after 5s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
This was generated by AI during work-loop execution.
Summary
${user_config.worktree_root}substitution into skill content is raw text substitution, notshell-escaped (confirmed this session against the official
plugins-reference § User configuration
docs), so neither quote style around the inline
--root '${user_config.worktree_root}'literal incontext/create.md/SKILL.mdwas fully safe: unset broke double-quoting (original finding), and aconfigured root containing
',$, or a backtick broke the interim single-quoted fix (feat(source-control): route /worktree create through a shared worktree-creation helper (#399 Phase A) #898).worktree-create.shgains an additive--root-file <path>flag that reads the root from a file'sfirst line instead of a
--rootprocess argument. Both render sites now write the substituted valueto a temp file via a quoted heredoc (
<<'WT_ROOT_EOF'— fully literal, no expansion orquote-processing) and pass
--root-file, never inlining the value in a--rootshell literal.EnterWorktree(name:)fallback) is reused unchanged and reached through the file path: an unset key still leaves the
literal
${user_config.worktree_root}token, the heredoc writes it verbatim, and the guard stillfires.
--rootis untouched and stays available for a caller that already holds the value as a realprocess argument (a hook, or direct CLI use) — no shell-literal risk on that path.
Test plan
plugins/source-control/scripts/worktree-create.test.shwith 8 new cases covering the--root-fileflag: mutual exclusivity with--root(exit 2), a missing file (exit 2), a rootcontaining
',$, and a backtick materializing at the exact computed path (exit 0), empty filecontent refusing (exit 3, reuses the unset guard), and the literal unexpanded
${user_config.worktree_root}token via the file refusing (exit 3).plugins/source-control/skills/worktree/evals/evals.json, id 9)asserting the skill renders the safe out-of-band handoff (no single-quoted
${user_config.worktree_root}shell literal) and preserves the exit-3 stop-and-surface contract.shellcheckclean on both the helper and its test file (after moving one# shellcheck disable=SC2016to the line it actually needed to cover).markdownlint-cli2clean on the two render-site markdown files after fixing anMD031 (blanks-around-fences) violation introduced by an early draft.
plugin.jsonand the extendedevals.jsonagainst their respective JSON Schemas(
check-jsonschema, bothok).origin/mainfor this diff: docs-only detector (notdocs-only, correctly runs full suite),
check-changed-skills.sh(worktree skill: PASS, 0 errors),check-changelog-parity.sh --checkand--check-bump(both pass — version bump 0.26.2 → 0.26.3with a matching
## [0.26.3]entry),check-skill-portability.sh(no unexcused coupling tokens).initially inherited
$TEST_TMPDIR's bare POSIX/tmp/...form, which defeated MSYS'sPOSIX→Windows argv auto-conversion for
git.exewhen the path also carried$/backtick bytes,silently landing the worktree at the wrong physical location even though the printed path and exit
code looked correct. Fixed by anchoring that one fixture to a
cygpath -m-normalized(
TEST_TMPDIR_NATIVE) drive-letter path instead — matching how a real Windowsdirectory-typeuserConfigvalue actually resolves.Related
Closes #965. Origin: #898 (interim single-quote fix) and the Codex review that surfaced the
special-char gap; consolidated triage/ratification trail is on the issue itself.