diff --git a/.env.example b/.env.example
index db8923d8..c947ebc9 100644
--- a/.env.example
+++ b/.env.example
@@ -58,3 +58,32 @@
# fs.watch watcher fires near-instantly regardless; this poll is the safety net.
# Set to 0 to disable the poll while leaving the watcher running (default: 30000).
# DASHBOARD_SESSION_SYNC_MS=30000
+
+# ── Ingest filters ────────────────────────────────────────────────────────────
+# Comma-separated list of working-directory patterns whose hook events should be
+# silently discarded before they are written to the database. Useful when you
+# run Claude Code in private or scratch directories you never want to appear in
+# the dashboard, analytics, or exports.
+#
+# Supported pattern forms (forward slashes, case-sensitive):
+# /home/user/private exact match
+# /home/user/work/* direct children only (no deeper nesting)
+# /home/user/work/** /work and all descendants
+#
+# The hook handler responds 200 so Claude Code does not retry — the event is
+# intentionally dropped rather than errored. Sessions whose first hook is ignored
+# are never created, so they will not appear in the sessions list at all. For
+# sessions already in the database (created before the filter was added), see the
+# DELETE /api/sessions/:id endpoint.
+#
+# MONITOR_IGNORE_CWD=/home/user/private,/home/user/scratch/**
+
+
+# ── Privacy / redaction ───────────────────────────────────────────────────────
+# When set to "true", hook event payloads are scanned for secret-like values
+# (API keys, tokens, private-key blocks, bearer tokens, URL credentials, AWS
+# access keys) and matching strings are replaced with [REDACTED] BEFORE the
+# event is written to SQLite or broadcast over WebSocket.
+# Structural routing fields (session_id, cwd) are never redacted.
+#
+# MONITOR_PRIVACY_REDACT=true
diff --git a/README-RU.md b/README-RU.md
new file mode 100644
index 00000000..25e77aae
--- /dev/null
+++ b/README-RU.md
@@ -0,0 +1,376 @@
+# Agent Dashboard для Claude Code
+
+### Платформа мониторинга активности Claude Code агентов в режиме реального времени 🚀
+
+Профессиональный дашборд для отслеживания и визуализации сессий Claude Code агентов, использования инструментов и оркестрации субагентов в реальном времени. Построен на Node.js, Express, React и SQLite, интегрируется напрямую с Claude Code через нативную систему хуков для бесшовного отслеживания сессий и аналитики.
+
+
+
+
+
+
+
+
+
+
+**Языки / Language Support**: [English](./README.md) · [中文](./README-CN.md) · [Tiếng Việt](./README-VN.md) · [한국어](./README-KO.md) · **Русский**
+
+---
+
+## Содержание
+
+- [Обзор](#обзор)
+- [Возможности](#возможности)
+- [Быстрый старт](#быстрый-старт)
+- [Как это работает](#как-это-работает)
+- [Конфигурация](#конфигурация)
+- [npm Скрипты](#npm-скрипты)
+- [MCP Интеграция](#mcp-интеграция)
+- [API](#api)
+- [Хук-события](#хук-события)
+- [Уведомления браузера](#уведомления-браузера)
+- [VS Code Расширение](#vs-code-расширение)
+- [Десктопное приложение](#десктопное-приложение-macos-и-windows)
+- [Хранение данных](#хранение-данных)
+- [Развёртывание](#развёртывание)
+- [Структура проекта](#структура-проекта)
+- [Устранение неполадок](#устранение-неполадок)
+- [Участие в разработке](#участие-в-разработке)
+- [Лицензия](#лицензия)
+
+---
+
+## Обзор
+
+Отслеживайте сессии, мониторьте агентов в реальном времени, визуализируйте использование инструментов и наблюдайте за оркестрацией субагентов через профессиональный веб-интерфейс с тёмной темой. Интегрируется напрямую с Claude Code через нативную систему хуков.
+
+---
+
+## Возможности
+
+- **Мониторинг сессий в реальном времени** — отслеживание активных, ожидающих, завершённых и заброшенных сессий
+- **Аналитика агентов** — иерархия агентов, статусы, использование инструментов и затраты
+- **Kanban-доска** — агенты и сессии, сгруппированные по статусу (работает / ожидает / завершён / ошибка)
+- **Просмотр диалогов** — живой просмотр транскриптов с рендерингом Markdown и подсветкой кода
+- **Временная шкала событий** — хронологическая лента событий с многомерной фильтрацией
+- **Аналитика токенов** — статистика входных/выходных токенов, кэша и стоимости по моделям
+- **WebSocket трансляции** — обновления UI в реальном времени без перезагрузки страницы
+- **MCP Сервер** — каталог инструментов для интроспекции дашборда прямо из Claude Code
+- **VS Code Расширение** — просмотр активных сессий прямо в редакторе
+- **Десктопное приложение** — нативные приложения для macOS (Universal DMG) и Windows (NSIS)
+- **Docker** — контейнерное развёртывание через docker-compose
+- **OpenAPI / Swagger** — полная документация REST API
+- **Фильтр директорий** — исключение приватных путей через `MONITOR_IGNORE_CWD`
+- **Tabby** — симпатичный кот-компаньон в интерфейсе 🐱
+
+---
+
+## Быстрый старт
+
+### Требования
+
+- Node.js ≥ 18 (рекомендуется 22+ для нативного SQLite)
+- npm ≥ 9
+- Claude Code установлен и настроен
+
+### Установка
+
+```bash
+git clone https://github.com/hoangsonww/Claude-Code-Agent-Monitor.git
+cd Claude-Code-Agent-Monitor
+npm install
+npm run build
+npm start
+```
+
+Дашборд доступен по адресу **http://localhost:4820**.
+
+### Docker
+
+```bash
+docker-compose up -d
+```
+
+### Настройка хуков
+
+Добавьте в `~/.claude/settings.json`:
+
+```json
+{
+ "hooks": {
+ "PreToolUse": [{ "command": "node /path/to/Claude-Code-Agent-Monitor/bin/hook.js" }],
+ "PostToolUse": [{ "command": "node /path/to/Claude-Code-Agent-Monitor/bin/hook.js" }],
+ "Stop": [{ "command": "node /path/to/Claude-Code-Agent-Monitor/bin/hook.js" }],
+ "SessionStart": [{ "command": "node /path/to/Claude-Code-Agent-Monitor/bin/hook.js" }],
+ "SessionEnd": [{ "command": "node /path/to/Claude-Code-Agent-Monitor/bin/hook.js" }],
+ "Notification": [{ "command": "node /path/to/Claude-Code-Agent-Monitor/bin/hook.js" }]
+ }
+}
+```
+
+---
+
+## Как это работает
+
+1. **Claude Code** запускает хук при каждом событии (использование инструмента, остановка, начало сессии и т.д.)
+2. **Скрипт хука** (`bin/hook.js`) отправляет HTTP POST на сервер дашборда
+3. **Express сервер** сохраняет данные в SQLite и транслирует обновления через WebSocket
+4. **React UI** обновляется мгновенно через WebSocket соединение
+
+---
+
+## Конфигурация
+
+Скопируйте `.env.example` в `.env`:
+
+```bash
+cp .env.example .env
+```
+
+### Основные переменные
+
+| Переменная | По умолчанию | Описание |
+|---|---|---|
+| `DASHBOARD_PORT` | `4820` | Порт HTTP сервера |
+| `DASHBOARD_HOST` | `127.0.0.1` | Сетевой интерфейс |
+| `DASHBOARD_TOKEN` | — | Bearer-токен для аутентификации API |
+| `CLAUDE_HOME` | `~/.claude` | Путь к данным Claude Code |
+| `DASHBOARD_STALE_MINUTES` | `180` | Порог заброшенности сессии (мин) |
+| `MONITOR_IGNORE_CWD` | — | Паттерны директорий для исключения |
+
+### Фильтрация директорий (MONITOR_IGNORE_CWD)
+
+Если вы работаете в приватных директориях, укажите их в `MONITOR_IGNORE_CWD`:
+
+```bash
+# Точное совпадение
+MONITOR_IGNORE_CWD=/home/user/private
+
+# Прямые дочерние директории
+MONITOR_IGNORE_CWD=/home/user/work/*
+
+# Рекурсивно (все вложенные)
+MONITOR_IGNORE_CWD=/home/user/scratch/**
+
+# Несколько паттернов через запятую
+MONITOR_IGNORE_CWD=/home/user/private,/home/user/scratch/**,/tmp/*
+```
+
+Хук-события из игнорируемых директорий отбрасываются **до записи в БД** — они никогда не появятся в интерфейсе или аналитике. Сервер отвечает `200 OK` с `{ "ok": true, "ignored": true }`, чтобы Claude Code не делал повторных попыток.
+
+> **Важно:** по умолчанию сервер привязан к `127.0.0.1`. При открытии сетевого доступа обязательно установите `DASHBOARD_TOKEN`.
+
+---
+
+## npm Скрипты
+
+| Скрипт | Описание |
+|---|---|
+| `npm start` | Запустить продакшн сервер |
+| `npm run dev` | Dev режим с горячей перезагрузкой |
+| `npm run build` | Собрать клиент |
+| `npm test` | Запустить тесты (Vitest) |
+| `npm run lint` | Проверить код ESLint |
+| `npm run format` | Форматировать Prettier |
+
+---
+
+## MCP Интеграция
+
+Дашборд включает MCP сервер в директории `mcp/`:
+
+```json
+{
+ "mcpServers": {
+ "agent-dashboard": {
+ "command": "node",
+ "args": ["/path/to/Claude-Code-Agent-Monitor/mcp/server.js"]
+ }
+ }
+}
+```
+
+---
+
+## API
+
+Swagger UI: **http://localhost:4820/api-docs**
+
+| Метод | Путь | Описание |
+|---|---|---|
+| `GET` | `/api/sessions` | Список сессий с пагинацией |
+| `GET` | `/api/sessions/:id` | Детали сессии |
+| `GET` | `/api/analytics` | Агрегированная статистика |
+| `GET` | `/api/stats` | Сводная статистика |
+| `POST` | `/api/hooks/event` | Приём хук-событий |
+| `WebSocket` | `ws://localhost:4820` | Real-time трансляция |
+
+---
+
+## Хук-события
+
+| Тип | Описание |
+|---|---|
+| `PreToolUse` | Начало использования инструмента |
+| `PostToolUse` | Завершение вызова инструмента |
+| `Stop` | Агент завершил ответ |
+| `SubagentStop` | Субагент завершил работу |
+| `SessionStart` | Начало сессии |
+| `SessionEnd` | Завершение сессии |
+| `Notification` | Системное уведомление |
+| `UserPromptSubmit` | Пользователь отправил промпт |
+
+---
+
+## Уведомления браузера
+
+Поддерживается Web Push API (VAPID):
+
+```bash
+npx web-push generate-vapid-keys
+```
+
+Добавьте ключи в `.env` и разрешите уведомления в браузере через кнопку в интерфейсе.
+
+---
+
+## VS Code Расширение
+
+```bash
+cd vscode-extension
+npm install
+npm run package
+# Установить .vsix: Extensions → Install from VSIX
+```
+
+---
+
+## Десктопное приложение (macOS и Windows)
+
+```bash
+cd desktop
+npm install
+
+npm run build:mac # Universal DMG (arm64 + x64)
+npm run build:win # NSIS installer + portable
+```
+
+Готовые релизы: [GitHub Releases](https://github.com/hoangsonww/Claude-Code-Agent-Monitor/releases)
+
+---
+
+## Хранение данных
+
+| | Описание |
+|---|---|
+| По умолчанию | `~/.claude-monitor/data/dashboard.db` |
+| `DASHBOARD_DATA_DIR` | Переопределение директории |
+| `DASHBOARD_DB_PATH` | Полный путь к файлу БД |
+
+---
+
+## Развёртывание
+
+```bash
+# Docker Compose
+docker-compose up -d
+
+# Helm
+helm install agent-monitor deployments/helm/
+
+# Kustomize
+kubectl apply -k deployments/kustomize/
+```
+
+Подробнее: [DEPLOYMENT.md](./DEPLOYMENT.md)
+
+---
+
+## Структура проекта
+
+```
+Claude-Code-Agent-Monitor/
+├── bin/ # Скрипты хуков и CLI
+├── client/ # React + TypeScript + Vite
+│ └── src/
+│ ├── components/ # React компоненты
+│ ├── pages/ # Страницы
+│ └── locales/ # Переводы (i18n)
+├── server/ # Express + SQLite
+│ ├── routes/ # REST API
+│ └── lib/ # Вспомогательные модули
+├── mcp/ # MCP сервер
+├── desktop/ # Electron приложение
+├── vscode-extension/ # VS Code расширение
+├── scripts/ # Утилиты
+├── deployments/ # Docker, Kubernetes, Helm
+└── docs/ # Документация
+```
+
+---
+
+## Устранение неполадок
+
+### Хуки не отправляют данные
+
+```bash
+curl http://localhost:4820/api/health
+```
+
+Проверьте пути в `~/.claude/settings.json` и логи сервера.
+
+### Ошибка SQLite при запуске
+
+```
+Error: better-sqlite3 could not be loaded
+```
+
+- **Вариант 1:** обновите Node.js до 22+
+- **Вариант 2:** `npm rebuild better-sqlite3`
+
+### Порт занят
+
+```bash
+lsof -i :4820 # macOS/Linux
+DASHBOARD_PORT=5000 npm start
+```
+
+### Данные не появляются
+
+Проверьте, не попадает ли рабочая директория под `MONITOR_IGNORE_CWD` в `.env`.
+
+---
+
+## Участие в разработке
+
+```bash
+gh repo fork hoangsonww/Claude-Code-Agent-Monitor
+git checkout -b feat/ваша-фича
+npm install && npm run dev
+npm test && npm run lint
+gh pr create
+```
+
+Хорошие задачи для начала:
+- Переводы UI (`client/src/locales/`)
+- Новые фильтры и параметры API
+- Улучшение документации
+
+[Открытые issues](https://github.com/hoangsonww/Claude-Code-Agent-Monitor/issues)
+
+---
+
+## Лицензия
+
+[MIT License](./LICENSE)
+
+---
+
+
+ Сделано с ❤️ сообществом Claude Code
+ English ·
+ 中文 ·
+ Tiếng Việt ·
+ 한국어 ·
+ Русский
+
diff --git a/README.md b/README.md
index 62274805..58c526f0 100644
--- a/README.md
+++ b/README.md
@@ -70,7 +70,7 @@ A professional dashboard to track and visualize your Claude Code agent sessions,

> [!TIP]
-> See also: [README-CN.md](./README-CN.md) (中文版本), [README-VN.md](./README-VN.md) (Phiên bản tiếng Việt), and [README-KO.md](./README-KO.md) (한국어 버전) for localized documentation with region-specific tips and best practices.
+> See also: [README-CN.md](./README-CN.md) (中文版本), [README-VN.md](./README-VN.md) (Phiên bản tiếng Việt), and [README-KO.md](./README-KO.md) (한국어 버전), and [README-RU.md](./README-RU.md) (Русская версия) for localized documentation with region-specific tips and best practices.
---
diff --git a/server/__tests__/cwd-filter.test.js b/server/__tests__/cwd-filter.test.js
new file mode 100644
index 00000000..86e6cbed
--- /dev/null
+++ b/server/__tests__/cwd-filter.test.js
@@ -0,0 +1,152 @@
+/**
+ * @file Unit tests for server/lib/cwd-filter.js — the MONITOR_IGNORE_CWD
+ * ingest-time ignore filter. Tests cover all three pattern forms (exact, /*,
+ * /**), edge cases (empty env, bad types, backslash normalisation, trailing
+ * slashes), and the fast-path when no patterns are configured.
+ */
+
+"use strict";
+
+const { describe, it, beforeEach, afterEach } = require("node:test");
+const assert = require("node:assert/strict");
+const { buildPatterns } = require("../lib/cwd-filter");
+
+// ─── helpers ───────────────────────────────────────────────────────────────
+
+/** Run isCwdIgnored against a freshly-built pattern list without touching process.env */
+function match(raw, cwd) {
+ const patterns = buildPatterns(raw);
+ if (typeof cwd !== "string" || !cwd || patterns.length === 0) return false;
+ const norm = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
+ return patterns.some((test) => test(norm));
+}
+
+// ─── no patterns ───────────────────────────────────────────────────────────
+
+describe("buildPatterns — empty / missing input", () => {
+ it("returns an empty array for undefined", () => {
+ assert.deepEqual(buildPatterns(undefined), []);
+ });
+ it("returns an empty array for empty string", () => {
+ assert.deepEqual(buildPatterns(""), []);
+ });
+ it("returns an empty array for whitespace-only", () => {
+ assert.deepEqual(buildPatterns(" , , "), []);
+ });
+});
+
+// ─── exact match ───────────────────────────────────────────────────────────
+
+describe("exact path match", () => {
+ const RAW = "/home/user/private";
+
+ it("matches the exact path", () => {
+ assert.ok(match(RAW, "/home/user/private"));
+ });
+ it("does not match a child directory", () => {
+ assert.ok(!match(RAW, "/home/user/private/sub"));
+ });
+ it("does not match a sibling directory", () => {
+ assert.ok(!match(RAW, "/home/user/other"));
+ });
+ it("does not match a parent directory", () => {
+ assert.ok(!match(RAW, "/home/user"));
+ });
+ it("strips a trailing slash from the cwd before comparing", () => {
+ assert.ok(match(RAW, "/home/user/private/"));
+ });
+});
+
+// ─── /* direct children ────────────────────────────────────────────────────
+
+describe("/* direct-children pattern", () => {
+ const RAW = "/home/user/work/*";
+
+ it("matches a direct child", () => {
+ assert.ok(match(RAW, "/home/user/work/projectA"));
+ });
+ it("does NOT match a grandchild", () => {
+ assert.ok(!match(RAW, "/home/user/work/projectA/src"));
+ });
+ it("does NOT match the prefix itself", () => {
+ assert.ok(!match(RAW, "/home/user/work"));
+ });
+ it("does NOT match a sibling prefix", () => {
+ assert.ok(!match(RAW, "/home/user/works/projectA"));
+ });
+ it("strips trailing slash from cwd before matching", () => {
+ assert.ok(match(RAW, "/home/user/work/projectA/"));
+ });
+});
+
+// ─── /** recursive ─────────────────────────────────────────────────────────
+
+describe("/** recursive-descendant pattern", () => {
+ const RAW = "/home/user/scratch/**";
+
+ it("matches the prefix directory itself", () => {
+ assert.ok(match(RAW, "/home/user/scratch"));
+ });
+ it("matches a direct child", () => {
+ assert.ok(match(RAW, "/home/user/scratch/a"));
+ });
+ it("matches a deeply nested descendant", () => {
+ assert.ok(match(RAW, "/home/user/scratch/a/b/c/d"));
+ });
+ it("does NOT match a sibling that starts with the same string", () => {
+ assert.ok(!match(RAW, "/home/user/scratch-backup"));
+ });
+ it("does NOT match an unrelated path", () => {
+ assert.ok(!match(RAW, "/home/user/projects"));
+ });
+});
+
+// ─── multiple patterns ─────────────────────────────────────────────────────
+
+describe("multiple comma-separated patterns", () => {
+ const RAW = "/home/user/private,/tmp/*,/home/user/scratch/**";
+
+ it("matches the exact-match pattern", () => {
+ assert.ok(match(RAW, "/home/user/private"));
+ });
+ it("matches the /* pattern", () => {
+ assert.ok(match(RAW, "/tmp/session-abc"));
+ });
+ it("matches the /** pattern", () => {
+ assert.ok(match(RAW, "/home/user/scratch/deep/path"));
+ });
+ it("does NOT match an unrelated path", () => {
+ assert.ok(!match(RAW, "/home/user/public"));
+ });
+ it("ignores extra whitespace around commas", () => {
+ assert.ok(match(" /home/user/private , /tmp/* ", "/home/user/private"));
+ });
+});
+
+// ─── edge cases ────────────────────────────────────────────────────────────
+
+describe("edge cases — non-string cwd values", () => {
+ it("returns false for null cwd", () => {
+ assert.ok(!match("/home/user/private", null));
+ });
+ it("returns false for numeric cwd", () => {
+ assert.ok(!match("/home/user/private", 42));
+ });
+ it("returns false for object cwd", () => {
+ assert.ok(!match("/home/user/private", {}));
+ });
+ it("returns false for empty string cwd", () => {
+ assert.ok(!match("/home/user/private", ""));
+ });
+});
+
+describe("Windows backslash normalisation", () => {
+ const RAW = "C:/Users/user/private";
+
+ it("matches when cwd uses Windows backslashes", () => {
+ assert.ok(match(RAW, "C:\\Users\\user\\private"));
+ });
+ it("matches when pattern uses Windows backslashes", () => {
+ assert.ok(match("C:\\Users\\user\\private", "C:/Users/user/private"));
+ });
+});
diff --git a/server/__tests__/privacy-filter.test.js b/server/__tests__/privacy-filter.test.js
new file mode 100644
index 00000000..dd78f5e6
--- /dev/null
+++ b/server/__tests__/privacy-filter.test.js
@@ -0,0 +1,262 @@
+/**
+ * @file privacy-filter.test.js
+ * @description Unit tests for the privacy-filter redaction module (issue #148).
+ * @author Son Nguyen
+ */
+"use strict";
+
+const { describe, it, before, after } = require("node:test");
+const assert = require("node:assert/strict");
+const { redactPayload, PATTERNS, MASK } = require("../lib/privacy-filter");
+
+// ─── helpers ───────────────────────────────────────────────────────────────
+
+function redact(data) {
+ return redactPayload(data);
+}
+
+// ─── passthrough (no secrets) ───────────────────────────────────────────────
+
+describe("passthrough — clean data", () => {
+ it("returns identical object when no secrets present", () => {
+ const data = { session_id: "abc123", hook_type: "Stop", cwd: "/home/user/project" };
+ const { data: out, redactedCount } = redact(data);
+ assert.deepEqual(out, data);
+ assert.equal(redactedCount, 0);
+ });
+
+ it("handles null", () => {
+ const { data: out, redactedCount } = redact(null);
+ assert.equal(out, null);
+ assert.equal(redactedCount, 0);
+ });
+
+ it("handles non-object primitives gracefully", () => {
+ const { data: out, redactedCount } = redact("plain string");
+ assert.equal(out, "plain string");
+ assert.equal(redactedCount, 0);
+ });
+
+ it("preserves numbers and booleans unchanged", () => {
+ const data = { tokens: 42, ok: true };
+ const { data: out, redactedCount } = redact(data);
+ assert.deepEqual(out, data);
+ assert.equal(redactedCount, 0);
+ });
+});
+
+// ─── SDK-style keys ────────────────────────────────────────────────────────
+
+describe("sdk-key-prefix patterns", () => {
+ it("redacts OpenAI sk- key", () => {
+ const data = { api_key: "sk-abcdefghijklmnopqrstuvwxyz123456" };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.api_key, MASK);
+ assert.equal(redactedCount, 1);
+ });
+
+ it("redacts Anthropic sk-ant- key", () => {
+ const data = { key: "sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890abcdef" };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.key, MASK);
+ assert.equal(redactedCount, 1);
+ });
+
+ it("redacts GitHub PAT ghp_ prefix", () => {
+ const data = { token: "ghp_1234567890abcdefghijklmnopqrstuvwxyz" };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.token, MASK);
+ assert.equal(redactedCount, 1);
+ });
+
+ it("redacts GitHub PAT github_pat_ prefix", () => {
+ const data = { token: "github_pat_11ABCDE_abcdefghijklmnopqrstu" };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.token, MASK);
+ assert.equal(redactedCount, 1);
+ });
+
+ it("redacts Slack bot token xoxb-", () => {
+ // Construct token dynamically so the literal is never stored in the file
+ const data = { slack: ["xoxb", "123456789", "abcdefghijklmno"].join("-") };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.slack, MASK);
+ assert.equal(redactedCount, 1);
+ });
+
+ it("redacts Google API key AIza prefix", () => {
+ const data = { gkey: "AIzaSyAbcdefghijklmnopqrstuvwxyz12345678" };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.gkey, MASK);
+ assert.equal(redactedCount, 1);
+ });
+});
+
+// ─── AWS keys ───────────────────────────────────────────────────────────────
+
+describe("aws-key pattern", () => {
+ it("redacts AKIA access key", () => {
+ const data = { aws: "AKIAIOSFODNN7EXAMPLE" };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.aws, MASK);
+ assert.equal(redactedCount, 1);
+ });
+
+ it("does not redact short uppercase string", () => {
+ const data = { val: "AKIA1234" };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.val, "AKIA1234");
+ assert.equal(redactedCount, 0);
+ });
+});
+
+// ─── PEM private key blocks ─────────────────────────────────────────────────
+
+describe("pem-private-key pattern", () => {
+ it("redacts RSA private key block in a value", () => {
+ const data = { cert: "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----" };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.cert, MASK);
+ assert.equal(redactedCount, 1);
+ });
+
+ it("redacts OPENSSH private key block", () => {
+ const data = { key: "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA..." };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.key, MASK);
+ assert.equal(redactedCount, 1);
+ });
+});
+
+// ─── Bearer token ───────────────────────────────────────────────────────────
+
+describe("bearer-token pattern", () => {
+ it("redacts Authorization header value", () => {
+ const data = { header: "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.sig" };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.header, MASK);
+ assert.equal(redactedCount, 1);
+ });
+});
+
+// ─── URL credentials ────────────────────────────────────────────────────────
+
+describe("url-credentials pattern", () => {
+ it("redacts postgres URL with embedded password", () => {
+ const data = { db: "postgres://user:supersecretpassword@localhost:5432/mydb" };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.db, MASK);
+ assert.equal(redactedCount, 1);
+ });
+});
+
+// ─── Generic api-key assignment ─────────────────────────────────────────────
+
+describe("api-key-generic scan pattern", () => {
+ it("redacts inline api_key=value assignment in a string", () => {
+ const data = { snippet: 'const config = { api_key: "abcdefghijklmnopqrstuvwxyz123456" }' };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.snippet, MASK);
+ assert.equal(redactedCount, 1);
+ });
+});
+
+// ─── Nested objects and arrays ──────────────────────────────────────────────
+
+describe("nested structures", () => {
+ it("redacts secret nested inside tool_input object", () => {
+ const data = {
+ hook_type: "PreToolUse",
+ tool_input: {
+ command: "curl https://api.example.com",
+ env: { API_KEY: "sk-abcdefghijklmnopqrstuvwxyz123456" },
+ },
+ };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.tool_input.env.API_KEY, MASK);
+ assert.equal(out.tool_input.command, "curl https://api.example.com");
+ assert.equal(redactedCount, 1);
+ });
+
+ it("redacts secrets inside arrays", () => {
+ const data = { args: ["--token", "sk-abcdefghijklmnopqrstuvwxyz123456", "--verbose"] };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.args[0], "--token");
+ assert.equal(out.args[1], MASK);
+ assert.equal(out.args[2], "--verbose");
+ assert.equal(redactedCount, 1);
+ });
+
+ it("counts multiple secrets across a deeply nested payload", () => {
+ const data = {
+ a: "sk-abcdefghijklmnopqrstuvwxyz123456",
+ b: { c: "sk-ant-api03-xyzxyzxyzxyzxyzxyzxyzxyz1234567890abc" },
+ };
+ const { redactedCount } = redact(data);
+ assert.equal(redactedCount, 2);
+ });
+});
+
+// ─── Structural fields are preserved ────────────────────────────────────────
+
+describe("structural field preservation", () => {
+ it("never redacts session_id", () => {
+ const data = { session_id: "sk-like-but-not-a-key" };
+ const { data: out } = redact(data);
+ // session_id is always preserved verbatim
+ assert.equal(out.session_id, "sk-like-but-not-a-key");
+ });
+
+ it("never redacts cwd", () => {
+ const data = { cwd: "/home/user/sk-project" };
+ const { data: out } = redact(data);
+ assert.equal(out.cwd, "/home/user/sk-project");
+ });
+});
+
+// ─── Edge cases ─────────────────────────────────────────────────────────────
+
+describe("edge cases", () => {
+ it("handles empty object", () => {
+ const { data: out, redactedCount } = redact({});
+ assert.deepEqual(out, {});
+ assert.equal(redactedCount, 0);
+ });
+
+ it("handles deeply nested empty arrays", () => {
+ const data = { items: [] };
+ const { data: out, redactedCount } = redact(data);
+ assert.deepEqual(out.items, []);
+ assert.equal(redactedCount, 0);
+ });
+
+ it("short strings under 8 chars are never redacted", () => {
+ const data = { val: "sk-abc" };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.val, "sk-abc");
+ assert.equal(redactedCount, 0);
+ });
+
+ it("null values in fields are preserved", () => {
+ const data = { token: null, name: "session" };
+ const { data: out, redactedCount } = redact(data);
+ assert.equal(out.token, null);
+ assert.equal(redactedCount, 0);
+ });
+});
+
+// ─── PATTERNS array ─────────────────────────────────────────────────────────
+
+describe("PATTERNS export", () => {
+ it("exports a non-empty array", () => {
+ assert.ok(Array.isArray(PATTERNS));
+ assert.ok(PATTERNS.length > 0);
+ });
+
+ it("each pattern has name and pattern fields", () => {
+ for (const p of PATTERNS) {
+ assert.ok(typeof p.name === "string", `pattern ${p.name} missing name`);
+ assert.ok(p.pattern instanceof RegExp, `pattern ${p.name} missing RegExp`);
+ }
+ });
+});
diff --git a/server/lib/cwd-filter.js b/server/lib/cwd-filter.js
new file mode 100644
index 00000000..d5eb77b8
--- /dev/null
+++ b/server/lib/cwd-filter.js
@@ -0,0 +1,65 @@
+/**
+ * @file Ingest-time CWD ignore filter.
+ *
+ * Reads MONITOR_IGNORE_CWD (comma-separated path patterns) at module-load
+ * time and exposes a single predicate that hooks.js calls before writing any
+ * event to the database.
+ *
+ * Supported pattern forms (forward-slash normalised, case-sensitive):
+ * /exact/path — strict equality
+ * /prefix/* — direct children only (no deeper nesting)
+ * /prefix/** — /prefix itself and all descendants
+ *
+ * See also: .env.example → MONITOR_IGNORE_CWD
+ */
+
+"use strict";
+
+/**
+ * Build an array of matcher functions from a raw MONITOR_IGNORE_CWD string.
+ * Exported so tests can call it with arbitrary input without touching process.env.
+ *
+ * @param {string} raw - comma-separated pattern string (may be empty / undefined)
+ * @returns {Array<(cwd: string) => boolean>}
+ */
+function buildPatterns(raw) {
+ if (!raw) return [];
+ return raw
+ .split(",")
+ .map((p) => p.trim())
+ .filter(Boolean)
+ .map((p) => {
+ // Normalise Windows backslashes and strip trailing slashes
+ const norm = p.replace(/\\/g, "/").replace(/\/+$/, "");
+ if (norm.endsWith("/**")) {
+ const prefix = norm.slice(0, -3);
+ return (cwd) => cwd === prefix || cwd.startsWith(prefix + "/");
+ }
+ if (norm.endsWith("/*")) {
+ const prefix = norm.slice(0, -2);
+ return (cwd) => {
+ if (!cwd.startsWith(prefix + "/")) return false;
+ return !cwd.slice(prefix.length + 1).includes("/");
+ };
+ }
+ return (cwd) => cwd === norm;
+ });
+}
+
+// Patterns compiled once at startup from the live env variable.
+const PATTERNS = buildPatterns(process.env.MONITOR_IGNORE_CWD);
+
+/**
+ * Returns true when the given working directory matches any configured
+ * ignore pattern and the hook event should be silently discarded.
+ *
+ * @param {unknown} cwd - the cwd field from the hook payload (may be any type)
+ * @returns {boolean}
+ */
+function isCwdIgnored(cwd) {
+ if (typeof cwd !== "string" || !cwd || PATTERNS.length === 0) return false;
+ const norm = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
+ return PATTERNS.some((test) => test(norm));
+}
+
+module.exports = { isCwdIgnored, buildPatterns };
diff --git a/server/lib/privacy-filter.js b/server/lib/privacy-filter.js
new file mode 100644
index 00000000..70c15e8d
--- /dev/null
+++ b/server/lib/privacy-filter.js
@@ -0,0 +1,142 @@
+"use strict";
+/**
+ * @file privacy-filter.js
+ * @description Server-side payload redaction for hook event ingestion (issue #148).
+ * @author Son Nguyen
+ *
+ * Scans event data objects recursively and masks values that match built-in
+ * secret-detection patterns before they are written to SQLite or broadcast over
+ * WebSocket. Behaviour is opt-in: redaction runs only when
+ * MONITOR_PRIVACY_REDACT=true is set in the environment.
+ *
+ * Exported:
+ * redactPayload(data) → { data: redactedCopy, redactedCount: number }
+ * PATTERNS (array, exported for tests)
+ * isRedactionEnabled() → boolean
+ */
+
+const MASK = "[REDACTED]";
+
+/**
+ * Each entry: { name, pattern }
+ * name – human-readable label surfaced in redaction metadata
+ * pattern – RegExp that matches the *whole value* of a string field (not a
+ * substring scan) OR that matches inside a longer string when
+ * `scan: true` is set.
+ */
+const PATTERNS = [
+ // Private / public key blocks
+ {
+ name: "pem-private-key",
+ scan: true,
+ pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
+ },
+ // Generic secret-looking assignments (FOO=sk-..., "api_key": "...")
+ {
+ name: "api-key-generic",
+ scan: true,
+ pattern: /(?:api[_-]?key|secret[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*["']?[A-Za-z0-9_\-\.]{16,}/i,
+ },
+ // OpenAI / Anthropic / common SDK key prefixes
+ {
+ name: "sdk-key-prefix",
+ scan: false,
+ pattern: /^(?:sk-[A-Za-z0-9\-_]{16,}|sk-ant-[A-Za-z0-9\-_]{16,}|ghp_[A-Za-z0-9]{36,}|gho_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,}|xoxb-[0-9]+-[A-Za-z0-9]+|xoxp-[0-9]+-[A-Za-z0-9]+|AIza[A-Za-z0-9_\-]{35,}|ya29\.[A-Za-z0-9_\-]+)$/,
+ },
+ // Bearer tokens in header-like strings
+ {
+ name: "bearer-token",
+ scan: true,
+ pattern: /\bBearer\s+[A-Za-z0-9\-_\.~+/]+=*/i,
+ },
+ // AWS access key IDs
+ {
+ name: "aws-key",
+ scan: false,
+ pattern: /^(?:AKIA|ASIA|AROA|AIPA|ANPA|ANVA|APKA)[A-Z0-9]{16}$/,
+ },
+ // Passwords / tokens in URL credentials (user:pass@host)
+ {
+ name: "url-credentials",
+ scan: true,
+ pattern: /[a-zA-Z][a-zA-Z0-9+\-.]*:\/\/[^@\s/]+:[^@\s/]+@/,
+ },
+];
+
+/** Returns true when MONITOR_PRIVACY_REDACT=true (case-insensitive). */
+function isRedactionEnabled() {
+ return (process.env.MONITOR_PRIVACY_REDACT || "").toLowerCase() === "true";
+}
+
+/**
+ * Test a single string value against all patterns.
+ * Returns the name of the first matching pattern, or null.
+ */
+function matchSecret(value) {
+ if (typeof value !== "string" || value.length < 8) return null;
+ for (const { name, pattern, scan } of PATTERNS) {
+ if (scan ? pattern.test(value) : pattern.test(value.trim())) return name;
+ }
+ return null;
+}
+
+/**
+ * Deep-clone `obj`, replacing string values that match a pattern with MASK.
+ * Skips the `cwd` field (already handled by cwd-filter) and non-sensitive
+ * structural fields that are never secret.
+ *
+ * @param {*} obj Value to scan (any JSON-compatible type)
+ * @param {number} [counter=0] Running count of redactions (internal)
+ * @returns {{ value: *, count: number }}
+ */
+function _redact(obj, counter = 0) {
+ if (obj === null || obj === undefined) return { value: obj, count: counter };
+
+ if (typeof obj === "string") {
+ const hit = matchSecret(obj);
+ if (hit) return { value: MASK, count: counter + 1 };
+ return { value: obj, count: counter };
+ }
+
+ if (Array.isArray(obj)) {
+ let count = counter;
+ const arr = obj.map((item) => {
+ const r = _redact(item, count);
+ count = r.count;
+ return r.value;
+ });
+ return { value: arr, count };
+ }
+
+ if (typeof obj === "object") {
+ let count = counter;
+ const out = {};
+ for (const [key, val] of Object.entries(obj)) {
+ // Never redact structural identity / routing keys
+ if (key === "session_id" || key === "hook_event_id" || key === "cwd") {
+ out[key] = val;
+ continue;
+ }
+ const r = _redact(val, count);
+ count = r.count;
+ out[key] = r.value;
+ }
+ return { value: out, count };
+ }
+
+ return { value: obj, count: counter };
+}
+
+/**
+ * Redact a hook event data object.
+ *
+ * @param {object} data Raw event payload from req.body.data
+ * @returns {{ data: object, redactedCount: number }}
+ */
+function redactPayload(data) {
+ if (!data || typeof data !== "object") return { data, redactedCount: 0 };
+ const { value, count } = _redact(data);
+ return { data: value, redactedCount: count };
+}
+
+module.exports = { redactPayload, isRedactionEnabled, PATTERNS, MASK };
diff --git a/server/routes/hooks.js b/server/routes/hooks.js
index 097c48e4..9d004e1e 100644
--- a/server/routes/hooks.js
+++ b/server/routes/hooks.js
@@ -15,6 +15,8 @@ const { ingestWorkflowsForSession } = require("../lib/workflow-ingest");
// Required as a module object (not destructured) so tests can swap
// `liveness.probeLiveCwds` and the watchdog picks the stub up at call time.
const liveness = require("../lib/session-liveness");
+const { isCwdIgnored } = require("../lib/cwd-filter");
+const { redactPayload, isRedactionEnabled } = require("../lib/privacy-filter");
const router = Router();
@@ -933,13 +935,29 @@ const processEvent = db.transaction((hookType, data) => {
});
router.post("/event", (req, res) => {
- const { hook_type, data } = req.body;
+ const { hook_type } = req.body;
+ let data = req.body.data;
if (!hook_type || !data) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "hook_type and data are required" },
});
}
+ // Drop hooks from ignored working directories (MONITOR_IGNORE_CWD).
+ // Return 200 so Claude Code does not retry — we intentionally discard the event.
+ if (isCwdIgnored(data.cwd)) {
+ return res.json({ ok: true, ignored: true });
+ }
+
+ // Redact secret-like values from the event payload before persisting or
+ // broadcasting (MONITOR_PRIVACY_REDACT=true opt-in).
+ let redactedCount = 0;
+ if (isRedactionEnabled()) {
+ const filtered = redactPayload(data);
+ data = filtered.data;
+ redactedCount = filtered.redactedCount;
+ }
+
const result = processEvent(hook_type, data);
if (!result) {
return res.status(400).json({
@@ -947,7 +965,7 @@ router.post("/event", (req, res) => {
});
}
- res.json({ ok: true, event: result });
+ res.json({ ok: true, event: result, ...(redactedCount > 0 && { redactedCount }) });
// Evaluate event-driven alert rules after the ingest transaction committed
// and the response is on its way — alerting must never slow down or fail