From bbf2a06696344b2d3a86b668ea6693a28caccd72 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 14 Apr 2026 17:15:59 +0100 Subject: [PATCH 1/5] fix: self-install deps from script location, remove SessionStart hook Move npm install into scan-tool-result.mjs using import.meta.url to resolve plugin root on disk. This removes the dependency on CLAUDE_PLUGIN_ROOT being set during SessionStart, which was unreliable and caused silent install failures. Deps are now installed on first PostToolUse scan, with a fast existsSync check skipping the install on all subsequent runs. Co-Authored-By: Claude Sonnet 4.6 --- hooks/hooks.json | 10 ---------- scripts/scan-tool-result.mjs | 37 +++++++++++++++++++++++++++--------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/hooks/hooks.json b/hooks/hooks.json index aa7ce9e..3e987ba 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,15 +1,5 @@ { "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "[ -d \"${CLAUDE_PLUGIN_ROOT}/node_modules/@stackone/defender\" ] || npm install --prefix \"${CLAUDE_PLUGIN_ROOT}\" --silent" - } - ] - } - ], "PostToolUse": [ { "matcher": "Bash|WebFetch|WebSearch|mcp__.*", diff --git a/scripts/scan-tool-result.mjs b/scripts/scan-tool-result.mjs index ea74d91..e5b056e 100755 --- a/scripts/scan-tool-result.mjs +++ b/scripts/scan-tool-result.mjs @@ -6,10 +6,36 @@ * Receives JSON on stdin with { tool_name, tool_input, tool_output, ... } * Exit 0 = pass, Exit 2 = block (stderr sent to Claude as feedback) * - * Defender is loaded from the plugin's own node_modules, installed on first - * session start by the SessionStart hook in hooks/hooks.json. + * Defender is loaded from the plugin's own node_modules. On first run after + * a fresh install, this script installs its own dependencies using its location + * on disk — no CLAUDE_PLUGIN_ROOT env var required. */ +import { createRequire } from "module"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { existsSync } from "fs"; +import { execSync } from "child_process"; + +// Resolve plugin root from this script's location (/scripts/scan-tool-result.mjs) +const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || + join(dirname(fileURLToPath(import.meta.url)), ".."); + +// Self-install deps on first run — subsequent runs skip this instantly +const defenderDir = join(pluginRoot, "node_modules", "@stackone", "defender"); +if (!existsSync(defenderDir)) { + try { + execSync(`npm install --prefix "${pluginRoot}" --silent --no-audit --no-fund`, { + timeout: 120_000, + }); + } catch { + // Install failed — skip scan silently rather than block the agent + process.exit(0); + } +} + +const requireFrom = createRequire(join(pluginRoot, "package.json")); + async function main() { const input = await readStdin(); if (!input) process.exit(0); @@ -29,15 +55,10 @@ async function main() { process.exit(0); } - // Load defender from the plugin's own node_modules (installed by SessionStart hook) let PromptDefense; try { - const { createRequire } = await import("module"); - const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT; - const requireFrom = createRequire(pluginRoot ? `${pluginRoot}/package.json` : import.meta.url); PromptDefense = requireFrom("@stackone/defender").PromptDefense; } catch { - // Defender not available — skip silently process.exit(0); } @@ -50,7 +71,6 @@ async function main() { ); if (!result.allowed) { - // Exit 2 = block, stderr is sent to Claude as feedback process.stderr.write( `[Defender] Tool result BLOCKED — risk: ${result.riskLevel}, ` + `tier2Score: ${result.tier2Score?.toFixed(3) ?? "n/a"}, ` + @@ -75,7 +95,6 @@ async function main() { process.stdout.write(ctx); } } catch (err) { - // Don't block on scanner errors process.stderr.write(`[Defender] Scan error: ${err.message}\n`); } From 061c8bd5716288b2510613ac34d1a3bb16480014 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 14 Apr 2026 17:27:14 +0100 Subject: [PATCH 2/5] fix: scan full MCP tool response via JSON.stringify fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP tools (Gmail, Notion, etc.) return arbitrary JSON objects rather than a string or a WebFetch-style {result} wrapper. Previously the script extracted raw.result ?? raw.output ?? "" which resolved to an empty string for all MCP responses, silently skipping the scan. Now falls back to JSON.stringify(raw) so all text fields (body, snippet, headers, …) are included. Confirmed blocking tier2Score 0.997 on a prompt-injection email via gmail_read_message. Co-Authored-By: Claude Sonnet 4.6 --- scripts/scan-tool-result.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/scan-tool-result.mjs b/scripts/scan-tool-result.mjs index e5b056e..63a4d58 100755 --- a/scripts/scan-tool-result.mjs +++ b/scripts/scan-tool-result.mjs @@ -47,10 +47,13 @@ async function main() { process.exit(0); } - // tool_output for Bash; WebFetch/WebSearch may provide an object response with content in .result, - // falling back to .output when .result is absent. + // tool_output for Bash; WebFetch/WebSearch provide an object with content in .result/.output; + // MCP tools (gmail, etc.) return arbitrary objects — fall back to JSON.stringify so all text + // fields (body, snippet, headers, …) are included in the scan. const raw = data.tool_output ?? data.tool_response; - const output = raw && typeof raw === "object" ? (raw.result ?? raw.output ?? "") : (raw ?? ""); + const output = raw && typeof raw === "object" + ? (raw.result ?? raw.output ?? JSON.stringify(raw)) + : (raw ?? ""); if (!output || typeof output !== "string" || output.length < 20) { process.exit(0); } From 1911f8e49381e45c48eadc6fda399079045c709a Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 15 Apr 2026 09:31:16 +0100 Subject: [PATCH 3/5] fix: address reviewer comments on scan-tool-result.mjs - Drop CLAUDE_PLUGIN_ROOT fallback for plugin root resolution; always derive from import.meta.url so the path cannot be redirected by env var tampering (P1) - Emit stderr warning on npm install failure instead of silently exiting, so users can diagnose why the scanner is disabled (P2) - Wrap JSON.stringify(raw) in try/catch to handle circular refs or BigInt values in MCP tool responses without crashing the hook (P2) Co-Authored-By: Claude Sonnet 4.6 --- scripts/scan-tool-result.mjs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/scripts/scan-tool-result.mjs b/scripts/scan-tool-result.mjs index 63a4d58..e2e39ee 100755 --- a/scripts/scan-tool-result.mjs +++ b/scripts/scan-tool-result.mjs @@ -17,9 +17,9 @@ import { fileURLToPath } from "url"; import { existsSync } from "fs"; import { execSync } from "child_process"; -// Resolve plugin root from this script's location (/scripts/scan-tool-result.mjs) -const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || - join(dirname(fileURLToPath(import.meta.url)), ".."); +// Always resolve plugin root from this script's on-disk location so the path +// cannot be redirected by environment variable tampering. +const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); // Self-install deps on first run — subsequent runs skip this instantly const defenderDir = join(pluginRoot, "node_modules", "@stackone", "defender"); @@ -28,8 +28,8 @@ if (!existsSync(defenderDir)) { execSync(`npm install --prefix "${pluginRoot}" --silent --no-audit --no-fund`, { timeout: 120_000, }); - } catch { - // Install failed — skip scan silently rather than block the agent + } catch (err) { + process.stderr.write(`[Defender] Dependency install failed — scanner disabled: ${err.message}\n`); process.exit(0); } } @@ -51,9 +51,14 @@ async function main() { // MCP tools (gmail, etc.) return arbitrary objects — fall back to JSON.stringify so all text // fields (body, snippet, headers, …) are included in the scan. const raw = data.tool_output ?? data.tool_response; - const output = raw && typeof raw === "object" - ? (raw.result ?? raw.output ?? JSON.stringify(raw)) - : (raw ?? ""); + let output; + if (raw && typeof raw === "object") { + if (typeof raw.result === "string") output = raw.result; + else if (typeof raw.output === "string") output = raw.output; + else { try { output = JSON.stringify(raw); } catch { output = ""; } } + } else { + output = raw ?? ""; + } if (!output || typeof output !== "string" || output.length < 20) { process.exit(0); } From 6e72f4040e45ac11e68a377fd33db33fd26d8c9e Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 15 Apr 2026 09:34:55 +0100 Subject: [PATCH 4/5] fix: use node release type for release-please release-please v17 dropped the generic release type. Switch to node which natively handles package.json and still supports extra-files with jsonpath for the plugin JSON files. Co-Authored-By: Claude Sonnet 4.6 --- .release-please-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.release-please-config.json b/.release-please-config.json index 0848cd1..9d25822 100644 --- a/.release-please-config.json +++ b/.release-please-config.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "release-type": "generic", + "release-type": "node", "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "changelog-path": "CHANGELOG.md", From 95d91a384b9591d2fbde1f9c550fd0c610a02e0a Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 15 Apr 2026 09:56:55 +0100 Subject: [PATCH 5/5] fix: log serialization errors instead of silently swallowing them Use process.stderr.write for consistency with the rest of the script. Co-Authored-By: Claude Sonnet 4.6 --- scripts/scan-tool-result.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/scan-tool-result.mjs b/scripts/scan-tool-result.mjs index e2e39ee..6ea2659 100755 --- a/scripts/scan-tool-result.mjs +++ b/scripts/scan-tool-result.mjs @@ -55,7 +55,7 @@ async function main() { if (raw && typeof raw === "object") { if (typeof raw.result === "string") output = raw.result; else if (typeof raw.output === "string") output = raw.output; - else { try { output = JSON.stringify(raw); } catch { output = ""; } } + else { try { output = JSON.stringify(raw); } catch (err) { process.stderr.write(`[Defender] Failed to serialize tool response: ${err.message}\n`); output = ""; } } } else { output = raw ?? ""; }