[object Object]

← back to Slack Idea Board

Harden /api/build against terminal-spawn storms

1ad54c9ad4b73d2050864deca865ad82b1a42917 · 2026-07-23 13:00:44 -0700 · Steve Abrams

Root cause: a /5x verification run (click-every-control pass over 3 sweeps ×
real browsers) clicked every 'Build new project ▶' / 'Add new skill ▶' button,
each of which POSTs /api/build and spawns a real iTerm2 tab — opening hundreds
of terminals. The endpoint had zero guards.

Three independent guards now stand between a click and a spawn:
- server requires explicit confirm:true (no-confirm = preview only) so any
  automated click-through no longer spawns anything
- per-(id|step) cooldown dedupe drops rapid repeats
- global rolling-window cap (default 5/30s) is a hard circuit breaker
- frontend adds a native confirm() dialog (defeats automated click-through) and
  a durable in-flight lock that survives re-render (DOM disabled did not)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 1ad54c9ad4b73d2050864deca865ad82b1a42917
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 23 13:00:44 2026 -0700

    Harden /api/build against terminal-spawn storms
    
    Root cause: a /5x verification run (click-every-control pass over 3 sweeps ×
    real browsers) clicked every 'Build new project ▶' / 'Add new skill ▶' button,
    each of which POSTs /api/build and spawns a real iTerm2 tab — opening hundreds
    of terminals. The endpoint had zero guards.
    
    Three independent guards now stand between a click and a spawn:
    - server requires explicit confirm:true (no-confirm = preview only) so any
      automated click-through no longer spawns anything
    - per-(id|step) cooldown dedupe drops rapid repeats
    - global rolling-window cap (default 5/30s) is a hard circuit breaker
    - frontend adds a native confirm() dialog (defeats automated click-through) and
      a durable in-flight lock that survives re-render (DOM disabled did not)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/index.html | 15 +++++++++++++--
 server.js         | 41 +++++++++++++++++++++++++++++++++++++++--
 2 files changed, 52 insertions(+), 4 deletions(-)

diff --git a/public/index.html b/public/index.html
index 527f193..edaa6b7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -128,15 +128,26 @@ async function copyAction(btn){
   else { toast('Could not access clipboard — command:<br><code>'+esc(cmd)+'</code>'); }
 }
 // Actually LAUNCH a Claude session in a new terminal via the backend.
+// Guarded three ways so a click-storm (e.g. a "click every control" verification pass) can't open
+// hundreds of terminals: (1) a native confirm() — automated click-through agents don't accept it,
+// so they never reach the spawn; (2) a durable module-level in-flight lock keyed by id|step that
+// survives re-render (the per-button `disabled` flag does not — render() replaces the node); (3) the
+// POST carries confirm:true, which the server now requires before it will spawn anything.
+const INFLIGHT = new Set();
 async function launchAction(id, step, label, btn){
+  const key=(id||'')+'|'+step;
+  if(INFLIGHT.has(key)) return; // a launch for this id+step is already running
+  if(!confirm(`Launch a REAL Claude Code session in a new iTerm2 tab?\n\n${label||step}`)) return;
+  INFLIGHT.add(key);
   if(btn){ btn.classList.add('copied'); var prev=btn.textContent; btn.textContent='launching…'; btn.disabled=true; }
   try{
-    const r=await fetch('/api/build',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,step})});
+    const r=await fetch('/api/build',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,step,confirm:true})});
     const j=await r.json();
     if(j.launched){ toast(`🚀 <b>${esc(label||step)}</b> launched in a new iTerm2 tab<br><code>${esc((j.prompt||'').slice(0,160))}…</code>`); }
+    else if(j.throttled){ toast('⏳ Throttled — '+esc(j.reason||'too many launches, try again shortly')); }
     else { toast('Launch failed: '+esc(j.error||'unknown')); }
   }catch(err){ toast('Launch failed: '+esc(String(err.message||err))); }
-  finally{ if(btn){ btn.classList.remove('copied'); btn.textContent=prev; btn.disabled=false; } }
+  finally{ INFLIGHT.delete(key); if(btn && btn.isConnected){ btn.classList.remove('copied'); btn.textContent=prev; btn.disabled=false; } }
 }
 // Wire clicks via delegation so re-renders never lose handlers.
 // Click = launch a real session · Shift-click = copy the command to paste manually.
diff --git a/server.js b/server.js
index 638ca46..e201be0 100644
--- a/server.js
+++ b/server.js
@@ -136,6 +136,21 @@ function canon(u) {
   } catch { return (u || '').toLowerCase(); }
 }
 
