docs: scrub the sidebar-agent ghost from comments and CLAUDE.md

20+ comments across 10 files still described the deleted sidebar-agent.ts as a
live process — including load-bearing architecture claims ('IMPORTED ONLY BY
sidebar-agent.ts', 'sidebar-agent fills this in on first prompt-injection
load', 'kill sidebar-agent' in shutdown docs) and ~60 lines of tombstone
blocks in server.ts enumerating deleted identifiers by name (a false grep
surface: searching processAgentEvent hit server.ts and looked live).

CLAUDE.md's security-stack section now documents the LIVE architecture: L1-L3
content filters + testsavant via the security sidecar subprocess; the
L4b/ensemble rows, the GSTACK_SECURITY_ENSEMBLE knob, and the 721MB DeBERTa
download are gone (deleted as dead code this wave) with an explicit
do-not-re-document note; attempts.jsonl is correctly attributed to
tunnel-denial-log.ts; the no-live-writer status of classifierStatus is stated.

Comments that survive now describe what IS, not what WAS: the promotion gate
in domain-skills.ts explains why classifier_score>0 is load-bearing given no
L4 load-time scan exists; file-permissions.ts names real sensitive files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-08-14 19:12:01 -07:00
parent 4328748136
commit d21f11af5d
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
10 changed files with 55 additions and 100 deletions

View File

@ -391,47 +391,39 @@ every `git pull`.
| Layer | Module | Lives in |
|-------|--------|----------|
| L1-L3 | `content-security.ts` | both server and agent — datamarking, hidden element strip, ARIA regex, URL blocklist, envelope wrapping |
| L4 | `security-classifier.ts` (TestSavantAI ONNX) | **sidebar-agent only** |
| L4b | `security-classifier.ts` (Claude Haiku transcript) | **sidebar-agent only** |
| L5 | `security.ts` (canary) | both — inject in compiled, check in agent |
| L6 | `security.ts` (combineVerdict ensemble) | both |
| L1-L3 | `content-security.ts` | server + read path — datamarking, hidden element strip, ARIA regex, URL blocklist, envelope wrapping |
| L4 | `security-classifier.ts` (TestSavantAI ONNX) | **security sidecar subprocess only** (`security-sidecar-entry.ts`, driven by `security-sidecar-client.ts` from server.ts) |
| Canary | `security.ts` (generate/inject/detect) | pure utilities — no production injector today (the chat prompt-builder that injected them was ripped) |
| Combiner | `security.ts` (combineVerdict + THRESHOLDS) | pure, tested; retains transcript/deberta vote handling for LayerSignal inputs no live layer produces anymore |
History note: an L4b Haiku transcript classifier and an opt-in DeBERTa ensemble
(`GSTACK_SECURITY_ENSEMBLE=deberta`) existed until the chat-path agent that
invoked them was ripped; both were deleted as dead code (zero production
callers). Do not re-document them as live.
**Critical constraint:** `security-classifier.ts` CANNOT be imported from the
compiled browse binary. `@huggingface/transformers` v4 requires `onnxruntime-node`
which fails to `dlopen` from Bun compile's temp extract dir. Only `security.ts`
(pure-string operations — canary, verdict combiner, attack log, status) is safe
for `server.ts`. See `~/.gstack/projects/garrytan-gstack/ceo-plans/2026-04-19-prompt-injection-guard.md`
§"Pre-Impl Gate 1 Outcome" for full architectural decision.
which fails to `dlopen` from Bun compile's temp extract dir — hence the sidecar
subprocess. Only `security.ts` (pure-string operations — canary utilities,
verdict combiner, status) is safe for `server.ts`. See
`~/.gstack/projects/garrytan-gstack/ceo-plans/2026-04-19-prompt-injection-guard.md`
§"Pre-Impl Gate 1 Outcome" for the original architectural decision.
**Thresholds** (in `security.ts`):
- `BLOCK: 0.85` — single-layer score that would cause BLOCK if cross-confirmed
- `WARN: 0.75` — cross-confirm threshold. When L4 AND L4b both >= 0.75 → BLOCK
- `LOG_ONLY: 0.40` — gates transcript classifier (skip Haiku when all layers < 0.40)
- `SOLO_CONTENT_BLOCK: 0.92` — single-layer threshold for label-less content classifiers
(testsavant, deberta). Intentionally higher than `BLOCK` because these layers can't
distinguish "this is an injection" from "this looks like phishing aimed at the user."
The transcript classifier keeps a separate, label-gated solo path at `BLOCK` (0.85).
**Ensemble rule:** BLOCK only when the ML content classifier AND the transcript
classifier both report >= WARN. Single-layer high confidence degrades to WARN —
this is the Stack Overflow instruction-writing FP mitigation. Canary leak
always BLOCKs (deterministic).
**Thresholds** (in `security.ts`): `BLOCK: 0.85`, `WARN: 0.75`, `LOG_ONLY: 0.40`,
`SOLO_CONTENT_BLOCK: 0.92` (label-less content classifiers can't distinguish
"injection" from "phishing aimed at the user", so their solo bar is higher).
The live L4 path applies these in server.ts's sidecar-scan handling; canary
leak always BLOCKs (deterministic).
**Env knobs:**
- `GSTACK_SECURITY_OFF=1` — emergency kill switch. Classifier stays off even if
warmed. Canary is still injected; just the ML scan is skipped.
- `GSTACK_SECURITY_ENSEMBLE=deberta` — opt-in DeBERTa-v3 ensemble. Adds
ProtectAI DeBERTa-v3-base-injection-onnx as L4c classifier for cross-model
agreement. 721MB first-run download. With ensemble enabled, BLOCK requires
2-of-3 ML classifiers agreeing at >= WARN (testsavant, deberta, transcript).
Without ensemble (default), BLOCK requires testsavant + transcript at >= WARN.
warmed; the L1-L3 filters keep running.
- Classifier model cache: `~/.gstack/models/testsavant-small/` (112MB, first run only)
plus `~/.gstack/models/deberta-v3-injection/` (721MB, only when ensemble enabled)
- Attack log: `~/.gstack/security/attempts.jsonl` (salted sha256 + domain only,
rotates at 10MB, 5 generations)
- Per-device salt: `~/.gstack/security/device-salt` (0600)
- Session state: `~/.gstack/security/session-state.json` (cross-process, atomic)
- Attack log: `~/.gstack/security/attempts.jsonl` — written by
`tunnel-denial-log.ts` (tunnel-surface rejections; rotates at 10MB, 5 generations)
- Session state: `~/.gstack/security/session-state.json` (cross-process, atomic;
NOTE: classifierStatus currently has no live writer — shield status derives
from what's on disk)
## Dev symlink awareness

View File

@ -244,7 +244,7 @@ export class BrowserManager {
// Called when the headed browser disconnects without intentional teardown
// (user closed the window). Wired up by server.ts to run full cleanup
// (sidebar-agent, state file, profile locks) before exiting with code 2.
// (terminal agent, state file, profile locks) before exiting with code 2.
// Returns void or a Promise; rejections are caught and fall back to exit(2).
// `exitCode` is the resolved process exit code from the disconnect cause:
// 0 on clean user-initiated quit (e.g., Cmd+Q on headed Chromium), 2 on
@ -680,7 +680,7 @@ export class BrowserManager {
// restart loop. Crash → process.exit(2) preserves the legacy headed
// semantics that's distinct from launch()'s code 1.
// Always calls onDisconnect() first to trigger full shutdown (kill
// sidebar-agent, save session, clean profile locks + state file) so
// terminal agent, save session, clean profile locks + state file) so
// crashes don't strand resources either.
if (this.browser) {
this.browser.on('disconnected', () => {

View File

@ -3,8 +3,8 @@
*
* Output for trusted methods is a plain JSON pretty-print.
* Output for untrusted methods is wrapped with the centralized UNTRUSTED EXTERNAL
* CONTENT envelope so the sidebar-agent classifier sees it (matches the pattern
* used by other untrusted-content commands in commands.ts).
* CONTENT envelope so downstream consumers treat it as data, not instructions
* (matches the pattern used by other untrusted-content commands in commands.ts).
*/
import type { BrowserManager } from './browser-manager';

View File

@ -442,8 +442,8 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
return state;
}
// BROWSE_NO_AUTOSTART: sidebar agent sets this so the child claude never
// spawns an invisible headless browser. If the headed server is down,
// BROWSE_NO_AUTOSTART: agent-spawned children (e.g. the terminal-agent PTY
// claude) set this so a child never spawns an invisible headless browser. If the headed server is down,
// fail fast with a clear error instead of silently starting a new one.
if (process.env.BROWSE_NO_AUTOSTART === '1') {
console.error('[browse] Server not available and BROWSE_NO_AUTOSTART is set.');
@ -527,7 +527,7 @@ export function extractTabId(args: string[]): { tabId: number | undefined; args:
async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise<void> {
// Precedence: CLI --tab-id flag > BROWSE_TAB env var.
// make-pdf always passes --tab-id; human users typically rely on BROWSE_TAB
// (set by sidebar-agent per-tab) or the active tab.
// or the active tab.
const extracted = extractTabId(args);
args = extracted.args;
const envTab = process.env.BROWSE_TAB;
@ -1116,10 +1116,6 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
console.log('(If you still don\'t see it, check Mission Control / other Spaces.)');
}
// sidebar-agent.ts spawn was here. Ripped alongside the chat queue —
// the Terminal pane runs an interactive PTY now, no more one-shot
// claude -p subprocesses to multiplex.
// Auto-start terminal agent (non-compiled bun process). Owns the PTY
// WebSocket for the sidebar Terminal pane. Routes through the shared
// spawnTerminalAgent helper so the CLI cold-start path and the

View File

@ -14,8 +14,9 @@
* - host is ALWAYS derived from the active tab's top-level origin (T3
* confused-deputy fix). Never accepted as an arg.
* - Save-time security uses content-security.ts L1-L3 filters (importable
* from the compiled binary, unlike the L4 ML classifier). The full L4
* scan happens in sidebar-agent.ts when the skill is loaded into a prompt.
* from the compiled binary, unlike the L4 ML classifier). There is NO
* load-time L4 scan today it died with the chat path; the
* classifier_score>0 promotion gate in domain-skills.ts compensates.
* - Output is structured: every success/error includes problem + cause +
* suggested-action. Matches the gstack house style.
*
@ -117,8 +118,8 @@ async function handleSave(args: string[], bm: BrowserManager): Promise<string> {
);
}
// L1-L3 content filters (datamarking, hidden-element strip, ARIA regex,
// URL blocklist). The full L4 ML classifier runs at sidebar-agent prompt
// injection time, not here (CLAUDE.md: classifier can't import in compiled binary).
// URL blocklist). No L4 ML scan here — the classifier can't import in the
// compiled binary, and the load-time scan path no longer exists.
const filterResult = runContentFilters(body, page.url(), 'domain-skill-save');
if (filterResult.blocked) {
logTelemetry({ event: 'domain_skill_save_blocked', host, reason: filterResult.message });
@ -128,9 +129,9 @@ async function handleSave(args: string[], bm: BrowserManager): Promise<string> {
'Action: review the body for suspicious instruction-like content; rewrite and retry.'
);
}
// L1-L3 score is binary (passed or not). For the L4 score field we leave 0
// (meaning "not yet scanned by ML classifier") — sidebar-agent fills this
// in on first prompt-injection load.
// L1-L3 score is binary (passed or not). The L4 score field stays 0
// ("never ML-scanned") — nothing fills it in today, which is exactly why
// the promotion gate in domain-skills.ts requires classifier_score > 0.
const slug = getCurrentProjectSlug();
const row = await writeSkill({
host,

View File

@ -287,7 +287,8 @@ export async function writeSkill(input: WriteSkillInput): Promise<DomainSkillRow
/**
* Promote a quarantined skill to active in its project after N=3 uses without
* classifier flagging. Called by sidebar-agent on successful skill use.
* classifier flagging. No production caller today the chat-path agent that
* invoked it on successful skill use was ripped with the chat queue.
*
* Auto-promote logic:
* - increment use_count
@ -296,11 +297,10 @@ export async function writeSkill(input: WriteSkillInput): Promise<DomainSkillRow
* - else stay quarantined with updated counter; user must run
* `domain-skill promote-to-global` manually
*
* The classifier_score > 0 gate is load-bearing: handleSave currently writes
* classifier_score=0 with the comment "L4 deferred to load-time / sidebar-agent
* fills this in on first prompt-injection load," but sidebar-agent was ripped
* (CLAUDE.md "Sidebar architecture") and nothing else updates the score, so
* skills authored via the production path never had their body scanned by L4.
* The classifier_score > 0 gate is load-bearing: handleSave writes
* classifier_score=0 (meaning "never ML-scanned"), and NOTHING updates the
* score today the load-time L4 scan died with the chat path, so skills
* authored via the production path never had their body scanned by L4.
* Without this gate, three benign uses promote any quarantined skill including
* one written under the influence of a poisoned page into the prompt context
* for every subsequent visit. The gate re-opens automatically the day L4 is

View File

@ -4,8 +4,8 @@
* Why this exists
* ----------------
* POSIX mode bits (`0o600` for files, `0o700` for dirs) are how gstack marks
* sensitive state files auth tokens, canary tokens, chat history, agent
* queue, device salt, per-tab security decisions. On Linux and macOS,
* sensitive state files auth tokens, PTY session state, tab context. On
* Linux and macOS,
* `fs.chmodSync(path, 0o600)` and `fs.writeFileSync(path, data, { mode: 0o600 })`
* do exactly what you'd hope: the file ends up readable and writable only
* by the owning user, no access for group / other.

View File

@ -496,10 +496,6 @@ function isRootRequest(req: Request): boolean {
return token !== null && isRootToken(token);
}
// Sidebar model router was here (sonnet vs opus by message intent). Ripped
// alongside the chat queue; the interactive PTY just runs whatever model
// the user's `claude` CLI is configured with.
// ─── Help text (auto-generated from COMMAND_DESCRIPTIONS) ────────
function generateHelpText(): string {
// Group commands by category
@ -572,15 +568,6 @@ function tmpStatePath(): string {
// ─── Sidebar agent / chat state ripped ──────────────────────────────
// ChatEntry, SidebarSession, TabAgentState interfaces; chatBuffer,
// chatBuffers, sidebarSession, agentProcess, agentStatus, agentStartTime,
// agentTabId, messageQueue, currentMessage, tabAgents; addChatEntry,
// loadSession, createSession, persistSession, processAgentEvent,
// killAgent, listSessions, getTabAgent, getTabAgentStatus, and the
// agentHealthInterval all lived here. Replaced by the live PTY in
// terminal-agent.ts; chat queue + per-tab agent multiplexing are no
// longer needed.
let lastConsoleFlushed = 0;
let lastNetworkFlushed = 0;
let lastDialogFlushed = 0;
@ -779,7 +766,7 @@ const browserManager = new BrowserManager();
// short-circuits idle-shutdown.
let activeBrowserManager: BrowserManager = browserManager;
// When the user closes the headed browser window, run full cleanup
// (kill sidebar-agent, save session, remove profile locks, delete state file)
// (kill terminal agent, save session, remove profile locks, delete state file)
// before exiting. Exit code 0 means user-initiated clean quit (Cmd+Q on
// macOS) so process supervisors like gbrowser's gbd skip the restart loop;
// 2 means a real crash that should respawn. The fallback `?? 2` preserves
@ -1031,7 +1018,7 @@ async function handleCommandInternalImpl(
if (!opts?.skipRateCheck && tokenInfo.token) recordCommand(tokenInfo.token);
}
// Pin to a specific tab if requested (set by BROWSE_TAB env var in sidebar agents).
// Pin to a specific tab if requested (set by BROWSE_TAB env var, e.g. per-tab agent contexts).
// This prevents parallel agents from interfering with each other's tab context.
// Safe because Bun's event loop is single-threaded — no concurrent handleCommand.
let savedTabId: number | null = null;
@ -1833,9 +1820,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
tabs: browserManager.getTabCount(),
// Security module status — drives the shield icon in the sidepanel.
// Returns {status: 'protected'|'degraded'|'inactive', layers: {...}}.
// The chat-path classifier no longer feeds this since
// sidebar-agent.ts was ripped; only the page-content side
// (canary, content-security) keeps reporting in.
// Fed by the page-content side (testsavant sidecar, canary state).
security: getSecurityStatus(),
// Terminal-agent discovery. ONLY a port number — never a token.
// Tokens flow via the /pty-session HttpOnly cookie path. See
@ -2559,15 +2544,6 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
}
// ─── Sidebar chat endpoints ripped ──────────────────────────────
// /sidebar-tabs, /sidebar-tabs/switch, /sidebar-chat[/clear],
// /sidebar-command, /sidebar-agent/{event,kill,stop},
// /sidebar-queue/dismiss, /sidebar-session{,/new,/list} all lived
// here. They drove the one-shot claude -p chat queue. Replaced by
// the interactive PTY in terminal-agent.ts; the queue + browser-tab
// multiplexing are no longer needed.
// ─── Batch endpoint — N commands, 1 HTTP round-trip ─────────────
// Accepts both root AND scoped tokens (same as /command).
// Executes commands sequentially through the full security pipeline.
@ -3110,11 +3086,6 @@ export async function start() {
console.log(`[browse] State file: ${config.stateFile}`);
console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`);
// initSidebarSession() ripped alongside the chat queue (it loaded
// chat.jsonl into memory and started the agent-health watchdog —
// both functions are gone). The Terminal pane manages its own state
// directly via terminal-agent.ts.
// ─── Tunnel startup (optional) ────────────────────────────────
// Start ngrok tunnel if BROWSE_TUNNEL=1 is set. Uses the dual-listener
// pattern: bind a dedicated tunnel listener on an ephemeral port and

View File

@ -3,8 +3,8 @@
* sidebar. Translates the phoenix gbrowser PTY (cmd/gbd/terminal.go) into
* Bun, with a few changes informed by codex's outside-voice review:
*
* - Lives in a separate non-compiled bun process from sidebar-agent.ts so
* a bug in WS framing or PTY cleanup can't take down the chat path.
* - Lives in a separate non-compiled bun process from the browse daemon so
* a bug in WS framing or PTY cleanup can't take down the command surface.
* - Binds 127.0.0.1 only never on the dual-listener tunnel surface.
* - Origin validation on the WS upgrade is REQUIRED (not defense-in-depth)
* because a localhost shell WS is a real cross-site WebSocket-hijacking

View File

@ -2,8 +2,7 @@
* gstack browse Side Panel
*
* Terminal pane (default): live claude PTY via xterm.js, driven by
* sidepanel-terminal.js. The chat queue + sidebar-agent.ts were ripped
* in favor of the interactive REPL no more one-shot claude -p.
* sidepanel-terminal.js.
*
* Debug tabs (behind the `debug` toggle): activity feed (SSE) + refs +
* inspector. Quick-actions toolbar (Cleanup / Screenshot / Cookies)
@ -994,8 +993,7 @@ inspectorSendBtn.addEventListener('click', async () => {
}
// 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).
// on the inspector data.
//
// Pre-scan via /pty-inject-scan before injection (D6, closes #1370).
// gstackScanForPTYInject is async; gstackInjectToTerminal stays sync.
@ -1022,9 +1020,6 @@ inspectorSendBtn.addEventListener('click', async () => {
* "Cleanup" injects a prompt into the running claude PTY. claude takes the
* prompt, snapshots the page, hides ads/banners/popups, leaves article
* content. The user watches it happen in the Terminal pane.
*
* Replaced the old chat-queue path (sidebar-agent.ts spawning a one-shot
* claude -p) we have a live REPL now, so route through that instead.
*/
async function runCleanup(...buttons) {
buttons.forEach(b => b?.classList.add('loading'));