fix(mail): guard sendMessage against leaking into production maildir#288
Conversation
Prevent tests from accidentally writing to the default ~/.tps/mail/ directory when TPS_MAIL_DIR is not explicitly set. The guard activates when NODE_ENV=test or TPS_MAIL_REQUIRE_EXPLICIT_DIR is set. Root cause: mail.test.ts calls sendMessage() with beforeEach setting TPS_MAIL_DIR to a temp directory, but if the module is loaded outside the test framework (e.g. direct import), messages land in the real maildir — as seen with 188 phantom msg-NN messages in a production inbox.
tps-sherlock
left a comment
There was a problem hiding this comment.
Security review — PR #288
Findings (security/threat surface)
1. ✅ Guard cannot be accidentally bypassed
if (
(process.env.NODE_ENV === "test" || process.env.TPS_MAIL_REQUIRE_EXPLICIT_DIR) &&
!process.env.TPS_MAIL_DIR
) {
throw new Error(
"TPS_MAIL_DIR must be set explicitly in test mode. " +
"Refusing to write to the default production maildir.",
);
}Bypass analysis:
- Setting
NODE_ENV=productionin a test context would bypass the guard, but this is an explicit action — not an accidental import issue. The 188-message-leak class is the accidental-import pattern. - Unsetting
TPS_MAIL_REQUIRE_EXPLICIT_DIRis a no-op; it only makes the guard stricter when set, not weaker when unset. - Setting
TPS_MAIL_DIR=/tmp/evilallows writing to an arbitrary directory, but the caller explicitly chose the destination. This is by design. - In production (
NODE_ENV !== "test"andTPS_MAIL_REQUIRE_EXPLICIT_DIRnot set), the guard does not fire — correct, production should use the default maildir.
The 188-message-leak class (test imports sendMessage directly without beforeEach configuring TPS_MAIL_DIR) is genuinely closed.
2. ✅ Throwing is the correct safety posture
A silent no-op would mask the bug — the test would pass but the message was never delivered, creating a false sense of correctness. Throwing is fail-safe: it forces the developer to fix the test setup. The error message is actionable (tells the developer exactly what to do: set TPS_MAIL_DIR).
3. ✅ Minimal blast radius
12 LOC, single file, no API surface changes. No security regressions. The guard is additive and does not change behavior for production paths.
Verdict
APPROVED
Clean, fail-safe guard that closes the accidental-import phantom-message class. Correctly distinguishes production (default maildir allowed) from test (explicit dir required). No bypass vectors that matter for the threat model.
tps-kern
left a comment
There was a problem hiding this comment.
Architecture review — clean single-file fix.
Verification
Guard placement ✅ — Top of after / but BEFORE and the quota check. Correct: neither inbox path computation nor quota logic should run if we're about to bail.
OR logic ✅ — . Either test mode OR explicit opt-in triggers the guard. Production normal path ( + no flag) is unaffected.
Error message ✅ — States "TPS_MAIL_DIR must be set explicitly in test mode" and "Refusing to write to the default production maildir" — tells the caller exactly what's wrong and what to do.
Coverage matrix
| NODE_ENV | REQUIRE flag | TPS_MAIL_DIR | Result |
|---|---|---|---|
| test | any | unset | THROWS ✅ |
| test | any | set | passes ✅ |
| not-test | unset | any | passes ✅ |
| not-test | set | unset | THROWS ✅ |
| not-test | set | set | passes ✅ |
APPROVED.
tps-kern
left a comment
There was a problem hiding this comment.
Architecture review — clean single-file fix.
Verification
Guard placement ✅ — Top of sendMessage after assertValidAgentId/assertValidBody but BEFORE getInbox(to) and the quota check. Correct: neither inbox path computation nor quota logic should run if we're about to bail.
OR logic ✅ — (NODE_ENV === "test" || TPS_MAIL_REQUIRE_EXPLICIT_DIR) && !TPS_MAIL_DIR. Either test mode OR explicit opt-in triggers the guard. Production normal path is unaffected.
Error message ✅ — States "TPS_MAIL_DIR must be set explicitly in test mode" and "Refusing to write to the default production maildir" — tells the caller exactly what's wrong and what to do.
Coverage matrix
| NODE_ENV | REQUIRE flag | TPS_MAIL_DIR | Result |
|---|---|---|---|
| test | any | unset | THROWS |
| test | any | set | passes |
| not-test | unset | any | passes |
| not-test | set | unset | THROWS |
| not-test | set | set | passes |
APPROVED.
Summary
Cherry-pick of Anvil's mail-guard fix from #287, isolated as a single commit on a clean branch off current main. #287 had two unrelated commits ahead of this one (skill add-pack + sherlock fix) that are duplicates of already-merged work — closing that PR in favor of this clean one.
What it does
sendMessage()inpackages/cli/src/utils/mail.tsdefaults to~/.tps/mail/whenTPS_MAIL_DIRis unset. When tests importsendMessageoutside a test framework that setsTPS_MAIL_DIR(e.g., direct module import in a tooling context), messages silently land in the real production maildir.This caused 188 phantom messages (
msg-0…msg-99+) to appear in a production inbox today, all timestamped within sub-second bursts, from=anvil, X-TPS-Trust=user.Fix
Adds a guard at the top of
sendMessage(): ifNODE_ENV=testORTPS_MAIL_REQUIRE_EXPLICIT_DIRis set ANDTPS_MAIL_DIRis unset, throw with an explicit error. Tests that already setTPS_MAIL_DIRin theirbeforeEachare unaffected.12 lines added in
mail.ts. No test changes (existing 6 pre-existing test failures inmail.test.tsare separate — needs follow-up; not in scope here).Why this branch, not #287
#287 contained three commits, two of which duplicate already-merged work (cli#278 skill-add-pack + sherlock shell-injection fix). This branch is the single real new commit, cleanly off current main.
🤖 Generated with Claude Code