← back to Butlr
lib/dnc-check.js
164 lines
// DNC (Do Not Call) gate — fail-closed pre-flight check for any outbound call or SMS.
//
// Sources (in order of authority):
// 1. Federal DNC snapshot at $DNC_FILE (default ~/.claude/data/dnc/national-dnc.txt)
// One number per line, any of: +14155551212 / 4155551212 / (415) 555-1212
// Obtained via the FTC SAN subscription (https://telemarketing.donotcall.gov/).
// File missing = HARD BLOCK (fail closed per Steve's 2026-05-13 standing rule).
// 2. Internal suppression at data/dnc-suppression.json
// JSON array of {phone, hash, reason, source, added_at}. Populated via
// addToInternalSuppression(num, reason) — used for STOP-keyword opt-outs,
// manual blocks, prior dispute resolutions.
//
// Bypass:
// TWILIO_DRY_RUN=1 short-circuits to {allowed:true, dry_run_bypass:true} so
// simulated dials don't require the federal snapshot file. Live mode always
// enforces the gate.
//
// Steve's standing rule (2026-05-13): "Do not allow butlr to call anyone on the
// do not call list for each state and do not sms too. Any do not on either,
// remove from list. Always check this before dialing."
//
// Compliance reasoning (from comms-compliance subagent):
// - Federal DNC covers residential + personal wireless. Most business 1-800
// lines are NOT on it. But Steve's belt-and-suspenders policy applies the
// check to BOTH business_phone and callback_phone before any dial or SMS.
// - TCPA private right of action is strict liability, $51,744 per call.
// Cost of false-positive block (one missed dial) is trivial.
// - The 5 state DNC lists (IN/LA/MO/OK/WY) require paid portals same as
// federal — not API-accessible bulk. Sticking with federal + internal.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DNC_FILE = process.env.DNC_FILE
|| path.join(process.env.HOME || '/root', '.claude/data/dnc/national-dnc.txt');
const INTERNAL_SUPPRESSION = path.join(__dirname, '..', 'data', 'dnc-suppression.json');
const BLOCKS_LOG = path.join(__dirname, '..', 'data', 'dnc-blocks.jsonl');
// Normalize to bare 10-digit US (strip +, 1, spaces, parens, hyphens).
// Non-US numbers (other lengths) return '' which means "skip — out of scope".
function e164Digits(num) {
const d = String(num || '').replace(/\D/g, '').replace(/^1/, '');
return d.length === 10 ? d : '';
}
function sha256(s) {
return crypto.createHash('sha256').update(String(s)).digest('hex');
}
function isOnFederalDNC(phone) {
const digits = e164Digits(phone);
if (!digits) return false; // non-US: skip
if (!fs.existsSync(DNC_FILE)) {
const err = new Error(`DNC_SNAPSHOT_MISSING: federal DNC file not found at ${DNC_FILE}. Subscribe at https://telemarketing.donotcall.gov/ and refresh before going live.`);
err.code = 'DNC_SNAPSHOT_MISSING';
throw err;
}
try {
const content = fs.readFileSync(DNC_FILE, 'utf8');
// Linear scan; fine for <100K entries. For 240M federal scale, switch to
// a sqlite or hash-set load on first access (cache for process lifetime).
return content.split('\n').some(line => e164Digits(line.trim()) === digits);
} catch (e) {
if (e.code === 'DNC_SNAPSHOT_MISSING') throw e;
const wrapped = new Error(`DNC_FILE_READ_FAIL: ${e.message}`);
wrapped.code = 'DNC_FILE_READ_FAIL';
throw wrapped;
}
}
function isInternalSuppressed(phone) {
const digits = e164Digits(phone);
if (!digits) return false;
if (!fs.existsSync(INTERNAL_SUPPRESSION)) return false;
try {
const list = JSON.parse(fs.readFileSync(INTERNAL_SUPPRESSION, 'utf8'));
if (!Array.isArray(list)) return false;
return list.some(entry => e164Digits(entry.phone) === digits);
} catch { return false; }
}
// Block log — append-only JSONL audit trail of every refusal. Steve can grep this.
function logBlock(phone, result, channel) {
try {
fs.mkdirSync(path.dirname(BLOCKS_LOG), { recursive: true });
fs.appendFileSync(BLOCKS_LOG, JSON.stringify({
at: new Date().toISOString(),
phone_last4: String(phone || '').slice(-4),
phone_hash: sha256(e164Digits(phone)),
channel,
reason: result.reason,
source: result.source,
}) + '\n');
} catch (e) { console.error('[dnc] block-log write failed:', e.message); }
}
// Main gate. Channel is 'call' or 'sms' (audit-trail labeling).
//
// Returns:
// { allowed: true } — dial/SMS ok
// { allowed: true, dry_run_bypass: true } — DRY_RUN env on
// { allowed: false, reason, source } — blocked
// throws { code:'DNC_SNAPSHOT_MISSING'|'DNC_FILE_READ_FAIL'} — fail closed
function checkPhone(phone, channel = 'call') {
if (process.env.TWILIO_DRY_RUN === '1') {
return { allowed: true, dry_run_bypass: true };
}
if (!phone) {
return { allowed: false, reason: 'no_phone', source: 'validation' };
}
if (isInternalSuppressed(phone)) {
const result = { allowed: false, reason: 'internal_suppression', source: 'internal' };
logBlock(phone, result, channel);
return result;
}
if (isOnFederalDNC(phone)) {
const result = { allowed: false, reason: 'federal_dnc', source: 'ftc_donotcall' };
logBlock(phone, result, channel);
return result;
}
return { allowed: true };
}
// Add a number to the internal suppression list. Idempotent.
// Reasons we add: explicit STOP/UNSUBSCRIBE keyword, manual Steve override,
// post-fact "I shouldn't have called them" cleanup, /admin/dnc-add CLI.
function addToInternalSuppression(phone, reason = 'manual', source = 'admin') {
const digits = e164Digits(phone);
if (!digits) return { ok: false, error: 'phone not US 10-digit' };
let list = [];
if (fs.existsSync(INTERNAL_SUPPRESSION)) {
try { list = JSON.parse(fs.readFileSync(INTERNAL_SUPPRESSION, 'utf8')); } catch {}
if (!Array.isArray(list)) list = [];
}
if (list.some(e => e164Digits(e.phone) === digits)) {
return { ok: true, already: true };
}
list.push({
phone: '+1' + digits,
hash: sha256(digits),
reason,
source,
added_at: new Date().toISOString(),
});
fs.mkdirSync(path.dirname(INTERNAL_SUPPRESSION), { recursive: true });
// Atomic write: tmp + rename so concurrent dialers can't read a partial JSON
const tmp = INTERNAL_SUPPRESSION + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
fs.renameSync(tmp, INTERNAL_SUPPRESSION);
return { ok: true, added: true, total: list.length };
}
module.exports = {
checkPhone,
addToInternalSuppression,
isOnFederalDNC,
isInternalSuppressed,
e164Digits,
// exported for tests so they can override paths via env
DNC_FILE,
INTERNAL_SUPPRESSION,
};