fix: address CodeRabbit review — convert to PreToolUse, add type annotations, logging

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 <noreply@anthropic.com>
This commit is contained in:
Nomadu27
2026-03-10 17:52:44 +01:00
parent 540f738cc7
commit 44dc96d2c6
5 changed files with 166 additions and 76 deletions

View File

@@ -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) | | **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) | | **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) | | **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 ### 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 | | **Prettier format** | `Edit` | Auto-formats JS/TS files with Prettier after edits |
| **TypeScript check** | `Edit` | Runs `tsc --noEmit` after editing `.ts`/`.tsx` files | | **TypeScript check** | `Edit` | Runs `tsc --noEmit` after editing `.ts`/`.tsx` files |
| **console.log warning** | `Edit` | Warns about `console.log` statements in edited 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 ### Lifecycle Hooks

View File

@@ -63,6 +63,18 @@
} }
], ],
"description": "Capture tool use observations for continuous learning" "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": [ "PreCompact": [
@@ -165,17 +177,6 @@
} }
], ],
"description": "Capture tool use results for continuous learning" "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": [ "Stop": [

View File

@@ -90,7 +90,7 @@
"description": "Filesystem operations (set your path)" "description": "Filesystem operations (set your path)"
}, },
"insaits": { "insaits": {
"command": "python", "command": "python3",
"args": ["-m", "insa_its.mcp_server"], "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" "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"
} }

View File

