Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions interview-app/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3615,7 +3615,7 @@ <h3 id="parent-gate-title">Parent Supervision Required</h3>
/\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,
Expand All @@ -3632,6 +3632,23 @@ <h3 id="parent-gate-title">Parent Supervision Required</h3>
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;
Expand All @@ -3649,9 +3666,7 @@ <h3 id="parent-gate-title">Parent Supervision Required</h3>
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');
Expand Down
23 changes: 20 additions & 3 deletions interview-app/safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, ''],
Expand Down Expand Up @@ -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(/>([^<]+)</g, (match, textNode) => {
return `>${scrubUnsafeInlineText(textNode)}<`;
});
}

// Sanitize HTML preview content for kid safety
export function sanitizePreviewHtml(html) {
if (!html) return html;
Expand Down Expand Up @@ -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);
Expand Down
55 changes: 45 additions & 10 deletions interview-app/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -698,6 +732,7 @@ function extractFeaturesFromConversation(messages) {
const userText = (messages || [])
.filter(m => m.role === 'user')
.map(messageText)
.map(cleanIdeaText)
.join(' ')
.toLowerCase();
const ideas = [];
Expand Down Expand Up @@ -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);
Expand Down
48 changes: 48 additions & 0 deletions interview-app/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,15 @@ Build with APIs and HTML.
const cleaned = sanitizePreviewHtml('<html><head></head><body><p>ok</p></body></html>');
assert.ok(cleaned.includes('Content-Security-Policy'));
});

it('does not break title tags while sanitizing preview content', () => {
const raw = `---PREVIEW---
<!doctype html><html><head><title>Garden Builder</title></head><body><p>tits</p></body></html>
---END_PREVIEW---`;
const cleaned = sanitizeBartResponse(raw);
assert.ok(cleaned.includes('<title>Garden Builder</title>'));
assert.ok(!cleaned.includes('<>Garden Builder</>'));
});
});

// =========================================================
Expand Down Expand Up @@ -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');
});
});

// =========================================================
Expand Down