Files
nntrivi2001 4aae61297f feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
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.
2026-10-06 17:12:28 +07:00

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,
};