@@ -1,11 +1,11 @@
#!/usr/bin/env python3 #!/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. Real-time security monitoring for Claude Code tool inputs.
Catches credential exposure, prompt injection, behavioral anomalies, Detects credential exposure, prompt injection, behavioral anomalies,
hallucination chains, and 20+ other anomaly types runs 100% locally. hallucination chains, and 20+ other anomaly types -- runs 100% locally.
Writes audit events to .insaits_audit_session.jsonl for forensic tracing. Writes audit events to .insaits_audit_session.jsonl for forensic tracing.
@@ -15,13 +15,13 @@ Setup:
Add to .claude/settings.json: Add to .claude/settings.json:
{ {
"hooks": { "hooks": {
"PostToolUse": [ "PreToolUse": [
{ {
"matcher": ".*", "matcher": ".*",
"hooks": [ "hooks": [
{ {
"type": "command", "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: How it works:
Claude Code passes tool result as JSON on stdin. Claude Code passes tool input as JSON on stdin.
This script runs InsAIts anomaly detection on the output. This script runs InsAIts anomaly detection on the content.
Exit code 0 = clean (pass through). 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: Detections include:
- Credential exposure (API keys, tokens, passwords in output) - Credential exposure (API keys, tokens, passwords)
- Prompt injection patterns - Prompt injection patterns
- Hallucination indicators (phantom citations, fact contradictions) - Hallucination indicators (phantom citations, fact contradictions)
- Behavioral anomalies (context loss, semantic drift) - Behavioral anomalies (context loss, semantic drift)
- Tool description divergence - Tool description divergence
- Shorthand emergence / jargon drift - 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 License: Apache 2.0
""" """
import sys from __future__ import annotations
import json
import os
import hashlib import hashlib
import json
import logging
import os
import sys
import time 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 importing InsAIts SDK
try: try:
from insa_its import insAItsMonitor from insa_its import insAItsMonitor
INSAITS_AVAILABLE = True INSAITS_AVAILABLE: bool = True
except ImportError: except ImportError:
INSAITS_AVAILABLE = False INSAITS_AVAILABLE = False
AUDIT_FILE = ".insaits_audit_session.jsonl" AUDIT_FILE: str = ".insaits_audit_session.jsonl"
def extract_content(data): def extract_content(data: Dict[str, Any]) -> Tuple[str, str]:
"""Extract inspectable text from a Claude Code tool result.""" """Extract inspectable text from a Claude Code tool input payload.
tool_name = data.get("tool_name", "")
tool_input = data.get("tool_input", {})
tool_result = data.get("tool_response", {})
text = "" Returns:
context = "" 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"): if tool_name in ("Write", "Edit", "MultiEdit"):
text = tool_input.get("content", "") or tool_input.get("new_string", "") 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": 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): if isinstance(tool_result, dict):
text = tool_result.get("output", "") or tool_result.get("stdout", "") output = tool_result.get("output", "") or tool_result.get("stdout", "")
elif isinstance(tool_result, str): if output:
text = tool_result text = text + "\n" + output
context = "bash:" + str(tool_input.get("command", ""))[:80] elif isinstance(tool_result, str) and tool_result:
text = text + "\n" + tool_result
context = "bash:" + command[:80]
elif "content" in data: elif "content" in data:
content = data["content"] content: Any = data["content"]
if isinstance(content, list): if isinstance(content, list):
text = "\n".join( text = "\n".join(
b.get("text", "") for b in content if b.get("type") == "text" b.get("text", "") for b in content if b.get("type") == "text"
) )
elif isinstance(content, str): elif isinstance(content, str):
text = content text = content
context = data.get("task", "") context = str(data.get("task", ""))
return text, context return text, context
def write_audit(event): 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."""
try: try:
event["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) event["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@@ -105,20 +129,24 @@ def write_audit(event):
).hexdigest()[:16] ).hexdigest()[:16]
with open(AUDIT_FILE, "a", encoding="utf-8") as f: with open(AUDIT_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(event) + "\n") f.write(json.dumps(event) + "\n")
except OSError: except OSError as exc:
pass log.warning("Failed to write audit log %s: %s", AUDIT_FILE, exc)
def format_feedback(anomalies): def format_feedback(anomalies: List[Any]) -> str:
"""Format detected anomalies as feedback for Claude Code.""" """Format detected anomalies as feedback for Claude Code.
lines = [
"== InsAIts Security Monitor — Issues Detected ==", 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): for i, a in enumerate(anomalies, 1):
sev = getattr(a, "severity", "MEDIUM") sev: str = getattr(a, "severity", "MEDIUM")
atype = getattr(a, "type", "UNKNOWN") atype: str = getattr(a, "type", "UNKNOWN")
detail = getattr(a, "detail", "") detail: str = getattr(a, "detail", "")
lines.extend([ lines.extend([
f"{i}. [{sev}] {atype}", f"{i}. [{sev}] {atype}",
f" {detail[:120]}", f" {detail[:120]}",
@@ -132,40 +160,38 @@ def format_feedback(anomalies):
return "\n".join(lines) return "\n".join(lines)
def main(): def main() -> None:
raw = sys.stdin.read().strip() """Entry point for the Claude Code PreToolUse hook."""
raw: str = sys.stdin.read().strip()
if not raw: if not raw:
sys.exit(0) sys.exit(0)
try: try:
data = json.loads(raw) data: Dict[str, Any] = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
data = {"content": raw} data = {"content": raw}
text, context = extract_content(data) 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: if len(text.strip()) < 10:
sys.exit(0) sys.exit(0)
if not INSAITS_AVAILABLE: if not INSAITS_AVAILABLE:
print( log.warning("Not installed. Run: pip install insa-its")
"[InsAIts] Not installed. Run: pip install insa-its",
file=sys.stderr,
)
sys.exit(0) sys.exit(0)
# Enable dev mode (no API key needed for local detection) monitor: insAItsMonitor = insAItsMonitor(
os.environ.setdefault("INSAITS_DEV_MODE", "true") session_name="claude-code-hook",
dev_mode=True,
monitor = insAItsMonitor(session_name="claude-code-hook") )
result = monitor.send_message( result: Dict[str, Any] = monitor.send_message(
text=text[:4000], text=text[:4000],
sender_id="claude-code", sender_id="claude-code",
llm_id=os.environ.get("INSAITS_MODEL", "claude-opus"), 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 event regardless of findings
write_audit({ write_audit({
@@ -177,22 +203,23 @@ def main():
}) })
if not anomalies: if not anomalies:
if os.environ.get("INSAITS_VERBOSE"): log.debug("Clean -- no anomalies detected.")
print("[InsAIts] Clean — no anomalies.", file=sys.stderr)
sys.exit(0) sys.exit(0)
# Check severity # Determine maximum severity
has_critical = any( has_critical: bool = any(
getattr(a, "severity", "") in ("CRITICAL", "critical") for a in anomalies getattr(a, "severity", "") in ("CRITICAL", "critical") for a in anomalies
) )
feedback = format_feedback(anomalies) feedback: str = format_feedback(anomalies)
if has_critical: if has_critical:
print(feedback) # stdout -> Claude Code shows to model # stdout feedback -> Claude Code shows to the model
sys.exit(2) # block action sys.stdout.write(feedback + "\n")
sys.exit(2) # PreToolUse exit 2 = block tool execution
else: else:
print(feedback, file=sys.stderr) # stderr -> logged only # Non-critical: warn via stderr (non-blocking)
log.warning("\n%s", feedback)
sys.exit(0) sys.exit(0)

View File

@@ -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);
});