Skip to content

Commit f974f81

Browse files
glebedelclaude
andcommitted
fix(defender): share the fingerprint module and separate lock errors from contention
Two issues cubic raised that I had not picked up, both in code added by this PR. `depsFingerprint()` had been copy-pasted into four files, and the client's value and the daemon's recorded value are compared to decide whether a daemon still matches the tree on disk. Nothing kept the copies in step, so editing one would have produced either a kill/respawn loop on every scan or a permanently stale daemon: precisely the failures the check exists to prevent. It now lives in scripts/deps-fingerprint.mjs, imported by both sides in each plugin. The state file comment claiming the daemon reads the client's stamp was also stale, since round three changed it to compute its own; corrected. `acquireDepsLock()` returned null for both contention and genuine failure, and the caller read every null as contention. A real error such as a permissions problem therefore marked an install in flight, which suppresses daemon replacement and leaves the old tree scanning indefinitely. It now reports the two cases separately: contention scans with the current tree, an error skips the event so the next hook retries. Verified: the shared module is deterministic and the only remaining copy, a free lock is taken, a live owner reports contention, and an unopenable lock path reports an error rather than false contention. Both suites pass, 15 and 12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f74ca97 commit f974f81

6 files changed

Lines changed: 112 additions & 108 deletions

File tree

‎plugins/security/stackone-defender-antigravity/scripts/defender-daemon.mjs‎

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { dirname, join, resolve } from "path";
1212
import { homedir } from "os";
1313
import { fileURLToPath } from "url";
1414
import { createServer } from "net";
15-
import { createHash } from "crypto";
15+
import { depsFingerprint as computeDepsFingerprint } from "./deps-fingerprint.mjs";
1616
import { unlinkSync, existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync, statSync, renameSync } from "fs";
1717

1818
const PROTOCOL_VERSION = 1;
@@ -26,26 +26,6 @@ const DAEMON_STATE = join(homedir(), ".claude", "defender-daemon.json");
2626
const scriptDir = dirname(fileURLToPath(import.meta.url));
2727
const pluginRoot = resolve(scriptDir, "..");
2828
const configPath = join(scriptDir, "defender-daemon.config.json");
29-
// Must stay in step with the same function in scan-tool-result.mjs.
30-
function depsFingerprint() {
31-
try {
32-
const pkg = JSON.parse(readFileSync(join(pluginRoot, "package.json"), "utf8"));
33-
const hash = createHash("sha256").update(
34-
JSON.stringify({ dependencies: pkg.dependencies ?? {}, overrides: pkg.overrides ?? {} }),
35-
);
36-
// npm resolves from the lockfile when one is present, so a lockfile-only change
37-
// (a transitive bump that needed no override) also changes what lands on disk.
38-
try {
39-
hash.update(readFileSync(join(pluginRoot, "package-lock.json")));
40-
} catch {
41-
// No lockfile: package.json alone decides resolution.
42-
}
43-
return hash.digest("hex");
44-
} catch {
45-
return null;
46-
}
47-
}
48-
4929
const requireFrom = createRequire(join(pluginRoot, "package.json"));
5030

