fix: enforce urgent chat override server-side
All checks were successful
Quality gates / quality (pull_request) Successful in 1m25s

This commit is contained in:
Timmy 2026-08-20 16:35:48 +00:00
parent 09f9528434
commit 05b4bafd96
12 changed files with 111 additions and 16 deletions

8
app.js
View File

@ -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'; import { mergeVisualSuggestion } from './src/analysis.js';
const STORE = 'timmy-ledger-v1'; 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.'; 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); 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)} function ledgerForAgent(){return entries.slice(-20).map(({photoDataUrl,...entry})=>entry)}
async function sendChat(event){ async function sendChat(event){
event.preventDefault();if(chatBusy)return;const input=document.querySelector('#chat-message');const message=String(input?.value||'').trim();if(!message)return; 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=''; 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} if(!agentStatus?.authenticated){chatMessages.push({role:'timmy',text:localChatReply(message)});timmy();return}
chatBusy=true;timmy(); 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})} 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

View File

@ -50,11 +50,15 @@ await page.route('**/api/agent/status', route => route.fulfill({
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ enabled: true, configured: true, authenticated: true, mode: 'hermes-agent' }), body: JSON.stringify({ enabled: true, configured: true, authenticated: true, mode: 'hermes-agent' }),
})); }));
await page.route('**/api/agent/chat', route => route.fulfill({ let hermesCalls = 0;
status: 200, await page.route('**/api/agent/chat', route => {
contentType: 'application/json', hermesCalls += 1;
body: JSON.stringify({ connected: true, reply: 'Your confirmed logs are mostly Type 4. I can explain that pattern, but I cannot diagnose a cause.' }), 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.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear()); 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 page.locator('#chat-message').fill('What pattern do you see?');
await tap('#send-chat', 500); await tap('#send-chat', 500);
await page.getByText(/mostly Type 4/i).waitFor(); 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 caption('Hermes keeps credentials, tools, and session continuity server-side. Photos stay out of chat.', 1600);
await sleep(500); 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(() => { await page.evaluate(() => {
document.querySelector('#demo-caption')?.remove(); document.querySelector('#demo-caption')?.remove();
const outro = document.createElement('div'); const outro = document.createElement('div');

View File

@ -1,4 +1,13 @@
const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas']; 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) { export function bucketForBristolType(type) {
const value = Number(type); const value = Number(type);
@ -14,11 +23,22 @@ export function detectUrgentFlags(symptoms = {}) {
urgent: flags.length > 0, urgent: flags.length > 0,
flags, flags,
message: flags.length 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.', : '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 = []) { 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.'; if (!entries.length) return 'No logs yet. Add one when you are ready and Ill summarize the pattern—not diagnose it.';
const counts = entries.reduce((acc, entry) => { const counts = entries.reduce((acc, entry) => {

View File

@ -1,6 +1,7 @@
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { randomBytes, timingSafeEqual } from 'node:crypto'; import { randomBytes, timingSafeEqual } from 'node:crypto';
import { isAbsolute } from 'node:path'; import { isAbsolute } from 'node:path';
import { detectUrgentText, hasUrgentLedgerContext, urgentChatMessage } from './domain.js';
const MAX_MESSAGE_CHARS = 4000; const MAX_MESSAGE_CHARS = 4000;
const MAX_LEDGER_ENTRIES = 20; const MAX_LEDGER_ENTRIES = 20;
@ -190,6 +191,8 @@ export function createHermesAgentService({
if (!message) throw new AgentGatewayError(400, 'Write a message first.'); if (!message) throw new AgentGatewayError(400, 'Write a message first.');
if (message.length > MAX_MESSAGE_CHARS) throw new AgentGatewayError(413, 'Message is too long.'); if (message.length > MAX_MESSAGE_CHARS) throw new AgentGatewayError(413, 'Message is too long.');
const ledger = sanitizeLedger(payload.ledger || []); 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; const cutoff = now() - RATE_WINDOW_MS;
session.requests = session.requests.filter(time => time > cutoff); 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.'); if (session.requests.length >= config.maxRequestsPerMinute || session.busy) throw new AgentGatewayError(429, 'Timmy is already thinking. Try again shortly.');

View File

@ -82,6 +82,19 @@ test('HTTP gateway binds browser auth to one opaque server-side Hermes conversat
assert.match(second.reply, /Continuity confirmed/); assert.match(second.reply, /Continuity confirmed/);
assert.doesNotMatch(JSON.stringify(second), /fixture_session/); 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 }); response = await post('/api/agent/chat', { message: 'Hijack', ledger: [], sessionId: 'attacker-session' }, { cookie: browserCookie });
assert.equal(response.status, 400); assert.equal(response.status, 400);

View File

@ -5,7 +5,9 @@ import {
bucketForBristolType, bucketForBristolType,
buildTimmySummary, buildTimmySummary,
detectUrgentFlags, detectUrgentFlags,
detectUrgentText,
exportLedger, exportLedger,
hasUrgentLedgerContext,
photoQualityMessage, photoQualityMessage,
sanitizeEntry, sanitizeEntry,
} from '../src/domain.js'; } 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); 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', () => { test('Timmy summary reports patterns without clearing food or diagnosing disease', () => {
const entries = [ const entries = [
{ bristolType: 3, occurredAt: '2026-08-15T08:00:00.000Z' }, { bristolType: 3, occurredAt: '2026-08-15T08:00:00.000Z' },

View File

@ -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/); 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', () => { 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.'); const parsed = parseHermesCliOutput('\u001b[32msession_id: 20260820_abcd\u001b[0m\nA concise answer.');
assert.deepEqual(parsed, { sessionId: '20260820_abcd', reply: 'A concise answer.' }); assert.deepEqual(parsed, { sessionId: '20260820_abcd', reply: 'A concise answer.' });

View File

@ -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, /AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis/);
assert.match(demo, /Hermes Agent connected/); assert.match(demo, /Hermes Agent connected/);
assert.match(demo, /Photos stay out of chat/); 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\./); assert.match(demo, /SLEEK\. SIMPLE\.<br>HERMES-POWERED\./);
}); });

View File

@ -37,6 +37,11 @@ await page.waitForSelector('.bubble.timmy >> text=mostly Type 4');
assert.equal(chatRequest.message, 'What pattern do you see?'); assert.equal(chatRequest.message, 'What pattern do you see?');
assert.ok(Array.isArray(chatRequest.ledger)); assert.ok(Array.isArray(chatRequest.ledger));
assert.equal(JSON.stringify(chatRequest).includes('photoDataUrl'), false, 'photos never enter chat context'); 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'); 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 }); await page.screenshot({ path: 'artifacts/sleek-hermes-chat-mobile.png', fullPage: true });

View File

@ -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('#chat-message').fill('Can I eat Taco Bell?');
await page.locator('#send-chat').click(); await page.locator('#send-chat').click();
const chatText = await page.locator('#chat').innerText(); const chatText = await page.locator('#chat').innerText();
assert.match(chatText, /cannot clear a food or restaurant/i); assert.match(chatText, /pause and get medical help/i);
assert.doesNotMatch(chatText, /Taco Bell is safe/i); assert.doesNotMatch(chatText, /Taco Bell is safe|cannot clear a food or restaurant/i);
await page.waitForTimeout(500); await page.waitForTimeout(500);
assert.equal(await page.evaluate(() => window.scrollY), 0, 'reply should not push the page header/navigation out of frame'); 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 }); await page.screenshot({ path: 'artifacts/timmy-chat-mobile.png', fullPage: false });