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
25 changes: 17 additions & 8 deletions webfetch-guard/scripts/test_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,32 @@
from pathlib import Path

HOOK = Path(__file__).parent / "webfetch-guard.py"
CURRENT_YEAR = datetime.now().year
OUTDATED_YEAR = CURRENT_YEAR - 1
now = datetime.now()
Y0 = now.year
M = now.month
Y1 = Y0 - 1
Y2 = Y0 - 2
GRACE = M <= 3

TESTS = [
("BLOCK outdated year", "WebFetch", {"url": f"https://example.com/{OUTDATED_YEAR}/api"}, "deny"),
("WARN current year", "WebSearch", {"query": f"Python best practices {CURRENT_YEAR}"}, "allow"),
("PASS silent", "WebFetch", {"url": "https://example.com/latest"}, None),
("IGNORE other", "Read", {"file_path": f"/file/{OUTDATED_YEAR}.txt"}, None),
("Block 2+ years ago", "WebFetch", {"url": f"https://example.com/{Y2}/api"}, "deny"),
(
f"{'Allow' if GRACE else 'Block'} previous year",
"WebSearch",
{"query": f"Python docs {Y1}"},
None if GRACE else "deny",
),
("Warn current year", "WebSearch", {"query": f"Python {Y0}"}, "allow"),
("Pass silent", "WebFetch", {"url": "https://example.com/latest"}, None),
("Ignore other tools", "Read", {"file_path": f"/file/{Y2}.txt"}, None),
]


def run_hook(tool_name: str, tool_input: dict) -> dict | None:
"""Run hook and return parsed output, or None if no output."""
payload = {"tool_name": tool_name, "tool_input": tool_input}
result = subprocess.run(
[sys.executable, str(HOOK)],
input=json.dumps(payload),
input=json.dumps({"tool_name": tool_name, "tool_input": tool_input}),
capture_output=True,
text=True,
)
Expand Down
181 changes: 64 additions & 117 deletions webfetch-guard/scripts/webfetch-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,143 +2,90 @@
"""
WebFetch Guard Hook

Intercepts WebFetch and WebSearch tool calls to:
1. BLOCK calls containing outdated year (previous year) in URL or query
2. WARN (but allow) calls containing current year with a date reminder

This helps ensure Claude uses current, up-to-date information.
Blocks outdated year references in WebFetch/WebSearch tool calls.
Grace period (Jan-Mar): allows previous year, blocks 2+ years ago.
After grace period (Apr-Dec): blocks previous year and older.
Warns (but allows) current year searches.
"""

import json
import re
import sys
from datetime import datetime


def get_current_date_info() -> dict:
"""Get current date information for warnings."""
now = datetime.now()
return {
"date": now.strftime("%Y-%m-%d"),
"year": now.year,
"formatted": now.strftime("%A, %B %d, %Y"),
}


def check_for_year_references(text: str, outdated_year: str, warn_year: str) -> dict:
"""
Check text for year references.

Args:
text: The text to search
outdated_year: Year to block (typically previous year)
warn_year: Year to warn about (typically current year)

Returns:
dict with 'has_outdated' and 'has_warn' boolean flags
"""
text_lower = text.lower()
return {
"has_outdated": outdated_year in text_lower,
"has_warn": warn_year in text_lower,
}


def build_deny_response(reason: str) -> dict:
"""Build a deny response for the hook."""
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}


def build_allow_response(reason: str) -> dict:
"""Build an allow response for the hook."""
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": reason,
}
}


def main():
# Read hook input from stdin
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Failed to parse input JSON: {e}", file=sys.stderr)
except json.JSONDecodeError:
sys.exit(1)

tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})

# Only process WebFetch and WebSearch
if tool_name not in ("WebFetch", "WebSearch"):
sys.exit(0)

# Extract the relevant text to check
# WebFetch uses 'url' and 'prompt', WebSearch uses 'query'
texts_to_check = []

if tool_name == "WebFetch":
url = tool_input.get("url", "")
prompt = tool_input.get("prompt", "")
texts_to_check = [url, prompt]
elif tool_name == "WebSearch":
query = tool_input.get("query", "")
texts_to_check = [query]

# Get current date info and determine years dynamically
date_info = get_current_date_info()
current_year = date_info["year"]
outdated_year = current_year - 1

# Combine all texts for checking
combined_text = " ".join(texts_to_check)
year_refs = check_for_year_references(
combined_text, str(outdated_year), str(current_year)
tool_input = input_data.get("tool_input", {})
text = (
f"{tool_input.get('url', '')} {tool_input.get('prompt', '')}"
if tool_name == "WebFetch"
else tool_input.get("query", "")
)
text_lower = text.lower()

# BLOCK: If outdated year is referenced
if year_refs["has_outdated"]:
deny_reason = (
f"BLOCKED: Your request contains '{outdated_year}' which is an outdated year reference.\n\n"
f"CURRENT DATE: {date_info['formatted']}\n"
f"CURRENT YEAR: {current_year}\n\n"
f"Please reformulate your request using the current year ({current_year}) "
f"or remove the year reference entirely to get current information.\n\n"
f"Tool: {tool_name}\n"
f"Input: {combined_text[:200]}..."
now = datetime.now()
current_year = now.year
current_month = now.month

# Grace period (Jan-Mar): allow previous year, block 2+ years ago
# After (Apr-Dec): block previous year and older
if current_month <= 3:
blocked_years = [current_year - 2]
else:
blocked_years = [current_year - 1, current_year - 2]

# Find blocked year in text using word boundaries
blocked_year = None
for year in blocked_years:
if re.search(rf"\b{year}\b", text_lower):
blocked_year = year
break

if blocked_year:
date_str = now.strftime("%B %d, %Y")
reason = (
f"BLOCKED: Your search contains '{blocked_year}' (outdated).\n\n"
f"Current date: {date_str}\n"
f"Current year: {current_year}\n\n"
f"Please search using the current year or remove the year reference."
)
output = build_deny_response(deny_reason)
print(json.dumps(output))
sys.exit(0)

# WARN: If current year is referenced (allow but warn)
if year_refs["has_warn"]:
warn_reason = (
f"WARNING: Date-specific search detected.\n\n"
f"====================================\n"
f"CURRENT DATE: {date_info['formatted']}\n"
f"CURRENT YEAR: {current_year}\n"
f"====================================\n\n"
f"IMPORTANT: Always verify the current date before running web searches. "
f"Your request contains '{current_year}', but remember:\n"
f"- Check the current date at the start of each session\n"
f"- Do not assume the year from previous searches\n"
f"- When searching for 'latest' or 'recent' content, include the current year\n\n"
f"Proceeding with {tool_name}..."
print(
json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
})
)
return

# Warn if current year is referenced (using word boundaries)
if re.search(rf"\b{current_year}\b", text_lower):
date_str = now.strftime("%B %d, %Y")
reason = (
f"WARNING: You're searching with the current year ({current_year}).\n\n"
f"Current date: {date_str}\n\n"
f"Always verify the current date before running date-specific searches."
)
print(
json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": reason,
}
})
)
output = build_allow_response(warn_reason)
print(json.dumps(output))
sys.exit(0)

# No year references - allow without special handling
sys.exit(0)


if __name__ == "__main__":
Expand Down
Loading