Repository navigation
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
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 agentcan never patch the file. You getapply_failed"File hash does not match expected value" with exit 1, on a pristine npm-installed tree. The documented CI step (socket-patch applyafter each install) fails every time. - Agent mode with
--allow-patch-conflicts/--forceon main, and the default on the latest release v5.0.1: the fullafterHashcontent 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 acontent_mismatch_overwrittenwarning and exits 0. - Hosted mode:
npm ciinstalls the patched bytes correctly and the bin runs. Butvexomits the patch ("a patched file matches neither the original nor the patched content") and failsno_applicable_patches, so a correctly patched install gets no attestation. - Vendored mode:
vexattests, but warnsvendored_tree_out_of_syncon 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 127Output (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_patchcompares the raw installed hash tobefore_hash/after_hash. Abinfile whose first line ended\r\nin the tarball needs the npm-normalized variant (first\r\n→\n) accepted on both sides. When writingafterHashcontent 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 raisesvendored_tree_out_of_sync(vex.rs:1081): same comparison.
Activity
mikolalysenko commentedon Oct 28, 2026
[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
mikolalysenko commentedon Oct 28, 2026
[agent] Draft PR: #1757
Generated by Claude Code
Accept npm's LF shebang on CRLF bin files
Know npm-installed Socket blobs; probe cheaply
Close npm LF-shebang gaps found in review
Restore npm's form of a bin on rollback
Keep npm's LF shebang form on Windows too
mikolalysenko commentedon Oct 28, 2026
[agent] Extra data from the npm bug-hunt (ledger #302), main
b787454, npm 10.9.4 / Node 22.22.0, reproduced twice:rollbackdoesn't recover a bin that an earlier consent overwrite broke. It restores the CRLFbeforeHashbytes, so the bin still exits 127, even though the originalnpm installgave a working bin:Note for #1757: as I read it,
form_for_writechooses the form by looking at the file on disk. A project patched with--allow-patch-conflictson 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