Skip to content

fix(exec): route remaining git/ffmpeg/ffprobe spawns through _safe_exec.resolve_required_binary (BE-5358) - #643

Open
mattmillerai wants to merge 2 commits into
matt/be-5357-cuda-detect-absolute-pathfrom
matt/be-5358-safe-exec-git-ffmpeg
Open

fix(exec): route remaining git/ffmpeg/ffprobe spawns through _safe_exec.resolve_required_binary (BE-5358)#643
mattmillerai wants to merge 2 commits into
matt/be-5357-cuda-detect-absolute-pathfrom
matt/be-5358-safe-exec-git-ffmpeg

Conversation

@mattmillerai

Copy link
Copy Markdown
Collaborator

STACKED — merging lands on matt/be-5357-cuda-detect-absolute-path (owned by @mattmillerai, PR #641), NOT main. This PR is based on #641's branch because it consumes comfy_cli/_safe_exec.py, which #641 introduces and which does not exist on main yet. GitHub will retarget this PR to main when #641 merges; review the net diff, and merge #641 first.

ELI-5

On Windows, when a program says "run git", Windows looks in the folder you happen to be standing in before it looks in the normal places. So if someone drops a fake git.exe into a folder and you run comfy from there, you run their fake git. PR #641 fixed that for the three hardware probes by adding a helper that turns a bare name like nvidia-smi into a full, trusted path. This PR does the same for everything else that was still asking for a program by bare name — git, ffmpeg, ffprobe. Nothing changes for anyone whose git/ffmpeg lives in a normal place.

What changed

1. comfy_cli/_safe_exec.py — a required-binary companion.

resolve_binary returns None ("skip this probe"), which lands cleanly on the probes' degrade-to-None contract. git/ffmpeg failing is usually fatal to the command, so this adds resolve_required_binary(name) -> str, a thin wrapper over resolve_binary that raises BinaryNotFoundError instead. Being a wrapper is the point: the _is_bare_name / _is_fully_qualified / is_planted_in_cwd gates live in exactly one place and cannot drift between the two entry points.

BinaryNotFoundError subclasses both RuntimeError and FileNotFoundError. FileNotFoundError is what subprocess already raises today when a bare-name spawn finds nothing, so every call site that already tolerated a missing binary (except (subprocess.SubprocessError, FileNotFoundError) in file_utils, except OSError in outdated, the three-arm handler in install) keeps degrading identically rather than starting to crash. That is what makes this a genuinely no-behaviour-change conversion at the tolerant sites, instead of one that silently turns graceful degradation into a traceback.

2. Every git spawn. All the sites the ticket named, plus three it did not: install.py:520 (fetch --tags), install.py:532 (tag --list), and install.py:677 (_git_capture, the shared helper behind the version-switch path).

file call site on unresolvable git
git_utils.py git_checkout_tag, checkout_pr (9 spawns) raises — as an absent git already did here (only CalledProcessError was caught)
cmdline.py updategit pull raises
command/install.py executegit checkout, clone_comfyui raises
command/install.py _resolve_latest_tag_from_local, _git_capture degrades (unchanged)
command/outdated.py _git_output returns None (unchanged)
file_utils.py list_git_tracked_files returns [] (unchanged)
registry/config_parser.py initialize_project_config raises (unchanged)

3. ffmpeg / ffprobe. command/preview.py's duplicate shutil.which("ffmpeg") and shutil.which("ffprobe") presence check is folded into the resolution, so both binaries are looked up once and the resolved paths are reused for both spawns. build_preview_cmd now takes a required keyword-only ffmpeg_bin (no "ffmpeg" default) so no future caller can silently reintroduce the bare name. output/preview.py's best-effort inline previewer uses resolve_binary (not the required variant) because its documented contract is to skip silently — a missing ffmpeg and a refused CWD-planted one degrade the same way, to no preview.

Judgment calls

  • Resolve before os.chdir, not a module-level _git() wrapper. The ticket suggested one wrapper in git_utils.py that "resolves once and prepends the resolved path". I used a per-function local instead, because in git_utils when resolution happens matters: a call-time wrapper resolves after the os.chdir(repo_path), so a git planted in the caller-supplied repo would win the lookup and be refused — safe, but it turns a plant into a denial of service against a user who has a perfectly good git on $PATH. Resolving before the chdir means the plant can neither be executed nor shadow the real binary. test_checkout_tag_spawns_resolved_path_not_the_repo_plant pins exactly this.
  • BinaryNotFoundError is not a typer error. The ticket offered "RuntimeError/typer error". A typer error would couple the leaf _safe_exec module to typer and would be wrong at the library-level call sites (file_utils, git_utils) that are not commands. command/preview.py does the typer translation itself, at the command boundary, keeping the existing ffmpeg_unavailable error code and message.
  • One user-visible behaviour change, inherited from fix(cuda): resolve nvidia-smi via absolute path to block Windows CWD planting (BE-5357) #641's design. Running comfy from a directory that is itself an absolute $PATH entry (/usr/bin, C:\Windows\System32) now makes git/ffmpeg unresolvable, so those commands fail instead of running. This is the known false positive already documented in resolve_binary; it is new only in that it now applies to git/ffmpeg as well as the probes. For a probe it degrades to None; for git it is a clear error message.

Not in scope (follow-ups worth filing)

  • GitPython. workspace_manager.py:90,100 and install.py:220 use git.Repo(...), and GitPython spawns git by bare name through its own GIT_PYTHON_GIT_EXECUTABLE (default "git"). Closing that means a global git.refresh(<abs path>) at import time, which changes import-time failure behaviour — a distinct, riskier change than this mechanical conversion, and the ticket enumerates subprocess sites only.
  • Other bare-name spawns. utils.py:75 (conda) and install.py:1134,1162,1178,1201,1210,1307,1314 (node, npm, pnpm, npx) are still bare-name. Same vector, outside this ticket's stated scope (git, ffmpeg/ffprobe).

Tests

New tests/comfy_cli/test_safe_exec_call_sites.py (13 cases) and tests/comfy_cli/output/test_inline_preview.py (4 cases), plus new cases in test_safe_exec.py and command/test_preview.py. They share a shutil.which stub that searches the CWD before $PATH — Windows' actual lookup order — so the planting vector is reproducible on any host. Each converted site asserts two things: the argv handed to subprocess starts with the resolved absolute path (never the bare name), and a binary planted directly in the CWD is never spawned (recorder.calls == []). The os.chdir-then-spawn sites get explicit regression tests, including one that the CWD is restored when git is unresolvable.

Three existing test files were updated because they asserted the bare name: test_update_version.py's FakeGit now dispatches on the argv basename, test_pr.py asserts the spawned path is absolute and named git, and test_preview.py passes an explicit ffmpeg_bin.

Status: ruff check clean, ruff format --diff clean (verified against the CI-pinned ruff==0.15.15), pytest — 3710 passed, 37 skipped.

Closes BE-5358.

Every remaining bare-name subprocess spawn in the repo passed `["git", ...]`
or `["ffmpeg", ...]`, so Windows' `CreateProcess` searched the current working
directory before `$PATH` and would execute a planted `git.exe`/`ffmpeg.exe`.
Several of these spawn *after* an `os.chdir()` into a user-supplied repo or
custom-node directory, so the attacker-controlled directory is the CWD by
construction.

Adds `_safe_exec.resolve_required_binary` — the required-binary companion to
`resolve_binary`, a thin wrapper over it so the bare-name / fully-qualified /
CWD-plant gates cannot drift — and converts every git, ffmpeg and ffprobe
spawn to a trusted absolute path.

`BinaryNotFoundError` subclasses both `RuntimeError` and `FileNotFoundError`
so the call sites that already degraded on a missing binary keep degrading
identically instead of starting to crash.

`comfy preview`'s duplicate `shutil.which` presence check is folded into the
resolution, so both binaries are looked up once and the paths reused.
@mattmillerai mattmillerai added the agent-coded PR authored by the agent-work loop label Jul 31, 2026
@mattmillerai
mattmillerai marked this pull request as ready for review July 31, 2026 08:55
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Jul 31, 2026
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f91b46c3-8783-4553-bdb2-dee2a0c8bfa1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@mattmillerai mattmillerai added the cursor-review Request Cursor bot review label Jul 31, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 10 finding(s).

Severity Count
🟠 High 2
🟡 Medium 4
🟢 Low 3
⚪ Nit 1

Panel: 8/8 reviewers contributed findings.

Comment thread comfy_cli/_safe_exec.py Outdated
Comment thread comfy_cli/file_utils.py Outdated
Comment thread comfy_cli/command/install.py Outdated
Comment thread comfy_cli/command/preview.py Outdated
Comment thread comfy_cli/registry/config_parser.py
Comment thread tests/comfy_cli/command/test_update_version.py Outdated
Comment thread comfy_cli/command/preview.py Outdated
Comment thread comfy_cli/git_utils.py Outdated
Comment thread comfy_cli/command/install.py
Comment thread comfy_cli/command/outdated.py
Addresses the Cursor review panel on #643.

resolve_required_binary collapsed two very different events into one
"was not found on PATH" message: git/ffmpeg genuinely not installed, and
git/ffmpeg found but refused because the only match was CWD-anchored.
That told users with a working binary to go install it, and hid the
interesting part — something named git was sitting in their CWD.

_safe_exec now reports *why* it refused (BinaryRefusal / BinaryNotFoundError
.reason / .candidate) and callers branch on it:

- file_utils.list_git_tracked_files: a *refused* git no longer degrades to
  [], which zip_files reads as "not a git repo" and answers by walking the
  whole directory — packaging untracked/gitignored files (.env, keys, venvs)
  into the archive `comfy node publish` uploads. An absent git still
  degrades as before. `comfy node pack` reports the refusal and aborts.
- command/preview: refusal renders the new ffmpeg_untrusted code instead of
  "install ffmpeg"; both ffprobe/ffmpeg spawns now have timeouts, so a
  corrupt or crafted media file can't hang `comfy preview` while buffering.
- git_utils.git_checkout_tag / checkout_pr: both are documented to return
  False on failure and both callers rely on it, so a refusal comes back as
  False rather than escaping as a traceback past their handlers.
- install.switch_comfyui_version: checks git up front as
  version_switch_git_unavailable — otherwise _git_capture's rc=1 made the
  first `rev-parse --verify <tag>` report "version not found: no tag vX".
- registry/config_parser: handler widened to OSError, so a refusal doesn't
  escape after create_comfynode_config() has already written pyproject.toml.

Also option-injection hardening on the git argv: `git clone -- <url> <dir>`,
and reject_option_like_ref for `git checkout <rev>`, where a `--` separator
cannot help (git scans options before the first positional). git tag/branch
both refuse to create a name starting with `-`, and a SHA never does, so
nothing legitimate is rejected.

Tests: the fake-binary fixtures pinned drive-less \usr\bin\<name>, which
_is_fully_qualified rejects on Windows — the exact platform this hardening
targets — so every test using them would have failed there. Pinned a
drive-qualified path under os.name == "nt".
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Jul 31, 2026
@mattmillerai

Copy link
Copy Markdown
Collaborator Author

STACKED — merging lands on matt/be-5357-cuda-detect-absolute-path (PR #641, @mattmillerai), NOT main. Land #641 first; merging this one on its own puts these commits on that branch.


Resolved all 10 Cursor panel findings in abf558f. The two 🟠 High ones shared a root cause, so they are fixed together:

Root cause — resolve_required_binary could not say why it refused. "git is not installed" and "git was found and refused because the only match is CWD-anchored" produced the same "was not found on PATH" message. _safe_exec now returns a BinaryResolution(path, reason, candidate), and BinaryNotFoundError carries .reason / .candidate / .is_absent. is_planted_in_cwd gained a tri-state companion so a deleted CWD ("could not look") is UNVERIFIABLE rather than borrowing the plant's message.

Everything downstream branches on that:

Finding Fix
🟠 _safe_exec.py — absent vs refused indistinguishable reason-aware refusals + per-reason diagnostics
🟠 file_utils.py — refused git → []zip_files walks everything into the published archive absent still degrades to []; refused now raises. node publish / node pack abort with the reason
🟡 install.pygit clone option injection git clone -- <url> <dir>; reject_option_like_ref for git checkout <rev>, where -- cannot help
🟡 preview.py — unbounded ffprobe/ffmpeg 30 s probe / 120 s render timeouts
🟡 config_parser.py — refusal escapes after pyproject.toml is written handler widened to OSError
🟡 tests — drive-less \usr\bin\git fails on Windows drive-qualified path under os.name == "nt"
🟢 preview.py — "install ffmpeg" for a refusal new ffmpeg_untrusted code
🟢 git_utils.py — refusal escapes the documented False contract git_checkout_tag and checkout_pr return False
🟢 install.py — refusal misreported as "version not found" up-front check → version_switch_git_unavailable
outdated.py — re-resolves per call declined, with measurements — see the thread

Verified: ruff check + ruff format --diff clean (pinned 0.15.15), 3613 passed / 13 skipped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-coded PR authored by the agent-work loop cursor-review Request Cursor bot review size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant