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
2 changed files with 107 additions and 1 deletions
+35 -1
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.');