fix: replace urgent regex patches with auditable expression grammar
All checks were successful
Quality gates / quality (pull_request) Successful in 3m31s

Second hostile review of 1aadca91 found nine ordinary urgent phrasings
bypassing the deterministic gate at detector and service layers, plus
punctuation-fragile contextual exclusions. Replace the accumulated
narrow regex table with a structured, versioned, frozen urgent-expression
grammar and one shared surface normalizer:

- normalizeUrgentText: case folding, apostrophe unification, contraction
  expansion (can't/cant/can not -> cannot, haven't -> have not, ...),
  hyphen splitting, punctuation stripping, whitespace collapse
- URGENT_EXPRESSION_GRAMMAR v2.0: per-flag ordered match expressions with
  bounded nonclinical anchor exclusions; anchors veto only the occurrence
  they sit beside (36-char window), so arbitrary future symptom language
  keeps escalating with no continuation-word allowlist
- new RED->GREEN coverage at every layer: 9 review phrases + 5 prior
  phrases with tense/plural/pronoun/word-order/case/contraction/
  punctuation variants, normalization-equivalence groups, anti-allowlist
  continuation sweep (147 combos), grammar structure audit, service
  zero-Hermes-call interception, and live-HTTP wiring proof with the
  bounded fake adapter (tests/escalation-http.test.js)

Gates: npm test 99/99, check:syntax, check:diff, audit 0 vulns,
staging-deploy 20/20, test:ui/test:photo/test:sleek against this
checkout. No merge, no deploy.
This commit is contained in:
Timmy 2026-08-22 23:24:51 +00:00
parent 1aadca91c6
commit 693e023758
6 changed files with 656 additions and 37 deletions

View File

@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/symptom-escalation.regression.test.js tests/escalation-boundary.test.js tests/escalation-wiring.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js",
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/symptom-escalation.regression.test.js tests/escalation-boundary.test.js tests/escalation-wiring.test.js tests/escalation-http.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js",
"test:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs",
"test:sleek": "node tests/sleek-chat.acceptance.mjs",

View File

