The file-upload command takes a local path and posts the contents to the production case-management API. The invocation looks like merus-fetch /uploads/add file=@/tmp/document.pdf case_file_id=N. The @/path syntax tells the helper to attach the file's bytes as a multipart form field.
The directories the firm uses for staging uploads are well-defined: /tmp/ for downloaded-and-staged documents, ~/.aaicase/ for tool-internal state. Documents that need to go to Merus are placed in one of those locations first, then uploaded. The convention is straightforward, and the tool enforces it: file paths that resolve outside the allowlist are refused before any network call.
That enforcement, until this release, was implemented as: const resolved = path.resolve(arg); if (!allowlist.some(prefix => resolved.startsWith(prefix))) refuse. path.resolve converts a relative path to an absolute path and collapses .. segments. It is a purely string-level transformation. It does not consult the filesystem.
What path.resolve does and doesn't do
If the attorney passes ../etc/passwd while sitting in /Users/.../aaicase, path.resolve turns that into /Users/.../etc/passwd — leaving the allowlist's /tmp/ and ~/.aaicase/ prefixes. The check correctly refuses it.
If the attorney passes /tmp/foo/../../../etc/passwd, path.resolve turns that into /etc/passwd via the .. collapse. The allowlist correctly refuses it.
If the attorney passes /tmp/legit-named-file.pdf, the check passes, and the file gets uploaded. So far so good.
What path.resolve does not do is ask the operating system whether the path it produced is a symlink. From path.resolve's point of view, /tmp/anything is a normalized path under /tmp/ — regardless of what /tmp/anything actually is on disk. If /tmp/anything happens to be a symlink to /etc/passwd, the resolve still produces /tmp/anything as the canonical answer. The check sees the path is under /tmp/. The check passes.
The next call after the check is readFileSync, which DOES consult the filesystem. readFileSync follows symlinks by default — that's standard POSIX file-open behavior. So readFileSync(/tmp/anything) returns the contents of /etc/passwd.
The bytes flow into a Blob, into a multipart form, into a POST to Merus. The server doesn't know anything about symlinks — it just sees a file upload. Whatever the local readFileSync returned is what got sent.
I verified this live before fixing it. ln -s /etc/passwd /tmp/r5-evil.pdf then merus-fetch /uploads/add file=@/tmp/r5-evil.pdf. The tool printed an empty array as the response (no case binding, but the upload itself completed). The audit log showed the in-flight entry, with the field redacted to [FILE]. The contents went over the wire.
What threat model this addresses
The local user on the attorney's machine can, of course, cat /etc/passwd directly. The system's allowlist isn't trying to protect against an external attacker; the attorney's laptop running their own tool is the attorney's laptop. The threat the allowlist exists for is integrity: the tool itself should only upload files from the directories the firm controls. If the tool can be tricked into uploading arbitrary files, then a script (or a phishing prompt, or a careless cleanup of the temp directory) that placed a symlink in /tmp/ with the right name could cause uploads the attorney didn't intend.
The audit log records the upload. The attorney later asking "what was uploaded to Merus on this case?" sees an entry with the local filename — r5-evil.pdf, in this case — and would have no signal that the contents were not what the filename suggested. The audit trail is honest in the sense that the upload happened; it's misleading in the sense that the filename suggests a PDF and the contents are root:x:0:0:System Administrator:/var/root:/bin/sh.
For a legal-grade tool whose audit log is meant to answer "what did this firm send to Merus," the property "the tool only sends files it was supposed to send" is what we're defending. The allowlist is the enforcement mechanism. The symlink loophole made the enforcement vacuous.
The fix
One function call. realpathSync instead of path.resolve. The standard library function that DOES consult the filesystem and follows symlinks to produce the canonical path of the final target. After realpathSync, /tmp/r5-evil.pdf becomes /private/etc/passwd (the macOS canonical form). The allowlist's prefix check sees the path is no longer under /tmp/ and refuses.
The function throws if the file doesn't exist, so the error-path code falls back to path.resolve to keep the "File not found" message clear at the next check. Files that do exist go through the symlink-following resolution. Symlinks within /tmp/ pointing at other files inside /tmp/ still work (the canonical path stays in the allowlist). Only symlinks pointing OUTSIDE get refused.
Verified three cases. Symlink to /etc/passwd refused. Normal /tmp/ file uploads. Non-existent file produces the "File not found" message it always did.
Two lessons
The first is the obvious one. String-level path normalization is not the same as filesystem-level path canonicalization. A check that uses one and then operates with the other has a window between them where the filesystem can shift the meaning of the path. Symlinks are the classic example, but bind mounts, NFS, FUSE, and overlays all do similar things. For any allowlist that affects what gets uploaded or executed, the canonical path needs to come from the filesystem itself.
The second is more general. The probe that found this didn't know in advance that there would be a symlink bug. It was running through the file-upload surface: what happens with a missing file? With an unreadable file? With a file that has .. segments in the path? Each probe is a small question. Most of them produced a clean refusal. One of them produced an upload. That's how this kind of bug surfaces: not by looking at the threat model first and then auditing the code, but by listing the input variants and trying each one. The threat model is what the bug is, after you find it.
The version on the install URL is 4.5.75. The work continues at the pace of one verified release at a time, and the allowlist is now actually enforced.