From 7eadc9bf58fba2fa3509a31241b1539139d26c1d Mon Sep 17 00:00:00 2001 From: Guillaume Lebedel Date: Wed, 23 Sep 2026 07:59:38 -0700 Subject: [PATCH 1/2] fix(defender): give each Defender plugin its own daemon stackone-defender and stackone-defender-antigravity both used ~/.claude/defender.sock and the same state, lock and log files. They pin different Defender versions (0.8.2 vs 0.7.0), and each client replaces any daemon that doesn't match its own tree, so with both installed every scan killed the other plugin's daemon and cold-started its own. The paths now live in scripts/daemon-paths.mjs, imported by the hook client and the daemon so the two can't disagree. The Claude Code plugin keeps its existing file names, so current installs see no change. Antigravity moves to defender-antigravity.*. Its README no longer promises a shared daemon. Measured with both plugins alternating over 6 scans in a clean HOME: main: 6 daemon spawns, 5 kills, 0.8s per scan after warm-up fixed: 2 spawns, 0 kills, 0.4s per scan, both daemons reused Cold start on an M-series Mac was 0.8-2.5s, well under the 5s scan budget. That lowers the fail-open risk raised in #37, but slower machines may differ. Cost: two model copies in memory when both plugins are installed. Fixes #37 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../stackone-defender-antigravity/README.md | 22 +++++++++---------- .../scripts/daemon-paths.mjs | 20 +++++++++++++++++ .../scripts/defender-daemon.mjs | 5 +---- .../scripts/scan-tool-result.mjs | 6 +---- .../scripts/daemon-paths.mjs | 20 +++++++++++++++++ .../scripts/defender-daemon.mjs | 5 +---- .../scripts/scan-tool-result.mjs | 7 +----- 7 files changed, 55 insertions(+), 30 deletions(-) create mode 100644 plugins/security/stackone-defender-antigravity/scripts/daemon-paths.mjs create mode 100644 plugins/security/stackone-defender/scripts/daemon-paths.mjs diff --git a/plugins/security/stackone-defender-antigravity/README.md b/plugins/security/stackone-defender-antigravity/README.md index 87bea38..e5ff6b2 100644 --- a/plugins/security/stackone-defender-antigravity/README.md +++ b/plugins/security/stackone-defender-antigravity/README.md @@ -26,11 +26,11 @@ agy plugin install ./plugins/security/stackone-defender-antigravity (A marketplace install path will land once the StackOne marketplace registry supports Antigravity. For now, install from the repo.) -**2. Trigger the first run.** Use any tool that returns more than ~500 bytes (e.g. read a file, or fetch any URL). The hook self-installs its ML dependencies (`@stackone/defender`, `onnxruntime-node`, `@huggingface/transformers`, `fasttext.wasm`) into the plugin's own `node_modules` on this first call. Expect a one-time 5–10 second pause; subsequent calls reuse a persistent daemon over `~/.claude/defender.sock` and complete in low milliseconds. +**2. Trigger the first run.** Use any tool that returns more than ~500 bytes (e.g. read a file, or fetch any URL). The hook self-installs its ML dependencies (`@stackone/defender`, `onnxruntime-node`, `@huggingface/transformers`, `fasttext.wasm`) into the plugin's own `node_modules` on this first call. Expect a one-time 5–10 second pause; subsequent calls reuse a persistent daemon over `~/.claude/defender-antigravity.sock` and complete in low milliseconds. That's it. There's no API key, no config file to edit, and no account to create. Defender is active from the next tool call onward. -> **Sharing the daemon with the Claude Code plugin.** This plugin reuses the same `~/.claude/defender.sock` socket as the [Claude Code variant](../stackone-defender/). If both plugins are installed, the daemon spawned by whichever fires first will serve both — one ONNX session in memory, both agents protected. Versions must match. +> **Running alongside the Claude Code plugin.** This plugin runs its own daemon, separate from the [Claude Code variant](../stackone-defender/)'s. The two pin different Defender versions, so a shared daemon made each one replace the other's on every scan. With both installed you get two daemons and two copies of the model in memory, and each one stays warm. ## What gets scanned @@ -102,7 +102,7 @@ Default thresholds and the model path live in `scripts/defender-daemon.config.js `enableTier1` is off by default. Tier 1 (regex patterns) is brittle and high-FP on prose discussing attacks. Tier 2 (the multihead ONNX classifier with Static Frequency Estimation preprocessing) is the sole decision-maker. -The daemon reads this config only on startup, and it is a detached long-lived process that outlives your shell. To pick up config changes, stop the running daemon (look up the PID in `~/.claude/defender-daemon.json` and `kill` it, or delete `~/.claude/defender.sock` plus `~/.claude/defender-daemon.json`) and the next tool call will spawn a fresh daemon with the new config. +The daemon reads this config only on startup, and it is a detached long-lived process that outlives your shell. To pick up config changes, stop the running daemon (look up the PID in `~/.claude/defender-antigravity-daemon.json` and `kill` it, or delete `~/.claude/defender-antigravity.sock` plus `~/.claude/defender-antigravity-daemon.json`) and the next tool call will spawn a fresh daemon with the new config. ## Privacy @@ -115,11 +115,11 @@ The daemon reads this config only on startup, and it is a detached long-lived pr | Path | Purpose | |---|---| -| `~/.claude/defender.sock` | Unix socket the hook talks to (shared with Claude Code plugin) | -| `~/.claude/defender-daemon.json` | Running daemon's PID + version state | -| `~/.claude/defender-daemon.log` | Daemon stderr (rotated) | -| `~/.claude/defender-client.log` | Hook-side errors (transient) | -| `~/.claude/defender-daemon.lock` | Spawn-time lockfile (transient) | +| `~/.claude/defender-antigravity.sock` | Unix socket the hook talks to | +| `~/.claude/defender-antigravity-daemon.json` | Running daemon's PID + version state | +| `~/.claude/defender-antigravity-daemon.log` | Daemon stderr (rotated) | +| `~/.claude/defender-antigravity-client.log` | Hook-side errors (transient) | +| `~/.claude/defender-antigravity-daemon.lock` | Spawn-time lockfile (transient) | All five are local-only. None get written to until Defender actually fires. @@ -127,13 +127,13 @@ All five are local-only. None get written to until Defender actually fires. ## Troubleshooting -**Defender doesn't seem to fire.** Tool outputs under 500 bytes are skipped intentionally. Check `~/.claude/defender-daemon.log` to confirm the daemon is alive. If the log is empty, the hook may have failed to install dependencies. Run `cd ~/.gemini/config/plugins/stackone-defender-antigravity && npm install` manually. (`$CLAUDE_PLUGIN_ROOT` is set by the host CLI at hook-runtime and is not available in your interactive shell.) +**Defender doesn't seem to fire.** Tool outputs under 500 bytes are skipped intentionally. Check `~/.claude/defender-antigravity-daemon.log` to confirm the daemon is alive. If the log is empty, the hook may have failed to install dependencies. Run `cd ~/.gemini/config/plugins/stackone-defender-antigravity && npm install` manually. (`$CLAUDE_PLUGIN_ROOT` is set by the host CLI at hook-runtime and is not available in your interactive shell.) **Hook receives an unexpected stdin shape.** Antigravity's `PostToolHookArgs` evolved across CLI versions. The hook accepts both proto3-camelCase (`toolName`, `toolResult`, `toolOutput`) and the snake_case fallbacks (`tool_name`, `tool_output`, `tool_response`). If Defender silently does nothing on every call, capture the stdin via a wrapper script and open an issue with the field names you see. **"Slow first scan."** Cold start spawns the daemon and warms up the ONNX session. Steady-state latency is a few milliseconds; first call after a fresh login can take 5–10 seconds. -**Daemon won't start.** Delete `~/.claude/defender.sock`, `~/.claude/defender-daemon.json`, and `~/.claude/defender-daemon.lock`, then retry. The hook recovers from stale state automatically but a manual clean is occasionally faster. +**Daemon won't start.** Delete `~/.claude/defender-antigravity.sock`, `~/.claude/defender-antigravity-daemon.json`, and `~/.claude/defender-antigravity-daemon.lock`, then retry. The hook recovers from stale state automatically but a manual clean is occasionally faster. **Architecture without `onnxruntime-node` binaries.** Rare on macOS / Linux x86_64 / arm64, but if you hit it, the daemon falls back to a smaller MLP head. Detection quality is lower; raise an issue with your platform string. @@ -161,7 +161,7 @@ This plugin follows the marketplace's lockstep version. Behavior-affecting chang | Stdin envelope | `{tool_name, tool_output, tool_response}` | `{toolName, toolResult, toolOutput}` (proto3-JSON) | | Stdout envelope | `{hookSpecificOutput: {hookEventName, additionalContext}}` | `{inject_steps: [{system_message: {text}}]}` | | Tool matcher | narrow allow-list (`Bash\|Read\|WebFetch\|…`) | `.*` (Antigravity's tool surface is less stable) | -| Daemon | shared at `~/.claude/defender.sock` | shared at `~/.claude/defender.sock` | +| Daemon | `~/.claude/defender.sock` | `~/.claude/defender-antigravity.sock` | | Skill behavior | silent-review-then-decide | silent-review-then-decide (same SKILL.md) | ## License diff --git a/plugins/security/stackone-defender-antigravity/scripts/daemon-paths.mjs b/plugins/security/stackone-defender-antigravity/scripts/daemon-paths.mjs new file mode 100644 index 0000000..14a1482 --- /dev/null +++ b/plugins/security/stackone-defender-antigravity/scripts/daemon-paths.mjs @@ -0,0 +1,20 @@ +/** + * Where this plugin's daemon lives. The hook client and the daemon both import these, so + * they can never disagree about the socket or state file they share. + * + * The name is per plugin. stackone-defender and stackone-defender-antigravity pin different + * dependency trees, and each client replaces any daemon that does not match its own. With + * one shared identity, installing both made them kill each other's daemon on every scan. + */ +import { homedir } from "os"; +import { join } from "path"; + +const DIR = join(homedir(), ".claude"); +const NAME = "defender-antigravity"; + +export const SOCKET_PATH = join(DIR, `${NAME}.sock`); +export const LOCK_PATH = join(DIR, `${NAME}-daemon.lock`); +export const STATE_PATH = join(DIR, `${NAME}-daemon.json`); +export const DAEMON_LOG = join(DIR, `${NAME}-daemon.log`); +// Separate from the daemon log so client appends don't race the daemon's rotation. +export const CLIENT_LOG = join(DIR, `${NAME}-client.log`); diff --git a/plugins/security/stackone-defender-antigravity/scripts/defender-daemon.mjs b/plugins/security/stackone-defender-antigravity/scripts/defender-daemon.mjs index b4a3787..debab57 100644 --- a/plugins/security/stackone-defender-antigravity/scripts/defender-daemon.mjs +++ b/plugins/security/stackone-defender-antigravity/scripts/defender-daemon.mjs @@ -9,19 +9,16 @@ import { createRequire } from "module"; import { dirname, join, resolve } from "path"; -import { homedir } from "os"; import { fileURLToPath } from "url"; import { createServer } from "net"; import { depsFingerprint as computeDepsFingerprint } from "./deps-fingerprint.mjs"; +import { SOCKET_PATH, DAEMON_LOG, STATE_PATH as DAEMON_STATE } from "./daemon-paths.mjs"; import { unlinkSync, existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync, statSync, renameSync } from "fs"; const PROTOCOL_VERSION = 1; const IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 min const UPTIME_CAP_MS = 12 * 60 * 60 * 1000; // 12 hours — graceful self-restart bound const LOG_SIZE_CAP_BYTES = 5 * 1024 * 1024; // 5 MB before rotation -const SOCKET_PATH = join(homedir(), ".claude", "defender.sock"); -const DAEMON_LOG = join(homedir(), ".claude", "defender-daemon.log"); -const DAEMON_STATE = join(homedir(), ".claude", "defender-daemon.json"); const scriptDir = dirname(fileURLToPath(import.meta.url)); const pluginRoot = resolve(scriptDir, ".."); diff --git a/plugins/security/stackone-defender-antigravity/scripts/scan-tool-result.mjs b/plugins/security/stackone-defender-antigravity/scripts/scan-tool-result.mjs index 51edca6..907d17f 100755 --- a/plugins/security/stackone-defender-antigravity/scripts/scan-tool-result.mjs +++ b/plugins/security/stackone-defender-antigravity/scripts/scan-tool-result.mjs @@ -28,7 +28,6 @@ */ import { dirname, join } from "path"; -import { homedir } from "os"; import { fileURLToPath } from "url"; import { existsSync, @@ -43,6 +42,7 @@ import { } from "fs"; import { execFileSync, spawn } from "child_process"; import { depsFingerprint as computeDepsFingerprint } from "./deps-fingerprint.mjs"; +import { SOCKET_PATH, LOCK_PATH, STATE_PATH, CLIENT_LOG as CLIENT_STDERR_LOG } from "./daemon-paths.mjs"; const depsFingerprint = () => computeDepsFingerprint(pluginRoot); import net from "net"; @@ -53,10 +53,6 @@ const DAEMON_SCRIPT = join(scriptDir, "defender-daemon.mjs"); const DEPS_STAMP_PATH = join(pluginRoot, "node_modules", ".stackone-deps-stamp"); // Per-plugin, so the two Defender variants never serialise against each other. const DEPS_LOCK_PATH = join(pluginRoot, ".stackone-deps-install.lock"); -const SOCKET_PATH = join(homedir(), ".claude", "defender.sock"); -const LOCK_PATH = join(homedir(), ".claude", "defender-daemon.lock"); -const STATE_PATH = join(homedir(), ".claude", "defender-daemon.json"); -const CLIENT_STDERR_LOG = join(homedir(), ".claude", "defender-client.log"); try { mkdirSync(dirname(CLIENT_STDERR_LOG), { recursive: true }); } catch { diff --git a/plugins/security/stackone-defender/scripts/daemon-paths.mjs b/plugins/security/stackone-defender/scripts/daemon-paths.mjs new file mode 100644 index 0000000..5f18af7 --- /dev/null +++ b/plugins/security/stackone-defender/scripts/daemon-paths.mjs @@ -0,0 +1,20 @@ +/** + * Where this plugin's daemon lives. The hook client and the daemon both import these, so + * they can never disagree about the socket or state file they share. + * + * The name is per plugin. stackone-defender and stackone-defender-antigravity pin different + * dependency trees, and each client replaces any daemon that does not match its own. With + * one shared identity, installing both made them kill each other's daemon on every scan. + */ +import { homedir } from "os"; +import { join } from "path"; + +const DIR = join(homedir(), ".claude"); +const NAME = "defender"; + +export const SOCKET_PATH = join(DIR, `${NAME}.sock`); +export const LOCK_PATH = join(DIR, `${NAME}-daemon.lock`); +export const STATE_PATH = join(DIR, `${NAME}-daemon.json`); +export const DAEMON_LOG = join(DIR, `${NAME}-daemon.log`); +// Separate from the daemon log so client appends don't race the daemon's rotation. +export const CLIENT_LOG = join(DIR, `${NAME}-client.log`); diff --git a/plugins/security/stackone-defender/scripts/defender-daemon.mjs b/plugins/security/stackone-defender/scripts/defender-daemon.mjs index b4a3787..debab57 100644 --- a/plugins/security/stackone-defender/scripts/defender-daemon.mjs +++ b/plugins/security/stackone-defender/scripts/defender-daemon.mjs @@ -9,19 +9,16 @@ import { createRequire } from "module"; import { dirname, join, resolve } from "path"; -import { homedir } from "os"; import { fileURLToPath } from "url"; import { createServer } from "net"; import { depsFingerprint as computeDepsFingerprint } from "./deps-fingerprint.mjs"; +import { SOCKET_PATH, DAEMON_LOG, STATE_PATH as DAEMON_STATE } from "./daemon-paths.mjs"; import { unlinkSync, existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync, statSync, renameSync } from "fs"; const PROTOCOL_VERSION = 1; const IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 min const UPTIME_CAP_MS = 12 * 60 * 60 * 1000; // 12 hours — graceful self-restart bound const LOG_SIZE_CAP_BYTES = 5 * 1024 * 1024; // 5 MB before rotation -const SOCKET_PATH = join(homedir(), ".claude", "defender.sock"); -const DAEMON_LOG = join(homedir(), ".claude", "defender-daemon.log"); -const DAEMON_STATE = join(homedir(), ".claude", "defender-daemon.json"); const scriptDir = dirname(fileURLToPath(import.meta.url)); const pluginRoot = resolve(scriptDir, ".."); diff --git a/plugins/security/stackone-defender/scripts/scan-tool-result.mjs b/plugins/security/stackone-defender/scripts/scan-tool-result.mjs index 5dd9cea..0b531e6 100755 --- a/plugins/security/stackone-defender/scripts/scan-tool-result.mjs +++ b/plugins/security/stackone-defender/scripts/scan-tool-result.mjs @@ -9,7 +9,6 @@ */ import { dirname, join } from "path"; -import { homedir } from "os"; import { fileURLToPath } from "url"; import { existsSync, @@ -24,6 +23,7 @@ import { } from "fs"; import { execFileSync, spawn } from "child_process"; import { depsFingerprint as computeDepsFingerprint } from "./deps-fingerprint.mjs"; +import { SOCKET_PATH, LOCK_PATH, STATE_PATH, CLIENT_LOG as CLIENT_STDERR_LOG } from "./daemon-paths.mjs"; const depsFingerprint = () => computeDepsFingerprint(pluginRoot); import net from "net"; @@ -34,11 +34,6 @@ const DAEMON_SCRIPT = join(scriptDir, "defender-daemon.mjs"); const DEPS_STAMP_PATH = join(pluginRoot, "node_modules", ".stackone-deps-stamp"); // Per-plugin, so the two Defender variants never serialise against each other. const DEPS_LOCK_PATH = join(pluginRoot, ".stackone-deps-install.lock"); -const SOCKET_PATH = join(homedir(), ".claude", "defender.sock"); -const LOCK_PATH = join(homedir(), ".claude", "defender-daemon.lock"); -const STATE_PATH = join(homedir(), ".claude", "defender-daemon.json"); -// Separate from defender-daemon.log so client appends don't race the daemon's rotation. -const CLIENT_STDERR_LOG = join(homedir(), ".claude", "defender-client.log"); try { mkdirSync(dirname(CLIENT_STDERR_LOG), { recursive: true }); } catch { From 42540c4c9e04697932a3a490c5b16fa210818300 Mon Sep 17 00:00:00 2001 From: Guillaume Lebedel Date: Wed, 23 Sep 2026 08:08:30 -0700 Subject: [PATCH 2/2] docs(defender): drop the last shared-daemon claims from the Antigravity README Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/security/stackone-defender-antigravity/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/security/stackone-defender-antigravity/README.md b/plugins/security/stackone-defender-antigravity/README.md index e5ff6b2..e826652 100644 --- a/plugins/security/stackone-defender-antigravity/README.md +++ b/plugins/security/stackone-defender-antigravity/README.md @@ -51,7 +51,7 @@ flowchart LR D -->|benign| P[silent pass] ``` -- The **daemon** keeps the ONNX model and tokenizer in memory across calls. One process per user; auto-respawns on version mismatch. Shared with the Claude Code plugin if both are installed. +- The **daemon** keeps the ONNX model and tokenizer in memory across calls. One process per user; auto-respawns on version mismatch. Separate from the Claude Code plugin's daemon, so the two never replace each other. - The **hook** is a thin stdin/stdout client. It reads Antigravity's `PostToolHookArgs` (proto3-JSON) from stdin, ships the tool output to the daemon over a Unix domain socket, waits up to 5 seconds for a verdict, and falls back to silent-pass if anything goes wrong (timeout, daemon down, install failed). Time-bounded and fails open: a hung daemon will delay the next turn by at most the scan timeout (and up to ~6 seconds on cold start while the daemon spawns), then the agent proceeds as if Defender weren't installed. - The **skill** (`skills/stackone-defender/SKILL.md`) is loaded into the agent's context and governs how the model reacts to flags. Default behavior: silent review on suspected false positives, refuse-and-tell-user on confirmed attacks, no flag-related noise otherwise. @@ -123,7 +123,7 @@ The daemon reads this config only on startup, and it is a detached long-lived pr All five are local-only. None get written to until Defender actually fires. -> **Why `~/.claude/`?** The daemon is shared with the Claude Code plugin; the path is historical. If you only install the Antigravity plugin, the daemon still lives under `~/.claude/`. This may change in a future version. +> **Why `~/.claude/`?** The path is historical: this plugin's daemon files sit next to the Claude Code plugin's, under their own `defender-antigravity` names. This may change in a future version. ## Troubleshooting