@ -1,33 +1,181 @@
const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas'];
const URGENT_MESSAGE = 'These reported symptoms can need prompt medical care. Contact a clinician or urgent service now; call emergency services for heavy or nonstop bleeding, fainting, or severe worsening symptoms.';
// Deterministic red-flag language table. Every match escalates and nothing that
// consumes this table can weaken it; the regression suite pins both the positive
// phrases and the idiomatic non-medical phrasings that must stay out.
// ============================================================================
// Urgent-expression grammar (auditable replacement for regex patch accretion)
// ============================================================================
// Classification runs in two deterministic stages:
//
// Context-handling contract (see tests/symptom-escalation.regression.test.js):
// positives key on the clinical vocabulary itself, and nonclinical controls are
// expressed as structured exclusions around it (named-infection compounds,
// figurative idioms, concrete objects). Continuation wording after a symptom
// word must NEVER be narrowed through an allowlist — arbitrary natural
// continuations ("right now", "is 103", "started this morning") keep escalating.
const URGENT_TEXT_PATTERNS = Object.freeze([
// Blood: explicit clinical objects/shapes only, so ordinary stool sentences
// ("my stool has been normal", "the poop contains seeds") stay silent while
// every blood-mention shape (in/on/with/has/contains/looks bloody) escalates.
['blood', /\b(?:rectal bleeding|rectal bleed(?:ing)?|bleeding from (?:the |my |his |her |their )?(?:rectum|bottom)|blood(?:y)? (?:in|on|with) (?:my |the |his |her |their )?(?:stool|poop|bowel movement|rectum)|(?:bloody|blood-streaked) (?:stool|poop|bowel movement)|(?:stool|poop|bowel movement) (?:with blood|has blood|contains blood|has (?:blood clots|bloody streaks)|is bloody|looks bloody|looks like blood))\b/i],
['blackOrDarkRed', /\b(?:(?:black|dark[- ]?red) (?:stool|poop|bowel movement)|(?:stool|poop|bowel movement) (?:is|looks?(?: like)?|appears?|seems?|was) (?:a )?(?:very )?(?:really )?(?:black|dark[- ]?red))s?\b/i],
// Severe pain: severity adjective + organ noun, with any determiner (or none)
// and optional location qualifiers between the preposition and the organ —
// never a specific possessive.
['severePain', /\b(?:(?:severe|constant|unrelenting|intense|excruciating) (?:abdominal|stomach|belly) pain|(?:severe|constant|unrelenting|intense|excruciating) pain (?:in|around) (?:the |my |his |her |your |their |this )?(?:(?:lower|upper|left|right) )*(?:abdomen|belly))\b/i],
['vomiting', /\b(?:vomit(?:ing|ed|s)?|throw(?:ing|s)? up|threw up|thrown up|puk(?:e|ed|ing|es)|barf(?:ed|ing|s)?|upchuck(?:ed|ing|s)?|toss(?:ed|ing|es)? (?:my|your|his|her|our|their|the) cookies|los(?:e|t|ing|es) (?:my|your|his|her|our|their|the) lunch|(?:i|we|you|he|she|they|someone) (?:(?:have|had|just|already|recently|am|are|was|were|kept) )?(?:hurl(?:s|ed|ing)?|spew(?:s|ed|ing)?)(?=\s*(?:[.!?]|$|again\b|twice\b|all night\b))|emesis)\b(?!\s+(?:my|your|his|her|our|their)\s+(?:hands|arms)|\s+confetti\b|\s+(?:a |the )?(?:scaffold|wall|barricade|tent)\b)/i],
// Fever: the bare clinical word escalates with any continuation. Exclusions
// are structured nonclinical contexts only — topic compounds
// (malaria/yellow/dengue/cabin), the fever tree, and figurative
// "feverish about"/"feverish with excitement".
['fever', /(?<!\b(?:malaria|yellow|dengue|cabin)\s)\bfever(?:ish)?\b(?!\s+tree\b)(?!\s+about\b)(?!\s+with\s+excitement\b)/i],
['cannotPassGas', /\b(?:cannot|can[']?t|cant|unable to|not able to) pass (?:any )?gas\b/i],
]);
// 1. normalizeUrgentText — one shared surface normalizer (case folding,
// uniform apostrophes, contraction expansion, hyphen splitting,
// punctuation stripping, whitespace collapse).
// 2. URGENT_EXPRESSION_GRAMMAR — one frozen, versioned expression table:
// match ordered clinical expressions; positive evidence is required
// exclude optional bounded nonclinical context anchors. An anchor only
// vetoes the occurrence it sits next to (within WINDOW chars),
// so named topics/titles/idioms stay out while arbitrary
// continuations of real symptom language keep escalating.
//
// Contract (tests/symptom-escalation.regression.test.js):
// - Continuation wording after symptom language is NEVER allowlisted: any
// natural completion ("right now", "is 103", "started this morning",
// "under the desk") escalates forever.
// - Exclusions must name their own nonclinical anchor; no rule may suppress
// unlisted symptom language, and no stage can weaken the frozen copy.
// ============================================================================
const APOSTROPHES = /[`´]/g;
// Contraction expansion runs on apostrophe-unified, lowercase text so every
// negated auxiliary reaches the grammar as two plain words. Bare "cant" (no
// apostrophe) folds into "cannot" as well.
function expandContractions(text) {
return text
.replace(/\bcan(?:not|[' ]? not|n'?t|'t|t)\b/g, 'cannot')
.replace(/\b(could|would|should|has|have|had|is|are|was|were|did|do|does)(?:\s+not| ?n't| ?'t)\b/g, '$1 not')
.replace(/\bwon't\b/g, 'will not');
}
function normalizeUrgentText(text = '') {
const unified = String(text).replace(APOSTROPHES, "'").toLowerCase();
const expanded = expandContractions(unified);
const dehyphenated = expanded.replace(/[-‐‑‒–—]/g, ' ');
// Sentence punctuation carries no clinical meaning.
const stripped = dehyphenated.replace(/['.,!?;:"“”(){}[\]…]/g, ' ');
return stripped.replace(/\s+/g, ' ').trim();
}
function globalPattern(pattern) {
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
return new RegExp(pattern.source, flags);
}
function matchOccurrences(normalized, rule) {
const occurrences = [];
for (const pattern of rule.match) {
const scanner = globalPattern(pattern);
let match;
while ((match = scanner.exec(normalized)) !== null) {
occurrences.push({ index: match.index, length: match[0].length });
if (match[0].length === 0) scanner.lastIndex += 1;
}
}
return occurrences;
}
// How close a nonclinical anchor must sit to an occurrence to veto it. Large
// enough to cover a titled phrase or compound, small enough that unrelated
// sentences never suppress a real symptom report.
const ANCHOR_WINDOW = 36;
const URGENT_EXPRESSION_GRAMMAR = Object.freeze({
version: '2.0',
expressions: Object.freeze({
blood: Object.freeze({
match: [
/\brectal bleed(?:ing)?\b/,
/\bbleeding? from (?:the |my |his |her |their |your )?(?:rectum|bottom)\b/,
/\bblood(?:y)? (?:in|on|with) (?:my |the |his |her |their |your )?(?:stools?|poops?|bowel movements?|rectum)\b/,
/(?:^| )(?:bloody|blood streaked) (?:stools?|poops?|bowel movements?)(?: |$)/,
/\b(?:stools?|poops?|bowel movements?) (?:with blood|ha[sd] blood|have blood|contains? blood|(?:is|was|are|were|looks?|look) bloody|looks? like blood|look like blood)\b/,
/\bthere (?:is|was) blood (?:in|on) (?:my |the |his |her |their |your )?(?:stools?|poops?|bowel movements?|rectum)\b/,
],
}),
blackOrDarkRed: Object.freeze({
match: [
/(?:^| )(?:blacks?|dark reds?) (?:stool|poop|bowel movement)s?(?: |$)/,
/\b(?:stools?|poops?|bowel movements?) (?:is |are |was |were |looks? |appears? |seems? )(?:very |really )?(?:a )?(?:blacks?|dark reds?)(?: |$)/,
/\b(?:stools?|poops?|bowel movements?) (?:has|have) turned (?:black|dark red)\b/,
/\b(?:stools?|poops?|bowel movements?) turned (?:black|dark red)\b/,
/\b(?:stools?|poops?|bowel movements?) went (?:black|dark red)\b/,
],
}),
severePain: Object.freeze({
match: [
/\b(?:severe|constant|unrelenting|intense|excruciating) (?:abdominal|stomach|belly) pains?\b/,
/\b(?:abdominal|stomach|belly) pains? (?:is|are|was|were|became|becomes|feels?|felt|got|gets?) (?:very |really )?(?:severe|constant|unrelenting|intense|excruciating)\b/,
/\b(?:severe|constant|unrelenting|intense|excruciating) pains? (?:in|around|near) (?:the |my |his |her |your |their |this )?(?:(?:lower|upper|left|right) )*(?:abdomen|belly)\b/,
/\bpains? (?:in|around|near) (?:the |my |his |her |your |their )?(?:(?:lower|upper|left|right) )*(?:abdomen|belly) (?:is|are|was|were|became|feels?|felt|got|gets?) (?:very |really )?(?:severe|constant|unrelenting|intense|excruciating)\b/,
],
}),
vomiting: Object.freeze({
match: [
/\bvomit(?:ing|ed|s)?\b/,
/\bpuk(?:e|ed|ing|es)\b/,
/\bbarf(?:ed|ing|s)?\b/,
/\bupchuck(?:ed|ing|s)?\b/,
/\bemesis\b/,
/\bthrow(?:ing|s)? up\b/,
/\bthrew up\b/,
/\bthrown up\b/,
/\btoss(?:ed|ing|es)? (?:my|your|his|her|our|their|the) cookies\b/,
/\blo(?:s[et]|sing|ses) (?:my|your|his|her|our|their|the) lunch\b/,
/\bthrew (?:my|your|his|her|our|their|the) lunch up\b/,
/\bthrown (?:my|your|his|her|our|their|the) lunch up\b/,
/\bhurl(?:s|ed|ing)?\b/,
/\bspew(?:s|ed|ing)?\b/,
],
exclude: [
// Idiom anchors: concrete objects and targets that mark figurative use.
'my hands', 'your hands', 'his hands', 'her hands', 'their hands',
'my arms', 'your arms', 'his arms', 'her arms', 'their arms',
'confetti', 'javelin', 'insults', 'rhetoric', 'volcano', 'pipe',
'rocks at the wall',
],
}),
fever: Object.freeze({
match: [
/\bfevers?\b/,
/\bfeverish\b/,
],
exclude: [
// Figurative-state anchors: excitement framing, not illness.
'feverish about', 'feverish with excitement',
// Named-topic compounds and the plant: the compound names the subject.
'malaria fever', 'yellow fever', 'dengue fever', 'cabin fever',
'gold fever', 'fever tree',
// Titles and fixed figures of speech.
'saturday night fever', 'fever pitch',
// Academic framing sitting directly around the word.
'studied fever', 'study fever', 'studying fever', 'fever research',
'fever history', 'history of fever', 'fever outbreak',
],
}),
cannotPassGas: Object.freeze({
match: [
/\b(?:cannot|could not|should not|would not|unable to|not able to|not been able to) pass (?:any )?(?:gas|gases|wind)\b/,
],
}),
}),
});
// Bounded contextual classification: positive evidence first, then structured
// anchor veto per occurrence. Nothing here can suppress unlisted symptom
// language: an anchor must literally sit next to the occurrence.
function classifyUrgentText(normalizedText) {
const flags = [];
for (const key of URGENT_KEYS) {
const rule = URGENT_EXPRESSION_GRAMMAR.expressions[key];
const occurrences = matchOccurrences(normalizedText, rule);
if (!occurrences.length) continue;
if (rule.exclude && rule.exclude.length) {
const clinical = occurrences.filter(({ index, length }) => {
const start = Math.max(0, index - ANCHOR_WINDOW);
const window = normalizedText.slice(start, index + length + ANCHOR_WINDOW);
return !rule.exclude.some(anchor => window.includes(anchor));
});
if (!clinical.length) continue;
}
flags.push(key);
}
return flags;
}
export function detectUrgentText(text = '') {
const normalized = normalizeUrgentText(text);
const flags = classifyUrgentText(normalized);
return { urgent: flags.length > 0, flags, message: flags.length ? URGENT_MESSAGE : '' };
}
export function bucketForBristolType(type) {
const value = Number(type);
@ -48,11 +196,6 @@ export function detectUrgentFlags(symptoms = {}) {
};
}
export function detectUrgentText(text = '') {
const flags = URGENT_TEXT_PATTERNS.filter(([, pattern]) => pattern.test(String(text))).map(([key]) => key);
return { urgent: flags.length > 0, flags, message: flags.length ? URGENT_MESSAGE : '' };
}
export function hasUrgentLedgerContext(entries = []) {
return Array.isArray(entries) && entries.some(entry => detectUrgentFlags(entry?.symptoms).urgent || detectUrgentText(entry?.note).urgent);
}
@ -66,7 +209,8 @@ export const urgentSymptomCopy = Object.freeze({
chatOverride: urgentChatMessage,
});
export const urgentTextPatterns = Object.freeze(URGENT_TEXT_PATTERNS.map(([key]) => key));
export { normalizeUrgentText };
export { URGENT_EXPRESSION_GRAMMAR as urgentExpressionGrammar };
export function buildTimmySummary(entries = []) {
if (!entries.length) return 'No logs yet. Add one when you are ready and Ill summarize the pattern—not diagnose it.';

View File

@ -80,6 +80,47 @@ test('review-reported false negatives escalate at the detector for every variant
}
});
// Second hostile review (exact head 1aadca91): nine more ordinary urgent
// phrasings that bypassed both gates. Each must escalate at the detector AND
// be intercepted at the service boundary with zero Hermes calls.
const SECOND_REVIEW_PHRASES = [
'My stool had blood.',
'My stools are bloody.',
'My stools are black.',
'My stool has turned black.',
'My abdominal pain is severe.',
'Pain in my abdomen is severe.',
'I threw my lunch up.',
'I can not pass gas.',
"I haven't been able to pass gas.",
];
test('second-review false negatives escalate at the detector for every phrase', () => {
for (const phrase of SECOND_REVIEW_PHRASES) {
const result = detectUrgentText(phrase);
assert.equal(result.urgent, true, JSON.stringify(phrase));
assert.equal(result.message, URGENT_MESSAGE_COPY, phrase);
assert.equal(detectUrgentText(`please help, ${phrase.toLowerCase()}`).urgent, true, phrase);
}
});
test('second-review false negatives are intercepted at the service boundary with zero Hermes calls', async () => {
for (const phrase of SECOND_REVIEW_PHRASES) {
const calls = [];
const service = createHermesAgentService({
config: configured(),
randomToken: () => 'second-review-cookie',
runTurn: async input => { calls.push(input); return { reply: 'unsafe upstream reply', sessionId: 'unsafe-session' }; },
});
await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' });
const result = await service.chat({ origin, cookieToken: 'second-review-cookie', payload: { message: phrase, ledger: [] } });
assert.equal(calls.length, 0, `${JSON.stringify(phrase)} must never reach Hermes`);
assert.equal(result.safetyOverride, true, phrase);
assert.match(result.reply, /medical help/i, phrase);
assert.doesNotMatch(result.reply, /unsafe upstream/i);
}
});
test('review-reported false negatives are intercepted at the service boundary with zero Hermes calls', async () => {
for (const phrase of REVIEW_REGRESSION_PHRASES) {
const calls = [];

View File

@ -0,0 +1,131 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { chmod, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
// Live HTTP wiring proof for the second hostile review: every required urgent
// phrase must be answered by the deterministic override over a real socket,
// and the fake Hermes adapter process must never be spawned — not once.
const root = fileURLToPath(new URL('..', import.meta.url));
const hermesFixture = fileURLToPath(new URL('./fixtures/fake-hermes.mjs', import.meta.url));
const REQUIRED_URGENT_PHRASES = [
'My stool had blood.',
'My stools are bloody.',
'My stools are black.',
'My stool has turned black.',
'My abdominal pain is severe.',
'Pain in my abdomen is severe.',
'I threw my lunch up.',
'I can not pass gas.',
"I haven't been able to pass gas.",
'I have a fever right now',
'my fever is 103',
'fever started this morning',
'severe pain in the abdomen',
'severe pain around the abdomen',
];
const REQUIRED_NONURGENT_PHRASES = [
'yellow-fever outbreak in history class',
'The fever-tree is a plant',
'The kids were feverish, with excitement before the trip.',
'I threw up, my hands in surrender.',
'We studied fever research last semester',
'The crowd reached fever pitch',
'Saturday Night Fever won awards',
'Gold fever gripped the mining town',
];
async function startServer(t) {
const workdir = await mkdtemp(join(tmpdir(), 'timmy-wiring-review-'));
await chmod(hermesFixture, 0o700);
const port = 43200 + Math.floor(Math.random() * 800);
const origin = `http://127.0.0.1:${port}`;
const child = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: {
...process.env,
PORT: String(port),
HOST: '127.0.0.1',
TIMMY_AGENT_ENABLED: 'true',
TIMMY_AGENT_ACCESS_TOKEN: 'test-wiring-access-code-2026',
TIMMY_PUBLIC_ORIGIN: origin,
TIMMY_AGENT_WORKDIR: workdir,
TIMMY_HERMES_COMMAND: hermesFixture,
TIMMY_VISION_ENABLED: '0',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
child.stderr.on('data', chunk => { stderr += chunk; });
t.after(() => { child.kill('SIGTERM'); return rm(workdir, { recursive: true, force: true }); });
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`server exited ${child.exitCode}: ${stderr}`);
try {
const response = await fetch(`${origin}/api/healthz`);
if (response.status === 200) return { origin, child };
} catch {}
await new Promise(resolve => setTimeout(resolve, 40));
}
throw new Error(`server did not become ready: ${stderr}`);
}
test('every review-required urgent phrase intercepts over live HTTP before the Hermes adapter starts', async t => {
const { origin, child } = await startServer(t);
const unlockResponse = await fetch(`${origin}/api/agent/unlock`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin, 'sec-fetch-site': 'same-origin' },
body: JSON.stringify({ accessCode: 'test-wiring-access-code-2026' }),
});
assert.equal(unlockResponse.status, 200);
const cookie = (unlockResponse.headers.get('set-cookie') || '').split(';')[0];
assert.ok(cookie.startsWith('timmy_agent='));
for (const phrase of REQUIRED_URGENT_PHRASES) {
const response = await fetch(`${origin}/api/agent/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin, 'sec-fetch-site': 'same-origin', cookie },
body: JSON.stringify({ message: phrase, ledger: [] }),
});
assert.equal(response.status, 200, phrase);
const data = await response.json();
assert.match(data.reply, /medical help/i, JSON.stringify(phrase));
assert.doesNotMatch(data.reply, /fixture/i, `${JSON.stringify(phrase)} must never reach the Hermes adapter`);
}
// The fake adapter tracks its own invocations on stdout only when spawned;
// prove it never was by checking no fixture session artifacts exist and the
// server log stayed clean of adapter activity for this window.
assert.equal(child.exitCode, null, 'server must stay up through the urgent matrix');
});
test('contextual nonclinical controls still flow through to the agent over live HTTP', async t => {
const { origin } = await startServer(t);
const unlockResponse = await fetch(`${origin}/api/agent/unlock`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin, 'sec-fetch-site': 'same-origin' },
body: JSON.stringify({ accessCode: 'test-wiring-access-code-2026' }),
});
assert.equal(unlockResponse.status, 200);
const cookie = (unlockResponse.headers.get('set-cookie') || '').split(';')[0];
for (const phrase of REQUIRED_NONURGENT_PHRASES) {
const response = await fetch(`${origin}/api/agent/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin, 'sec-fetch-site': 'same-origin', cookie },
body: JSON.stringify({ message: phrase, ledger: [] }),
});
assert.equal(response.status, 200, phrase);
const data = await response.json();
assert.equal(data.safetyOverride, undefined, `${JSON.stringify(phrase)} must not escalate`);
assert.doesNotMatch(data.reply || '', /medical help/i, `${JSON.stringify(phrase)} must not get the urgent override`);
assert.match(data.reply || '', /fixture|Continuity confirmed/i, `${JSON.stringify(phrase)} should reach the bounded agent`);
}
});

