Skip to content

After the #1756 fix, a CRLF-shebang npm bin that v5.0.1 already broke stays broken: apply reports "already patched", and repair and rollback keep the unrunnable CRLF shebang #1791

Closed

Description

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

Summary

#1757 (fixing #1756) makes a fresh agent apply write npm's fixBin form (an LF shebang) for a bin file published with a #!…\r\n first line, and rollback restore that form too. But npm_bin::form_for_write picks the form from the file currently on disk: it writes the fixBin form only when the file already has an LF shebang. A project that v5.0.1 patched (its default overwrite path, or --allow-patch-conflicts on main before #1757) has the raw CRLF afterHash bytes on disk. That is the state #1756 called "the bin exits 127". Upgrading to main doesn't recover it:

  • apply / scan --mode agent: the raw bytes hash to afterHash, so it reports 1 already patched, exit 0, and leaves the bin broken.
  • apply --check: "Patches are in sync", exit 0.
  • repair: "Repair complete.", and the bin is still broken.
  • rollback: the on-disk shebang is CRLF, so it writes the recorded CRLF beforeHash bytes. The bin still exits 127, and the file now differs from what npm install gave (which ran).
  • vex: attests the patch.

I flagged this as a risk on #1756 before #1757 merged (comment). This confirms it on the merged code.

Impact

Every v5.0.1 agent-mode user whose patch touched a CRLF-shebang bin has a CLI that dies with /usr/bin/env: 'node\r': No such file or directory. Upgrading socket-patch, then running apply, repair or rollback, which is the natural way out, changes nothing and reports success. Only a reinstall (rm -rf node_modules && npm ci, then apply) recovers. npm never leaves a CRLF shebang in a bin file (fixBin runs on Windows too, per the npm_bin.rs module doc), so a CRLF shebang on disk in an npm package's bin is never the right form.

Repro

v5.0.1 from npm (npm i @socketsecurity/socket-patch@5.0.1) as $OLD, and a main build as $SP:

#!/usr/bin/env bash
set -u
W=$(mktemp -d); cd "$W"
mkdir pkg && printf '{"name":"crlfbin","version":"1.0.0","bin":{"crlfbin":"cli.js"}}' > pkg/package.json
printf '#!/usr/bin/env node\r\nconsole.log("hi");\r\n' > pkg/cli.js
(cd pkg && npm pack --silent >/dev/null) && mv pkg/crlfbin-1.0.0.tgz .
mkdir proj && cd proj
echo '{"name":"p","version":"1.0.0","dependencies":{"crlfbin":"file:../crlfbin-1.0.0.tgz"}}' > package.json
npm install --no-audit --no-fund >/dev/null 2>&1
gsha() { python3 -c 'import hashlib,sys;b=open(sys.argv[1],"rb").read();print(hashlib.sha256(b"blob %d\0"%len(b)+b).hexdigest())' "$1"; }
cp ../pkg/cli.js before.js; cp before.js after.js; printf '// SOCKET-PATCHED\r\n' >> after.js
B=$(gsha before.js); A=$(gsha after.js)
mkdir -p .socket/blobs && cp before.js .socket/blobs/$B && cp after.js .socket/blobs/$A && rm before.js after.js
cat > .socket/manifest.json <<JSON
{"patches":{"pkg:npm/crlfbin@1.0.0":{"uuid":"e2f99e5d-3afd-5cbd-be80-6a0102bcd6c2","exportedAt":"2026-01-01T00:00:00Z",
 "files":{"package/cli.js":{"beforeHash":"$B","afterHash":"$A"}},
 "vulnerabilities":{"GHSA-xxxx-yyyy-zzzz":{"cves":["CVE-2099-0001"],"summary":"t","severity":"high","description":"d"}},
 "description":"d","license":"MIT","tier":"free"}}}
