Hotfix: enforce deterministic urgent-symptom override at the server boundary #50
8
app.js
8
app.js
|
|
@ -1,4 +1,4 @@
|
|||
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, exportLedger, importLedger, photoQualityMessage, sanitizeEntry } from './src/domain.js';
|
||||
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js';
|
||||
import { mergeVisualSuggestion } from './src/analysis.js';
|
||||
|
||||
const STORE = 'timmy-ledger-v1';
|
||||
|
|
@ -84,15 +84,11 @@ function localChatReply(message){
|
|||
if(/food|eat|restaurant|taco/.test(lower))return 'A bowel journal cannot clear a food or restaurant. I can help you compare confirmed entries over time, not decide what is safe to eat.';
|
||||
return buildTimmySummary(entries);
|
||||
}
|
||||
function urgentChatReply(message){
|
||||
if(!/(blood|black (?:or |and )?dark[- ]?red stool|black stool|dark[- ]?red stool|severe|constant abdominal pain|vomit|fever|cannot pass gas|can['’]?t pass gas)/i.test(message))return '';
|
||||
return 'Pause and get medical help. Those symptoms can need prompt medical assessment. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.';
|
||||
}
|
||||
function ledgerForAgent(){return entries.slice(-20).map(({photoDataUrl,...entry})=>entry)}
|
||||
async function sendChat(event){
|
||||
event.preventDefault();if(chatBusy)return;const input=document.querySelector('#chat-message');const message=String(input?.value||'').trim();if(!message)return;
|
||||
chatMessages.push({role:'user',text:message});chatError='';input.value='';
|
||||
const urgent=urgentChatReply(message);if(urgent){chatMessages.push({role:'timmy',text:urgent});timmy();return}
|
||||
const urgent=detectUrgentText(message).urgent||hasUrgentLedgerContext(ledgerForAgent());if(urgent){chatMessages.push({role:'timmy',text:urgentChatMessage});timmy();return}
|
||||
if(!agentStatus?.authenticated){chatMessages.push({role:'timmy',text:localChatReply(message)});timmy();return}
|
||||
chatBusy=true;timmy();
|
||||
try{const response=await fetch('/api/agent/chat',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({message,ledger:ledgerForAgent()})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Hermes is unavailable.');chatMessages.push({role:'timmy',text:data.reply})}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 314 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 355 KiB After Width: | Height: | Size: 290 KiB |
|
|
@ -50,11 +50,15 @@ await page.route('**/api/agent/status', route => route.fulfill({
|
|||
contentType: 'application/json',
|
||||
body: JSON.stringify({ enabled: true, configured: true, authenticated: true, mode: 'hermes-agent' }),
|
||||
}));
|
||||
await page.route('**/api/agent/chat', route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ connected: true, reply: 'Your confirmed logs are mostly Type 4. I can explain that pattern, but I cannot diagnose a cause.' }),
|
||||
}));
|
||||
let hermesCalls = 0;
|
||||
await page.route('**/api/agent/chat', route => {
|
||||
hermesCalls += 1;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ connected: true, reply: 'Your confirmed logs are mostly Type 4. I can explain that pattern, but I cannot diagnose a cause.' }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
|
|
@ -117,8 +121,13 @@ await caption('Hermes Agent connected — free-text, contextual, and server-side
|
|||
await page.locator('#chat-message').fill('What pattern do you see?');
|
||||
await tap('#send-chat', 500);
|
||||
await page.getByText(/mostly Type 4/i).waitFor();
|
||||
await caption('Hermes keeps credentials, tools, and session continuity server-side. Photos stay out of chat.', 1800);
|
||||
await sleep(500);
|
||||
await caption('Hermes keeps credentials, tools, and session continuity server-side. Photos stay out of chat.', 1600);
|
||||
await page.locator('#chat-message').fill('I have rectal bleeding');
|
||||
await tap('#send-chat', 450);
|
||||
await page.getByText(/Pause and get medical help/i).waitFor();
|
||||
if (hermesCalls !== 1) throw new Error('Urgent chat must be intercepted before Hermes');
|
||||
await caption('Urgent language is intercepted deterministically before Hermes', 1800);
|
||||
await sleep(400);
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('#demo-caption')?.remove();
|
||||
const outro = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
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.';
|
||||
const URGENT_TEXT_PATTERNS = Object.freeze([
|
||||
['blood', /\b(?:rectal bleeding|bleeding from (?:the )?(?:rectum|bottom)|blood(?:y)? (?:in|on|with) (?:my |the )?(?:stool|poop|bowel movement)|(?:stool|poop) (?:has|contains|with) blood)\b/i],
|
||||
['blackOrDarkRed', /\b(?:(?:black|dark[- ]?red) (?:stool|poop|bowel movement)|(?:stool|poop|bowel movement) (?:is|looks?) (?:black|dark[- ]?red))s?\b/i],
|
||||
['severePain', /\b(?:severe|constant|unrelenting) (?:abdominal|stomach|belly) pain\b/i],
|
||||
['vomiting', /\b(?:vomit(?:ing|ed)?|throwing up)\b/i],
|
||||
['fever', /\bfever(?:ish)?\b/i],
|
||||
['cannotPassGas', /\b(?:cannot|can['’]?t|cant|unable to|not able to) pass gas\b/i],
|
||||
]);
|
||||
|
||||
export function bucketForBristolType(type) {
|
||||
const value = Number(type);
|
||||
|
|
@ -14,11 +23,22 @@ export function detectUrgentFlags(symptoms = {}) {
|
|||
urgent: flags.length > 0,
|
||||
flags,
|
||||
message: flags.length
|
||||
? '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.'
|
||||
? URGENT_MESSAGE
|
||||
: 'No urgent symptom was selected. This tracker is not a diagnosis; seek care whenever you are worried or symptoms persist.',
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export const urgentChatMessage = `Pause and get medical help. ${URGENT_MESSAGE}`;
|
||||
|
||||
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.';
|
||||
const counts = entries.reduce((acc, entry) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { detectUrgentText, hasUrgentLedgerContext, urgentChatMessage } from './domain.js';
|
||||
|
||||
const MAX_MESSAGE_CHARS = 4000;
|
||||
const MAX_LEDGER_ENTRIES = 20;
|
||||
|
|
@ -190,6 +191,8 @@ export function createHermesAgentService({
|
|||
if (!message) throw new AgentGatewayError(400, 'Write a message first.');
|
||||
if (message.length > MAX_MESSAGE_CHARS) throw new AgentGatewayError(413, 'Message is too long.');
|
||||
const ledger = sanitizeLedger(payload.ledger || []);
|
||||
const urgent = detectUrgentText(message).urgent || hasUrgentLedgerContext(ledger);
|
||||
if (urgent) return { reply: urgentChatMessage, connected: true, safetyOverride: true };
|
||||
const cutoff = now() - RATE_WINDOW_MS;
|
||||
session.requests = session.requests.filter(time => time > cutoff);
|
||||
if (session.requests.length >= config.maxRequestsPerMinute || session.busy) throw new AgentGatewayError(429, 'Timmy is already thinking. Try again shortly.');
|
||||
|
|
|
|||
|
|
@ -82,6 +82,19 @@ test('HTTP gateway binds browser auth to one opaque server-side Hermes conversat
|
|||
assert.match(second.reply, /Continuity confirmed/);
|
||||
assert.doesNotMatch(JSON.stringify(second), /fixture_session/);
|
||||
|
||||
response = await post('/api/agent/chat', { message: 'I have rectal bleeding', ledger: [] }, { cookie: browserCookie });
|
||||
assert.equal(response.status, 200);
|
||||
const urgentMessage = await response.json();
|
||||
assert.equal(urgentMessage.safetyOverride, true);
|
||||
assert.match(urgentMessage.reply, /medical help/i);
|
||||
assert.doesNotMatch(urgentMessage.reply, /bounded text-only|Continuity confirmed/i);
|
||||
|
||||
response = await post('/api/agent/chat', { message: 'Summarize this.', ledger: [{ bristolType: 4, color: 'brown', symptoms: { cannotPassGas: true } }] }, { cookie: browserCookie });
|
||||
assert.equal(response.status, 200);
|
||||
const urgentLedger = await response.json();
|
||||
assert.equal(urgentLedger.safetyOverride, true);
|
||||
assert.match(urgentLedger.reply, /medical help/i);
|
||||
|
||||
response = await post('/api/agent/chat', { message: 'Hijack', ledger: [], sessionId: 'attacker-session' }, { cookie: browserCookie });
|
||||
assert.equal(response.status, 400);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import {
|
|||
bucketForBristolType,
|
||||
buildTimmySummary,
|
||||
detectUrgentFlags,
|
||||
detectUrgentText,
|
||||
exportLedger,
|
||||
hasUrgentLedgerContext,
|
||||
photoQualityMessage,
|
||||
sanitizeEntry,
|
||||
} from '../src/domain.js';
|
||||
|
|
@ -41,6 +43,24 @@ test('does not invent reassurance when no urgent flags are reported', () => {
|
|||
assert.match(result.message, /not a diagnosis/i);
|
||||
});
|
||||
|
||||
test('detects common urgent symptom language without matching unrelated blood wording', () => {
|
||||
for (const message of [
|
||||
'I have rectal bleeding',
|
||||
'There is blood in my stool',
|
||||
'My stool is black',
|
||||
'I have severe stomach pain',
|
||||
'I am throwing up and have a fever',
|
||||
'I am unable to pass gas',
|
||||
]) assert.equal(detectUrgentText(message).urgent, true, message);
|
||||
assert.equal(detectUrgentText('My blood pressure was checked').urgent, false);
|
||||
});
|
||||
|
||||
test('detects urgent flags or language in confirmed ledger context', () => {
|
||||
assert.equal(hasUrgentLedgerContext([{ symptoms: { cannotPassGas: true }, note: '' }]), true);
|
||||
assert.equal(hasUrgentLedgerContext([{ symptoms: {}, note: 'I have rectal bleeding' }]), true);
|
||||
assert.equal(hasUrgentLedgerContext([{ symptoms: {}, note: 'ordinary entry' }]), false);
|
||||
});
|
||||
|
||||
test('Timmy summary reports patterns without clearing food or diagnosing disease', () => {
|
||||
const entries = [
|
||||
{ bristolType: 3, occurredAt: '2026-08-15T08:00:00.000Z' },
|
||||
|
|
|
|||
|
|
@ -118,6 +118,33 @@ test('chat keeps Hermes session IDs server-side, resumes continuity, and strips
|
|||
assert.doesNotMatch(JSON.stringify([first, second]), /hermes-private-session/);
|
||||
});
|
||||
|
||||
test('authoritative chat service intercepts urgent message and ledger symptoms before Hermes', async () => {
|
||||
const calls = [];
|
||||
const service = createHermesAgentService({
|
||||
config: configured(),
|
||||
randomToken: () => 'safety-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 messageResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { message: 'I have rectal bleeding', ledger: [] } });
|
||||
const ledgerResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: {
|
||||
message: 'What does my journal show?',
|
||||
ledger: [{ bristolType: 4, color: 'brown', note: '', symptoms: { cannotPassGas: true } }],
|
||||
} });
|
||||
const noteResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: {
|
||||
message: 'Summarize this entry',
|
||||
ledger: [{ bristolType: 4, color: 'brown', note: 'unable to pass gas', symptoms: {} }],
|
||||
} });
|
||||
|
||||
assert.equal(calls.length, 0);
|
||||
for (const result of [messageResult, ledgerResult, noteResult]) {
|
||||
assert.equal(result.safetyOverride, true);
|
||||
assert.match(result.reply, /medical help/i);
|
||||
assert.doesNotMatch(result.reply, /unsafe upstream/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('Hermes CLI parser accepts quiet output, strips control sequences, and rejects leaked metadata', () => {
|
||||
const parsed = parseHermesCliOutput('\u001b[32msession_id: 20260820_abcd\u001b[0m\nA concise answer.');
|
||||
assert.deepEqual(parsed, { sessionId: '20260820_abcd', reply: 'A concise answer.' });
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ test('release demo visibly explains the CI-protected browser path without overst
|
|||
assert.match(demo, /AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis/);
|
||||
assert.match(demo, /Hermes Agent connected/);
|
||||
assert.match(demo, /Photos stay out of chat/);
|
||||
assert.match(demo, /I have rectal bleeding/);
|
||||
assert.match(demo, /Urgent language is intercepted deterministically before Hermes/);
|
||||
assert.match(demo, /SLEEK\. SIMPLE\.<br>HERMES-POWERED\./);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ await page.waitForSelector('.bubble.timmy >> text=mostly Type 4');
|
|||
assert.equal(chatRequest.message, 'What pattern do you see?');
|
||||
assert.ok(Array.isArray(chatRequest.ledger));
|
||||
assert.equal(JSON.stringify(chatRequest).includes('photoDataUrl'), false, 'photos never enter chat context');
|
||||
const previousRequest = chatRequest;
|
||||
await page.locator('#chat-message').fill('I am unable to pass gas');
|
||||
await page.locator('#send-chat').click();
|
||||
await page.waitForSelector('.bubble.timmy >> text=Pause and get medical help');
|
||||
assert.equal(chatRequest, previousRequest, 'urgent language must be intercepted before the Hermes request');
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'chat must not overflow horizontally');
|
||||
await page.screenshot({ path: 'artifacts/sleek-hermes-chat-mobile.png', fullPage: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ await page.locator('[data-view="timmy"]').last().click();
|
|||
await page.locator('#chat-message').fill('Can I eat Taco Bell?');
|
||||
await page.locator('#send-chat').click();
|
||||
const chatText = await page.locator('#chat').innerText();
|
||||
assert.match(chatText, /cannot clear a food or restaurant/i);
|
||||
assert.doesNotMatch(chatText, /Taco Bell is safe/i);
|
||||
assert.match(chatText, /pause and get medical help/i);
|
||||
assert.doesNotMatch(chatText, /Taco Bell is safe|cannot clear a food or restaurant/i);
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await page.evaluate(() => window.scrollY), 0, 'reply should not push the page header/navigation out of frame');
|
||||
await page.screenshot({ path: 'artifacts/timmy-chat-mobile.png', fullPage: false });
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user