Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions plugins/security/stackone-defender-antigravity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

## What gets scanned

Expand All @@ -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.

Expand Down Expand Up @@ -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

Expand All @@ -115,25 +115,25 @@ 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 |
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
| `~/.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.

> **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

**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.

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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`);
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
*/

import { dirname, join } from "path";
import { homedir } from "os";
import { fileURLToPath } from "url";
import {
existsSync,
Expand All @@ -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";
Expand All @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions plugins/security/stackone-defender/scripts/daemon-paths.mjs
Original file line number Diff line number Diff line change
@@ -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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This entire script set is vendored twice: plugins/security/stackone-defender-antigravity/scripts/daemon-paths.mjs is byte-identical to this file except for NAME = "defender-antigravity", and the two scan-tool-result.mjs/defender-daemon.mjs copies are likewise duplicated. Every future change to the daemon client protocol, path layout, or log handling must now be applied in two trees, and a third Defender variant would need a third copy. Consider generating NAME from the plugin's package identity and sharing the scripts between the two plugin trees at packaging time (or a script that syncs them) so the paths can't drift.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/security/stackone-defender/scripts/daemon-paths.mjs, line 13:

<comment>This entire script set is vendored twice: `plugins/security/stackone-defender-antigravity/scripts/daemon-paths.mjs` is byte-identical to this file except for `NAME = "defender-antigravity"`, and the two `scan-tool-result.mjs`/`defender-daemon.mjs` copies are likewise duplicated. Every future change to the daemon client protocol, path layout, or log handling must now be applied in two trees, and a third Defender variant would need a third copy. Consider generating `NAME` from the plugin's package identity and sharing the scripts between the two plugin trees at packaging time (or a script that syncs them) so the paths can't drift.</comment>

<file context>
@@ -0,0 +1,20 @@
+import { join } from "path";
+
+const DIR = join(homedir(), ".claude");
+const NAME = "defender";
+
+export const SOCKET_PATH = join(DIR, `${NAME}.sock`);
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point, but I'm keeping it out of this PR.

The duplication predates this change. The two plugins already carry separate copies of scan-tool-result.mjs, defender-daemon.mjs and deps-fingerprint.mjs, because each is installed on its own into a different host with its own node_modules. daemon-paths.mjs follows that existing layout, and the only intended difference between the two copies is NAME.

Sharing the scripts at packaging time, or adding a sync check, is worth doing, but it's a change to how both plugins are built. It should be reviewed on its own rather than folded into a bug fix.


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`);
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
*/

import { dirname, join } from "path";
import { homedir } from "os";
import { fileURLToPath } from "url";
import {
existsSync,
Expand All @@ -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";
Expand All @@ -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 {
Expand Down
Loading