feat(observer): real PII counter — STATUS.md stops lying

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 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-20 12:51:28 +03:00
parent cae0f1fbce
commit 8a0c84be88
6 changed files with 126 additions and 7 deletions
+35
View File
@@ -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;
+37 -1
View File
@@ -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);
});
});
+23 -4
View File
@@ -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);
}
/**
+14 -1
View File
@@ -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', () => {
+12 -1
View File
@@ -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);
+5
View File
@@ -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/);
});
});