whatsappman runs an always-on local daemon that holds live WhatsApp linked-device credentials and can send messages as you. That makes it a more sensitive target than the sibling email tool (mailman), which has no daemon and no persistent socket. This document is the threat model, the controls, and — importantly — an honest statement of what is and isn't enforceable.
Read STANDARDS.md for the baseline conventions; this file is the security deep-dive behind them.
- Baileys credentials (
sessions/<label>/auth/) — these are the WhatsApp session. Whoever copies them can impersonate the number from another machine. - The ability to send — even without stealing creds, anything that can talk to the daemon can send messages as you.
- Message + recipient metadata (
sent.jsonl) — PII.
The security boundary is your OS user account, not the whatsappman application. A Unix-domain socket with
0600permissions restricts access to your UID — not to a specific binary. Unix permissions are user-based, not application-based. So any process running as you can connect to the socket. This is not a defect we can code away: any code running as your user already has your SSH keys, your OS keychain, your browser cookies, and mailman's credentials. whatsappman adds no attack surface beyond what same-user code already has.
What we therefore promise, precisely:
- ✅ No network surface — unreachable from any other machine.
- ✅ No other OS user can reach the daemon (peer-UID enforced).
- ✅ No blind/accidental same-user connection succeeds (capability token).
- ✅ Every request is validated + audited.
⚠️ Dedicated same-user malware is not stopped by the above — only OS-level sandboxing does that (see "Going further"). We say so plainly rather than overclaiming "no other app can access it."
Five layers; the first four are enforced, the fifth is best-effort.
The daemon listens only on a Unix domain socket (~/.whatsappman/daemon.sock,
0600) inside a 0700 config dir. On Windows, a named pipe
(\\.\pipe\whatsappman). There is no TCP listener and no loopback fallback in
the shipped product — this removes the entire remote-attacker class. (A future
opt-in remote mode would require TLS + bearer token + explicit config and is
out of scope.)
On every accepted connection the daemon reads the peer's credentials
(getpeereid() on macOS/BSD, SO_PEERCRED on Linux) and drops any peer whose
UID ≠ the daemon's own UID. This makes cross-user access impossible on a
shared machine even if socket permissions were somehow loosened.
On startup the daemon generates a 256-bit random token, writes it to
~/.whatsappman/daemon.token (0600), and requires it on every IPC request.
The token rotates on each daemon restart. Clients (the MCP server + CLI) read it
from the file. This blocks any connector that hasn't read your 0600 files —
port-scanners, accidental connections, processes probing the socket blindly.
Every request is parsed against a zod schema; unknown methods and malformed payloads are rejected and audited. Sizes are bounded (text length, attachment count, bulk recipient count). Request IDs are unguessable UUIDs.
Optionally, the daemon resolves the peer PID → executable path
(proc_pidpath / /proc/<pid>/exe) and warns (or, if strictPeer is enabled,
denies) when the caller is not the whatsappman binary. Documented explicitly
as a speed-bump, not a security boundary — PID reuse is racy and the path is
spoofable. It exists to catch honest mistakes, not determined attackers.
Named pipes are not restricted to the owner by default. The daemon must create the pipe with:
- an explicit DACL granting access only to the current user's SID, and
PIPE_REJECT_REMOTE_CLIENTSso no remote SMB client can connect. This is a known Windows gotcha and a required Phase-5 checklist item.
sessions/<label>/auth/is0700; the config dir is0700; the socket,daemon.token,daemon.pid, and logs are0600.- Encrypt the creds. Baileys writes
auth/as plaintext JSON by default. A copied folder (backup, cloud sync, USB) is a fully portable, cloneable session. Mitigation, mirroring mailman'saccounts.jsonmodel: encrypt theauth/payloads with an AES-256-GCM key held in the OS keychain via keytar — the key never touches the config dir, so a copied folder yields useless ciphertext on any other machine. (Phase-8 hardening; flagged High.) - Keep
~/.whatsappmanout of cloud sync / backups. Document that users should exclude it from iCloud Drive / Time Machine / Dropbox / OneDrive — syncing live session creds off-box defeats the machine-bound protection. .gitignorealready excludes any local.whatsappman/.
The daemon reads arbitrary local files by absolute path to attach them. A caller
could try to "send" ~/.ssh/id_rsa, a .env, or a keychain export to an
attacker-controlled number. Controls:
- Human-visible preview — the normal
draft_message → confirm_sendflow surfaces the filename and recipient to the human before dispatch. - Audit every attachment — absolute path + recipient JID logged to
activity.logon every send. - Sensitive-path denylist — refuse paths under
~/.ssh,*.env,~/.aws, keychain/keyring files, and the whatsappman config dir itself; returnATTACHMENT_FORBIDDEN. - Size cap — enforced before read (
ATTACHMENT_TOO_LARGE), never truncated silently.
Caveat, stated honestly: the human preview is a UX-layer guard (Claude shows it, the user says yes). The daemon cannot prove a human approved a given
confirm_send— a same-user caller holding the token could scriptdraft+confirm. So the enforced guards against silent exfil are the denylist, the audit log, and the token — not the preview.
Baileys is unofficial; automated bulk sending risks the number being banned, and a rogue local caller could weaponize your number for spam (reputational/legal exposure). Controls, all enforced in the daemon (not just the client):
- the capability token gates who can send at all;
- a per-session token-bucket rate limiter (
src/daemon/rate-limit.ts, 30 burst / ~1 per second) on every send path (sendText/sendDraft/sendBulk) →RATE_LIMITED, so a runaway loop or rogue caller can't blast the number into a ban. Sized so normal interactive/bulk use never trips it; settings.defaultDelayMsbetween bulk messages +settings.maxBulkRecipientscap →BULK_LIMIT_EXCEEDED. The cap refuses the whole send rather than trimming to the first N, so a 1000-number list fails loudly instead of half-succeeding;- the inter-send delay is jittered ±25% (
withJitter). A batch firing at exactly 2000ms is machine-obvious, and mechanical regularity is precisely what automated-behaviour detection looks for; - a circuit breaker (
BulkGuard, 3 consecutive failures). This is the one that matters most. The send loop used to catch every error and carry on, so if WhatsApp began rejecting sends — which is exactly what throttling and an early block look like from inside — it would keep hammering through all 100 recipients. That is the behaviour most likely to turn a warning into a ban. It now stops, reports the remainder asskipped(never contacted, so the result cannot imply otherwise), and raises a desktop notification. Isolated failures reset the counter: one dead number in a list must not stop a batch.
The breaker lives in a small class rather than inline in the loop specifically so the decision protecting the number is unit-testable, instead of only reachable through a live socket.
The README carries a prominent caution. None of this makes WhatsApp a bulk-marketing channel — the guards keep ordinary multi-recipient use (an on-call page, a team broadcast) from looking automated. Sending unsolicited messages at volume will get the number banned regardless.
- Never runs as root. The launchd job runs in the user's GUI/Aqua domain;
the systemd unit is a
--userunit. No step usessudo. - systemd unit sandboxing (Linux):
NoNewPrivileges=true,ProtectSystem=strict,ReadWritePaths=%h/.whatsappman,PrivateTmp=true,ProtectControlGroups=true,RestrictSUIDSGID=true,LockPersonality=true. Note:RestrictAddressFamiliescannot dropAF_INET— Baileys needs outbound TLS to WhatsApp — butAF_UNIX+AF_INET+AF_INET6only, no others. - launchd (macOS): user agent,
RunAtLoad+KeepAlive{Crashed:true, SuccessfulExit:false},ThrottleInterval≥10s to avoid crash-loops, logs to~/.whatsappman/logs/at0600. - Single-instance lock — pidfile +
flock; a stale socket is unlinked only after confirming no live process holds the pid (no blind unlink race). - Graceful shutdown — SIGTERM flushes state, closes each Baileys socket, exits 0 so the OS supervisor doesn't restart a clean stop.
- Least dependency privilege — the launcher needs no elevated permissions;
install writes only to
~/.whatsappman,~/Library/LaunchAgents(mac) or~/.config/systemd/user(linux).
Baileys carries a large transitive dependency tree, and the daemon is long-lived with your creds in memory — a compromised dep has persistent access. Controls:
- Commit
package-lock.json; install withnpm ci(no drift). - Pin
@whiskeysockets/baileys/qrcode/pinoto exact-ish ranges. npm audit(or equivalent) in CI; Dependabot for security bumps.- Review dependency additions; prefer fewer deps.
pinoat the daemon redacts credentials and message bodies by default — they never reachdaemon.*.log.- The pairing QR is a live credential — printed only to the interactive terminal, never written to the daemon log, short-lived.
sent.jsonlstores send metadata (timestamp, session, recipient JID, kind, messageId) — never inbound content (there is no inbound handling) —0600, size-capped/rotated.- The
--imagepairing QR is written through an explicit0600fd inside the0700config dir — never the shared tmpdir, where a predictable name would let any local user read it and become a linked device — and removed when pairing ends, including on Ctrl-C.
summary is the one feature that reads data it did not create: Claude Code
session transcripts under ~/.claude/projects and ~/.iclaude/projects. Those
files contain every prompt and reply you have typed, some of it secret — and
the digest can be sent over WhatsApp. Two rules make that safe:
- Metadata only. The digest carries the session's own generated title,
counts, durations, git branch and file names. No prompt text, no reply text,
no file contents.
test/digest.test.tsplants a fake AWS key in a synthetic transcript and asserts it reaches neither the digest object nor the rendered message. - No model call. Summarising is arithmetic over the transcript, not an LLM round-trip. Shipping a digest that phoned an API would break the "no third-party API, runs on your machine" promise the rest of this document rests on.
Sending is still your decision: summary prints locally and only transmits
when you pass --to.
delete and reset destroy credentials. Both prompt through one layer
(src/cli/prompts.ts) that defaults the confirmation to No and treats
Esc/Ctrl-C as No — the safe answer is the one you get by panicking out of
the prompt. Non-interactive shells cannot be prompted at all, so there they
still require an explicit --yes rather than proceeding. eval/cli-surface.eval.ts
fails the build if a destructive command stops using that helper, or if any file
hand-rolls its own prompt.
whatsappman update pulls @integratex/whatsappman from registry.npmjs.org
over TLS; integrity is enforced by lockfile hashes; no elevated/sudo
postinstall runs. Publishing is manual, only after explicit confirmation — never
automated.
For users who genuinely need to wall the daemon off from same-user code — the only thing that actually delivers "no other application can access it":
- macOS: ship a codesigned + notarized build, run under the App Sandbox with a minimal entitlement set, and rely on TCC so other apps can't inspect it; a hardened-runtime binary resists code injection.
- Linux: run the daemon under seccomp + an AppArmor/SELinux profile
that restricts file and syscall access to exactly
~/.whatsappmanand the WhatsApp egress. - Containers: run the daemon in a rootless container/namespace with only the config volume mounted.
These are documented as an advanced opt-in, not the default install, because they add packaging/signing overhead disproportionate to a personal-use local tool — but they are the honest answer to "hard isolation."
- No inbound message processing → no inbound-content attack surface.
- No web/HTTP surface, no database, no multi-tenant auth to get wrong.
- No secrets in the repo; no telemetry; nothing leaves the machine except the WhatsApp traffic itself.