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
2 changes: 1 addition & 1 deletion .release-please-config.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 0 additions & 10 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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__.*",
Expand Down
51 changes: 39 additions & 12 deletions scripts/scan-tool-result.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// 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");
if (!existsSync(defenderDir)) {
try {
execSync(`npm install --prefix "${pluginRoot}" --silent --no-audit --no-fund`, {
timeout: 120_000,
Comment thread
hiskudin marked this conversation as resolved.
});
Comment thread
hiskudin marked this conversation as resolved.
} catch (err) {
process.stderr.write(`[Defender] Dependency install failed — scanner disabled: ${err.message}\n`);
process.exit(0);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
}

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

async function main() {
const input = await readStdin();
if (!input) process.exit(0);
Expand All @@ -21,23 +47,26 @@ 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 ?? "");
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 (err) { process.stderr.write(`[Defender] Failed to serialize tool response: ${err.message}\n`); output = ""; } }
} else {
output = raw ?? "";
}
if (!output || typeof output !== "string" || output.length < 20) {
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);
}

Expand All @@ -50,7 +79,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"}, ` +
Expand All @@ -75,7 +103,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`);
}

Expand Down
Loading