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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user