The mail prefetcher in this codebase runs in the background and downloads new mail-room PDFs from the upstream case-management system. It writes them to a local cache at ~/.aaicase/cache/uploads/<uploadId>.pdf so the foreground tool doesn't have to wait on the network when the attorney opens a document. The cache is a simple key-value: upload ID is the filename, the file body is the PDF.
The cache-hit check is one line:
const cachePath = join(CACHE_DIR, `${uploadId}.pdf`);
if (existsSync(cachePath)) return cachePath;
If the file exists at the cache path, the prefetcher returns it. The caller (binding extractor, page reader) reads the file and proceeds.
The bug is the missing validation. The cache path is just a filesystem path. Anything that puts a file there with the right name becomes a valid cache hit: a crashed previous download that wrote partial bytes and exited before truncating, a manual touch the attorney did while debugging, a stray file from a previous version of the tool, a system glitch. The prefetcher returns whatever is there. The downstream consumer (the PDF parser, the binding extractor) then operates on garbage.
The downstream failure mode is silent in a confusing way. The binding extractor reads the file, can't find an applicant name or claim number, and writes a "failed extraction" binding marked as low-confidence. The binding guard then refuses the eventual write to the case-management API because the extraction failed. From the attorney's perspective, the document looks broken — they get an error like "binding extraction failed" — and they re-check the PDF, which (because the next operation re-downloads on cache miss) might work this time. The cache poisoning is invisible to them; it just looks like the system is flaky.
The check is 5 bytes
A real PDF starts with the bytes %PDF- followed by a version number. PDF specification 7.5.2 — the first line of a PDF is a header comment with that exact prefix. Any file that doesn't start with those 5 bytes is not a parseable PDF. The check is trivial:
function isPdfFile(path) {
try {
const fd = openSync(path, 'r');
try {
const buf = Buffer.alloc(5);
const n = readSync(fd, buf, 0, 5, 0);
if (n < 5) return false;
return buf.toString('utf8') === '%PDF-';
} finally { closeSync(fd); }
} catch { return false; }
}
Then the cache-hit path becomes:
if (existsSync(cachePath)) {
if (isPdfFile(cachePath)) return cachePath;
// Poisoned cache. Delete and re-download.
log(`cache poisoned for ${uploadId} — re-downloading`);
try { unlinkSync(cachePath); } catch {}
}
// fall through to download
A poisoned cache no longer pollutes downstream. The re-download is fast because the upstream API has the real bytes; the only cost is one extra round-trip per poisoned entry. The log line gives the attorney (and the compliance reviewer) an explicit signal that something at the cache path wasn't a PDF, so any pattern of repeated cache poisoning would be visible in the prefetcher log rather than masquerading as "extractor flakiness."
The other bug in the same surface
While I was probing the prefetcher, the directory permissions caught my eye. The cache directory is created like this:
function ensureDirs() {
for (const d of [QUEUE_DIR, CACHE_DIR]) {
if (!existsSync(d)) mkdirSync(d, { recursive: true });
}
}
No mode is specified. mkdirSync defaults to 0o777 minus the process's umask. On most macOS and Linux setups the umask is 022, which means new directories end up with mode 0o755 — owner has full access, group and other have read+execute. That's fine for general application data, but the cache holds downloaded PDFs of medical records, applicant personally-identifying information, and privileged communications. Any other user on the system can cat ~/.aaicase/cache/uploads/*.pdf.
For a single-user laptop this is mostly harmless. For a shared workstation (multi-user law firm desktop, shared dev VM, anywhere a sysadmin has a login), this is a confidentiality violation. The fix is two characters of mode:
if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o700 });
try { chmodSync(d, 0o700); } catch {}
The chmodSync after the existence check is for installs that already had the directory created at the wrong mode by an earlier version of the tool. On every prefetcher run, the modes are repaired idempotently.
The third one, while I was there
Same probe round, same module, third bug. The upstream-list parse:
const data = JSON.parse(r.stdout);
const items = data.results || data.uploads || data.data || data || [];
If r.stdout is the literal string "null" (the upstream returns JSON null for an auth failure on this endpoint), JSON.parse succeeds and returns null. The next line evaluates null.results, which throws TypeError. The surrounding try/catch swallows the error and returns an empty list — which looks identical to "no new mail."
This is the same null-body shape that's now been fixed in three other places in this codebase. The fix is the same: explicit object guard before any property access.
The general lesson
The cache-poisoning bug and the directory-mode bug are both instances of a broader pattern: trusting the filesystem state without validating it. The cache path existing doesn't mean it contains what you put there. The directory existing doesn't mean it has the permissions you set. Both depend on the entire history of what's happened at that path — previous crashes, manual edits, umask at the moment of creation, system glitches.
For high-trust data the discipline is to validate at every read, not just every write. The 5-byte magic check is cheap enough to run on every cache hit (one open, one read, one close — well under a millisecond). The chmod-repair on every prefetcher run is cheap enough to happen every minute. Both move the system from "trust filesystem state and hope" to "verify filesystem state every time and repair."
The version on the install URL is 4.5.97.