← back to Ventura Claw
server/lib/comms-compliance.js
168 lines
// VenturaClaw — comms-compliance gate (CAN-SPAM / §17529.5 / TCPA / DNC scrub)
// Runs before any sensitive comms action executes.
// Per Steve's MEMORY.md project_comms_compliance_agent.md: fail-closed if list missing or stale.
const fs = require("fs");
const path = require("path");
const os = require("os");
// Suppression list: array of { kind: "email"|"phone"|"slack_user", value, reason, ts }
let suppression = [];
const SUPPRESSION_FILE = path.join(__dirname, "..", "data", "comms-suppression.json");
function loadSuppression() {
try { suppression = JSON.parse(fs.readFileSync(SUPPRESSION_FILE, "utf8")); }
catch { suppression = []; }
}
function isSuppressed(kind, value) {
if (!value) return false;
const v = String(value).toLowerCase().trim();
return suppression.some(s => s.kind === kind && String(s.value).toLowerCase().trim() === v);
}
loadSuppression();
// Federal Do-Not-Call snapshot — fail-closed if missing or stale (>31 days).
// File location per Steve's MEMORY rule (project_comms_compliance_agent): ~/.claude/data/dnc/national-dnc.txt
const DNC_PATH = path.join(os.homedir(), ".claude", "data", "dnc", "national-dnc.txt");
let _dncCache = { mtimeMs: 0, lookup: null };
function _loadDnc() {
try {
const st = fs.statSync(DNC_PATH);
if (st.mtimeMs === _dncCache.mtimeMs && _dncCache.lookup) return _dncCache.lookup;
const text = fs.readFileSync(DNC_PATH, "utf8");
const set = new Set();
for (const line of text.split("\n")) {
const norm = line.replace(/\D/g, "");
if (norm.length >= 10) set.add(norm);
}
_dncCache = { mtimeMs: st.mtimeMs, lookup: set };
return set;
} catch { return null; }
}
function dncSnapshotFresh() {
try { return (Date.now() - fs.statSync(DNC_PATH).mtimeMs) < 31 * 86400 * 1000; }
catch { return false; }
}
function isOnFederalDNC(phone) {
const set = _loadDnc();
if (!set) return false;
const n = String(phone || "").replace(/\D/g, "");
return set.has(n) || (n.length === 11 && n.startsWith("1") && set.has(n.slice(1)));
}
// Action → comms kind mapping
const COMMS_ACTIONS = {
"slack.chat.postMessage": { kind: "slack", needs: ["channel", "text"] },
"slack.channels.create": null, // not outbound msg
"twilio.sms.send": { kind: "sms", needs: ["to", "body"] },
"twilio.voice.call": { kind: "voice", needs: ["to"] },
"twilio.whatsapp.send": { kind: "sms", needs: ["to", "body"] },
"gmail.message.send": { kind: "email", needs: ["to", "subject", "body"] },
"outlook.message.send": { kind: "email", needs: ["to", "subject", "body"] },
"purelymail.smtp.send": { kind: "email", needs: ["to", "subject", "body"] },
"mailchimp.campaign.send": { kind: "email_blast", needs: ["campaign_id"] },
"klaviyo.flow.trigger": { kind: "email_blast", needs: [] },
"constant_contact.campaign.send": { kind: "email_blast", needs: [] },
"facebook.post.create": null,
"x_twitter.dm.send": { kind: "dm", needs: ["to"] },
"instagram.reply.dm": { kind: "dm", needs: ["to"] }
};
function isCommsAction(action) {
return action && COMMS_ACTIONS[action] !== undefined && COMMS_ACTIONS[action] !== null;
}
// Federal CAN-SPAM minimums for outbound email
function checkEmailContent(input) {
const violations = [];
const body = String(input?.body || "");
const subject = String(input?.subject || "");
if (!subject.trim()) violations.push({ rule: "CAN-SPAM §A.5.a.1", msg: "subject required" });
// CAN-SPAM requires unsubscribe (clear and conspicuous) for commercial mail
if (!/unsubscribe|opt[-\s]?out|stop[\s]+receiving/i.test(body) && body.length > 80) {
violations.push({ rule: "CAN-SPAM §A.5.a.5", msg: "no unsubscribe link/instruction in body" });
}
// CAN-SPAM: physical postal address
if (!/\b\d{1,5}\s+\w+(\s\w+)*,\s*[\w\s]+,?\s*[A-Z]{2}\s*\d{5}\b/.test(body) &&
!/\bP\.?O\.?\s*Box\s+\d+/i.test(body) && body.length > 80) {
violations.push({ rule: "CAN-SPAM §A.5.a.5", msg: "no postal address in body" });
}
return violations;
}
function checkSmsContent(input) {
const violations = [];
const body = String(input?.body || "");
// TCPA: opt-out instruction required for marketing SMS
if (body.length > 0 && !/STOP|opt[-\s]?out|unsubscribe/i.test(body)) {
violations.push({ rule: "TCPA marketing SMS", msg: 'no STOP/opt-out instruction (e.g. "Reply STOP to unsubscribe")' });
}
return violations;
}
function checkSlackContent(input) {
return []; // Internal Slack channels don't trigger CAN-SPAM/TCPA
}
function scrubRecipient(meta, input) {
const violations = [];
if (meta.kind === "email" || meta.kind === "email_blast") {
const to = String(input?.to || "");
if (to && isSuppressed("email", to)) violations.push({ rule: "suppression list", msg: `recipient ${to} is suppressed` });
}
if (meta.kind === "sms" || meta.kind === "voice") {
const to = String(input?.to || "");
if (to && isSuppressed("phone", to)) violations.push({ rule: "DNC suppression", msg: `recipient ${to} on suppression list` });
// TCPA: minimum normalized E.164 phone format
if (to && !/^\+?\d{10,15}$/.test(to.replace(/[\s\-().]/g, ""))) {
violations.push({ rule: "phone format", msg: `recipient ${to} is not a valid phone number` });
}
// Federal DNC: fail-closed if snapshot missing/stale (per Steve's standing rule)
if (!dncSnapshotFresh()) {
violations.push({ rule: "Federal DNC", msg: "DNC snapshot missing or >31 days old at ~/.claude/data/dnc/national-dnc.txt — send blocked. Renew via telemarketing.donotcall.gov." });
} else if (to && isOnFederalDNC(to)) {
violations.push({ rule: "Federal DNC", msg: `${to} is on the Federal Do-Not-Call registry` });
}
}
if (meta.kind === "dm" || meta.kind === "slack") {
const to = String(input?.to || input?.channel || "");
if (to && isSuppressed("slack_user", to)) violations.push({ rule: "suppression list", msg: `recipient ${to} is suppressed` });
}
return violations;
}
// Main entry — returns { ok, kind, violations[], message }
function scrub(action, input, opts = {}) {
const meta = COMMS_ACTIONS[action];
if (!meta) return { ok: true, kind: "non_comms", violations: [] };
// Validate required fields
const missing = (meta.needs || []).filter(k => !input || input[k] == null || String(input[k]).trim() === "");
const violations = [];
if (missing.length) violations.push({ rule: "missing fields", msg: `required: ${missing.join(", ")}` });
// Recipient scrub
violations.push(...scrubRecipient(meta, input));
// Content scrub
if (meta.kind === "email" || meta.kind === "email_blast") violations.push(...checkEmailContent(input));
if (meta.kind === "sms" || meta.kind === "voice") violations.push(...checkSmsContent(input));
if (meta.kind === "slack") violations.push(...checkSlackContent(input));
return {
ok: violations.length === 0,
kind: meta.kind,
violations,
message: violations.length ? `${violations.length} compliance violation(s); approval cannot execute until resolved or override flag is set` : null
};
}
function suppress(kind, value, reason) {
if (!value) throw new Error("value required");
if (isSuppressed(kind, value)) return { added: false, alreadyOnList: true };
suppression.push({ kind, value, reason: reason || "manual_admin", ts: new Date().toISOString() });
fs.writeFileSync(SUPPRESSION_FILE, JSON.stringify(suppression, null, 2));
return { added: true, total: suppression.length };
}
function listSuppression() { return suppression; }
function reload() { loadSuppression(); }
module.exports = { scrub, isCommsAction, suppress, listSuppression, reload, COMMS_ACTIONS };