/** * @file privacy.js * @description Ingest-time privacy controls, both opt-in and off by default: * * 1. Secret redaction (`AGENTHUB_REDACT_SECRETS=1`): every event payload and * summary passes through here on its way into SQLite (`trim_event_data(?)` / * `redact_text(?)`, registered in `server/db.js`), and a secret-shaped value * is replaced by `[redacted]` — API keys and tokens of known shapes, bearer * headers, PEM private keys, `password=…`/`TOKEN: …` assignments, and every * string under a secret-named JSON key. Only what is STORED is redacted: the * hook route still reads the raw payload in memory, and the transcript on * disk is the CLI's own file, untouched. * 2. Ignored folders (`AGENTHUB_IGNORE_CWD`, `path.delimiter`-separated — * a colon on Linux/macOS): a hook whose `cwd` is one of them or under one * (path-boundary match, never a bare string prefix) is dropped whole, and the * history import and the Codex journal reader skip such sessions too. * * Both live in the settings file (`settings-env.js`) like every other value the * dashboard saves, so the real environment can pin them (`env_owned`). * @author Nguyễn Ngọc Trí Vĩ */ const os = require("node:os"); const path = require("node:path"); const REDACT_KEY = "AGENTHUB_REDACT_SECRETS"; const IGNORE_KEY = "AGENTHUB_IGNORE_CWD"; const REDACTED = "[redacted]"; // Whole-match secret shapes. Each is distinctive enough that a hit is a secret. const SECRET_PATTERNS = [ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, /\bsk-ant-[A-Za-z0-9_-]{16,}/g, // Anthropic /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}/g, // OpenAI-style /\bgh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub tokens /\bgithub_pat_[A-Za-z0-9_]{20,}/g, /\bglpat-[A-Za-z0-9_-]{20,}/g, // GitLab /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, // Slack /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, // AWS access key id /\bAIza[0-9A-Za-z_-]{35}/g, // Google API key /\bnpm_[A-Za-z0-9]{36}\b/g, /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, // JWT ]; // The value after a label: the label stays, so the text still reads. const LABELLED_PATTERNS = [ /(\bBearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi, /(\bBasic\s+)[A-Za-z0-9+/=]{8,}/g, // password=…, DB_PASSWORD: …, "api_key": "…", GITHUB_TOKEN=… — not // max_tokens / token_count, whose name does not END in the word. The name's // prefix is bounded: unbounded, "a-a-a-…" backtracks quadratically. /(\b[A-Za-z0-9_-]{0,40}(?:password|passwd|secret|token|api[_-]?key|access[_-]?key)["']?\s*[=:]\s*)("[^"\n]*"|'[^'\n]*'|[^\s"',;&]+)/gi, ]; // A JSON key whose string value is a secret whatever it looks like. const SECRET_KEY = /^(?:.*[_-])?(?:password|passwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|authorization)$/i; function redactionEnabled(env = process.env) { return env[REDACT_KEY] === "1"; } /** `text` with every secret-shaped run replaced; the input when none. */ function redactText(text) { if (typeof text !== "string" || text.length < 8) return text; let out = text; for (const re of SECRET_PATTERNS) out = out.replace(re, REDACTED); for (const re of LABELLED_PATTERNS) { out = out.replace(re, (_m, label) => `${label}${REDACTED}`); } return out; } /** A redacted copy of a parsed payload, and how many strings changed. */ function redactValue(value, key = null, counter = { n: 0 }) { if (typeof value === "string") { const next = key && SECRET_KEY.test(key) && value ? REDACTED : redactText(value); if (next !== value) counter.n++; return next; } if (Array.isArray(value)) return value.map((v) => redactValue(v, null, counter)); if (value && typeof value === "object") { const out = {}; for (const [k, v] of Object.entries(value)) out[k] = redactValue(v, k, counter); return out; } return value; } /** * The SQL-side entry for `events.data` (chained in front of the trim in * `event-payload.js`): JSON in, JSON out. Unchanged — byte for byte — when * redaction is off, nothing matched, or the text is not a JSON object. */ function redactEventJson(json) { if (typeof json !== "string" || !redactionEnabled()) return json; let parsed; try { parsed = JSON.parse(json); } catch { return redactText(json); } if (!parsed || typeof parsed !== "object") return json; const counter = { n: 0 }; const out = redactValue(parsed, null, counter); if (counter.n === 0) return json; if (!Array.isArray(out)) out._redacted = counter.n; return JSON.stringify(out); } /** `redact_text(?)` for `events.summary`: a no-op while redaction is off. */ function redactSummary(text) { return redactionEnabled() ? redactText(text) : text; } function expandHome(p) { return p.replace(/^~(?=[/\\]|$)/, os.homedir()); } /** The ignored folders, absolute and without a trailing separator. */ function ignoredCwds(env = process.env) { return String(env[IGNORE_KEY] || "") .split(path.delimiter) .map((p) => p.trim()) .filter(Boolean) .map((p) => path.resolve(expandHome(p))) .filter((p) => path.isAbsolute(p) && p !== path.parse(p).root); } /** Whether `cwd` is an ignored folder or lies under one. */ function isIgnoredCwd(cwd, env = process.env) { // Not absolute here (a Windows path pushed to a POSIX server) cannot be // under an entry, and resolving it would graft it onto this process's cwd. if (typeof cwd !== "string" || !path.isAbsolute(cwd)) return false; const list = ignoredCwds(env); if (list.length === 0) return false; const c = path.resolve(cwd); return list.some((p) => c === p || c.startsWith(p + path.sep)); } /** * Check a list of folders from the Settings form. Each must be absolute (after * `~` expansion), not a filesystem root (that would drop every hook), and free * of control characters and the list separator. Returns them resolved. */ function validateIgnoreList(list) { const bad = (message) => Object.assign(new Error(message), { code: "EBADPATH" }); if (!Array.isArray(list)) throw bad("ignore_cwd must be an array of paths"); if (list.length > 100) throw bad("at most 100 ignored folders"); const out = []; for (const raw of list) { if (typeof raw !== "string") throw bad("every ignored folder must be a string"); const p = raw.trim(); if (!p) continue; // eslint-disable-next-line no-control-regex -- intentional control-char reject if (p.length > 1024 || /[\x00-\x1f]/.test(p) || p.includes(path.delimiter)) { throw bad(`not a usable path: ${p.slice(0, 80)}`); } const resolved = path.resolve(expandHome(p)); if (!path.isAbsolute(expandHome(p))) throw bad(`must be an absolute path: ${p}`); if (resolved === path.parse(resolved).root) throw bad("a filesystem root cannot be ignored"); if (!out.includes(resolved)) out.push(resolved); } return out; } module.exports = { REDACT_KEY, IGNORE_KEY, REDACTED, SECRET_PATTERNS, redactionEnabled, redactText, redactValue, redactEventJson, redactSummary, ignoredCwds, isIgnoredCwd, validateIgnoreList, };