From 8a0c84be88356663eee7e1ef49beb2c9e7d44bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 20 May 2026 12:51:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(observer):=20real=20PII=20counter=20?= =?UTF-8?q?=E2=80=94=20STATUS.md=20stops=20lying?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes brain-retro 2026-05-20 #3 SIMPLIFIED — sanitizeWithCount in pii-filter (counts matches per pattern) + persistent monthly counter docs/observer/.pii-counters.json (bumped by Stop-hook on each episode write) + status-md-generator reads real count (no more piiMatches: 0 hardcode). PII patterns themselves NOT changed (F7 of parallel session already extended to 13 patterns). Counter is informational — write failure never blocks Stop-event. 5+1+1=7 new vitest tests, 256/256 GREEN. Co-Authored-By: Claude Sonnet 4.6 --- tools/observer-pii-filter.mjs | 35 +++++++++++++++++++++++++++ tools/observer-pii-filter.test.mjs | 38 +++++++++++++++++++++++++++++- tools/observer-stop-hook.mjs | 27 +++++++++++++++++---- tools/observer-stop-hook.test.mjs | 15 +++++++++++- tools/status-md-generator.mjs | 13 +++++++++- tools/status-md-generator.test.mjs | 5 ++++ 6 files changed, 126 insertions(+), 7 deletions(-) diff --git a/tools/observer-pii-filter.mjs b/tools/observer-pii-filter.mjs index b3587196..ac672b46 100644 --- a/tools/observer-pii-filter.mjs +++ b/tools/observer-pii-filter.mjs @@ -56,6 +56,41 @@ function sanitizeString(s) { .replace(POSIX_HOME, '$1***'); } +const PATTERNS = { + RU_PHONE, EMAIL, JWT, AWS_KEY, YC_STATIC, YC_SESSION, YC_OAUTH, + SENTRY_TOKEN, OPENAI_TOKEN, GENERIC_BEARER, IPV4, WIN_USER_PATH, POSIX_HOME, +}; + +function countString(s, counts) { + if (typeof s !== 'string') return; + for (const [name, re] of Object.entries(PATTERNS)) { + const reFresh = new RegExp(re.source, re.flags); + const matches = s.match(reFresh); + counts[name] = (counts[name] || 0) + (matches ? matches.length : 0); + } +} + +function walkAndCount(input, counts) { + if (typeof input === 'string') { countString(input, counts); return; } + if (input === null || input === undefined) return; + if (Array.isArray(input)) { input.forEach((v) => walkAndCount(v, counts)); return; } + if (typeof input === 'object') { + for (const v of Object.values(input)) walkAndCount(v, counts); + } +} + +/** + * Sanitize input AND count matches per pattern type. + * Returns { sanitized, counts: { PATTERN_NAME: N, ... } }. + * counts is pre-initialised to 0 for all 13 known patterns. + */ +export function sanitizeWithCount(input) { + const counts = {}; + for (const k of Object.keys(PATTERNS)) counts[k] = 0; + walkAndCount(input, counts); + return { sanitized: sanitize(input), counts }; +} + export function sanitize(input) { if (typeof input === 'string') return sanitizeString(input); if (input === null || input === undefined) return input; diff --git a/tools/observer-pii-filter.test.mjs b/tools/observer-pii-filter.test.mjs index 83161081..3f41d937 100644 --- a/tools/observer-pii-filter.test.mjs +++ b/tools/observer-pii-filter.test.mjs @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { sanitize } from './observer-pii-filter.mjs'; +import { sanitize, sanitizeWithCount } from './observer-pii-filter.mjs'; describe('observer-pii-filter sanitize', () => { it('masks Russian phone numbers', () => { @@ -92,3 +92,39 @@ describe('observer-pii-filter sanitize', () => { expect(sanitize(masked)).toBe(masked); }); }); + +describe('sanitizeWithCount (Task 3)', () => { + it('counts matches per pattern type alongside sanitizing', () => { + const input = 'Phone +71234567890 and email user@example.com'; + const { sanitized, counts } = sanitizeWithCount(input); + expect(sanitized).toContain('+7XXXXXXXXXX'); + expect(sanitized).toContain('***@***'); + expect(counts.RU_PHONE).toBe(1); + expect(counts.EMAIL).toBe(1); + }); + it('returns zero for absent patterns', () => { + const { counts } = sanitizeWithCount('plain text with no PII'); + expect(counts.RU_PHONE).toBe(0); + expect(counts.EMAIL).toBe(0); + expect(counts.JWT).toBe(0); + }); + it('counts recursively over objects', () => { + const input = { msg: 'call +71112223344', meta: { contact: 'a@b.co' } }; + const { counts } = sanitizeWithCount(input); + expect(counts.RU_PHONE).toBe(1); + expect(counts.EMAIL).toBe(1); + }); + it('returns counts object with all 13 pattern keys pre-initialised to 0', () => { + const { counts } = sanitizeWithCount(''); + const keys = Object.keys(counts).sort(); + expect(keys).toEqual([ + 'AWS_KEY', 'EMAIL', 'GENERIC_BEARER', 'IPV4', 'JWT', + 'OPENAI_TOKEN', 'POSIX_HOME', 'RU_PHONE', 'SENTRY_TOKEN', + 'WIN_USER_PATH', 'YC_OAUTH', 'YC_SESSION', 'YC_STATIC', + ].sort()); + }); + it('handles arrays', () => { + const { counts } = sanitizeWithCount(['+71111111111', 'no pii']); + expect(counts.RU_PHONE).toBe(1); + }); +}); diff --git a/tools/observer-stop-hook.mjs b/tools/observer-stop-hook.mjs index 31a208ad..b79e239f 100644 --- a/tools/observer-stop-hook.mjs +++ b/tools/observer-stop-hook.mjs @@ -14,9 +14,9 @@ * Per Pravila §16.2 + ADR-011 + spec v1.1 §5.2.1. */ -import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; -import { sanitize } from './observer-pii-filter.mjs'; +import { sanitize, sanitizeWithCount } from './observer-pii-filter.mjs'; import { parseTranscript, extractLastUserPromptText } from './observer-transcript-parser.mjs'; import { detectMethodDirected, loadKnownNodes } from './observer-routing-detector.mjs'; @@ -45,6 +45,21 @@ const RATIONALE_FIELDS = [ 'task_classification', ]; +/** Update the monthly PII counter JSON with counts from a single episode write. */ +function bumpPiiCounter(counts, baseDir, month) { + const counterPath = join(baseDir, 'docs', 'observer', '.pii-counters.json'); + let store = {}; + if (existsSync(counterPath)) { + try { store = JSON.parse(readFileSync(counterPath, 'utf-8')); } catch { store = {}; } + } + store[month] = store[month] || {}; + for (const [k, n] of Object.entries(counts)) { + if (n > 0) store[month][k] = (store[month][k] || 0) + n; + } + try { writeFileSync(counterPath, JSON.stringify(store, null, 2) + '\n', 'utf-8'); } + catch { /* counter is informational — never fail the Stop-event */ } +} + function validateRationale(rationale) { for (const f of RATIONALE_FIELDS) { if (rationale[f] === undefined) { @@ -73,7 +88,9 @@ export function appendEpisode(episode, baseDir = process.cwd(), month = currentM throw new Error(`observer_error marker field missing: ${f}`); } } - appendFileSync(file, JSON.stringify(sanitize(episode)) + '\n', 'utf-8'); + const { sanitized: sanitizedErr, counts: countsErr } = sanitizeWithCount(episode); + appendFileSync(file, JSON.stringify(sanitizedErr) + '\n', 'utf-8'); + bumpPiiCounter(countsErr, baseDir, month); return; } @@ -92,7 +109,9 @@ export function appendEpisode(episode, baseDir = process.cwd(), month = currentM } validateRationale(episode.primary_rationale); - appendFileSync(file, JSON.stringify(sanitize(episode)) + '\n', 'utf-8'); + const { sanitized, counts } = sanitizeWithCount(episode); + appendFileSync(file, JSON.stringify(sanitized) + '\n', 'utf-8'); + bumpPiiCounter(counts, baseDir, month); } /** diff --git a/tools/observer-stop-hook.test.mjs b/tools/observer-stop-hook.test.mjs index d5e3e880..8e03ca5f 100644 --- a/tools/observer-stop-hook.test.mjs +++ b/tools/observer-stop-hook.test.mjs @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, mkdirSync } from 'fs'; +import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, mkdirSync, readdirSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { appendEpisode, buildEpisodeFromContext, buildObserverError, routingGateDecision } from './observer-stop-hook.mjs'; @@ -126,6 +126,19 @@ describe('appendEpisode', () => { appendEpisode({ schema_version: 2, observer_error: true, task_id: 'x' }, workdir, '2026-05') ).toThrow(/observer_error marker field missing/i); }); + + it('persists PII match counts to .pii-counters.json (Task 3)', () => { + const ep = v2Episode({ + events: [{ kind: 'tool_summary', counts: { Bash: 1 } }], + task_size: { tool_calls: 1, files_touched: 0, files: ['+71234567890.txt'] }, + }); + appendEpisode(ep, workdir, '2026-05'); + const counterPath = join(workdir, 'docs', 'observer', '.pii-counters.json'); + expect(existsSync(counterPath)).toBe(true); + const store = JSON.parse(readFileSync(counterPath, 'utf-8')); + expect(store['2026-05']).toBeDefined(); + expect(store['2026-05'].RU_PHONE).toBeGreaterThanOrEqual(1); + }); }); describe('buildEpisodeFromContext', () => { diff --git a/tools/status-md-generator.mjs b/tools/status-md-generator.mjs index faa0d9d7..a4eea078 100644 --- a/tools/status-md-generator.mjs +++ b/tools/status-md-generator.mjs @@ -51,6 +51,17 @@ function countEpisodes() { return readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean).length; } +function countPiiMatches() { + const file = join('docs', 'observer', '.pii-counters.json'); + if (!existsSync(file)) return 0; + try { + const data = JSON.parse(readFileSync(file, 'utf-8')); + const month = new Date().toISOString().slice(0, 7); + const monthCounts = data[month] || {}; + return Object.values(monthCounts).reduce((s, n) => s + n, 0); + } catch { return 0; } +} + function countObserverErrors() { const dir = 'docs/observer'; if (!existsSync(dir)) return 0; @@ -78,7 +89,7 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/status-md- observer: { episodeCount: countEpisodes(), observerErrors: countObserverErrors(), - piiMatches: 0, + piiMatches: countPiiMatches(), }, }; const md = renderStatus(inputs); diff --git a/tools/status-md-generator.test.mjs b/tools/status-md-generator.test.mjs index f479cbc4..8d37de87 100644 --- a/tools/status-md-generator.test.mjs +++ b/tools/status-md-generator.test.mjs @@ -43,4 +43,9 @@ describe('renderStatus', () => { expect(md).toContain('capability-readiness'); expect(md).toContain('feedback_brain_unused_tools_not_problem'); }); + + it('shows piiMatches > 0 when counter file has data (Task 3)', () => { + const md = renderStatus(baseInputs({ observer: { episodeCount: 24, observerErrors: 0, piiMatches: 7 } })); + expect(md).toMatch(/7 PII matches before filter/); + }); });