From 540f738cc7991466254e054a8a01e0b795d77c50 Mon Sep 17 00:00:00 2001 From: Nomadu27 Date: Tue, 10 Mar 2026 01:02:58 +0100 Subject: [PATCH 1/8] feat: add InsAIts PostToolUse security monitoring hook - Add insaits-security-monitor.py: real-time AI security monitoring hook that catches credential exposure, prompt injection, hallucinations, and 20+ other anomaly types - Update hooks.json with InsAIts PostToolUse entry - Update hooks/README.md with InsAIts in PostToolUse table - Add InsAIts MCP server entry to mcp-configs/mcp-servers.json InsAIts (https://github.com/Nomadu27/InsAIts) is an open-source runtime security layer for multi-agent AI. It runs 100% locally and writes tamper-evident audit logs to .insaits_audit_session.jsonl. Install: pip install insa-its Co-Authored-By: Claude Opus 4.6 --- hooks/README.md | 1 + hooks/hooks.json | 11 ++ mcp-configs/mcp-servers.json | 5 + scripts/hooks/insaits-security-monitor.py | 200 ++++++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 scripts/hooks/insaits-security-monitor.py diff --git a/hooks/README.md b/hooks/README.md index 3b81d6b2..1fc1fb23 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -36,6 +36,7 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes | **Prettier format** | `Edit` | Auto-formats JS/TS files with Prettier after edits | | **TypeScript check** | `Edit` | Runs `tsc --noEmit` after editing `.ts`/`.tsx` files | | **console.log warning** | `Edit` | Warns about `console.log` statements in edited files | +| **InsAIts security monitor** | `.*` | Real-time AI security: catches credential exposure, prompt injection, hallucinations, behavioral anomalies (23 types). Writes audit log to `.insaits_audit_session.jsonl`. Requires `pip install insa-its`. [Details](../scripts/hooks/insaits-security-monitor.py) | ### Lifecycle Hooks diff --git a/hooks/hooks.json b/hooks/hooks.json index 342b5b81..096a1ac6 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -165,6 +165,17 @@ } ], "description": "Capture tool use results for continuous learning" + }, + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "python \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/insaits-security-monitor.py\"", + "timeout": 15 + } + ], + "description": "InsAIts AI security monitor: catches credential exposure, prompt injection, hallucinations, and 20+ anomaly types. Requires: pip install insa-its" } ], "Stop": [ diff --git a/mcp-configs/mcp-servers.json b/mcp-configs/mcp-servers.json index f6642e31..aa4044e4 100644 --- a/mcp-configs/mcp-servers.json +++ b/mcp-configs/mcp-servers.json @@ -88,6 +88,11 @@ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/your/projects"], "description": "Filesystem operations (set your path)" + }, + "insaits": { + "command": "python", + "args": ["-m", "insa_its.mcp_server"], + "description": "AI-to-AI security monitoring — anomaly detection, credential exposure, hallucination checks, forensic tracing. 23 anomaly types, OWASP MCP Top 10 coverage. 100% local. Install: pip install insa-its" } }, "_comments": { diff --git a/scripts/hooks/insaits-security-monitor.py b/scripts/hooks/insaits-security-monitor.py new file mode 100644 index 00000000..97129e8f --- /dev/null +++ b/scripts/hooks/insaits-security-monitor.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +InsAIts Security Monitor — PostToolUse Hook for Claude Code +============================================================ + +Real-time security monitoring for Claude Code tool outputs. +Catches credential exposure, prompt injection, behavioral anomalies, +hallucination chains, and 20+ other anomaly types — runs 100% locally. + +Writes audit events to .insaits_audit_session.jsonl for forensic tracing. + +Setup: + pip install insa-its + + Add to .claude/settings.json: + { + "hooks": { + "PostToolUse": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python scripts/hooks/insaits-security-monitor.py" + } + ] + } + ] + } + } + +How it works: + Claude Code passes tool result as JSON on stdin. + This script runs InsAIts anomaly detection on the output. + Exit code 0 = clean (pass through). + Exit code 2 = critical issue found (blocks action, shows feedback to Claude). + +Detections include: + - Credential exposure (API keys, tokens, passwords in output) + - Prompt injection patterns + - Hallucination indicators (phantom citations, fact contradictions) + - Behavioral anomalies (context loss, semantic drift) + - Tool description divergence + - Shorthand emergence / jargon drift + +All processing is local — no data leaves your machine. + +Author: Cristi Bogdan — YuyAI (https://github.com/Nomadu27/InsAIts) +License: Apache 2.0 +""" + +import sys +import json +import os +import hashlib +import time + +# Try importing InsAIts SDK +try: + from insa_its import insAItsMonitor + INSAITS_AVAILABLE = True +except ImportError: + INSAITS_AVAILABLE = False + +AUDIT_FILE = ".insaits_audit_session.jsonl" + + +def extract_content(data): + """Extract inspectable text from a Claude Code tool result.""" + tool_name = data.get("tool_name", "") + tool_input = data.get("tool_input", {}) + tool_result = data.get("tool_response", {}) + + text = "" + context = "" + + if tool_name in ("Write", "Edit", "MultiEdit"): + text = tool_input.get("content", "") or tool_input.get("new_string", "") + context = "file:" + tool_input.get("file_path", "")[:80] + elif tool_name == "Bash": + if isinstance(tool_result, dict): + text = tool_result.get("output", "") or tool_result.get("stdout", "") + elif isinstance(tool_result, str): + text = tool_result + context = "bash:" + str(tool_input.get("command", ""))[:80] + elif "content" in data: + content = data["content"] + if isinstance(content, list): + text = "\n".join( + b.get("text", "") for b in content if b.get("type") == "text" + ) + elif isinstance(content, str): + text = content + context = data.get("task", "") + + return text, context + + +def write_audit(event): + """Append an audit event to the JSONL audit log.""" + try: + event["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + event["hash"] = hashlib.sha256( + json.dumps(event, sort_keys=True).encode() + ).hexdigest()[:16] + with open(AUDIT_FILE, "a", encoding="utf-8") as f: + f.write(json.dumps(event) + "\n") + except OSError: + pass + + +def format_feedback(anomalies): + """Format detected anomalies as feedback for Claude Code.""" + lines = [ + "== InsAIts Security Monitor — Issues Detected ==", + "", + ] + for i, a in enumerate(anomalies, 1): + sev = getattr(a, "severity", "MEDIUM") + atype = getattr(a, "type", "UNKNOWN") + detail = getattr(a, "detail", "") + lines.extend([ + f"{i}. [{sev}] {atype}", + f" {detail[:120]}", + "", + ]) + lines.extend([ + "-" * 56, + "Fix the issues above before continuing.", + "Audit log: " + AUDIT_FILE, + ]) + return "\n".join(lines) + + +def main(): + raw = sys.stdin.read().strip() + if not raw: + sys.exit(0) + + try: + data = json.loads(raw) + except json.JSONDecodeError: + data = {"content": raw} + + text, context = extract_content(data) + + # Skip very short or binary content + if len(text.strip()) < 10: + sys.exit(0) + + if not INSAITS_AVAILABLE: + print( + "[InsAIts] Not installed. Run: pip install insa-its", + file=sys.stderr, + ) + sys.exit(0) + + # Enable dev mode (no API key needed for local detection) + os.environ.setdefault("INSAITS_DEV_MODE", "true") + + monitor = insAItsMonitor(session_name="claude-code-hook") + result = monitor.send_message( + text=text[:4000], + sender_id="claude-code", + llm_id=os.environ.get("INSAITS_MODEL", "claude-opus"), + ) + + anomalies = result.get("anomalies", []) + + # Write audit event regardless of findings + write_audit({ + "tool": data.get("tool_name", "unknown"), + "context": context, + "anomaly_count": len(anomalies), + "anomaly_types": [getattr(a, "type", "") for a in anomalies], + "text_length": len(text), + }) + + if not anomalies: + if os.environ.get("INSAITS_VERBOSE"): + print("[InsAIts] Clean — no anomalies.", file=sys.stderr) + sys.exit(0) + + # Check severity + has_critical = any( + getattr(a, "severity", "") in ("CRITICAL", "critical") for a in anomalies + ) + + feedback = format_feedback(anomalies) + + if has_critical: + print(feedback) # stdout -> Claude Code shows to model + sys.exit(2) # block action + else: + print(feedback, file=sys.stderr) # stderr -> logged only + sys.exit(0) + + +if __name__ == "__main__": + main() From 44dc96d2c6a96f0bd38e91ee05fc25168a3bcded Mon Sep 17 00:00:00 2001 From: Nomadu27 Date: Tue, 10 Mar 2026 17:52:44 +0100 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20convert=20to=20PreToolUse,=20add=20type=20annotatio?= =?UTF-8?q?ns,=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fixes: - Convert hook from PostToolUse to PreToolUse so exit(2) blocking works - Change all python references to python3 for cross-platform compat - Add insaits-security-wrapper.js to bridge run-with-flags.js to Python Standard fixes: - Wrap hook with run-with-flags.js so users can disable via ECC_DISABLED_HOOKS="pre:insaits-security" - Add "async": true to hooks.json entry - Add type annotations to all function signatures (Dict, List, Tuple, Any) - Replace all print() statements with logging module (stderr) - Fix silent OSError swallow in write_audit — now logs warning - Remove os.environ.setdefault('INSAITS_DEV_MODE') — pass dev_mode=True through monitor constructor instead - Update hooks/README.md: moved to PreToolUse table, "detects" not "catches", clarify blocking vs non-blocking behavior Co-Authored-By: Claude Opus 4.6 --- hooks/README.md | 2 +- hooks/hooks.json | 23 ++-- mcp-configs/mcp-servers.json | 2 +- scripts/hooks/insaits-security-monitor.py | 153 +++++++++++++--------- scripts/hooks/insaits-security-wrapper.js | 62 +++++++++ 5 files changed, 166 insertions(+), 76 deletions(-) create mode 100644 scripts/hooks/insaits-security-wrapper.js diff --git a/hooks/README.md b/hooks/README.md index 1fc1fb23..dde8a020 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -25,6 +25,7 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes | **Git push reminder** | `Bash` | Reminds to review changes before `git push` | 0 (warns) | | **Doc file warning** | `Write` | Warns about non-standard `.md`/`.txt` files (allows README, CLAUDE, CONTRIBUTING, CHANGELOG, LICENSE, SKILL, docs/, skills/); cross-platform path handling | 0 (warns) | | **Strategic compact** | `Edit\|Write` | Suggests manual `/compact` at logical intervals (every ~50 tool calls) | 0 (warns) | +| **InsAIts security monitor** | `*` | Detects credential exposure, prompt injection, hallucinations, and behavioral anomalies (23 types) before tool execution. Blocks on critical findings, warns on non-critical. Writes audit log to `.insaits_audit_session.jsonl`. Requires `pip install insa-its`. [Details](../scripts/hooks/insaits-security-monitor.py) | 2 (blocks critical) / 0 (warns) | ### PostToolUse Hooks @@ -36,7 +37,6 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes | **Prettier format** | `Edit` | Auto-formats JS/TS files with Prettier after edits | | **TypeScript check** | `Edit` | Runs `tsc --noEmit` after editing `.ts`/`.tsx` files | | **console.log warning** | `Edit` | Warns about `console.log` statements in edited files | -| **InsAIts security monitor** | `.*` | Real-time AI security: catches credential exposure, prompt injection, hallucinations, behavioral anomalies (23 types). Writes audit log to `.insaits_audit_session.jsonl`. Requires `pip install insa-its`. [Details](../scripts/hooks/insaits-security-monitor.py) | ### Lifecycle Hooks diff --git a/hooks/hooks.json b/hooks/hooks.json index 096a1ac6..3edc6778 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -63,6 +63,18 @@ } ], "description": "Capture tool use observations for continuous learning" + }, + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:insaits-security\" \"scripts/hooks/insaits-security-wrapper.js\" \"standard,strict\"", + "async": true, + "timeout": 15 + } + ], + "description": "InsAIts AI security monitor: detects credential exposure, prompt injection, hallucinations, and 20+ anomaly types before tool execution. Requires: pip install insa-its" } ], "PreCompact": [ @@ -165,17 +177,6 @@ } ], "description": "Capture tool use results for continuous learning" - }, - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "python \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/insaits-security-monitor.py\"", - "timeout": 15 - } - ], - "description": "InsAIts AI security monitor: catches credential exposure, prompt injection, hallucinations, and 20+ anomaly types. Requires: pip install insa-its" } ], "Stop": [ diff --git a/mcp-configs/mcp-servers.json b/mcp-configs/mcp-servers.json index aa4044e4..64b1ad00 100644 --- a/mcp-configs/mcp-servers.json +++ b/mcp-configs/mcp-servers.json @@ -90,7 +90,7 @@ "description": "Filesystem operations (set your path)" }, "insaits": { - "command": "python", + "command": "python3", "args": ["-m", "insa_its.mcp_server"], "description": "AI-to-AI security monitoring — anomaly detection, credential exposure, hallucination checks, forensic tracing. 23 anomaly types, OWASP MCP Top 10 coverage. 100% local. Install: pip install insa-its" } diff --git a/scripts/hooks/insaits-security-monitor.py b/scripts/hooks/insaits-security-monitor.py index 97129e8f..e0a8ab14 100644 --- a/scripts/hooks/insaits-security-monitor.py +++ b/scripts/hooks/insaits-security-monitor.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """ -InsAIts Security Monitor — PostToolUse Hook for Claude Code +InsAIts Security Monitor -- PreToolUse Hook for Claude Code ============================================================ -Real-time security monitoring for Claude Code tool outputs. -Catches credential exposure, prompt injection, behavioral anomalies, -hallucination chains, and 20+ other anomaly types — runs 100% locally. +Real-time security monitoring for Claude Code tool inputs. +Detects credential exposure, prompt injection, behavioral anomalies, +hallucination chains, and 20+ other anomaly types -- runs 100% locally. Writes audit events to .insaits_audit_session.jsonl for forensic tracing. @@ -15,13 +15,13 @@ Setup: Add to .claude/settings.json: { "hooks": { - "PostToolUse": [ + "PreToolUse": [ { "matcher": ".*", "hooks": [ { "type": "command", - "command": "python scripts/hooks/insaits-security-monitor.py" + "command": "python3 scripts/hooks/insaits-security-monitor.py" } ] } @@ -30,73 +30,97 @@ Setup: } How it works: - Claude Code passes tool result as JSON on stdin. - This script runs InsAIts anomaly detection on the output. + Claude Code passes tool input as JSON on stdin. + This script runs InsAIts anomaly detection on the content. Exit code 0 = clean (pass through). - Exit code 2 = critical issue found (blocks action, shows feedback to Claude). + Exit code 2 = critical issue found (blocks tool execution). + Stderr output = non-blocking warning shown to Claude. Detections include: - - Credential exposure (API keys, tokens, passwords in output) + - Credential exposure (API keys, tokens, passwords) - Prompt injection patterns - Hallucination indicators (phantom citations, fact contradictions) - Behavioral anomalies (context loss, semantic drift) - Tool description divergence - Shorthand emergence / jargon drift -All processing is local — no data leaves your machine. +All processing is local -- no data leaves your machine. -Author: Cristi Bogdan — YuyAI (https://github.com/Nomadu27/InsAIts) +Author: Cristi Bogdan -- YuyAI (https://github.com/Nomadu27/InsAIts) License: Apache 2.0 """ -import sys -import json -import os +from __future__ import annotations + import hashlib +import json +import logging +import os +import sys import time +from typing import Any, Dict, List, Optional, Tuple + +# Configure logging to stderr so it does not interfere with stdout protocol +logging.basicConfig( + stream=sys.stderr, + format="[InsAIts] %(message)s", + level=logging.DEBUG if os.environ.get("INSAITS_VERBOSE") else logging.WARNING, +) +log = logging.getLogger("insaits-hook") # Try importing InsAIts SDK try: from insa_its import insAItsMonitor - INSAITS_AVAILABLE = True + INSAITS_AVAILABLE: bool = True except ImportError: INSAITS_AVAILABLE = False -AUDIT_FILE = ".insaits_audit_session.jsonl" +AUDIT_FILE: str = ".insaits_audit_session.jsonl" -def extract_content(data): - """Extract inspectable text from a Claude Code tool result.""" - tool_name = data.get("tool_name", "") - tool_input = data.get("tool_input", {}) - tool_result = data.get("tool_response", {}) +def extract_content(data: Dict[str, Any]) -> Tuple[str, str]: + """Extract inspectable text from a Claude Code tool input payload. - text = "" - context = "" + Returns: + A (text, context) tuple where *text* is the content to scan and + *context* is a short label for the audit log. + """ + tool_name: str = data.get("tool_name", "") + tool_input: Dict[str, Any] = data.get("tool_input", {}) + tool_result: Any = data.get("tool_response", {}) + + text: str = "" + context: str = "" if tool_name in ("Write", "Edit", "MultiEdit"): text = tool_input.get("content", "") or tool_input.get("new_string", "") - context = "file:" + tool_input.get("file_path", "")[:80] + context = "file:" + str(tool_input.get("file_path", ""))[:80] elif tool_name == "Bash": + command: str = str(tool_input.get("command", "")) + # For PreToolUse we inspect the command itself + text = command + # Also check tool_response if present (for flexibility) if isinstance(tool_result, dict): - text = tool_result.get("output", "") or tool_result.get("stdout", "") - elif isinstance(tool_result, str): - text = tool_result - context = "bash:" + str(tool_input.get("command", ""))[:80] + output = tool_result.get("output", "") or tool_result.get("stdout", "") + if output: + text = text + "\n" + output + elif isinstance(tool_result, str) and tool_result: + text = text + "\n" + tool_result + context = "bash:" + command[:80] elif "content" in data: - content = data["content"] + content: Any = data["content"] if isinstance(content, list): text = "\n".join( b.get("text", "") for b in content if b.get("type") == "text" ) elif isinstance(content, str): text = content - context = data.get("task", "") + context = str(data.get("task", "")) return text, context -def write_audit(event): +def write_audit(event: Dict[str, Any]) -> None: """Append an audit event to the JSONL audit log.""" try: event["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) @@ -105,20 +129,24 @@ def write_audit(event): ).hexdigest()[:16] with open(AUDIT_FILE, "a", encoding="utf-8") as f: f.write(json.dumps(event) + "\n") - except OSError: - pass + except OSError as exc: + log.warning("Failed to write audit log %s: %s", AUDIT_FILE, exc) -def format_feedback(anomalies): - """Format detected anomalies as feedback for Claude Code.""" - lines = [ - "== InsAIts Security Monitor — Issues Detected ==", +def format_feedback(anomalies: List[Any]) -> str: + """Format detected anomalies as feedback for Claude Code. + + Returns: + A human-readable multi-line string describing each finding. + """ + lines: List[str] = [ + "== InsAIts Security Monitor -- Issues Detected ==", "", ] for i, a in enumerate(anomalies, 1): - sev = getattr(a, "severity", "MEDIUM") - atype = getattr(a, "type", "UNKNOWN") - detail = getattr(a, "detail", "") + sev: str = getattr(a, "severity", "MEDIUM") + atype: str = getattr(a, "type", "UNKNOWN") + detail: str = getattr(a, "detail", "") lines.extend([ f"{i}. [{sev}] {atype}", f" {detail[:120]}", @@ -132,40 +160,38 @@ def format_feedback(anomalies): return "\n".join(lines) -def main(): - raw = sys.stdin.read().strip() +def main() -> None: + """Entry point for the Claude Code PreToolUse hook.""" + raw: str = sys.stdin.read().strip() if not raw: sys.exit(0) try: - data = json.loads(raw) + data: Dict[str, Any] = json.loads(raw) except json.JSONDecodeError: data = {"content": raw} text, context = extract_content(data) - # Skip very short or binary content + # Skip very short content (e.g. "OK", empty bash results) if len(text.strip()) < 10: sys.exit(0) if not INSAITS_AVAILABLE: - print( - "[InsAIts] Not installed. Run: pip install insa-its", - file=sys.stderr, - ) + log.warning("Not installed. Run: pip install insa-its") sys.exit(0) - # Enable dev mode (no API key needed for local detection) - os.environ.setdefault("INSAITS_DEV_MODE", "true") - - monitor = insAItsMonitor(session_name="claude-code-hook") - result = monitor.send_message( + monitor: insAItsMonitor = insAItsMonitor( + session_name="claude-code-hook", + dev_mode=True, + ) + result: Dict[str, Any] = monitor.send_message( text=text[:4000], sender_id="claude-code", llm_id=os.environ.get("INSAITS_MODEL", "claude-opus"), ) - anomalies = result.get("anomalies", []) + anomalies: List[Any] = result.get("anomalies", []) # Write audit event regardless of findings write_audit({ @@ -177,22 +203,23 @@ def main(): }) if not anomalies: - if os.environ.get("INSAITS_VERBOSE"): - print("[InsAIts] Clean — no anomalies.", file=sys.stderr) + log.debug("Clean -- no anomalies detected.") sys.exit(0) - # Check severity - has_critical = any( + # Determine maximum severity + has_critical: bool = any( getattr(a, "severity", "") in ("CRITICAL", "critical") for a in anomalies ) - feedback = format_feedback(anomalies) + feedback: str = format_feedback(anomalies) if has_critical: - print(feedback) # stdout -> Claude Code shows to model - sys.exit(2) # block action + # stdout feedback -> Claude Code shows to the model + sys.stdout.write(feedback + "\n") + sys.exit(2) # PreToolUse exit 2 = block tool execution else: - print(feedback, file=sys.stderr) # stderr -> logged only + # Non-critical: warn via stderr (non-blocking) + log.warning("\n%s", feedback) sys.exit(0) diff --git a/scripts/hooks/insaits-security-wrapper.js b/scripts/hooks/insaits-security-wrapper.js new file mode 100644 index 00000000..6223b368 --- /dev/null +++ b/scripts/hooks/insaits-security-wrapper.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * InsAIts Security Monitor — wrapper for run-with-flags compatibility. + * + * This thin wrapper receives stdin from the hooks infrastructure and + * delegates to the Python-based insaits-security-monitor.py script. + * + * The wrapper exists because run-with-flags.js spawns child scripts + * via `node`, so a JS entry point is needed to bridge to Python. + */ + +'use strict'; + +const path = require('path'); +const { spawnSync } = require('child_process'); + +const MAX_STDIN = 1024 * 1024; + +let raw = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => { + if (raw.length < MAX_STDIN) { + raw += chunk.substring(0, MAX_STDIN - raw.length); + } +}); + +process.stdin.on('end', () => { + const scriptDir = __dirname; + const pyScript = path.join(scriptDir, 'insaits-security-monitor.py'); + + // Try python3 first (macOS/Linux), fall back to python (Windows) + const pythonCandidates = ['python3', 'python']; + let result; + + for (const pythonBin of pythonCandidates) { + result = spawnSync(pythonBin, [pyScript], { + input: raw, + encoding: 'utf8', + env: process.env, + cwd: process.cwd(), + timeout: 14000, + }); + + // ENOENT means binary not found — try next candidate + if (result.error && result.error.code === 'ENOENT') { + continue; + } + break; + } + + if (!result || (result.error && result.error.code === 'ENOENT')) { + process.stderr.write('[InsAIts] python3/python not found. Install Python 3.9+ and: pip install insa-its\n'); + process.stdout.write(raw); + process.exit(0); + } + + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + + const code = Number.isInteger(result.status) ? result.status : 0; + process.exit(code); +}); From 6c56e541dd9c1ba6524cffd61e89927ca1fea809 Mon Sep 17 00:00:00 2001 From: Nomadu27 Date: Tue, 10 Mar 2026 18:08:19 +0100 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20address=20cubic-dev-ai=20review=20?= =?UTF-8?q?=E2=80=94=203=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: Log non-ENOENT spawn errors (timeout, signal kill) to stderr instead of silently exiting 0. Separate handling for result.error and null result.status so users know when the security monitor failed to run. P1: Remove "async": true from hooks.json — async hooks run in the background and cannot block tool execution. The security hook needs to be synchronous so exit(2) actually prevents credential exposure and other critical findings from proceeding. P2: Remove dead tool_response/tool_result code from extract_content. In a PreToolUse hook the tool hasn't executed yet, so tool_response is never populated. Removed the variable and the unreachable branch that appended its content. Co-Authored-By: Claude Opus 4.6 --- hooks/hooks.json | 1 - scripts/hooks/insaits-security-monitor.py | 12 ++---------- scripts/hooks/insaits-security-wrapper.js | 19 +++++++++++++++++-- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/hooks/hooks.json b/hooks/hooks.json index 3edc6778..64b2fe83 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -70,7 +70,6 @@ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:insaits-security\" \"scripts/hooks/insaits-security-wrapper.js\" \"standard,strict\"", - "async": true, "timeout": 15 } ], diff --git a/scripts/hooks/insaits-security-monitor.py b/scripts/hooks/insaits-security-monitor.py index e0a8ab14..5e8c0b34 100644 --- a/scripts/hooks/insaits-security-monitor.py +++ b/scripts/hooks/insaits-security-monitor.py @@ -58,7 +58,7 @@ import logging import os import sys import time -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Tuple # Configure logging to stderr so it does not interfere with stdout protocol logging.basicConfig( @@ -87,7 +87,6 @@ def extract_content(data: Dict[str, Any]) -> Tuple[str, str]: """ tool_name: str = data.get("tool_name", "") tool_input: Dict[str, Any] = data.get("tool_input", {}) - tool_result: Any = data.get("tool_response", {}) text: str = "" context: str = "" @@ -96,16 +95,9 @@ def extract_content(data: Dict[str, Any]) -> Tuple[str, str]: text = tool_input.get("content", "") or tool_input.get("new_string", "") context = "file:" + str(tool_input.get("file_path", ""))[:80] elif tool_name == "Bash": + # PreToolUse: the tool hasn't executed yet, inspect the command command: str = str(tool_input.get("command", "")) - # For PreToolUse we inspect the command itself text = command - # Also check tool_response if present (for flexibility) - if isinstance(tool_result, dict): - output = tool_result.get("output", "") or tool_result.get("stdout", "") - if output: - text = text + "\n" + output - elif isinstance(tool_result, str) and tool_result: - text = text + "\n" + tool_result context = "bash:" + command[:80] elif "content" in data: content: Any = data["content"] diff --git a/scripts/hooks/insaits-security-wrapper.js b/scripts/hooks/insaits-security-wrapper.js index 6223b368..eba87128 100644 --- a/scripts/hooks/insaits-security-wrapper.js +++ b/scripts/hooks/insaits-security-wrapper.js @@ -54,9 +54,24 @@ process.stdin.on('end', () => { process.exit(0); } + // Log non-ENOENT spawn errors (timeout, signal kill, etc.) so users + // know the security monitor did not run — fail-open with a warning. + if (result.error) { + process.stderr.write(`[InsAIts] Security monitor failed to run: ${result.error.message}\n`); + process.stdout.write(raw); + process.exit(0); + } + if (result.stdout) process.stdout.write(result.stdout); if (result.stderr) process.stderr.write(result.stderr); - const code = Number.isInteger(result.status) ? result.status : 0; - process.exit(code); + // result.status is null when the process was killed by a signal or + // timed out. Treat that as an error rather than silently passing. + if (!Number.isInteger(result.status)) { + const signal = result.signal || 'unknown'; + process.stderr.write(`[InsAIts] Security monitor killed (signal: ${signal}). Tool execution continues.\n`); + process.exit(0); + } + + process.exit(result.status); }); From 0405ade5f4d70b1f1d65cadd48dc648fcab84630 Mon Sep 17 00:00:00 2001 From: Nomadu27 Date: Tue, 10 Mar 2026 18:09:02 +0100 Subject: [PATCH 4/8] fix: make dev_mode configurable via INSAITS_DEV_MODE env var Defaults to true (no API key needed) but can be disabled by setting INSAITS_DEV_MODE=false for production deployments with an API key. Co-Authored-By: Claude Opus 4.6 --- scripts/hooks/insaits-security-monitor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/hooks/insaits-security-monitor.py b/scripts/hooks/insaits-security-monitor.py index 5e8c0b34..4cf64074 100644 --- a/scripts/hooks/insaits-security-monitor.py +++ b/scripts/hooks/insaits-security-monitor.py @@ -175,7 +175,7 @@ def main() -> None: monitor: insAItsMonitor = insAItsMonitor( session_name="claude-code-hook", - dev_mode=True, + dev_mode=os.environ.get("INSAITS_DEV_MODE", "true").lower() in ("1", "true", "yes"), ) result: Dict[str, Any] = monitor.send_message( text=text[:4000], From 68fc85ea492b8da946e89f190e917879005b417f Mon Sep 17 00:00:00 2001 From: Nomadu27 Date: Tue, 10 Mar 2026 18:25:23 +0100 Subject: [PATCH 5/8] fix: address cubic-dev-ai + coderabbit round 3 review cubic-dev-ai P2: dev_mode now defaults to "false" (strict mode). Users opt in to dev mode by setting INSAITS_DEV_MODE=true. cubic-dev-ai P2: Move null-status check above stdout/stderr writes in wrapper so partial/corrupt output is never leaked. Pass through original raw input on signal kill, matching the result.error path. coderabbit major: Wrap insAItsMonitor() and send_message() in try/except so SDK errors don't crash the hook. Logs warning and exits 0 (fail-open) on exception. coderabbit nitpick: write_audit now creates a new dict (enriched) instead of mutating the caller's event dict. coderabbit nitpick: Extract magic numbers to named constants: MIN_CONTENT_LENGTH=10, MAX_SCAN_LENGTH=4000, DEFAULT_MODEL. Also: added env var documentation to module docstring. Co-Authored-By: Claude Opus 4.6 --- scripts/hooks/insaits-security-monitor.py | 53 ++++++++++++++++------- scripts/hooks/insaits-security-wrapper.js | 10 +++-- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/scripts/hooks/insaits-security-monitor.py b/scripts/hooks/insaits-security-monitor.py index 4cf64074..43e5be74 100644 --- a/scripts/hooks/insaits-security-monitor.py +++ b/scripts/hooks/insaits-security-monitor.py @@ -36,6 +36,12 @@ How it works: Exit code 2 = critical issue found (blocks tool execution). Stderr output = non-blocking warning shown to Claude. +Environment variables: + INSAITS_DEV_MODE Set to "true" to enable dev mode (no API key needed). + Defaults to "false" (strict mode). + INSAITS_MODEL LLM model identifier for fingerprinting. Default: claude-opus. + INSAITS_VERBOSE Set to any value to enable debug logging. + Detections include: - Credential exposure (API keys, tokens, passwords) - Prompt injection patterns @@ -75,7 +81,11 @@ try: except ImportError: INSAITS_AVAILABLE = False +# --- Constants --- AUDIT_FILE: str = ".insaits_audit_session.jsonl" +MIN_CONTENT_LENGTH: int = 10 +MAX_SCAN_LENGTH: int = 4000 +DEFAULT_MODEL: str = "claude-opus" def extract_content(data: Dict[str, Any]) -> Tuple[str, str]: @@ -113,14 +123,20 @@ def extract_content(data: Dict[str, Any]) -> Tuple[str, str]: def write_audit(event: Dict[str, Any]) -> None: - """Append an audit event to the JSONL audit log.""" + """Append an audit event to the JSONL audit log. + + Creates a new dict to avoid mutating the caller's *event*. + """ try: - event["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - event["hash"] = hashlib.sha256( - json.dumps(event, sort_keys=True).encode() + enriched: Dict[str, Any] = { + **event, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + enriched["hash"] = hashlib.sha256( + json.dumps(enriched, sort_keys=True).encode() ).hexdigest()[:16] with open(AUDIT_FILE, "a", encoding="utf-8") as f: - f.write(json.dumps(event) + "\n") + f.write(json.dumps(enriched) + "\n") except OSError as exc: log.warning("Failed to write audit log %s: %s", AUDIT_FILE, exc) @@ -166,22 +182,29 @@ def main() -> None: text, context = extract_content(data) # Skip very short content (e.g. "OK", empty bash results) - if len(text.strip()) < 10: + if len(text.strip()) < MIN_CONTENT_LENGTH: sys.exit(0) if not INSAITS_AVAILABLE: log.warning("Not installed. Run: pip install insa-its") sys.exit(0) - monitor: insAItsMonitor = insAItsMonitor( - session_name="claude-code-hook", - dev_mode=os.environ.get("INSAITS_DEV_MODE", "true").lower() in ("1", "true", "yes"), - ) - result: Dict[str, Any] = monitor.send_message( - text=text[:4000], - sender_id="claude-code", - llm_id=os.environ.get("INSAITS_MODEL", "claude-opus"), - ) + # Wrap SDK calls so an internal error does not crash the hook + try: + monitor: insAItsMonitor = insAItsMonitor( + session_name="claude-code-hook", + dev_mode=os.environ.get( + "INSAITS_DEV_MODE", "false" + ).lower() in ("1", "true", "yes"), + ) + result: Dict[str, Any] = monitor.send_message( + text=text[:MAX_SCAN_LENGTH], + sender_id="claude-code", + llm_id=os.environ.get("INSAITS_MODEL", DEFAULT_MODEL), + ) + except Exception as exc: + log.warning("SDK error, skipping security scan: %s", exc) + sys.exit(0) anomalies: List[Any] = result.get("anomalies", []) diff --git a/scripts/hooks/insaits-security-wrapper.js b/scripts/hooks/insaits-security-wrapper.js index eba87128..2ae10857 100644 --- a/scripts/hooks/insaits-security-wrapper.js +++ b/scripts/hooks/insaits-security-wrapper.js @@ -62,16 +62,18 @@ process.stdin.on('end', () => { process.exit(0); } - if (result.stdout) process.stdout.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); - // result.status is null when the process was killed by a signal or - // timed out. Treat that as an error rather than silently passing. + // timed out. Check BEFORE writing stdout to avoid leaking partial + // or corrupt monitor output. Pass through original raw input instead. if (!Number.isInteger(result.status)) { const signal = result.signal || 'unknown'; process.stderr.write(`[InsAIts] Security monitor killed (signal: ${signal}). Tool execution continues.\n`); + process.stdout.write(raw); process.exit(0); } + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + process.exit(result.status); }); From e30109829b3070a276ff0abdf33339b996237238 Mon Sep 17 00:00:00 2001 From: Nomadu27 Date: Tue, 10 Mar 2026 18:53:21 +0100 Subject: [PATCH 6/8] fix: dict anomaly access, configurable fail mode, exception type logging - Add get_anomaly_attr() helper that handles both dict and object anomalies. The SDK's send_message() returns dicts, so getattr() was silently returning defaults -- critical blocking never triggered. - Fix field name: "detail" -> "details" (matches SDK schema). - Make fail-open/fail-closed configurable via INSAITS_FAIL_MODE env var (defaults to "open" for backward compatibility). - Include exception type name in fail-open log for diagnostics. - Normalize severity comparison with .upper() for case-insensitive matching. Co-Authored-By: Claude Opus 4.6 --- scripts/hooks/insaits-security-monitor.py | 36 +++++++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/scripts/hooks/insaits-security-monitor.py b/scripts/hooks/insaits-security-monitor.py index 43e5be74..16afbfbf 100644 --- a/scripts/hooks/insaits-security-monitor.py +++ b/scripts/hooks/insaits-security-monitor.py @@ -40,6 +40,8 @@ Environment variables: INSAITS_DEV_MODE Set to "true" to enable dev mode (no API key needed). Defaults to "false" (strict mode). INSAITS_MODEL LLM model identifier for fingerprinting. Default: claude-opus. + INSAITS_FAIL_MODE "open" (default) = continue on SDK errors. + "closed" = block tool execution on SDK errors. INSAITS_VERBOSE Set to any value to enable debug logging. Detections include: @@ -141,6 +143,18 @@ def write_audit(event: Dict[str, Any]) -> None: log.warning("Failed to write audit log %s: %s", AUDIT_FILE, exc) +def get_anomaly_attr(anomaly: Any, key: str, default: str = "") -> str: + """Get a field from an anomaly that may be a dict or an object. + + The SDK's ``send_message()`` returns anomalies as dicts, while + other code paths may return dataclass/object instances. This + helper handles both transparently. + """ + if isinstance(anomaly, dict): + return str(anomaly.get(key, default)) + return str(getattr(anomaly, key, default)) + + def format_feedback(anomalies: List[Any]) -> str: """Format detected anomalies as feedback for Claude Code. @@ -152,9 +166,9 @@ def format_feedback(anomalies: List[Any]) -> str: "", ] for i, a in enumerate(anomalies, 1): - sev: str = getattr(a, "severity", "MEDIUM") - atype: str = getattr(a, "type", "UNKNOWN") - detail: str = getattr(a, "detail", "") + sev: str = get_anomaly_attr(a, "severity", "MEDIUM") + atype: str = get_anomaly_attr(a, "type", "UNKNOWN") + detail: str = get_anomaly_attr(a, "details", "") lines.extend([ f"{i}. [{sev}] {atype}", f" {detail[:120]}", @@ -203,7 +217,17 @@ def main() -> None: llm_id=os.environ.get("INSAITS_MODEL", DEFAULT_MODEL), ) except Exception as exc: - log.warning("SDK error, skipping security scan: %s", exc) + fail_mode: str = os.environ.get("INSAITS_FAIL_MODE", "open").lower() + if fail_mode == "closed": + sys.stdout.write( + f"InsAIts SDK error ({type(exc).__name__}); " + "blocking execution to avoid unscanned input.\n" + ) + sys.exit(2) + log.warning( + "SDK error (%s), skipping security scan: %s", + type(exc).__name__, exc, + ) sys.exit(0) anomalies: List[Any] = result.get("anomalies", []) @@ -213,7 +237,7 @@ def main() -> None: "tool": data.get("tool_name", "unknown"), "context": context, "anomaly_count": len(anomalies), - "anomaly_types": [getattr(a, "type", "") for a in anomalies], + "anomaly_types": [get_anomaly_attr(a, "type") for a in anomalies], "text_length": len(text), }) @@ -223,7 +247,7 @@ def main() -> None: # Determine maximum severity has_critical: bool = any( - getattr(a, "severity", "") in ("CRITICAL", "critical") for a in anomalies + get_anomaly_attr(a, "severity").upper() in ("CRITICAL",) for a in anomalies ) feedback: str = format_feedback(anomalies) From 9ea415c0370aa67baf4fd5db348a57f5c2b5c1a7 Mon Sep 17 00:00:00 2001 From: Nomadu27 Date: Tue, 10 Mar 2026 19:06:56 +0100 Subject: [PATCH 7/8] fix: extract BLOCKING_SEVERITIES constant, document broad catch - Extract BLOCKING_SEVERITIES frozenset for extensible severity checks. - Add inline comment on broad Exception catch explaining intentional SDK fault-tolerance pattern (BLE001 acknowledged). Co-Authored-By: Claude Opus 4.6 --- scripts/hooks/insaits-security-monitor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/hooks/insaits-security-monitor.py b/scripts/hooks/insaits-security-monitor.py index 16afbfbf..2e3080af 100644 --- a/scripts/hooks/insaits-security-monitor.py +++ b/scripts/hooks/insaits-security-monitor.py @@ -88,6 +88,7 @@ AUDIT_FILE: str = ".insaits_audit_session.jsonl" MIN_CONTENT_LENGTH: int = 10 MAX_SCAN_LENGTH: int = 4000 DEFAULT_MODEL: str = "claude-opus" +BLOCKING_SEVERITIES: frozenset = frozenset({"CRITICAL"}) def extract_content(data: Dict[str, Any]) -> Tuple[str, str]: @@ -216,7 +217,7 @@ def main() -> None: sender_id="claude-code", llm_id=os.environ.get("INSAITS_MODEL", DEFAULT_MODEL), ) - except Exception as exc: + except Exception as exc: # Broad catch intentional: unknown SDK internals fail_mode: str = os.environ.get("INSAITS_FAIL_MODE", "open").lower() if fail_mode == "closed": sys.stdout.write( @@ -247,7 +248,8 @@ def main() -> None: # Determine maximum severity has_critical: bool = any( - get_anomaly_attr(a, "severity").upper() in ("CRITICAL",) for a in anomalies + get_anomaly_attr(a, "severity").upper() in BLOCKING_SEVERITIES + for a in anomalies ) feedback: str = format_feedback(anomalies) From 9c1e8dd1e41b2d7f004a375f3517458a0b59482b Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 10 Mar 2026 20:47:09 -0700 Subject: [PATCH 8/8] fix: make insaits hook opt-in --- hooks/README.md | 2 +- hooks/hooks.json | 4 +-- scripts/hooks/insaits-security-monitor.py | 5 +-- scripts/hooks/insaits-security-wrapper.js | 9 +++++ tests/hooks/hooks.test.js | 41 +++++++++++++++++++++++ 5 files changed, 56 insertions(+), 5 deletions(-) diff --git a/hooks/README.md b/hooks/README.md index dde8a020..490c09ba 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -25,7 +25,7 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes | **Git push reminder** | `Bash` | Reminds to review changes before `git push` | 0 (warns) | | **Doc file warning** | `Write` | Warns about non-standard `.md`/`.txt` files (allows README, CLAUDE, CONTRIBUTING, CHANGELOG, LICENSE, SKILL, docs/, skills/); cross-platform path handling | 0 (warns) | | **Strategic compact** | `Edit\|Write` | Suggests manual `/compact` at logical intervals (every ~50 tool calls) | 0 (warns) | -| **InsAIts security monitor** | `*` | Detects credential exposure, prompt injection, hallucinations, and behavioral anomalies (23 types) before tool execution. Blocks on critical findings, warns on non-critical. Writes audit log to `.insaits_audit_session.jsonl`. Requires `pip install insa-its`. [Details](../scripts/hooks/insaits-security-monitor.py) | 2 (blocks critical) / 0 (warns) | +| **InsAIts security monitor (opt-in)** | `Bash\|Write\|Edit\|MultiEdit` | Optional security scan for high-signal tool inputs. Disabled unless `ECC_ENABLE_INSAITS=1`. Blocks on critical findings, warns on non-critical, and writes audit log to `.insaits_audit_session.jsonl`. Requires `pip install insa-its`. [Details](../scripts/hooks/insaits-security-monitor.py) | 2 (blocks critical) / 0 (warns) | ### PostToolUse Hooks diff --git a/hooks/hooks.json b/hooks/hooks.json index 64b2fe83..3147db2e 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -65,7 +65,7 @@ "description": "Capture tool use observations for continuous learning" }, { - "matcher": "*", + "matcher": "Bash|Write|Edit|MultiEdit", "hooks": [ { "type": "command", @@ -73,7 +73,7 @@ "timeout": 15 } ], - "description": "InsAIts AI security monitor: detects credential exposure, prompt injection, hallucinations, and 20+ anomaly types before tool execution. Requires: pip install insa-its" + "description": "Optional InsAIts AI security monitor for Bash/Edit/Write flows. Enable with ECC_ENABLE_INSAITS=1. Requires: pip install insa-its" } ], "PreCompact": [ diff --git a/scripts/hooks/insaits-security-monitor.py b/scripts/hooks/insaits-security-monitor.py index 2e3080af..da1bbf24 100644 --- a/scripts/hooks/insaits-security-monitor.py +++ b/scripts/hooks/insaits-security-monitor.py @@ -11,17 +11,18 @@ Writes audit events to .insaits_audit_session.jsonl for forensic tracing. Setup: pip install insa-its + export ECC_ENABLE_INSAITS=1 Add to .claude/settings.json: { "hooks": { "PreToolUse": [ { - "matcher": ".*", + "matcher": "Bash|Write|Edit|MultiEdit", "hooks": [ { "type": "command", - "command": "python3 scripts/hooks/insaits-security-monitor.py" + "command": "node scripts/hooks/insaits-security-wrapper.js" } ] } diff --git a/scripts/hooks/insaits-security-wrapper.js b/scripts/hooks/insaits-security-wrapper.js index 2ae10857..9f3e46d8 100644 --- a/scripts/hooks/insaits-security-wrapper.js +++ b/scripts/hooks/insaits-security-wrapper.js @@ -16,6 +16,10 @@ const { spawnSync } = require('child_process'); const MAX_STDIN = 1024 * 1024; +function isEnabled(value) { + return ['1', 'true', 'yes', 'on'].includes(String(value || '').toLowerCase()); +} + let raw = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { @@ -25,6 +29,11 @@ process.stdin.on('data', chunk => { }); process.stdin.on('end', () => { + if (!isEnabled(process.env.ECC_ENABLE_INSAITS)) { + process.stdout.write(raw); + process.exit(0); + } + const scriptDir = __dirname; const pyScript = path.join(scriptDir, 'insaits-security-monitor.py'); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 2c873204..be7dd394 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -203,6 +203,24 @@ async function runTests() { } })) passed++; else failed++; + // insaits-security-wrapper.js tests + console.log('\ninsaits-security-wrapper.js:'); + + if (await asyncTest('passes through input unchanged when integration is disabled', async () => { + const stdinData = JSON.stringify({ + tool_name: 'Write', + tool_input: { file_path: 'src/index.ts', content: 'console.log("ok");' } + }); + const result = await runScript( + path.join(scriptsDir, 'insaits-security-wrapper.js'), + stdinData, + { ECC_ENABLE_INSAITS: '' } + ); + assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); + assert.strictEqual(result.stdout, stdinData, 'Should pass stdin through unchanged'); + assert.strictEqual(result.stderr, '', 'Should stay silent when integration is disabled'); + })) passed++; else failed++; + // check-console-log.js tests console.log('\ncheck-console-log.js:'); @@ -1237,6 +1255,29 @@ async function runTests() { } })) passed++; else failed++; + if (test('InsAIts hook is opt-in and scoped to high-signal tool inputs', () => { + const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); + const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); + const insaitsHook = hooks.hooks.PreToolUse.find(entry => + entry.description && entry.description.includes('InsAIts') + ); + + assert.ok(insaitsHook, 'Should define an InsAIts PreToolUse hook'); + assert.strictEqual( + insaitsHook.matcher, + 'Bash|Write|Edit|MultiEdit', + 'InsAIts hook should avoid matching every tool' + ); + assert.ok( + insaitsHook.description.includes('ECC_ENABLE_INSAITS=1'), + 'InsAIts hook should document explicit opt-in' + ); + assert.ok( + insaitsHook.hooks[0].command.includes('insaits-security-wrapper.js'), + 'InsAIts hook should execute through the JS wrapper' + ); + })) passed++; else failed++; + // plugin.json validation console.log('\nplugin.json Validation:');