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.
156 lines
6.1 KiB
JavaScript
156 lines
6.1 KiB
JavaScript
/**
|
|
* @file Tests server/lib/privacy.js — the secret patterns (each shape redacted,
|
|
* look-alikes such as `max_tokens: 5` left alone), secret-named JSON keys, the
|
|
* payload entry point being a byte-identical no-op while off or when nothing
|
|
* matched, `trim_event_data` redacting before it trims, and the ignored-folder
|
|
* list's path-boundary match and validation.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, beforeEach, afterEach } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("node:path");
|
|
const privacy = require("../lib/privacy");
|
|
const { trimEventData } = require("../lib/event-payload");
|
|
|
|
const R = privacy.REDACTED;
|
|
|
|
describe("redactText", () => {
|
|
const cases = [
|
|
["anthropic key", "key sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv here", `key ${R} here`],
|
|
["openai key", "OPENAI sk-proj-AbCdEfGhIjKlMnOpQrStUvWx", `OPENAI ${R}`],
|
|
["github token", "ghp_0123456789abcdefghijABCDEFGHIJ", R],
|
|
["aws key id", "id AKIAABCDEFGHIJKLMNOP", `id ${R}`],
|
|
["slack", "xoxb-1234567890-abcdef", R],
|
|
[
|
|
"bearer header",
|
|
"curl -H 'Authorization: Bearer abc.def-ghi_jkl'",
|
|
`curl -H 'Authorization: Bearer ${R}'`,
|
|
],
|
|
["password assignment", "mysql password=hunter22 -h db", `mysql password=${R} -h db`],
|
|
["env token", "export GITHUB_TOKEN=abcd1234efgh", `export GITHUB_TOKEN=${R}`],
|
|
["json-ish key", '{"api_key": "abcd1234"}', `{"api_key": ${R}}`],
|
|
[
|
|
"private key",
|
|
"a\n-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA\n-----END OPENSSH PRIVATE KEY-----\nz",
|
|
`a\n${R}\nz`,
|
|
],
|
|
];
|
|
for (const [name, input, want] of cases) {
|
|
it(`redacts ${name}`, () => assert.equal(privacy.redactText(input), want));
|
|
}
|
|
|
|
it("leaves look-alikes alone", () => {
|
|
for (const s of [
|
|
"max_tokens: 4096",
|
|
"token_count=5 input_tokens: 9",
|
|
"the author wrote a secretive note",
|
|
"sk-short",
|
|
"Bearer of bad news",
|
|
]) {
|
|
assert.equal(privacy.redactText(s), s);
|
|
}
|
|
});
|
|
|
|
it("is idempotent", () => {
|
|
const once = privacy.redactText("password=hunter22 and ghp_0123456789abcdefghijABCDEFGHIJ");
|
|
assert.equal(privacy.redactText(once), once);
|
|
});
|
|
|
|
it("stays linear on a pathological run", () => {
|
|
const t = Date.now();
|
|
privacy.redactText("a-".repeat(200_000));
|
|
assert.ok(Date.now() - t < 2000);
|
|
});
|
|
});
|
|
|
|
describe("redactValue", () => {
|
|
it("redacts any string under a secret-named key, whatever it looks like", () => {
|
|
const out = privacy.redactValue({
|
|
tool_input: { env: { DB_PASSWORD: "x", API_KEY: "y", max_tokens: "5", authorization: "z" } },
|
|
author: "me",
|
|
});
|
|
assert.deepEqual(out, {
|
|
tool_input: { env: { DB_PASSWORD: R, API_KEY: R, max_tokens: "5", authorization: R } },
|
|
author: "me",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("redactEventJson / trim_event_data", () => {
|
|
let saved;
|
|
beforeEach(() => {
|
|
saved = process.env[privacy.REDACT_KEY];
|
|
});
|
|
afterEach(() => {
|
|
if (saved === undefined) delete process.env[privacy.REDACT_KEY];
|
|
else process.env[privacy.REDACT_KEY] = saved;
|
|
});
|
|
const payload = JSON.stringify({
|
|
session_id: "s",
|
|
tool_input: { command: "curl -H 'Authorization: Bearer abcdefgh12345678' x" },
|
|
});
|
|
|
|
it("is a byte-identical no-op while off", () => {
|
|
delete process.env[privacy.REDACT_KEY];
|
|
assert.equal(privacy.redactEventJson(payload), payload);
|
|
assert.equal(trimEventData(payload), payload);
|
|
});
|
|
|
|
it("redacts and counts when on, and leaves a clean payload byte-identical", () => {
|
|
process.env[privacy.REDACT_KEY] = "1";
|
|
const out = JSON.parse(trimEventData(payload));
|
|
assert.equal(out.tool_input.command, `curl -H 'Authorization: Bearer ${R}' x`);
|
|
assert.equal(out._redacted, 1);
|
|
const clean = JSON.stringify({ session_id: "s", tool_input: { command: "ls" } });
|
|
assert.equal(trimEventData(clean), clean);
|
|
});
|
|
|
|
it("redacts a private key before the trim could cut it in two", () => {
|
|
process.env[privacy.REDACT_KEY] = "1";
|
|
const key = `-----BEGIN RSA PRIVATE KEY-----\n${"A".repeat(20_000)}\n-----END RSA PRIVATE KEY-----`;
|
|
const json = JSON.stringify({ tool_name: "Read", tool_response: { file: { text: key } } });
|
|
const out = trimEventData(json);
|
|
assert.ok(!out.includes("AAAAAAAAAA"));
|
|
assert.ok(out.includes(R));
|
|
});
|
|
|
|
it("leaves summaries alone while off", () => {
|
|
delete process.env[privacy.REDACT_KEY];
|
|
assert.equal(privacy.redactSummary("password=hunter22"), "password=hunter22");
|
|
process.env[privacy.REDACT_KEY] = "1";
|
|
assert.equal(privacy.redactSummary("password=hunter22"), `password=${R}`);
|
|
});
|
|
});
|
|
|
|
describe("ignored folders", () => {
|
|
const env = (list) => ({ [privacy.IGNORE_KEY]: list.join(path.delimiter) });
|
|
|
|
it("matches the folder and what lies under it, never a sibling sharing the prefix", () => {
|
|
const e = env(["/work/secret", "/tmp/x/"]);
|
|
assert.equal(privacy.isIgnoredCwd("/work/secret", e), true);
|
|
assert.equal(privacy.isIgnoredCwd("/work/secret/app", e), true);
|
|
assert.equal(privacy.isIgnoredCwd("/work/secret-two", e), false);
|
|
assert.equal(privacy.isIgnoredCwd("/tmp/x/y", e), true);
|
|
assert.equal(privacy.isIgnoredCwd("/work", e), false);
|
|
assert.equal(privacy.isIgnoredCwd(undefined, e), false);
|
|
assert.equal(privacy.isIgnoredCwd("/anything", {}), false);
|
|
});
|
|
|
|
it("never treats a root or a relative entry as a folder to ignore", () => {
|
|
assert.deepEqual(privacy.ignoredCwds(env(["/", "rel/dir", "/ok"])).length, 2);
|
|
assert.equal(privacy.isIgnoredCwd("/home/u", env(["/"])), false);
|
|
});
|
|
|
|
it("validates the Settings list", () => {
|
|
assert.deepEqual(privacy.validateIgnoreList(["/a/b/", " /a/b ", "", "/c"]), ["/a/b", "/c"]);
|
|
assert.throws(() => privacy.validateIgnoreList(["relative"]), { code: "EBADPATH" });
|
|
assert.throws(() => privacy.validateIgnoreList(["/"]), { code: "EBADPATH" });
|
|
assert.throws(() => privacy.validateIgnoreList([`/a${path.delimiter}/b`]), {
|
|
code: "EBADPATH",
|
|
});
|
|
assert.throws(() => privacy.validateIgnoreList(["/a\nb"]), { code: "EBADPATH" });
|
|
assert.throws(() => privacy.validateIgnoreList("/a"), { code: "EBADPATH" });
|
|
});
|
|
});
|