Skip to content

scan --sync / --mode agent never re-applies an already-recorded patch, so after a fresh Hatch env (or any reinstall) it exits 0 with the package unpatched #454

Description

[agent] Found by the scheduled Hatch bug-hunt routine (ledger #314).

Summary

scan --mode agent and scan --sync only run the nested apply when the run downloaded a new or updated patch record. When every discovered patch is already in .socket/manifest.json, the records come back skipped and apply never runs. The installed files aren't checked or re-patched, and the scan reports "status": "success", applied: 0, exit 0.

So after anything that reinstalls the package (hatch env remove + hatch env create, hatch env prune, a CI cache miss, a new matrix env, pip install --force-reinstall, or a failed first apply), re-running the scan leaves the environment unpatched and reports success. The same happens with --global-prefix / -g after a first run whose apply failed: once the prefix is writable again, the re-run exits 0 and still doesn't patch it.

This isn't Hatch-specific. The gate is in the shared download_and_apply_patches_with. I found it with real Hatch, where recreating envs is routine.

Impact

--sync is documented as the one-shot reconciliation: the help text at crates/socket-patch-cli/src/commands/scan/mod.rs:289 says "a cron job or CI workflow can run socket-patch scan --json --sync to end up fully reconciled in one invocation". A CI job that runs hatch env create && socket-patch scan --sync --json passes green on every run after the first while the env runs the vulnerable code. The only signal is that vex afterwards refuses to attest (not_applied), which is correct but easy to miss.

Repro (Linux, Hatch 1.18.1, mock patch API serving a six 1.16.0 patch)

mkdir rr && cd rr && mkdir app && touch app/__init__.py
cat > pyproject.toml <<'EOF'
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "app"
version = "0.1.0"
dependencies = ["six==1.16.0"]

[tool.hatch.build.targets.wheel]
packages = ["app"]

[tool.hatch.envs.default]
path = ".venv"
EOF
export SOCKET_API_URL=http://127.0.0.1:8765 SOCKET_API_TOKEN=fake SOCKET_ORG_SLUG=org SOCKET_PATCH_SERVER_URL=http://127.0.0.1:8765
hatch env create
socket-patch scan --mode agent --json --yes      # applied 1, six patched
hatch env remove default && hatch env create      # fresh env, pristine six
socket-patch scan --mode agent --json --yes; echo $?   # success, skipped 1, applied 0, exit 0, six NOT patched
hatch env remove default && hatch env create
socket-patch scan --sync --json --yes; echo $?         # same: success, skipped 1, exit 0, six NOT patched
socket-patch apply --json                              # applied 1: the only thing that actually reconciles

Output on 1.18.1 (the 1.7.0 output is identical):

  scan --mode agent: success applied 1 skipped 0 exit=0 import_patched=True
 -- hatch env remove + create
  scan --mode agent: success applied 0 skipped 1 exit=0 import_patched=False
 -- hatch env remove + create
  scan --sync: success applied 0 skipped 1 exit=0 import_patched=False
Warning: omitting pkg:pypi/six@1.16.0 from VEX: the patched files still hold the original content (not_applied)

Global-prefix variant, without Hatch: pip install --target <prefix> six==1.16.0, then scan --global-prefix <prefix> --mode agent (applied 1), reinstall six, then re-scan. The re-scan gives success, skipped 1, exit 0, unpatched. --sync does the same, and each ran twice. Starting from a read-only prefix as non-root (runuser -u nobody), the first scan exits 1 (its JSON is the #424 shape), and the second scan exits 0 with success even after the prefix is made writable again.

Expected vs actual

  • Expected: scan --mode agent records and applies (docs/usage.md "Agent mode records patches and applies them to installed files"), and --sync ends "fully reconciled in one invocation". A record that's already in the manifest but not applied to the installed copy should be applied, as apply does. At minimum, the scan shouldn't report success / exit 0 while a discovered, recorded patch is unapplied.
  • Actual: skipped records short-circuit the nested apply, and the installed tree is never looked at.

OS × version

Cell Result
Linux, Hatch 1.18.1, .venv env, scan --mode agent after env recreate ❌ exit 0, unpatched
Linux, Hatch 1.18.1, scan --sync after env recreate ❌ exit 0, unpatched
Linux, Hatch 1.7.0, both of the above ❌
Linux, --global-prefix (pip --target), after reinstall, agent and --sync ❌
Linux, --global-prefix read-only then writable, non-root ❌ second run exits 0
socket-patch apply in the same states (control) ✅ re-applies
vex in the same states (control) ✅ omits not_applied

The logic is OS-independent (no path or filesystem handling is involved).

First bad version

Released v4.0.0 (PyPI socket-patch==4.0.0) behaves the same, so this isn't a v5 regression.

Suspect code

  • crates/socket-patch-cli/src/commands/get.rs:2438: let apply_lock = if !params.save_only && downloaded > 0 {. The nested apply (and its lock) only exists when something was downloaded.
  • crates/socket-patch-cli/src/commands/get.rs:2485: apply_failed is gated on downloaded > 0 too, so an all-skipped run can never fail.

Related, but a separate defect: #424 (the scan JSON drops the apply failure on the first run).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions