Skip to content

Commit 7f42ca4

Browse files
hiskudinclaude
andcommitted
fix(ENG-2966): log the classifier's load error, scan empty keys, document the marker
- Capture the library's warmup console warnings into the daemon log, so the notice and README point at a log that holds the actual load error. - Fallback field pattern /^/ instead of /./, so an empty key is scanned too. - README file table lists the per-session degraded marker; hook header comment covers the degraded notice; drop an unused test variable. Verification: node --test 23/23. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent e80c7ed commit 7f42ca4

4 files changed

Lines changed: 35 additions & 8 deletions

File tree

‎plugins/security/stackone-defender/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,9 @@ The daemon reads this config only on startup, and it is a detached long-lived pr
106106
| `~/.claude/defender-daemon.log` | Daemon stderr (rotated) |
107107
| `~/.claude/defender-client.log` | Hook-side errors (transient) |
108108
| `~/.claude/defender-daemon.lock` | Spawn-time lockfile (transient) |
109+
| `~/.claude/defender-degraded-<session>` | Marks that a session was told the classifier is unavailable (empty, one per session) |
109110

110-
All five are local-only. None of them get written to until Defender actually fires.
111+
All six are local-only. None of them get written to until Defender actually fires.
111112

112113
> [!NOTE]
113114
> Older versions wrote `~/.claude/defender-feedback.jsonl` and read `~/.claude/defender-collector.json` for an internal FP-labeling loop. Both are gone from v2.6 onward; if either file is on your machine from an older install, it's harmless and can be deleted.

‎plugins/security/stackone-defender/scripts/build-defense.mjs‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,16 @@
88
*/
99
export async function buildDefense(PromptDefense, options, log) {
1010
const defense = new PromptDefense(options);
11-
await defense.warmupTier2();
11+
// The library reports why a load failed only through console.warn, which nobody sees.
12+
const warnings = [];
13+
const consoleWarn = console.warn;
14+
console.warn = (...args) => warnings.push(args.join(" "));
15+
try {
16+
await defense.warmupTier2();
17+
} finally {
18+
console.warn = consoleWarn;
19+
}
20+
if (warnings.length > 0) log("warmup warnings", { warnings });
1221
if (defense.isTier2Ready()) return { defense, tier2Ready: true };
1322

1423
log("Tier 2 unavailable, falling back to Tier 1 patterns");
@@ -18,7 +27,8 @@ export async function buildDefense(PromptDefense, options, log) {
1827
...options,
1928
enableTier1: true,
2029
enableTier2: false,
21-
config: { ...options.config, riskyFields: { fieldNames: [], fieldPatterns: [/./] } },
30+
// /^/, not /./, so an empty key ("") is scanned too.
31+
config: { ...options.config, riskyFields: { fieldNames: [], fieldPatterns: [/^/] } },
2232
});
2333
return { defense: fallback, tier2Ready: false };
2434
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
/**
44
* PostToolUse hook — thin client that scans tool output via the defender daemon.
5-
* Reads JSON on stdin, writes one-line JSON to stdout on flagged content,
6-
* silent-exits otherwise. Self-installs node_modules on first run (the daemon
5+
* Reads JSON on stdin, writes one-line JSON to stdout on flagged content or,
6+
* once per session, when the classifier is unavailable; silent-exits otherwise. Self-installs node_modules on first run (the daemon
77
* needs @stackone/defender resolvable before spawn). Falls back to silent-skip
88
* if the daemon is unreachable.
99
*/

‎plugins/security/stackone-defender/tests/degraded-tier2.test.mjs‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ const here = dirname(fileURLToPath(import.meta.url));
1717
const pluginRoot = resolve(here, "..");
1818
const requireFrom = createRequire(join(pluginRoot, "package.json"));
1919
const { PromptDefense } = requireFrom("@stackone/defender");
20-
const defenderRoot = dirname(requireFrom.resolve("@stackone/defender"));
2120

2221
const shippedConfig = JSON.parse(
2322
readFileSync(join(pluginRoot, "scripts", "defender-daemon.config.json"), "utf8"),
@@ -37,17 +36,22 @@ const INJECTION = {
3736
// file would hide the failure being tested. The loadable path is covered by qa-fixtures.
3837
test("a missing model falls back to Tier 1 and still blocks an overt injection", async () => {
3938
const messages = [];
39+
const logged = {};
4040

4141
const { defense, tier2Ready } = await buildDefense(
4242
PromptDefense,
4343
withModelPath(join(tmpdir(), "no-such-defender-model")),
44-
(msg) => messages.push(msg),
44+
(msg, extra) => {
45+
messages.push(msg);
46+
logged[msg] = extra;
47+
},
4548
);
4649
const result = await defense.defendToolResult(INJECTION, "WebFetch");
4750

4851
assert.equal(tier2Ready, false);
4952
assert.equal(result.allowed, false);
50-
assert.deepEqual(messages, ["Tier 2 unavailable, falling back to Tier 1 patterns"]);
53+
assert.deepEqual(messages, ["warmup warnings", "Tier 2 unavailable, falling back to Tier 1 patterns"]);
54+
assert.match(logged["warmup warnings"].warnings.join("\n"), /no-such-defender-model/);
5155
});
5256

5357
test("the Tier 1 fallback still allows benign prose", async () => {
@@ -65,6 +69,18 @@ test("the Tier 1 fallback still allows benign prose", async () => {
6569
assert.equal(result.allowed, true);
6670
});
6771

72+
test("the Tier 1 fallback scans a field with an empty name", async () => {
73+
const { defense } = await buildDefense(
74+
PromptDefense,
75+
withModelPath(join(tmpdir(), "no-such-defender-model")),
76+
() => {},
77+
);
78+
79+
const result = await defense.defendToolResult({ "": INJECTION.output }, "WebFetch");
80+
81+
assert.equal(result.allowed, false);
82+
});
83+
6884
test("the Tier 1 fallback flags text that only discusses an injection phrase", async () => {
6985
// Accepted cost of degraded mode: without the classifier, patterns cannot tell an attack
7086
// from a file describing one. The degraded notice warns about this.

0 commit comments

Comments
 (0)