diff --git a/hooks/README.md b/hooks/README.md index 3b81d6b2a..490c09ba9 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 (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 342b5b813..3147db2ec 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -63,6 +63,17 @@ } ], "description": "Capture tool use observations for continuous learning" + }, + { + "matcher": "Bash|Write|Edit|MultiEdit", + "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\"", + "timeout": 15 + } + ], + "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/mcp-configs/mcp-servers.json b/mcp-configs/mcp-servers.json index f6642e316..64b1ad004 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": "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" } }, "_comments": { diff --git a/scripts/hooks/insaits-security-monitor.py b/scripts/hooks/insaits-security-monitor.py new file mode 100644 index 000000000..da1bbf242 --- /dev/null +++ b/scripts/hooks/insaits-security-monitor.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +""" +InsAIts Security Monitor -- PreToolUse Hook for Claude Code +============================================================ + +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. + +Setup: + pip install insa-its + export ECC_ENABLE_INSAITS=1 + + Add to .claude/settings.json: + { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "node scripts/hooks/insaits-security-wrapper.js" + } + ] + } + ] + } + } + +How it works: + 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 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_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: + - 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. + +Author: Cristi Bogdan -- YuyAI (https://github.com/Nomadu27/InsAIts) +License: Apache 2.0 +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import sys +import time +from typing import Any, Dict, List, 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: bool = True +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" +BLOCKING_SEVERITIES: frozenset = frozenset({"CRITICAL"}) + + +def extract_content(data: Dict[str, Any]) -> Tuple[str, str]: + """Extract inspectable text from a Claude Code tool input payload. + + 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", {}) + + text: str = "" + context: str = "" + + if tool_name in ("Write", "Edit", "MultiEdit"): + 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", "")) + text = command + context = "bash:" + command[:80] + elif "content" in data: + 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 = str(data.get("task", "")) + + return text, context + + +def write_audit(event: Dict[str, Any]) -> None: + """Append an audit event to the JSONL audit log. + + Creates a new dict to avoid mutating the caller's *event*. + """ + try: + 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(enriched) + "\n") + except OSError as exc: + 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. + + 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: 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]}", + "", + ]) + lines.extend([ + "-" * 56, + "Fix the issues above before continuing.", + "Audit log: " + AUDIT_FILE, + ]) + return "\n".join(lines) + + +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: Dict[str, Any] = json.loads(raw) + except json.JSONDecodeError: + data = {"content": raw} + + text, context = extract_content(data) + + # Skip very short content (e.g. "OK", empty bash results) + 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) + + # 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: # Broad catch intentional: unknown SDK internals + 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", []) + + # Write audit event regardless of findings + write_audit({ + "tool": data.get("tool_name", "unknown"), + "context": context, + "anomaly_count": len(anomalies), + "anomaly_types": [get_anomaly_attr(a, "type") for a in anomalies], + "text_length": len(text), + }) + + if not anomalies: + log.debug("Clean -- no anomalies detected.") + sys.exit(0) + + # Determine maximum severity + has_critical: bool = any( + get_anomaly_attr(a, "severity").upper() in BLOCKING_SEVERITIES + for a in anomalies + ) + + feedback: str = format_feedback(anomalies) + + if has_critical: + # stdout feedback -> Claude Code shows to the model + sys.stdout.write(feedback + "\n") + sys.exit(2) # PreToolUse exit 2 = block tool execution + else: + # Non-critical: warn via stderr (non-blocking) + log.warning("\n%s", feedback) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/scripts/hooks/insaits-security-wrapper.js b/scripts/hooks/insaits-security-wrapper.js new file mode 100644 index 000000000..9f3e46d83 --- /dev/null +++ b/scripts/hooks/insaits-security-wrapper.js @@ -0,0 +1,88 @@ +#!/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; + +function isEnabled(value) { + return ['1', 'true', 'yes', 'on'].includes(String(value || '').toLowerCase()); +} + +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', () => { + 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'); + + // 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); + } + + // 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); + } + + // result.status is null when the process was killed by a signal or + // 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); +}); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 154af48f6..8cbfa3acd 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -290,6 +290,28 @@ 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:'); @@ -1649,6 +1671,24 @@ 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:');