fix(runs): validate run ids and stop a run ref escaping the runs dir#110
Merged
Conversation
A positional run ref reached `run_dir_for`'s exact branch raw, which recomposes `project / RUNS_DIR / ref` — so `bmad-loop delete ../../x` rmtree'd any outside directory that happened to hold a state.json. The hidden `--run-id` flag on run/sweep was never checked either, and lands in three positions at once: a directory name, a multiplexer session name, and a git ref component. A run id has exactly one legitimate producer (`new_run_id`), so validate and reject rather than sanitize — a coerced id would no longer name the run the caller asked for, breaking the id<->path<->session bijection. - `runs.RUN_ID_RE` + `is_valid_run_id`: identity for every `new_run_id()` output; excludes separators, `..`, the Windows illegal set and reserved device basenames, `.`/`:` (session-name mangling), and whitespace. - `cmd_run` / `cmd_sweep` reject an invalid supplied `--run-id` at the boundary, before the preflight side effects and before any path. - `resolve_run_dir` skips the exact branch for a ref that is absolute, climbs with `..`, or carries a separator; it falls through to partial matching, which only yields names `list_run_dirs` enumerated. - `prunable_sessions` skips session names whose stripped id is invalid, so a foreign tmux session cannot steer a run-dir path. `platform_util._MAX_SEGMENT` is promoted to a public `MAX_SEGMENT` — the run-id cap is the same filesystem-segment cap, and must stay in lockstep. Closes #104
WalkthroughRun IDs now follow a constrained format and length limit. CLI ChangesRun ID safety
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant CLI
participant runs.is_valid_run_id
participant RunSetup
participant stderr
CLI->>runs.is_valid_run_id: validate --run-id
runs.is_valid_run_id-->>CLI: valid or invalid
alt invalid
CLI->>stderr: print error
CLI-->>CLI: return 1
else valid
CLI->>RunSetup: continue run or sweep initialization
end
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
_ctl_window_candidates reverse-maps <kind>-<run_id> control-session window names through a fully permissive (.+) group, so a foreign window named run-../../x steered run_dir_for's liveness read — and, for an untagged window, the run-dir ownership fallback — at a path outside the runs dir. Same untrusted-name class prunable_sessions already guards; apply the same is_valid_run_id gate before recomposing. Legit windows are unaffected: the TUI is the only creator and names them from enumerated run ids.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #104
The bug
--run-idwas the reported hole, but exploring it widened the finding — the positional run refs are worse:resolve_run_dir's exact branch recomposesproject / RUNS_DIR / reffrom the raw string, so any outside directory that happens to hold astate.jsonis a valid target fordelete/stop/archive/resume/status. Verified againstmainbefore and after the fix.Meanwhile the hidden
--run-idflag onrun/sweepreached three sinks unchecked — a directory name, a multiplexer session name (bmad-loop-<id>), and a git ref component (bmad-loop/<id>/<unit>). Both commands build the run dir inline (project / RUNS_DIR / run_id), not viarun_dir_for, so a guard insiderun_dir_foralone would have missed the primary sinks.The shape
A run id has exactly one legitimate producer —
runs.new_run_id(), format\d{8}-\d{6}-[0-9a-f]{4}. So reject, don't sanitize: coercing an id would leave the caller unable to name the run they just started, breaking the id↔path↔session bijection the CLI depends on.runs.RUN_ID_RE+is_valid_run_id()—[A-Za-z0-9][A-Za-z0-9_-]*, capped atMAX_SEGMENT. Identity for everynew_run_id()output. The charset excludes by construction: path separators and.., the Windows illegal set<>:"|?*plus trailing dots/spaces,.and:(which mangle a tmux session name), and all whitespace/control chars. Asafe_segmentidentity check adds the one rule the regex can't express — reserved Windows device basenames (CON,NUL,COM1…).cmd_run/cmd_sweepreject an invalid supplied--run-idat the boundary — before the preflight side effects (_reconcile_staletears down worktrees) and before the id touches a path.resolve_run_dirskips the exact branch when the ref is absolute, has a.., or carries a separator, falling through to partial matching — which can only ever yield a namelist_run_dirsenumerated, so it cannot escape. Gated on the escape shape, not onis_valid_run_id, so a run dir created by an older version (or by hand) stays addressable.prunable_sessionsskips session names whose stripped id is invalid — a session name is untrusted input, andbmad-loop-../../xwould otherwise steerrun_dir_for(andprune_sessions' kill) at a path outside the runs dir._ctl_window_candidates(added on final review) applies the same gate to the one other outside-world reverse mapping — a<kind>-<id>control-session window name, matched by a fully permissive(.+). Unguarded,run-../../xsteered the liveness read — and an untagged window's run-dir ownership fallback — at a path outside the runs dir. Blast radius is reads only (no path write, no arbitrary kill), guarded for class-consistency withprunable_sessions; legit windows are unaffected since the TUI is the only creator and names them from enumerated run ids.platform_util._MAX_SEGMENT→ publicMAX_SEGMENT: a run id is a filesystem segment, and the two caps must not drift. Mechanical rename; tests already reached for the private name.Compatibility
--run-idcaller and its only producer isruns.new_run_id()— never rejected.aaaa,aa,025-a1b2) and exact-id lookups are unaffected; existing pinstest_resolve_run_dir_exact_and_partial,test_resolve_run_dir_exact_wins_over_ambiguityandtest_run_honors_preassigned_run_id_and_writes_pidstay green untouched.Tests
test_is_valid_run_id_is_identity_for_generated_ids— pins that a generated id survives both sanitizers byte-for-byte (safe_segmentfor the directory,safe_ref_segmentfor the branch), which is whyis_valid_run_idneeds no ref check of its own.test_is_valid_run_id_accepts/_rejects— 33-case negative matrix (traversal, both separator flavours, drive-absolute and drive-relativeC:rel, tmux metachars, git-ref metachars, control chars, trailing dot/space, reserved basenames, over-cap).test_start_rejects_invalid_run_id— parametrized overrun×sweep× 12 payloads. Asserts the stderr message, not justrc == 1: an unguardedrunwould return 1 here anyway (dirty tree / missing base skills), so rc alone would false-pass.test_resolve_run_dir_never_escapes_the_runs_dir— plants astate.jsonexactly where the un-gated exact branch would land. Stated as containment rather than no-match becausea\bis one legal directory name on POSIX (inside the runs dir, so it legitimately resolves) and a nested path on Windows (where it must not).test_resolve_run_dir_absolute_ref_never_escapes—/-join discards the project prefix entirely, so an absolute ref escapes without needing a...test_prunable_sessions_skips_invalid_run_ids.test_prune_ctl_windows_skips_invalid_run_ids— three hostile window rows: tagged traversal, tagged bad-charset, and an untagged traversal pointed at a planted outsidestate.json(which would otherwise claim the window as ours). All three legs verified to fail against the unguarded function.Each new guard test was checked to fail against the unpatched functions (the sole exception is the
a\bleg, a Windows-only escape — it passes on POSIX by construction, and Windows CI covers it).Full suite: 2021 passed, 1 skipped. Full
trunk check: clean.Summary by CodeRabbit