Skip to content
Merged
251 changes: 128 additions & 123 deletions plugins/security/stackone-defender-antigravity/package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions plugins/security/stackone-defender-antigravity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@
"onnxruntime-node": "^1.24.3"
},
"overrides": {
"adm-zip": "0.6.0",
"adm-zip": "0.6.1",

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.

P2: When this plugin is upgraded in place, the new pins may never reach users: the hook skips npm install once direct dependencies exist, and the daemon is not restarted for override changes. Add a dependency/lockfile version check or force a refresh and restart the daemon on plugin upgrades.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/security/stackone-defender-antigravity/package.json, line 25:

<comment>When this plugin is upgraded in place, the new pins may never reach users: the hook skips `npm install` once direct dependencies exist, and the daemon is not restarted for override changes. Add a dependency/lockfile version check or force a refresh and restart the daemon on plugin upgrades.</comment>

<file context>
@@ -22,10 +22,10 @@
   },
   "overrides": {
-    "adm-zip": "0.6.0",
+    "adm-zip": "0.6.1",
     "nanoid": "3.3.18",
     "protobufjs": "7.6.5",
</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.

Verified and fixed in 1c55017 — good catch, this defeated the point of the PR.

ensureDepsInstalled() only checked that each direct dependency folder existed, never versions, and never looked at overrides. Since both security pins are transitive (adm-zip via onnxruntime-node, sharp via @huggingface/transformers), every existing install would have kept 0.6.0 and 0.35.0 indefinitely and the bumps would have reached new installs only.

The hook now writes a sha256 fingerprint of dependencies + overrides to node_modules/.stackone-deps-stamp after each install, and reinstalls when it moves. Verified across three states: existing install with no stamp reinstalls, stamped install skips, changed pin reinstalls. Steady state still skips, so no hot-path cost.

I did not add the daemon restart you also suggested: the daemon already restarts on an @stackone/defender version change, and neither pin here is loaded into the daemon process, so a restart would add risk without changing what gets scanned.

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.

Correction to my earlier reply: I was wrong to decline the daemon-restart half of your comment.

I said neither pin was loaded into the daemon process. @stackone/defender declares @huggingface/transformers and onnxruntime-node as peer dependencies, so the daemon does load the tree containing sharp and adm-zip, and it would have kept serving scans from the old one after an upgrade. Fixed in b83ce9f: the daemon records the dependency stamp in its state and the client replaces any daemon whose stamp differs. Your original comment was right on both halves.

"nanoid": "3.3.18",
"protobufjs": "7.6.5",
"sharp": "0.35.0",
"sharp": "0.35.4",
"tar": "7.5.21"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ const DAEMON_STATE = join(homedir(), ".claude", "defender-daemon.json");
const scriptDir = dirname(fileURLToPath(import.meta.url));
const pluginRoot = resolve(scriptDir, "..");
const configPath = join(scriptDir, "defender-daemon.config.json");
const DEPS_STAMP_PATH = join(pluginRoot, "node_modules", ".stackone-deps-stamp");

function readDepsStamp() {
try {
return readFileSync(DEPS_STAMP_PATH, "utf8").trim();
} catch {
return null;
}
}

const requireFrom = createRequire(join(pluginRoot, "package.json"));

function rotateLogIfNeeded() {
Expand Down Expand Up @@ -275,6 +285,9 @@ server.listen(SOCKET_PATH, () => {
const state = {
pid: process.pid,
defenderVersion,
// Same stamp the client writes after an install. Recording it lets the client
// tell that this daemon predates a dependency change and needs replacing.
depsStamp: readDepsStamp(),
protocolVersion: PROTOCOL_VERSION,
startedAt: new Date().toISOString(),
socket: SOCKET_PATH,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,16 @@ import {
closeSync,
unlinkSync,
statSync,
writeFileSync,
} from "fs";
import { execSync, spawn } from "child_process";
import { createHash } from "crypto";
import net from "net";

const scriptDir = dirname(fileURLToPath(import.meta.url));
const pluginRoot = join(scriptDir, "..");
const DAEMON_SCRIPT = join(scriptDir, "defender-daemon.mjs");
const DEPS_STAMP_PATH = join(pluginRoot, "node_modules", ".stackone-deps-stamp");
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");
Expand Down Expand Up @@ -91,14 +94,57 @@ function readPluginDeps() {
}
}

// Fingerprint of everything that decides which versions end up in node_modules.
// `overrides` matters as much as `dependencies` here: security pins for transitive
// packages live there, and a plugin upgrade that only moves a pin would otherwise
// leave an existing install on the old, vulnerable version.
function depsFingerprint() {
try {
const pkg = JSON.parse(readFileSync(join(pluginRoot, "package.json"), "utf8"));
const hash = createHash("sha256").update(
JSON.stringify({ dependencies: pkg.dependencies ?? {}, overrides: pkg.overrides ?? {} }),
);
// npm resolves from the lockfile when one is present, so a lockfile-only change
// (a transitive bump that needed no override) also changes what lands on disk.
try {
hash.update(readFileSync(join(pluginRoot, "package-lock.json")));
} catch {
// No lockfile: package.json alone decides resolution.
}
return hash.digest("hex");
} catch {
return null;
}
}

function readDepsStamp() {
try {
return readFileSync(DEPS_STAMP_PATH, "utf8").trim();
} catch {
return null;
}
}

function ensureDepsInstalled() {
const deps = readPluginDeps();
const missing = deps.find((d) => !existsSync(join(pluginRoot, "node_modules", d)));
if (!missing) return true;
const fingerprint = depsFingerprint();
// Reinstall when a direct dependency is absent, or when dependencies/overrides have
// moved since the last install. A null fingerprint means package.json is unreadable,
// in which case fall back to the presence check alone rather than reinstalling forever.
const stale = fingerprint !== null && readDepsStamp() !== fingerprint;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
if (!missing && !stale) return true;
try {
execSync(`npm install --prefix "${pluginRoot}" --silent --no-audit --no-fund`, {
Comment thread
Copilot marked this conversation as resolved.
Outdated
timeout: 120_000,
});
if (fingerprint !== null) {
try {
writeFileSync(DEPS_STAMP_PATH, fingerprint);
} catch {
// A missing stamp only costs a redundant install next run.
}
}
Comment thread
Copilot marked this conversation as resolved.
Outdated
return true;
} catch (err) {
process.stderr.write(`[Defender] Dependency install failed — scanner disabled: ${err.message}\n`);
Expand Down Expand Up @@ -211,6 +257,7 @@ function waitForSocket(deadline) {

async function ensureDaemonRunning() {
const expectedVersion = getExpectedDefenderVersion();
const expectedStamp = readDepsStamp();
const running = getRunningDaemonInfo();
if (running) {
if (!processAlive(running.pid)) {
Expand All @@ -220,6 +267,14 @@ async function ensureDaemonRunning() {
running.pid,
`defender version mismatch: running=${running.defenderVersion} expected=${expectedVersion}`,
);
} else if (expectedStamp && running.depsStamp !== expectedStamp) {
// The daemon loads the plugin's dependency tree into its own process, so new
// pins only take effect once it restarts. `defenderVersion` does not move when
// an override does, which would otherwise leave the old tree serving scans.
await killAndClean(
Comment thread
Copilot marked this conversation as resolved.
Outdated
running.pid,
`dependency fingerprint mismatch: running=${running.depsStamp ?? "none"} expected=${expectedStamp}`,
);
}
} else if (existsSync(SOCKET_PATH)) {
try {
Expand Down
Loading
Loading