diff --git a/interview-app/public/index.html b/interview-app/public/index.html index 9b49dc9..5f6c6ca 100644 --- a/interview-app/public/index.html +++ b/interview-app/public/index.html @@ -3615,7 +3615,7 @@

Parent Supervision Required

/\b(?:cr[a@]p|crap+y)\b/gi, /\b(?:p+[i1!]+ss+)\w*\b/gi, /\b(?:d[i1!]+ck|c[o0]ck|pen[i1!]s|boner|dong)\b/gi, - /\b(?:boob|tit|titty|tits|nipple|puss+y)\w*\b/gi, + /\b(?:boob(?:s|ies)?|titty|titties|tits|nipple(?:s)?|puss+y)\w*\b/gi, /\b(?:bastard|whor+e|slut|skank)\w*\b/gi, /\b(?:n[i1!]+gg+|f+[a@]+g+|retard|spaz)\w*\b/gi, /\b(?:sex+(?:y|ual|ting)?|porn|xxx|nude|naked|horny)\b/gi, @@ -3632,6 +3632,23 @@

Parent Supervision Required

return cleaned.replace(/\s{2,}/g, ' ').replace(/\.\s*\./g, '.').replace(/,\s*,/g, ',').replace(/\s+([.,!?])/g, '$1').trim(); } +function sanitizeAiDisplayText(text) { + if (!text) return text; + // Never show build internals in chat bubbles. + let cleaned = text.replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/g, ''); + // Protect preview HTML from text sanitization so tags remain valid. + const previewBlocks = []; + cleaned = cleaned.replace(/---PREVIEW---[\s\S]*?---END_PREVIEW---/g, (match) => { + previewBlocks.push(match); + return `__PREVIEW_${previewBlocks.length - 1}__`; + }); + cleaned = sanitizeForKids(cleaned); + for (let i = 0; i < previewBlocks.length; i++) { + cleaned = cleaned.replace(`__PREVIEW_${i}__`, previewBlocks[i]); + } + return cleaned; +} + // Sanitize HTML content for iframe display (strip external images) function sanitizePreviewHtml(html) { if (!html) return html; @@ -3649,9 +3666,7 @@

Parent Supervision Required

function addMessage(role, text, imageUrl) { // Client-side safety net: scrub AI text before display if (role === 'ai') { - // Strip build spec if it somehow leaked through to the frontend - text = text.replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/g, ''); - text = sanitizeForKids(text); + text = sanitizeAiDisplayText(text); } const div = document.createElement('div'); diff --git a/interview-app/safety.js b/interview-app/safety.js index 1bd5a3f..d9622f4 100644 --- a/interview-app/safety.js +++ b/interview-app/safety.js @@ -15,7 +15,7 @@ export const UNSAFE_PATTERNS = [ [/\b(?:cr[a@]p|crap+y)\b/gi, ''], [/\b(?:p+[i1!]+ss+)\w*\b/gi, ''], [/\b(?:d[i1!]+ck|c[o0]ck|pen[i1!]s|ball?s(?:ack)?|boner|dong)\b/gi, ''], - [/\b(?:boob|tit|titty|tits|nipple|va[gj]|puss+y)\w*\b/gi, ''], + [/\b(?:boob(?:s|ies)?|titty|titties|tits|nipple(?:s)?|va[gj]|puss+y)\w*\b/gi, ''], [/\b(?:bastard|whor+e|slut|skank|ho+e)\w*\b/gi, ''], // Slurs (abbreviated patterns to avoid reproducing them fully) [/\b(?:n[i1!]+gg+|f+[a@]+g+|retard|spaz|tard)\w*\b/gi, ''], @@ -66,6 +66,23 @@ export function scrubUnsafeWords(text) { return cleaned; } +function scrubUnsafeInlineText(text) { + if (!text) return text; + let cleaned = text; + for (const [pattern, replacement] of UNSAFE_PATTERNS) { + cleaned = cleaned.replace(pattern, replacement); + pattern.lastIndex = 0; + } + return cleaned; +} + +function scrubUnsafeTextNodes(html) { + if (!html) return html; + return html.replace(/>([^<]+) { + return `>${scrubUnsafeInlineText(textNode)}<`; + }); +} + // Sanitize HTML preview content for kid safety export function sanitizePreviewHtml(html) { if (!html) return html; @@ -167,8 +184,8 @@ export function sanitizeBartResponse(text) { // Sanitize and restore preview blocks (HTML content for iframes) for (let i = 0; i < previewBlocks.length; i++) { let block = previewBlocks[i]; - // Scrub unsafe words from preview HTML text content - block = scrubUnsafeWords(block); + // Scrub only text nodes so HTML tags/attributes remain intact. + block = scrubUnsafeTextNodes(block); // Strip external image loading to prevent inappropriate AI-generated images block = sanitizePreviewHtml(block); cleaned = cleaned.replace(`__PREVIEW_${i}__`, block); diff --git a/interview-app/server.js b/interview-app/server.js index 22bf33c..38fd309 100644 --- a/interview-app/server.js +++ b/interview-app/server.js @@ -663,20 +663,54 @@ function messageText(msg) { return String(msg.content || ''); } -function extractAppNameFromConversation(messages) { - const texts = (messages || []).map(messageText); +function userMessageTexts(messages) { + return (messages || []) + .filter((m) => m?.role === 'user') + .map(messageText); +} + +function cleanIdeaText(raw) { + return String(raw || '') + // Remove preview/build payloads if they somehow appear in message history. + .replace(/---PREVIEW---[\s\S]*?---END_PREVIEW---/g, ' ') + .replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/g, ' ') + // Remove html/xml tags to avoid parsing attribute values as app names. + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizeAppNameCandidate(raw) { + const candidate = String(raw || '') + .replace(/\s+/g, ' ') + .replace(/[.,!?;:]+$/g, '') + .trim(); + if (!candidate) return null; + if (candidate.length < 3) return null; + if (/content-security-policy|http-equiv|charset|viewport|doctype/i.test(candidate)) return null; + return candidate.slice(0, 40); +} + +function extractAppNameFromConversation(messages, fallback = 'My Dream App') { + const texts = userMessageTexts(messages).map(cleanIdeaText); for (let i = texts.length - 1; i >= 0; i--) { const t = texts[i]; - const quoted = t.match(/"([^"]{3,40})"/); - if (quoted) return quoted[1].trim(); - const named = t.match(/\b(?:called|named|name is)\s+([A-Za-z0-9][A-Za-z0-9 ]{2,40})/i); - if (named) return named[1].trim(); + const quoted = t.match(/["“]([^"”]{3,40})["”]/); + if (quoted) { + const name = normalizeAppNameCandidate(quoted[1]); + if (name) return name; + } + const named = t.match(/\b(?:called|named|name is)\s+([A-Za-z0-9][A-Za-z0-9 '&_-]{2,40})/i); + if (named) { + const name = normalizeAppNameCandidate(named[1]); + if (name) return name; + } } - return 'My Dream App'; + return normalizeAppNameCandidate(fallback) || 'My Dream App'; } function extractAudienceFromConversation(messages) { - const text = (messages || []).map(messageText).join(' ').toLowerCase(); + const text = userMessageTexts(messages).map(cleanIdeaText).join(' ').toLowerCase(); if (/\bkids?\b|\bchildren\b/.test(text)) return 'kids'; if (/\bstudents?\b|\bclass\b/.test(text)) return 'students'; if (/\bparents?\b|\bfamil(y|ies)\b/.test(text)) return 'families'; @@ -687,7 +721,7 @@ function extractAudienceFromConversation(messages) { } function extractVibeFromConversation(messages) { - const text = (messages || []).map(messageText).join(' ').toLowerCase(); + const text = userMessageTexts(messages).map(cleanIdeaText).join(' ').toLowerCase(); if (/\bcalm\b|\bcozy\b|\bsoft\b|\bgentle\b/.test(text)) return 'calm and cozy'; if (/\bfast\b|\benergy\b|\bneon\b|\bparty\b|\bbold\b/.test(text)) return 'energetic and bold'; if (/\badventure\b|\bquest\b|\bexplore\b|\bspace\b|\bocean\b|\bjungle\b/.test(text)) return 'adventurous and playful'; @@ -698,6 +732,7 @@ function extractFeaturesFromConversation(messages) { const userText = (messages || []) .filter(m => m.role === 'user') .map(messageText) + .map(cleanIdeaText) .join(' ') .toLowerCase(); const ideas = []; @@ -766,7 +801,7 @@ Special Details: Keep it safe, bright, and easy to use on a phone`; } function chatWithLocalBart(userMessage, activeApp, messages) { - const appName = extractAppNameFromConversation(messages); + const appName = extractAppNameFromConversation(messages, activeApp?.name || 'My Dream App'); const audience = extractAudienceFromConversation(messages); const vibe = extractVibeFromConversation(messages); const features = extractFeaturesFromConversation(messages); diff --git a/interview-app/test.js b/interview-app/test.js index 452efa8..7706f5f 100644 --- a/interview-app/test.js +++ b/interview-app/test.js @@ -596,6 +596,15 @@ Build with APIs and HTML. const cleaned = sanitizePreviewHtml('

ok

'); assert.ok(cleaned.includes('Content-Security-Policy')); }); + + it('does not break title tags while sanitizing preview content', () => { + const raw = `---PREVIEW--- +Garden Builder

tits

+---END_PREVIEW---`; + const cleaned = sanitizeBartResponse(raw); + assert.ok(cleaned.includes('Garden Builder')); + assert.ok(!cleaned.includes('<>Garden Builder')); + }); }); // ========================================================= @@ -908,6 +917,45 @@ describe('API Endpoints (integration — requires server)', () => { assert.ok(startEntry, 'Should have a session_start entry'); assert.ok(startEntry.ts, 'session_start should have timestamp'); }); + + it('demo chat naming ignores CSP from assistant preview HTML', async (t) => { + if (skipIfNoServer()) return t.skip('Server not available'); + + const createResp = await fetch(`${BASE}/api/apps`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Demo Name Test' }), + }); + const { id } = await createResp.json(); + await fetch(`${BASE}/api/apps/${id}/activate`, { method: 'PUT' }); + + const firstMessages = [{ role: 'user', content: 'I want to build an app called Garden Buddy for kids' }]; + const firstChat = await fetch(`${BASE}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: firstMessages }), + }); + assert.equal(firstChat.status, 200); + const firstData = await firstChat.json(); + const firstAssistant = firstData.response; + assert.ok(firstAssistant.includes('---PREVIEW---')); + assert.ok(firstAssistant.includes('Content-Security-Policy')); + + const secondMessages = [ + ...firstMessages, + { role: 'assistant', content: firstAssistant }, + { role: 'user', content: 'everyone' }, + ]; + const secondChat = await fetch(`${BASE}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: secondMessages }), + }); + assert.equal(secondChat.status, 200); + const secondData = await secondChat.json(); + assert.ok(secondData.response.includes('Garden Buddy'), 'Expected extracted app name from user input'); + assert.ok(!secondData.response.includes('Content-Security-Policy!'), 'Should not treat CSP as app name'); + }); }); // =========================================================