JSON
$OLD apply --offline; ./node_modules/.bin/crlfbin; echo "bin exit $?"   # 127 (the #1756 breakage)
# upgrade to main:
$SP apply --offline;          ./node_modules/.bin/crlfbin; echo "bin exit $?"
$SP apply --check --offline;  echo "check exit $?"
$SP repair --offline;         ./node_modules/.bin/crlfbin; echo "bin exit $?"
$SP rollback --offline;       ./node_modules/.bin/crlfbin; echo "bin exit $?"

Output on main b4b6d98, npm 10.9.4 / Node 22.22.0 (reproduced three times):

Summary: 1 of 1 targeted patch applied, 0 already patched, 0 not found on disk     # v5.0.1
/usr/bin/env: 'node\r': No such file or directory
bin exit 127
Summary: 0 of 1 targeted patch applied, 1 already patched, 0 not found on disk     # main apply
/usr/bin/env: 'node\r': No such file or directory
bin exit 127
Patches are in sync (1 patch checked).
check exit 0
Repair complete.
/usr/bin/env: 'node\r': No such file or directory
bin exit 127
Freed 101 B of unused blobs and archives                                            # main rollback
/usr/bin/env: 'node\r': No such file or directory
bin exit 127

Control: the same script without the $OLD step on main gives hi / exit 0 after apply, vex attests, and rollback restores exactly the bytes npm install wrote. So #1757 itself works for fresh trees.

Expected vs actual

  • Expected: CLI_CONTRACT.md promises rollback returns the package to its original state, and apply / repair converge to the patched state. For an npm bin file, after Fix npm CRLF-shebang bin hash checks (#1756) #1757 that means npm's fixBin form, a runnable shebang. A file holding the raw CRLF afterHash bytes in a bin is the known-broken output of v5.0.1. Re-applying (or repair) should rewrite it in the fixBin form, and rollback should restore the fixBin form of beforeHash.
  • Actual: the CRLF form is treated as correct ("already patched", "in sync"), and rollback writes the CRLF original.

OS × version

OS npm v5.0.1 → main apply repair rollback
Linux 10.9.4 fail (already patched, bin 127) fail fail (CRLF original, bin 127)
macOS any untested (same fixBin path; expected fail)
Windows any untested (cmd-shim runs via node, so a CRLF shebang is probably harmless there)

The first bad release is v5.0.1, which creates the state. Main from ec9aabc (#1757) on doesn't create it, but can't recover from it.

Suspect code

  • crates/socket-patch-core/src/patch/npm_bin.rs:72-95 (form_for_write): npm_installed requires lf_shebang on disk. For a file that is listed in the package's bin (or, more simply, any npm-package file whose recorded content fix_bin would change), the form shouldn't depend on the current on-disk shebang.
  • crates/socket-patch-core/src/patch/apply.rs:275-286: current_hash == after_hash returns AlreadyPatched before considering that the npm-installed form would be fix_bin(after). A CRLF afterHash match on an npm bin could be reported as needing a rewrite (or repair could normalize it).
  • crates/socket-patch-core/src/patch/rollback.rs:773: same form_for_write call, so rollback writes CRLF.

Activity

  1. mikolalysenko commented on Oct 28, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triage: priority:p1 (npm). Not a duplicate: #1756/#1757 fixed fresh trees; this is the leftover v5.0.1 state, which npm_bin::form_for_write (crates/socket-patch-core/src/patch/npm_bin.rs) can't recover because it chooses the fixBin form only when the on-disk shebang is already LF, and apply returns AlreadyPatched on the raw CRLF afterHash match first.

    [agent] Claiming this issue (shared root cause: npm bin form chosen from the on-disk shebang instead of the package's bin status, so a CRLF shebang left by v5.0.1 is treated as correct). Branch: agent/fix-npm-bin-crlf-recovery. Claim-ID: 2026-10-28T19:21:35Z-abd076


    Generated by Claude Code

  2. mikolalysenko commented on Oct 28, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #1792


    Generated by Claude Code

  3. added 2 commits that reference this issue on Oct 28, 2026
    2d07dc0
    bb8b488
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