5131
function rotateLogIfNeeded() {
@@ -296,9 +276,9 @@ server.listen(SOCKET_PATH, () => {
296276
const state = {
297277
pid: process.pid,
298278
defenderVersion,
299-
// Same stamp the client writes after an install. Recording it lets the client
300-
// tell that this daemon predates a dependency change and needs replacing.
301-
depsStamp: depsFingerprint(),
279+
// Computed here, not read from the client's stamp file, so the client can tell
280+
// that this daemon predates a dependency change and needs replacing.
281+
depsStamp: computeDepsFingerprint(pluginRoot),
302282
protocolVersion: PROTOCOL_VERSION,
303283
startedAt: new Date().toISOString(),
304284
socket: SOCKET_PATH,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Fingerprint of everything that decides which package versions end up in node_modules.
3+
*
4+
* Imported by both scan-tool-result.mjs and defender-daemon.mjs so the client's expected
5+
* value and the daemon's recorded value can never drift. They are compared to decide
6+
* whether a running daemon still matches the tree on disk, so two copies of this logic
7+
* would mean either a kill/respawn loop on every scan or a permanently stale daemon.
8+
*/
9+
import { readFileSync } from "fs";
10+
import { join } from "path";
11+
import { createHash } from "crypto";
12+
13+
export function depsFingerprint(pluginRoot) {
14+
try {
15+
const pkg = JSON.parse(readFileSync(join(pluginRoot, "package.json"), "utf8"));
16+
const hash = createHash("sha256").update(
17+
JSON.stringify({ dependencies: pkg.dependencies ?? {}, overrides: pkg.overrides ?? {} }),
18+
);
19+
// npm resolves from the lockfile when one is present, so a lockfile-only change
20+
// (a transitive bump that needed no override) also changes what lands on disk.
21+
try {
22+
hash.update(readFileSync(join(pluginRoot, "package-lock.json")));
23+
} catch {
24+
// No lockfile: package.json alone decides resolution.
25+
}
26+
return hash.digest("hex");
27+
} catch {
28+
return null;
29+
}
30+
}

‎plugins/security/stackone-defender-antigravity/scripts/scan-tool-result.mjs‎

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ import {
4242
writeFileSync,
4343
} from "fs";
4444
import { execSync, spawn } from "child_process";
45-
import { createHash } from "crypto";
45+
import { depsFingerprint as computeDepsFingerprint } from "./deps-fingerprint.mjs";
46+
47+
const depsFingerprint = () => computeDepsFingerprint(pluginRoot);
4648
import net from "net";
4749

4850
const scriptDir = dirname(fileURLToPath(import.meta.url));
@@ -96,29 +98,6 @@ function readPluginDeps() {
9698
}
9799
}
98100

99-
// Fingerprint of everything that decides which versions end up in node_modules.
100-
// `overrides` matters as much as `dependencies` here: security pins for transitive
101-
// packages live there, and a plugin upgrade that only moves a pin would otherwise
102-
// leave an existing install on the old, vulnerable version.
103-
function depsFingerprint() {
104-
try {
105-
const pkg = JSON.parse(readFileSync(join(pluginRoot, "package.json"), "utf8"));
106-
const hash = createHash("sha256").update(
107-
JSON.stringify({ dependencies: pkg.dependencies ?? {}, overrides: pkg.overrides ?? {} }),
108-
);
109-
// npm resolves from the lockfile when one is present, so a lockfile-only change
110-
// (a transitive bump that needed no override) also changes what lands on disk.
111-
try {
112-
hash.update(readFileSync(join(pluginRoot, "package-lock.json")));
113-
} catch {
114-
// No lockfile: package.json alone decides resolution.
115-
}
116-
return hash.digest("hex");
117-
} catch {
118-
return null;
119-
}
120-
}
121-
122101
function readDepsStamp() {
123102
try {
124103
return readFileSync(DEPS_STAMP_PATH, "utf8").trim();
@@ -136,24 +115,30 @@ const DEPS_LOCK_STALE_MS = 180_000;
136115
// a daemon mid-install would let the replacement import a half-written node_modules.
137116
let depsInstallInFlight = false;
138117

118+
// Contention and failure are different answers. Contention means another hook is
119+
// installing, so the tree will be current shortly and this hook should leave the daemon
120+
// alone. A genuine error means the refresh cannot happen at all, and pretending an
121+
// install is in flight would keep a stale daemon serving the old tree indefinitely.
139122
function acquireDepsLock() {
140123
for (let attempt = 0; attempt < 2; attempt += 1) {
141124
try {
142-
return openSync(DEPS_LOCK_PATH, "wx");
125+
return { fd: openSync(DEPS_LOCK_PATH, "wx"), contended: false };
143126
} catch (err) {
144127
if (err.code !== "EEXIST") {
145128
process.stderr.write(`[Defender] Dependency lock failed — scanner disabled: ${err.message}\n`);
146-
return null;
129+
return { fd: null, contended: false };
147130
}
148131
try {
149-
if (Date.now() - statSync(DEPS_LOCK_PATH).mtimeMs <= DEPS_LOCK_STALE_MS) return null;
132+
if (Date.now() - statSync(DEPS_LOCK_PATH).mtimeMs <= DEPS_LOCK_STALE_MS) {
133+
return { fd: null, contended: true };
134+
}
150135
unlinkSync(DEPS_LOCK_PATH);
151136
} catch {
152137
// The owner released it between our open and our stat. Try once more.
153138
}
154139
}
155140
}
156-
return null;
141+
return { fd: null, contended: true };
157142
}
158143

159144
function depsUpToDate(missing) {
@@ -171,13 +156,20 @@ function ensureDepsInstalled() {
171156

172157
// Serialise installs. After an upgrade every concurrent hook sees the same stale
173158
// stamp, and npm is not safe to run against one prefix from several processes.
174-
const lockFd = acquireDepsLock();
175-
if (lockFd === null) {
159+
const lock = acquireDepsLock();
160+
if (lock.fd === null) {
161+
if (!lock.contended) {
162+
// The lock could not be taken at all, so the refresh will not happen. Do not mark
163+
// an install in flight: that would suppress daemon replacement and quietly keep the
164+
// old tree scanning. Skip this event instead and let the next hook retry.
165+
return false;
166+
}
176167
// Another hook owns the install. The tree on disk is in flux, so this process must
177168
// not touch the daemon either; scan with what is already running, if anything.
178169
depsInstallInFlight = true;
179170
return !missing;
180171
}
172+
const lockFd = lock.fd;
181173

182174
try {
183175
// Recheck under the lock: whoever held it first may have finished the install.

‎plugins/security/stackone-defender/scripts/defender-daemon.mjs‎

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { dirname, join, resolve } from "path";
1212
import { homedir } from "os";
1313
import { fileURLToPath } from "url";
1414
import { createServer } from "net";
15-
import { createHash } from "crypto";
15+
import { depsFingerprint as computeDepsFingerprint } from "./deps-fingerprint.mjs";
1616
import { unlinkSync, existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync, statSync, renameSync } from "fs";
1717

1818
const PROTOCOL_VERSION = 1;
@@ -26,26 +26,6 @@ const DAEMON_STATE = join(homedir(), ".claude", "defender-daemon.json");
2626
const scriptDir = dirname(fileURLToPath(import.meta.url));
2727
const pluginRoot = resolve(scriptDir, "..");
2828
const configPath = join(scriptDir, "defender-daemon.config.json");
29-
// Must stay in step with the same function in scan-tool-result.mjs.
30-
function depsFingerprint() {
31-
try {
32-
const pkg = JSON.parse(readFileSync(join(pluginRoot, "package.json"), "utf8"));
33-
const hash = createHash("sha256").update(
34-
JSON.stringify({ dependencies: pkg.dependencies ?? {}, overrides: pkg.overrides ?? {} }),
35-
);
36-
// npm resolves from the lockfile when one is present, so a lockfile-only change
37-
// (a transitive bump that needed no override) also changes what lands on disk.
38-
try {
39-
hash.update(readFileSync(join(pluginRoot, "package-lock.json")));
40-
} catch {
41-
// No lockfile: package.json alone decides resolution.
42-
}
43-
return hash.digest("hex");
44-
} catch {
45-
return null;
46-
}
47-
}
48-
4929
const requireFrom = createRequire(join(pluginRoot, "package.json"));
5030

5131
function rotateLogIfNeeded() {
@@ -296,9 +276,9 @@ server.listen(SOCKET_PATH, () => {
296276
const state = {
297277
pid: process.pid,
298278
defenderVersion,
299-
// Same stamp the client writes after an install. Recording it lets the client
300-
// tell that this daemon predates a dependency change and needs replacing.
301-
depsStamp: depsFingerprint(),
279+
// Computed here, not read from the client's stamp file, so the client can tell
280+
// that this daemon predates a dependency change and needs replacing.
281+
depsStamp: computeDepsFingerprint(pluginRoot),
302282
protocolVersion: PROTOCOL_VERSION,
303283
startedAt: new Date().toISOString(),
304284
socket: SOCKET_PATH,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Fingerprint of everything that decides which package versions end up in node_modules.
3+
*
4+
* Imported by both scan-tool-result.mjs and defender-daemon.mjs so the client's expected
5+
* value and the daemon's recorded value can never drift. They are compared to decide
6+
* whether a running daemon still matches the tree on disk, so two copies of this logic
7+
* would mean either a kill/respawn loop on every scan or a permanently stale daemon.
8+
*/
9+
import { readFileSync } from "fs";
10+
import { join } from "path";
11+
import { createHash } from "crypto";
12+
13+
export function depsFingerprint(pluginRoot) {
14+
try {
15+
const pkg = JSON.parse(readFileSync(join(pluginRoot, "package.json"), "utf8"));
16+
const hash = createHash("sha256").update(
17+
JSON.stringify({ dependencies: pkg.dependencies ?? {}, overrides: pkg.overrides ?? {} }),
18+
);
19+
// npm resolves from the lockfile when one is present, so a lockfile-only change
20+
// (a transitive bump that needed no override) also changes what lands on disk.
21+
try {
22+
hash.update(readFileSync(join(pluginRoot, "package-lock.json")));
23+
} catch {
24+
// No lockfile: package.json alone decides resolution.
25+
}
26+
return hash.digest("hex");
27+
} catch {
28+
return null;
29+
}
30+
}

‎plugins/security/stackone-defender/scripts/scan-tool-result.mjs‎

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import {
2323
writeFileSync,
2424
} from "fs";
2525
import { execSync, spawn } from "child_process";
26-
import { createHash } from "crypto";
26+
import { depsFingerprint as computeDepsFingerprint } from "./deps-fingerprint.mjs";
27+
28+
const depsFingerprint = () => computeDepsFingerprint(pluginRoot);
2729
import net from "net";
2830

2931
const scriptDir = dirname(fileURLToPath(import.meta.url));
@@ -83,29 +85,6 @@ function readPluginDeps() {
8385
}
8486
}
8587

86-
// Fingerprint of everything that decides which versions end up in node_modules.
87-
// `overrides` matters as much as `dependencies` here: security pins for transitive
88-
// packages live there, and a plugin upgrade that only moves a pin would otherwise
89-
// leave an existing install on the old, vulnerable version.
90-
function depsFingerprint() {
91-
try {
92-
const pkg = JSON.parse(readFileSync(join(pluginRoot, "package.json"), "utf8"));
93-
const hash = createHash("sha256").update(
94-
JSON.stringify({ dependencies: pkg.dependencies ?? {}, overrides: pkg.overrides ?? {} }),
95-
);
96-
// npm resolves from the lockfile when one is present, so a lockfile-only change
97-
// (a transitive bump that needed no override) also changes what lands on disk.
98-
try {
99-
hash.update(readFileSync(join(pluginRoot, "package-lock.json")));
100-
} catch {
101-
// No lockfile: package.json alone decides resolution.
102-
}
103-
return hash.digest("hex");
104-
} catch {
105-
return null;
106-
}
107-
}
108-
10988
function readDepsStamp() {
11089
try {
11190
return readFileSync(DEPS_STAMP_PATH, "utf8").trim();
@@ -123,24 +102,30 @@ const DEPS_LOCK_STALE_MS = 180_000;
123102
// a daemon mid-install would let the replacement import a half-written node_modules.
124103
let depsInstallInFlight = false;
125104

105+
// Contention and failure are different answers. Contention means another hook is
106+
// installing, so the tree will be current shortly and this hook should leave the daemon
107+
// alone. A genuine error means the refresh cannot happen at all, and pretending an
108+
// install is in flight would keep a stale daemon serving the old tree indefinitely.
126109
function acquireDepsLock() {
127110
for (let attempt = 0; attempt < 2; attempt += 1) {
128111
try {
129-
return openSync(DEPS_LOCK_PATH, "wx");
112+
return { fd: openSync(DEPS_LOCK_PATH, "wx"), contended: false };
130113
} catch (err) {
131114
if (err.code !== "EEXIST") {
132115
process.stderr.write(`[Defender] Dependency lock failed — scanner disabled: ${err.message}\n`);
133-
return null;
116+
return { fd: null, contended: false };
134117
}
135118
try {
136-
if (Date.now() - statSync(DEPS_LOCK_PATH).mtimeMs <= DEPS_LOCK_STALE_MS) return null;
119+
if (Date.now() - statSync(DEPS_LOCK_PATH).mtimeMs <= DEPS_LOCK_STALE_MS) {
120+
return { fd: null, contended: true };
121+
}
137122
unlinkSync(DEPS_LOCK_PATH);
138123
} catch {
139124
// The owner released it between our open and our stat. Try once more.
140125
}
141126
}
142127
}
143-
return null;
128+
return { fd: null, contended: true };
144129
}
145130

146131
function depsUpToDate(missing) {
@@ -158,13 +143,20 @@ function ensureDepsInstalled() {
158143

159144
// Serialise installs. After an upgrade every concurrent hook sees the same stale
160145
// stamp, and npm is not safe to run against one prefix from several processes.
161-
const lockFd = acquireDepsLock();
162-
if (lockFd === null) {
146+
const lock = acquireDepsLock();
147+
if (lock.fd === null) {
148+
if (!lock.contended) {
149+
// The lock could not be taken at all, so the refresh will not happen. Do not mark
150+
// an install in flight: that would suppress daemon replacement and quietly keep the
151+
// old tree scanning. Skip this event instead and let the next hook retry.
152+
return false;
153+
}
163154
// Another hook owns the install. The tree on disk is in flux, so this process must
164155
// not touch the daemon either; scan with what is already running, if anything.
165156
depsInstallInFlight = true;
166157
return !missing;
167158
}
159+
const lockFd = lock.fd;
168160

169161
try {
170162
// Recheck under the lock: whoever held it first may have finished the install.

0 commit comments

Comments
 (0)