feat(extension): route gstackInjectToTerminal through /pty-inject-scan (#1370)

Closes the documented-vs-shipped gap codex flagged in #1370. The
sidebar's two PTY-injection call sites (Inspector "Send to Code" and
toolbar Cleanup) now pre-scan via the new /pty-inject-scan endpoint
before writing to the live claude REPL.

Adds window.gstackScanForPTYInject(text, origin) to
extension/sidepanel-terminal.js:
- Async, returns { allow, verdict, reasons, l4 }
- POST to /pty-inject-scan with the existing root-token auth
- WARN+confirm on scan failure (network down, sidecar absent, etc.)
  rather than silent PASS — D7 honest-degradation

gstackInjectToTerminal stays synchronous, returns boolean. Per D6:
keeping the inject sync means existing `const ok = ...?.()` callers
don't break, and the invariant test in
test/extension-pty-inject-invariant.test.ts can statically pin that
every call goes through the scan first.

extension/sidepanel.js call sites updated:
- inspectorSendBtn click → await scan, BLOCK drops + WARN prompts via
  window.confirm, PASS injects silently
- runCleanup() → same flow. Static cleanup prompt always PASSes but
  still routes through scan to honor the invariant.

C20 of the security-stack wave. C21 adds the static invariant test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-05-18 21:44:22 -07:00
parent 33d6eae996
commit 9a643dc17d
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 107 additions and 1 deletions

View File

@ -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);

View File

@ -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.');