Skip to content

npm rewrites a CRLF shebang in an installed bin file, so agent apply fails on it (or, with consent and on v5.0.1, breaks the bin) and hosted vex refuses a correctly patched install #1756

Closed

Description

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

Summary

On Linux and macOS, npm's bin-links runs fixBin on every file a package lists in bin. It chmods the file and rewrites a Windows hashbang line, #!…\r\n, to #!…\n (node_modules/bin-links/lib/fix-bin.js, dos2Unix). So the installed file differs from the published tarball by one byte. socket-patch hashes the installed file against the patch's beforeHash / afterHash, which are taken from the tarball, and never accounts for npm's rewrite. Packages published from Windows with CRLF files are common, and the bin entry is often the very file a CLI fix touches.

Impact

  • Agent mode (main b787454): apply / scan --mode agent can never patch the file. You get apply_failed "File hash does not match expected value" with exit 1, on a pristine npm-installed tree. The documented CI step (socket-patch apply after each install) fails every time.
  • Agent mode with --allow-patch-conflicts / --force on main, and the default on the latest release v5.0.1: the full afterHash content is written back, CRLF shebang included. The bin then dies with /usr/bin/env: 'node\r': No such file or directory (exit 127), while socket-patch reports success. v5.0.1 only adds a content_mismatch_overwritten warning and exits 0.
  • Hosted mode: npm ci installs the patched bytes correctly and the bin runs. But vex omits the patch ("a patched file matches neither the original nor the patched content") and fails no_applicable_patches, so a correctly patched install gets no attestation.
  • Vendored mode: vex attests, but warns vendored_tree_out_of_sync on a tree that is in sync.

Windows is presumably unaffected: there bin-links writes cmd-shims and leaves the file alone. That's untested; probe branches are unavailable to this routine.

Repro (offline, hand-staged agent manifest)

#!/usr/bin/env bash
set -u
SP=${SP:-socket-patch}
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      # as published (CRLF)
(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
head -c 21 node_modules/crlfbin/cli.js | od -c | head -2       # "n o d e \n": npm dropped the \r
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
$SP apply --offline; echo "exit $?"                              # main: exit 1, "File hash does not match expected value"
$SP apply --offline --allow-patch-conflicts; echo "exit $?"      # exit 0, "applied the full verified patched content"
./node_modules/.bin/crlfbin; echo "exit $?"                      # /usr/bin/env: 'node\r': No such file or directory, exit 127

Output (main b787454, npm 10.9.4 / Node 22.22.0, reproduced twice):

0000020   o   d   e  \n   c
Error: Failed to patch pkg:npm/crlfbin@1.0.0: Cannot apply patch: package/cli.js - File hash does not match expected value
Summary: 0 of 1 targeted patch applied, 0 already patched, 1 failed, 0 not found on disk
exit 1
Warning: pkg:npm/crlfbin@1.0.0 package/cli.js did not match the patch's expected original content; applied the full verified patched content instead (pass --strict to fail on mismatches)
Summary: 1 of 1 targeted patch applied, 0 already patched, 0 not found on disk
exit 0
/usr/bin/env: 'node\r': No such file or directory
exit 127

I reproduced the hosted and vendored symptoms against a local mock of the patch API and registry, serving the same package: scan --mode hosted|vendored, rm -rf node_modules && npm ci, then vex --output f. Hosted gave skipped … patch omitted from VEX: a patched file matches neither the original nor the patched content and no_applicable_patches. Vendored gave verified plus vendored_tree_out_of_sync. In both cases the installed file ends in the patch line and the bin prints hi.

Expected vs actual

  • Expected: an npm install that socket-patch didn't modify, or one npm installed from the hosted / vendored patched tarball, counts as "original" / "patched". CLI_CONTRACT.md "Existing project patches" reserves the drift refusal for content that matches neither hash because someone changed it. This difference is npm's own deterministic install transform. The bytes written must also keep the bin runnable.
  • Actual: npm's install transform is treated as user drift, and the consent path writes a shebang that can't execute.

OS × version

OS npm Agent default Agent + consent / v5.0.1 Hosted vex Vendored vex
Linux 6.14.18 fail (apply_failed) not run not run not run
Linux 8.19.4 fail not run fail (omitted) warns out-of-sync
Linux 10.9.4 fail fail (bin exit 127) fail (omitted) warns out-of-sync
Linux 12.2.1 fail not run fail (omitted) warns out-of-sync
macOS any untested (same fixBin path)
Windows any untested (cmd-shim, no fixBin; expected pass)

v5.0.1 (published binary, npm 10.9.4): scan --mode agent exits 0 with content_mismatch_overwritten, and the bin then fails with exit 127. On main, #1646 (consent before overwriting unknown drift) turned that silent breakage into a hard apply_failed.

Suspect code

  • crates/socket-patch-core/src/patch/apply.rs:288: verify_file_patch compares the raw installed hash to before_hash / after_hash. A bin file whose first line ended \r\n in the tarball needs the npm-normalized variant (first \r\n → \n) accepted on both sides. When writing afterHash content there, it should apply the same normalization npm would.
  • crates/socket-patch-cli/src/commands/vex.rs:1966 (hash_mismatch), plus the vendored in-sync check that raises vendored_tree_out_of_sync (vex.rs:1081): same comparison.

Activity

  1. mikolalysenko commented on Oct 28, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Claiming this issue (shared root cause: hash checks compare the raw installed bin file against the tarball hashes and ignore npm's fixBin CRLF-shebang rewrite). Branch: agent/fix-npm-bin-crlf-shebang. Claim-ID: 2026-10-28T01:20:56Z-236382


    Generated by Claude Code

  2. mikolalysenko commented on Oct 28, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #1757


    Generated by Claude Code

  3. added 5 commits that reference this issue on Oct 28, 2026
    1f8728a
    7fff1e2
    1441714
    aea403e
    1206981
  4. mikolalysenko commented on Oct 28, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Extra data from the npm bug-hunt (ledger #302), main b787454, npm 10.9.4 / Node 22.22.0, reproduced twice:

    rollback doesn't recover a bin that an earlier consent overwrite broke. It restores the CRLF beforeHash bytes, so the bin still exits 127, even though the original npm install gave a working bin:

    # crlfbin-1.0.0.tgz: package/bin/cli.js = '#!/usr/bin/env node\r\nconsole.log("crlfbin ok")\r\n', "bin": {"crlfbin": "bin/cli.js"}
    npm install file:../crlfbin-1.0.0.tgz      # npx crlfbin -> "crlfbin ok" (npm's fixBin wrote an LF shebang)
    socket-patch get pkg:npm/crlfbin@1.0.0 --mode agent --yes --allow-patch-conflicts   # exit 0
    npx crlfbin                                 # rc=127: /usr/bin/env: 'node\r'
    socket-patch rollback --yes --json          # status success, events rolledBack + removed
    npx crlfbin                                 # rc=127 again; od shows "node\r\n" on disk

    Note for #1757: as I read it, form_for_write chooses the form by looking at the file on disk. A project patched with --allow-patch-conflicts on v5.0.1 or today's main already has the CRLF form on disk, so rolling back with the fixed build may write the CRLF original again. Upgrading and then rolling back is a likely way out for affected users, so it may be worth a test.


    Generated by Claude Code

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