Commit Graph

6 Commits

Author SHA1 Message Date
Garry Tan a487205605 feat(security): decision file primitives for human-in-the-loop review
Adds writeDecision/readDecision/clearDecision around
~/.gstack/security/decisions/tab-<id>.json plus excerptForReview() for
safe UI display of tool output. Also extends Verdict with
'user_overrode' so attack-log audit trails distinguish genuine blocks
from user-acknowledged continues.

Pure primitives, no behavior change on their own.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:25:04 +08:00
Garry Tan 88b12c2b4c fix(security): tool-output context allows single-layer BLOCK
combineVerdict's 2-of-N ensemble rule was designed for user input —
the Stack Overflow FP mitigation where a dev asking about injection
shouldn't kill the session. For tool output (page content, Read/Grep
results), the content wasn't user-authored, so that FP risk doesn't
apply. Before this change: testsavant_content=0.99 on a hostile page
downgraded to WARN when the transcript classifier degraded (timeout,
Haiku unavailable) or voted differently.

Add CombineVerdictOpts.toolOutput flag. When true, a single ML
classifier >= BLOCK threshold blocks directly. User-input default
path unchanged — still requires 2-of-N to block.

Caller: sidebar-agent.ts tool-result scan now passes { toolOutput: true }.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:07:18 +08:00
Garry Tan 905d5a2e29 fix(security): cache device salt in-process to survive fs-unwritable
getDeviceSalt returned a new randomBytes(16) on every call when the
salt file couldn't be persisted (read-only home, disk full). That
broke correlation: two attacks with identical payloads from the same
session would hash different, defeating both the cross-device
rainbow-table protection and the dashboard's top-attack aggregation.

Cache the salt in a module-level variable on first generation. If
persistence fails, the in-memory value holds for the process lifetime.
Next process gets a new salt, but within-session correlation works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:17:23 +08:00
Garry Tan b4e49d080d feat(security): 3-way ensemble verdict combiner with deberta_content layer
Updates combineVerdict to support a third ML signal layer (deberta_content)
for opt-in DeBERTa-v3 ensemble. Rule becomes:

  * Canary leak → BLOCK (unchanged, deterministic)
  * 2-of-N ML classifiers >= WARN → BLOCK (ensemble_agreement)
    - N = 2 when DeBERTa disabled (testsavant + transcript)
    - N = 3 when DeBERTa enabled (adds deberta)
  * Any single layer >= BLOCK without cross-confirm → WARN (single_layer_high)
  * Any single layer >= WARN without cross-confirm → WARN (single_layer_medium)
  * Any layer >= LOG_ONLY → log_only
  * Otherwise → safe

Backward compatible: when DeBERTa signal has confidence 0 (meta.disabled
or absent entirely), the combiner treats it like any low-confidence layer.
Existing 2-of-2 ensemble path still fires for testsavant + transcript.

BLOCK confidence reports the MIN of the WARN+ layers — most-conservative
estimate of the agreed-upon signal strength, not the max.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 04:55:23 +08:00
Garry Tan f68fa4a9ee feat(security): wire logAttempt to gstack-telemetry-log (fire-and-forget)
Every local attempt.jsonl write now also triggers a subprocess call to
gstack-telemetry-log with the attack_attempt event type. The binary handles
tier gating internally (community → Supabase upload, anonymous → local
JSONL only, off → no-op), so security.ts doesn't need to re-check.

Binary resolution follows the skill preamble pattern — never relies on PATH,
which breaks in compiled-binary contexts:

  1. ~/.claude/skills/gstack/bin/gstack-telemetry-log  (global install)
  2. .claude/skills/gstack/bin/gstack-telemetry-log    (symlinked dev)
  3. bin/gstack-telemetry-log                          (in-repo dev)

Fire-and-forget:
  * spawn with stdio: 'ignore', detached: true, unref()
  * .on('error') swallows failures
  * Missing binary is non-fatal — local attempts.jsonl still gives audit trail

Never throws. Never blocks. Existing 37 security tests pass unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 19:16:26 +08:00
Garry Tan 900cc0902b feat(security): add security.ts foundation for prompt injection defense
Establishes the module structure for the L5 canary and L6 verdict aggregation
layers. Pure-string operations only — safe to import from the compiled browse
binary.

Includes:
  * THRESHOLDS constants (BLOCK 0.85 / WARN 0.60 / LOG_ONLY 0.40), calibrated
    against BrowseSafe-Bench smoke + developer content benign corpus.
  * combineVerdict() implementing the ensemble rule: BLOCK only when the ML
    content classifier AND the transcript classifier both score >= WARN.
    Single-layer high confidence degrades to WARN to prevent any one
    classifier's false-positives from killing sessions (Stack Overflow
    instruction-writing-style FPs at 0.99 on TestSavantAI alone).
  * generateCanary / injectCanary / checkCanaryInStructure — session-scoped
    secret token, recursively scans tool arguments, URLs, file writes, and
    nested objects per the plan's all-channel coverage decision.
  * logAttempt with 10MB rotation (keeps 5 generations). Salted SHA-256 hash,
    per-device salt at ~/.gstack/security/device-salt (0600).
  * Cross-process session state at ~/.gstack/security/session-state.json
    (atomic temp+rename). Required because server.ts (compiled) and
    sidebar-agent.ts (non-compiled) are separate processes.
  * getStatus() for shield icon rendering via /health.

ML classifier code will live in a separate module (security-classifier.ts)
loaded only by sidebar-agent.ts — compiled browse binary cannot load the
native ONNX runtime.

Plan: ~/.gstack/projects/garrytan-gstack/ceo-plans/2026-04-19-prompt-injection-guard.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:46:23 +08:00