Context
Follow-up from PR #145 (closes #144) — reviewer's clean-with-nits finding.
After #144 lands, non-gh loadouts (claude-jira / claude-notion / claude-local) can pass any opaque string as the spec identifier. task-reviewer writes it unquoted into the worktree's .info file:
printf 'ISSUE_NUMBER=%s\n' "$ISSUE_NUMBER"
Producing lines like:
ISSUE_NUMBER=PROJ-$(date)
task-done then reads the file via source "$INFO_FILE" (claude-gh/bin/task-done:54). The shell re-parses the value — $(...) executes, $VAR expands, backticks execute.
Practical risk: zero
- Real Jira keys:
PROJ-123 — no metacharacters.
- Real Notion URLs:
https://notion.so/...?p=... — %-encoded special chars.
- Real local slugs:
kebab-case-string — no metacharacters.
The vector requires explicitly crafted identifiers and would be a self-inflicted local operation. Filed for completeness and code-hygiene, not because exploitation is realistic.
Why fix anyway
Proposed fix
Two options:
Option A — quote on write (defensive at source):
- printf 'ISSUE_NUMBER=%s\n' "$ISSUE_NUMBER"
+ printf 'ISSUE_NUMBER=%q\n' "$ISSUE_NUMBER"
…in task-reviewer. task-done's source then sees a quoted assignment that bash parses safely.
Option B — replace source with awk parser in task-done (defensive at read):
task-done already uses an awk-based read for TAB_ID (claude-gh/bin/task-done:116, 121):
_WORKER_TAB_ID=$(awk -F= '/^TAB_ID=/ { print $2; exit }' "$INFO_FILE")
Extend the same pattern to every field task-done currently relies on from $INFO_FILE:
Drop the source "$INFO_FILE" line. Each field gets a parser-side awk read; nothing in the file is ever eval'd.
Recommended: Option B. Defends regardless of write-side discipline (a future loadout adding a new field can't accidentally introduce the vector). Mirrors the existing TAB_ID pattern.
Files (expected touchpoints)
| File |
Action |
claude-gh/bin/task-done (+ all 4 claude + 3 kiro variants in drift groups task-done-std / task-done-local) |
MODIFY — replace source with field-wise awk reads |
tests/task_done.bats |
EXTEND — add a test that passes a .info containing ISSUE_NUMBER=PROJ-$(date) and asserts task-done completes without $(date) evaluating |
claude-gh/bin/task-reviewer (and 3 sibling drift members) |
(Option A only) — write printf %q quoted value |
tools/check-drift.sh should stay green — the changes are inside existing confirm-and-cleanup / worktree-context regions.
Verification
bats tests/task_done.bats — green, including the new injection-attempt test.
tools/check-drift.sh — green.
- End-to-end: PM dispatches
task-reviewer 42 'PROJ-$(date)' in a claude-jira scratch project; later task-done --remove-worktree in that reviewer's worktree completes without $(date) being substituted.
Why P2 / no milestone
References
Context
Follow-up from PR #145 (closes #144) — reviewer's clean-with-nits finding.
After #144 lands, non-gh loadouts (claude-jira / claude-notion / claude-local) can pass any opaque string as the spec identifier.
task-reviewerwrites it unquoted into the worktree's.infofile:Producing lines like:
task-donethen reads the file viasource "$INFO_FILE"(claude-gh/bin/task-done:54). The shell re-parses the value —$(...)executes,$VARexpands, backticks execute.Practical risk: zero
PROJ-123— no metacharacters.https://notion.so/...?p=...—%-encoded special chars.kebab-case-string— no metacharacters.The vector requires explicitly crafted identifiers and would be a self-inflicted local operation. Filed for completeness and code-hygiene, not because exploitation is realistic.
Why fix anyway
sourceof user-influenced data is a code-smell that ages badly — future contributors adding fields to.infomay not realize the value getseval'd.task-done's cleanup path is getting attention anyway — bundling the parse-hardening keepstask-donepolished.Proposed fix
Two options:
Option A — quote on write (defensive at source):
…in
task-reviewer.task-done'ssourcethen sees a quoted assignment that bash parses safely.Option B — replace
sourcewith awk parser intask-done(defensive at read):task-donealready uses an awk-based read forTAB_ID(claude-gh/bin/task-done:116, 121):_WORKER_TAB_ID=$(awk -F= '/^TAB_ID=/ { print $2; exit }' "$INFO_FILE")Extend the same pattern to every field
task-donecurrently relies on from$INFO_FILE:BASE_BRANCHSLUGGH_URLPR_NUMBER(post-task-done --remove-worktree leaks reviewer branch + state; blocks re-dispatch of task-reviewer #148)ISSUE_NUMBERDrop the
source "$INFO_FILE"line. Each field gets a parser-side awk read; nothing in the file is ever eval'd.Recommended: Option B. Defends regardless of write-side discipline (a future loadout adding a new field can't accidentally introduce the vector). Mirrors the existing TAB_ID pattern.
Files (expected touchpoints)
claude-gh/bin/task-done(+ all 4 claude + 3 kiro variants in drift groupstask-done-std/task-done-local)tests/task_done.bats.infocontainingISSUE_NUMBER=PROJ-$(date)and asserts task-done completes without$(date)evaluatingclaude-gh/bin/task-reviewer(and 3 sibling drift members)printf %qquoted valuetools/check-drift.shshould stay green — the changes are inside existingconfirm-and-cleanup/worktree-contextregions.Verification
bats tests/task_done.bats— green, including the new injection-attempt test.tools/check-drift.sh— green.task-reviewer 42 'PROJ-$(date)'in a claude-jira scratch project; latertask-done --remove-worktreein that reviewer's worktree completes without$(date)being substituted.Why P2 / no milestone
task-donehardening); reasonable to bundle in atask-donepolish pass after v0.3.0 ships.References
claude-gh/bin/task-done:54— thesourcelineclaude-gh/bin/task-done:116,121— the existingawk -F=pattern to mirrorclaude-gh/bin/task-reviewer— the write-sidetask-doneissues