fix: replace urgent regex patches with auditable expression grammar
All checks were successful
Quality gates / quality (pull_request) Successful in 3m31s
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:
parent
1aadca91c6
commit
693e023758
|
|
@ -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",
|
||||
|
|
|
|||
210
src/domain.js
210
src/domain.js
|
|
@ -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 I’ll summarize the pattern—not diagnose it.';
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
131
tests/escalation-http.test.js
Normal file
131
tests/escalation-http.test.js
Normal 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`);
|
||||
}
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 haven’t 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 haven’t 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, {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user