4aae61297f
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
179 lines
7.0 KiB
JavaScript
179 lines
7.0 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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,
|
|
};
|