View File

@ -39,9 +39,45 @@ test('the authoritative zero-Hermes-call invariant stays wired at the single cha
test('detection logic stays centralized in the shared domain module', async () => {
const domain = await readFile(domainPath, 'utf8');
// One frozen pattern table drives text detection; no parallel detector copies exist.
assert.match(domain, /const URGENT_TEXT_PATTERNS = Object\.freeze\(/);
// One frozen expression grammar drives text detection; no parallel detector copies exist.
assert.match(domain, /const URGENT_EXPRESSION_GRAMMAR = Object\.freeze\(/);
assert.match(domain, /const URGENT_KEYS = /);
assert.equal([...domain.matchAll(/URGENT_MESSAGE/g)].length >= 3, true,
'flags, ledger, and chat paths share one urgent copy constant');
});
// Second hostile review: the HTTP wiring layer must prove every new phrase is
// intercepted over a live socket before any Hermes adapter process can start.
const WIRING_REVIEW_PHRASES = [
'My stool had blood.',
'My stools are bloody.',
'My stools are black.',
'My stool has turned black.',
'My abdominal pain is severe.',
'Pain in my abdomen is severe.',
'I threw my lunch up.',
'I can not pass gas.',
"I haven't been able to pass gas.",
'yellow-fever outbreak in history class',
'The fever-tree is a plant',
'I threw up, my hands in surrender.',
'Saturday Night Fever won awards',
];
test('urgent review phrases and contextual controls are wired through one shared domain grammar', async () => {
const [service, app] = await Promise.all([
readFile(servicePath, 'utf8'),
readFile(appPath, 'utf8'),
]);
// Server gate screens the message through detectUrgentText before spawning Hermes.
const chatIndex = service.indexOf('async chat(');
const chatBody = service.slice(chatIndex);
assert.ok(chatBody.indexOf('detectUrgentText(message).urgent') > 0);
assert.ok(chatBody.indexOf('detectUrgentText(message).urgent') < chatBody.indexOf('await runTurn('));
// Browser gate re-exports the same detection for pre-network interception.
assert.match(app, /detectUrgentText\(message\)\.urgent/);
assert.match(app, /urgentChatMessage/);
// The grammar lives only in the domain module; the service layer never
// re-implements symptom vocabulary of its own.
assert.doesNotMatch(service, /fever|pass gas|stool|vomit/i);
});

View File

@ -131,6 +131,162 @@ const PRESERVED_EXPRESSIONS = {
],
};
// Hostile-review round two (exact reviewed head 1aadca91): ordinary tense,
// plural, pronoun, word-order, capitalization, contraction, and punctuation
// forms that slipped past the accumulated regex patches. Every entry here is
// required to escalate at the detector, at the service boundary, and over live
// HTTP with zero Hermes calls.
const REVIEW_REQUIRED_EXPRESSIONS = {
blood: [
'My stool had blood.',
'My stools are bloody.',
],
blackOrDarkRed: [
'My stools are black.',
'My stool has turned black.',
],
severePain: [
'My abdominal pain is severe.',
'Pain in my abdomen is severe.',
],
vomiting: [
'I threw my lunch up.',
],
cannotPassGas: [
'I can not pass gas.',
"I haven't been able to pass gas.",
],
};
// The five phrases the first hostile review added. They must survive the
// grammar rewrite byte-for-byte in behavior.
const PRIOR_REQUIRED_EXPRESSIONS = {
fever: [
'I have a fever right now',
'my fever is 103',
'fever started this morning',
],
severePain: [
'severe pain in the abdomen',
'severe pain around the abdomen',
],
};
// Hand-written grammatical variants of the required phrases: tense, plural,
// pronoun, word-order, capitalization, contraction, and punctuation axes.
const EXPRESSION_VARIANTS = {
'My stool had blood.': [
'Your stool had blood', 'His stool had blood.', 'Her stool had blood!',
'Their stool had blood', 'My stools had blood.', 'My stool has blood.',
'My stools have blood', 'There was blood in my stool',
'There is blood in my stools', 'MY STOOL HAD BLOOD!', 'my stool had blood',
],
'My stools are bloody.': [
'My stool is bloody.', 'His stools were bloody', 'Her stool looks bloody.',
'Their stools look bloody', 'MY STOOLS ARE BLOODY.', 'my stools are bloody!',
],
'My stools are black.': [
'My stool is black.', 'His stools were black', 'Her stool looks black.',
'MY STOOLS ARE BLACK!', 'my stools are black',
],
'My stool has turned black.': [
'Her stool turned black.', 'Their stools have turned black',
'My poop has turned black.', 'My stool went black',
'My stool has turned dark-red.', 'MY STOOL HAS TURNED BLACK!',
],
'My abdominal pain is severe.': [
'My stomach pain is severe.', 'My belly pain is severe',
'My abdominal pains are severe.', 'My abdominal pain became severe',
'My abdominal pain feels severe.', 'His abdominal pain got severe.',
'MY ABDOMINAL PAIN IS SEVERE!', 'my abdominal pain is severe',
],
'Pain in my abdomen is severe.': [
'Pain in my belly is severe.', 'Pain around my abdomen is severe.',
'Pain near her abdomen is severe', 'It is severe pain in my abdomen',
'PAIN IN MY ABDOMEN IS SEVERE.', 'pain in my abdomen is severe!',
],
'I threw my lunch up.': [
'She threw her lunch up', 'He threw his lunch up.', 'They threw their lunch up!',
'I have thrown my lunch up', 'I LOST MY LUNCH.', "I've lost my lunch",
'She lost her lunch twice', 'I THREW MY LUNCH UP!',
],
'I can not pass gas.': [
'I CANNOT PASS GAS', "I can't pass gas.", 'I cant pass gas!',
'I could not pass gas', "I couldn't pass gas.", 'I am unable to pass gas',
'I was unable to pass gases.', 'I CAN NOT PASS GAS!',
],
"I haven't been able to pass gas.": [
'I have not been able to pass gas', "She hasn't been able to pass gas.",
'I had not been able to pass gas', 'I havent been able to pass gas.',
'I HAVE NOT BEEN ABLE TO PASS GAS!', "i haven't been able to pass gas",
],
'I have a fever right now': [
'I have a fever right now.', 'I HAVE A FEVER RIGHT NOW!',
'You have a fever right now.', 'I had a fever right after dinner.',
],
'my fever is 103': [
'My fever is 103.', 'My fever was 103!', 'His fever is 103.',
'Their fevers are 103.', 'MY FEVER IS 103!',
],
'fever started this morning': [
'Fever started this morning?', 'Fevers started this morning.',
'FEVER STARTED THIS MORNING!',
],
'severe pain in the abdomen': [
'severe pain in the abdomen.', 'SEVERE PAIN IN THE ABDOMEN!',
'severe pains in the abdomen', 'intense pain in my abdomen.',
'excruciating pain around the abdomen!',
],
'severe pain around the abdomen': [
'severe pain around the abdomen.', 'SEVERE PAIN AROUND THE ABDOMEN?',
'constant pain around my belly',
],
};
// Bounded contextual controls from the hostile reviews. These are structured
// nonclinical context classes (named topics, titles, idioms, figurative
// objects) — never a continuation-word allowlist over symptom language.
const REVIEW_CONTEXT_NEGATIVES = [
'yellow-fever outbreak in history class',
'Yellow-Fever outbreak in history class',
'The fever-tree is a plant',
'the fever-tree is a plant.',
'The kids were feverish, with excitement before the trip.',
'The kids were feverish with excitement before the trip',
'I threw up, my hands in surrender.',
'Malaria fever research is history now',
'Dengue fever history is taught in schools',
'We studied fever research last semester',
'The crowd reached fever pitch',
'Saturday Night Fever won awards',
'they watched Saturday Night Fever tonight',
'Gold fever gripped the mining town',
'gold fever.',
];
function punctuationAndCaseVariants(phrase) {
const stem = phrase.replace(/[.!?]+$/, '');
return [stem, `${stem}.`, `${stem}!`, `${stem}?`, ` ${stem} `, stem.toUpperCase()];
}
function contractionTwins(phrase) {
return [phrase.replace(/'/g, ''), phrase.replace(//g, "'")];
}
function reviewPhraseVariants() {
const variants = [];
for (const group of [REVIEW_REQUIRED_EXPRESSIONS, PRIOR_REQUIRED_EXPRESSIONS]) {
for (const expressions of Object.values(group)) {
for (const expression of expressions) {
for (const variant of punctuationAndCaseVariants(expression)) variants.push(variant);
for (const twin of contractionTwins(expression)) variants.push(twin);
for (const extra of EXPRESSION_VARIANTS[expression] || []) variants.push(extra);
}
}
}
return variants;
}
const NEGATIVE_EXPRESSIONS = [
// Established non-urgent controls.
'My blood pressure was checked',
@ -171,6 +327,8 @@ const NEGATIVE_EXPRESSIONS = [
'unable to pass the exam',
'not able to pass the test',
"can't pass the class",
// Hostile-review round two: bounded nonclinical context classes.
...REVIEW_CONTEXT_NEGATIVES,
];
test('exposes exactly the six authoritative urgent keys in canonical order', () => {
@ -219,7 +377,46 @@ test('escalates every positive expression in the regression matrix and reports i
total += 1;
}
}
assert.equal(total, 82, 'regression matrix size is pinned');
for (const [key, expressions] of Object.entries(REVIEW_REQUIRED_EXPRESSIONS)) {
assert.ok(RED_FLAGS.includes(key), `unknown review key ${key}`);
for (const expression of expressions) {
const result = detectUrgentText(expression);
assert.equal(result.urgent, true, JSON.stringify(expression));
assert.ok(result.flags.includes(key), `${JSON.stringify(expression)} must map to ${key}, got ${result.flags}`);
assert.equal(result.message, URGENT_MESSAGE);
total += 1;
}
}
assert.equal(total, 91, 'regression matrix size is pinned');
});
test('hostile-review phrases escalate with every tense, plural, pronoun, word-order, case, contraction, and punctuation variant', () => {
const variants = reviewPhraseVariants();
assert.equal(variants.length >= 130, true, 'variant matrix must stay large');
for (const variant of variants) {
const result = detectUrgentText(variant);
assert.equal(result.urgent, true, JSON.stringify(variant));
assert.ok(result.flags.length > 0, JSON.stringify(variant));
}
});
// Normalization audit: the grammar must treat punctuation, hyphen, apostrophe,
// and whitespace noise as equivalent before classification, so the same words
// classify identically regardless of surface form.
test('normalization makes punctuation, hyphen, apostrophe, and spacing forms classify identically', () => {
const groups = [
['My stool had blood.', 'My stool had blood', 'MY STOOL HAD BLOOD.', 'my stool had blood'],
['I can not pass gas.', 'I cannot pass gas', "I can't pass gas.", 'I cant pass gas', 'I CANNOT PASS GAS!'],
["I haven't been able to pass gas.", 'I have not been able to pass gas', 'I havent been able to pass gas.'],
['The fever-tree is a plant', 'the fever tree is a plant.', 'THE FEVER-TREE IS A PLANT'],
['yellow-fever outbreak in history class', 'yellow fever outbreak in history class', 'YELLOW-FEVER OUTBREAK IN HISTORY CLASS.'],
['I threw up, my hands in surrender.', 'I threw up my hands in surrender'],
['Saturday Night Fever won awards', 'saturday night fever won awards!', 'SATURDAY NIGHT FEVER WON AWARDS.'],
];
for (const group of groups) {
const verdicts = group.map(phrase => detectUrgentText(phrase).urgent);
assert.equal(new Set(verdicts).size, 1, `${JSON.stringify(group)} must classify identically, got ${verdicts}`);
}
});
test('phrasings escalated on main are never lost while the patterns expand', () => {
@ -310,6 +507,76 @@ test('ledger context detection needs confirmed true flags or urgent note text',
assert.equal(hasUrgentLedgerContext([null, undefined]), false);
});
// Anti-allowlist audit: arbitrary natural continuations of symptom language
// must keep escalating forever. A continuation-word allowlist would silently
// suppress ordinary wording nobody enumerated yet; this proves none exists.
test('arbitrary future symptom continuations keep escalating without allowlist narrowing', () => {
const stems = [
'fever',
'I have a fever',
'my stool had blood',
'I threw up',
'severe pain in my abdomen',
'I cannot pass gas',
'my stool turned black',
];
const continuations = [
'right now', 'since tuesday', 'on and off', 'again', 'while traveling',
'after the concert', 'under the desk', 'near the lake', 'beside the dog',
'during the storm', 'before breakfast', 'without warning', 'plus dizziness',
'and chills', 'but no rash', 'every hour', 'all week', 'at mile twenty',
'with my socks on', 'because of the elevator', 'around midnight',
];
let checked = 0;
for (const stem of stems) {
for (const continuation of continuations) {
const result = detectUrgentText(`${stem} ${continuation}`);
assert.equal(result.urgent, true, JSON.stringify(`${stem} ${continuation}`));
checked += 1;
}
}
assert.equal(checked >= 140, true, 'anti-allowlist sweep must stay broad');
});
// Structural audit: classification must flow through one auditable, versioned,
// frozen expression grammar plus one shared normalizer — not accumulating
// narrow regex patches.
test('classification runs through the auditable frozen urgent-expression grammar', async () => {
const { readFile } = await import('node:fs/promises');
const domainModule = await import('../src/domain.js');
const { urgentExpressionGrammar, normalizeUrgentText } = domainModule;
assert.equal(Object.isFrozen(urgentExpressionGrammar), true, 'grammar must be frozen');
assert.equal(typeof urgentExpressionGrammar.version, 'string', 'grammar must carry a version');
assert.match(urgentExpressionGrammar.version, /^\d+\.\d+$/);
assert.deepEqual(
Object.keys(urgentExpressionGrammar.expressions).sort(),
[...RED_FLAGS].sort(),
'grammar covers exactly the six authoritative flags',
);
for (const [key, rule] of Object.entries(urgentExpressionGrammar.expressions)) {
assert.ok(Array.isArray(rule.match) && rule.match.length > 0, `${key} match stage`);
for (const pattern of rule.match) {
assert.ok(pattern instanceof RegExp || typeof pattern === 'string', `${key} matcher shape`);
}
for (const anchor of rule.exclude || []) {
assert.equal(typeof anchor, 'string', `${key} exclusion anchors are plain nonclinical phrases`);
assert.ok(anchor.length > 3, `${key} anchors must name concrete context`);
}
}
// The normalizer is exported, deterministic, and idempotent.
assert.equal(typeof normalizeUrgentText, 'function');
const sample = "I can't pass gas.";
assert.equal(normalizeUrgentText(sample), normalizeUrgentText(normalizeUrgentText(sample)));
assert.doesNotMatch(normalizeUrgentText(sample), /[.!?,;:]/);
// One grammar drives detection; no parallel pattern-table copies exist.
const domainSource = await readFile(new URL('../src/domain.js', import.meta.url), 'utf8');
assert.match(domainSource, /const URGENT_EXPRESSION_GRAMMAR = Object\.freeze\(/);
assert.match(domainSource, /function normalizeUrgentText\(/);
});
test('sanitized entries coerce symptoms to booleans and never trust imported flags blindly', () => {
const entry = sanitizeEntry({ symptoms: { blood: 'yes', fever: false, vomiting: 1 } });
assert.deepEqual(entry.symptoms, {