+// --- /api/build spawn guards ---------------------------------------------------------------
+// A real /api/build call opens an iTerm2 tab running `claude --model opus`. That side effect is
+// irreversible and unbounded, so it MUST be storm-proof: on 2026-07-23 a single `/5x` verification
+// run (a "click every control" pass across 3 sweeps × real browsers) clicked ~34 spawn buttons per
+// sweep and opened hundreds of terminals. Three independent guards now stand between a click and a
+// spawn: (1) the server requires an explicit human `confirm:true` — a plain/automated POST only gets
+// a preview, never a spawn; (2) a per-(id|step) cooldown dedupes rapid repeats; (3) a global
+// rolling-window cap is a hard circuit breaker against any looping caller.
+const BUILD_INFLIGHT = new Set();  // (id|step) keys currently spawning
+const BUILD_LAST = new Map();      // (id|step) -> last real-launch epoch-ms (cooldown)
+let BUILD_TIMES = [];              // recent real-launch timestamps (rolling window)
+const BUILD_COOLDOWN_MS   = parseInt(process.env.BUILD_COOLDOWN_MS   || '5000',  10); // same idea can't relaunch within 5s
+const BUILD_WINDOW_MS     = parseInt(process.env.BUILD_WINDOW_MS     || '30000', 10); // rolling window size
+const BUILD_MAX_PER_WINDOW= parseInt(process.env.BUILD_MAX_PER_WINDOW|| '5',     10); // max real spawns per window
+
 let ENRICH_BUSY = false;
 const CONCURRENCY = parseInt(process.env.ENRICH_CONCURRENCY || '4', 10); // parallel local-model calls
 async function enrichOne(idea) {
@@ -209,8 +224,30 @@ const server = http.createServer(async (req, res) => {
       if (j.id) { try { idea = (await collectIdeas()).find(i => i.id === j.id) || null; } catch {} }
       const prompt = buildPrompt(idea, step);
       const cwd = /skill|agent/i.test(step) ? os.homedir() : ACCEL_DIR;
-      if (j.dryRun) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ launched: false, dryRun: true, step, cwd, prompt })); }
-      const r = await launchClaude(prompt, cwd);
+      // Guard 1 — NEVER spawn without an explicit human confirm. dryRun or a request that hasn't set
+      // confirm:true returns a preview only. This alone defeats any automated "click every control"
+      // pass (CTA/3x/5x), which POSTs without confirm and so can no longer open a single terminal.
+      if (j.dryRun || !j.confirm) {
+        res.writeHead(200, { 'Content-Type': 'application/json' });
+        return res.end(JSON.stringify({ launched: false, dryRun: !!j.dryRun, needsConfirm: !j.dryRun, step, cwd, prompt }));
+      }
+      const key = (j.id || '') + '|' + step;
+      const now = Date.now();
+      // Guard 2 — per-idea in-flight + cooldown dedupe: drop a repeat of the same idea within the cooldown.
+      if (BUILD_INFLIGHT.has(key) || (BUILD_LAST.get(key) && now - BUILD_LAST.get(key) < BUILD_COOLDOWN_MS)) {
+        res.writeHead(429, { 'Content-Type': 'application/json' });
+        return res.end(JSON.stringify({ launched: false, throttled: true, reason: 'duplicate or within cooldown', step }));
+      }
+      // Guard 3 — global rolling-window circuit breaker: cap total real spawns regardless of caller.
+      BUILD_TIMES = BUILD_TIMES.filter(t => now - t < BUILD_WINDOW_MS);
+      if (BUILD_TIMES.length >= BUILD_MAX_PER_WINDOW) {
+        res.writeHead(429, { 'Content-Type': 'application/json' });
+        return res.end(JSON.stringify({ launched: false, throttled: true, reason: `rate limit: max ${BUILD_MAX_PER_WINDOW} launches / ${BUILD_WINDOW_MS / 1000}s`, step }));
+      }
+      BUILD_INFLIGHT.add(key); BUILD_TIMES.push(now);
+      let r;
+      try { r = await launchClaude(prompt, cwd); }
+      finally { BUILD_INFLIGHT.delete(key); BUILD_LAST.set(key, Date.now()); }
       res.writeHead(r.ok ? 200 : 500, { 'Content-Type': 'application/json' });
       return res.end(JSON.stringify({ launched: r.ok, step, cwd, prompt, error: r.ok ? undefined : (r.err || 'osascript failed') }));
     }

← c25818f auto-save: 2026-07-23T12:51:13 (3 files) — 5x/sweep-2.md 5x/  ·  back to Slack Idea Board  ·  gate ideas.agentabrams.com by default (admin/DW2024!), was p c788fcd →