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 через нативную систему хуков для бесшовного отслеживания сессий и аналитики. + +![Claude Code](https://img.shields.io/badge/Claude_Code-orange?style=flat-square&logo=claude&logoColor=white) +![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-339933?style=flat-square&logo=node.js&logoColor=white) +![React](https://img.shields.io/badge/React-18.3-61DAFB?style=flat-square&logo=react&logoColor=white) +![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178C6?style=flat-square&logo=typescript&logoColor=white) +![SQLite](https://img.shields.io/badge/SQLite-3-003B57?style=flat-square&logo=sqlite&logoColor=white) +![WebSocket](https://img.shields.io/badge/WebSocket-RFC_6455-010101?style=flat-square&logo=socketdotio&logoColor=white) +![Docker](https://img.shields.io/badge/Docker-20.10-2496ED?style=flat-square&logo=docker&logoColor=white) +![MIT License](https://img.shields.io/badge/License-MIT-yellow?style=flat-square) + +**Языки / 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, ![MIT License](https://img.shields.io/badge/License-MIT-yellow?style=flat-square) > [!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