diff --git a/extension/sidepanel-terminal.js b/extension/sidepanel-terminal.js index dc3a0cd75..4ac0065d0 100644 --- a/extension/sidepanel-terminal.js +++ b/extension/sidepanel-terminal.js @@ -226,6 +226,18 @@ * Used by the toolbar's Cleanup button and the Inspector's "Send to Code" * action so the user can drive claude from outside-the-keyboard surfaces. * Returns true if the bytes went out, false if no live session. + * + * IMPORTANT (D6): this function stays SYNCHRONOUS and SCAN-FREE. Page- + * derived input MUST be pre-scanned via window.gstackScanForPTYInject() + * before calling this. The invariant test in + * test/extension-pty-inject-invariant.test.ts fails the build if any + * extension/*.js path calls this without the preceding scan. + * + * Why not move the scan inside this function: callers already use the + * sync `const ok = gstackInjectToTerminal?.(text)` pattern. Making the + * inject async would turn `ok` into a Promise and silently break every + * existing call site. Pre-scanning at the caller keeps the boundary + * clean and the invariant testable. */ window.gstackInjectToTerminal = function (text) { if (!text || !ws || ws.readyState !== WebSocket.OPEN) return false; @@ -237,6 +249,66 @@ } }; + /** + * Scan page-derived text via the browse server's /pty-inject-scan + * endpoint before injecting it into the PTY. Returns: + * { allow: true, verdict: "PASS" } → safe to inject + * { allow: true, verdict: "WARN", reasons: [...] } → caller should + * prompt the user before injecting + * { allow: false, verdict: "BLOCK", reasons: [...]} → drop the text; + * caller should surface a banner to the user + * + * On any network / endpoint failure: returns + * { allow: true, verdict: "WARN", reasons: ["scan-unreachable"] } + * so the caller falls back to WARN+confirm rather than silent PASS. + * + * Closes #1370. + */ + window.gstackScanForPTYInject = async function (text, origin) { + if (!text) return { allow: false, verdict: 'BLOCK', reasons: ['empty-text'] }; + try { + const resp = await fetch('http://127.0.0.1:34567/pty-inject-scan', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${await getAuthTokenForScan()}`, + }, + body: JSON.stringify({ text, origin: origin || 'extension' }), + }); + if (!resp.ok) { + return { allow: true, verdict: 'WARN', reasons: [`scan-http-${resp.status}`] }; + } + const body = await resp.json(); + const verdict = body.verdict || 'WARN'; + const allow = verdict !== 'BLOCK'; + return { allow, verdict, reasons: body.reasons || [], l4: body.l4 }; + } catch (err) { + return { + allow: true, + verdict: 'WARN', + reasons: ['scan-unreachable', err && err.message ? err.message : 'fetch-failed'], + }; + } + }; + + // The auth token for /pty-inject-scan comes from the same source the + // sidepanel uses for /pty-session — a runtime fetch from /health (which + // already returns AUTH_TOKEN in headed mode per CLAUDE.md's v1.1 TODO). + // We don't echo the token here; this helper is a thin proxy around the + // existing pattern. + async function getAuthTokenForScan() { + if (window.__gstackPtyScanToken) return window.__gstackPtyScanToken; + try { + const resp = await fetch('http://127.0.0.1:34567/health'); + const body = await resp.json(); + const token = body.AUTH_TOKEN || body.authToken || ''; + if (token) window.__gstackPtyScanToken = token; + return token; + } catch { + return ''; + } + } + async function connect() { if (state !== STATE.IDLE) return; // already connecting/live setState(STATE.CONNECTING); diff --git a/extension/sidepanel.js b/extension/sidepanel.js index 8d216a10a..6328d7c51 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -683,7 +683,7 @@ function updateSendButton() { } } -inspectorSendBtn.addEventListener('click', () => { +inspectorSendBtn.addEventListener('click', async () => { if (!inspectorData) return; let message; @@ -708,6 +708,20 @@ inspectorSendBtn.addEventListener('click', () => { // Inject into the running claude PTY so the user can ask claude to act // on the inspector data. Replaces the old `sidebar-command` route which // spawned a one-shot claude -p (sidebar-agent.ts is gone). + // + // Pre-scan via /pty-inject-scan before injection (D6, closes #1370). + // gstackScanForPTYInject is async; gstackInjectToTerminal stays sync. + const verdict = await window.gstackScanForPTYInject?.(message + '\n', 'inspector-send'); + if (verdict?.verdict === 'BLOCK') { + console.warn('[gstack sidebar] Inspector send BLOCKED by /pty-inject-scan:', verdict.reasons); + return; + } + if (verdict?.verdict === 'WARN') { + const confirmed = window.confirm( + `Inspector send flagged as suspicious (${(verdict.reasons || []).join(', ')}). Inject anyway?`, + ); + if (!confirmed) return; + } const ok = window.gstackInjectToTerminal?.(message + '\n'); if (!ok) { console.warn('[gstack sidebar] Inspector send needs an active Terminal session.'); @@ -735,6 +749,26 @@ async function runCleanup(...buttons) { 'header/masthead, headline, article body, images, byline, and date. Also', 'unlock scrolling if the page is scroll-locked.', ].join('\n'); + // Pre-scan via /pty-inject-scan before injection (D6, closes #1370). + // The cleanup prompt is a STATIC template (no page-derived content), so + // it will always PASS, but we still route it through the scan path so + // the invariant test in test/extension-pty-inject-invariant.test.ts + // confirms every call site goes through gstackScanForPTYInject first. + const verdict = await window.gstackScanForPTYInject?.(cleanupPrompt + '\n', 'cleanup-button'); + if (verdict?.verdict === 'BLOCK') { + console.warn('[gstack sidebar] Cleanup BLOCKED by /pty-inject-scan:', verdict.reasons); + setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 200); + return; + } + if (verdict?.verdict === 'WARN') { + const confirmed = window.confirm( + `Cleanup flagged as suspicious (${(verdict.reasons || []).join(', ')}). Inject anyway?`, + ); + if (!confirmed) { + setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 200); + return; + } + } const sent = window.gstackInjectToTerminal?.(cleanupPrompt + '\n'); if (!sent) { console.warn('[gstack sidebar] Cleanup needs an active Terminal session.');