Files
2026-07-13 12:35:03 +08:00

188 lines
4.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""
ArcKit BeforeTool Hook for Gemini CLI -- File Protection.
Blocks edits to sensitive files (environment files, credentials,
private keys, lock files).
Python equivalent of arckit-claude/hooks/file-protection.mjs.
Hook Type: BeforeTool
Input (stdin): JSON with tool_name, tool_input, etc.
Output (stdout): JSON with decision:block if blocked
Exit codes: 0 = allow, 2 = block
"""
import os
import re
import sys
sys.path.insert(0, os.path.dirname(__file__))
from hook_utils import parse_hook_input, output_json, output_block
# Files and paths to protect
PROTECTED_PATHS = [
# Environment files
".env",
".env.local",
".env.production",
".env.development",
# Lock files
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"Gemfile.lock",
"poetry.lock",
"Cargo.lock",
# Version control
".git/",
# Credentials directories
".aws/",
".ssh/",
".gnupg/",
# Common secret files
"credentials",
"credentials.json",
"secrets.json",
"secrets.yaml",
"secrets.yml",
".secrets",
# Private keys and certificates
"*.pem",
"*.key",
"*.p12",
"*.pfx",
"id_rsa",
"id_ed25519",
"id_ecdsa",
# Token files
".npmrc",
".pypirc",
".netrc",
# Local configuration with secrets
"config.local.json",
]
# Sensitive keywords in filenames (case-insensitive substring match)
SENSITIVE_FILENAME_KEYWORDS = [
"api key",
"apikey",
"api-key",
"api_key",
"password",
"passwd",
"secret",
"token",
"credential",
"private key",
"privatekey",
]
# Keywords that must match as whole words only
SENSITIVE_WHOLE_WORD_KEYWORDS = [
"pin",
"pat",
]
# Files that are allowed exceptions to the sensitive keyword rule
ALLOWED_EXCEPTIONS = [
".secrets.baseline",
".pre-commit-config.yaml",
"secret-detection.mjs",
"secret-file-scanner.mjs",
]
# Directories where sensitive keywords in filenames are allowed
ALLOWED_DIRECTORIES = [
"arckit-gemini/commands/",
"arckit-gemini/templates/",
"arckit-gemini/agents/",
"arckit-gemini/hooks/",
"docs/",
".arckit/templates/",
"projects/",
]
def is_protected(file_path):
"""Check if a file path is protected. Returns (blocked, reason)."""
parts = file_path.replace("\\", "/").split("/")
file_name = os.path.basename(file_path)
file_name_lower = file_name.lower()
# Check for allowed exceptions first
if file_name in ALLOWED_EXCEPTIONS:
return False, ""
# Check if file is in an allowed directory
for allowed_dir in ALLOWED_DIRECTORIES:
if allowed_dir in file_path:
return False, ""
# Check protected paths
for protected in PROTECTED_PATHS:
if protected.startswith("*"):
# Wildcard suffix match (e.g., *.pem)
if file_path.endswith(protected[1:]):
return True, f"Protected file type: {protected}"
elif protected.endswith("/"):
# Directory match - check if directory appears as a path segment
dir_name = protected[:-1]
if dir_name in parts:
return True, f"Protected directory: {protected}"
else:
# Exact filename match (not substring)
if file_name == protected or file_path.endswith("/" + protected):
return True, f"Protected file: {protected}"
# Check for sensitive keywords in filename (case-insensitive substring match)
for keyword in SENSITIVE_FILENAME_KEYWORDS:
if keyword in file_name_lower:
return True, f"Sensitive keyword in filename: '{keyword}'"
# Check for whole-word sensitive keywords
for keyword in SENSITIVE_WHOLE_WORD_KEYWORDS:
escaped = re.escape(keyword)
if re.search(rf"\b{escaped}\b", file_name_lower, re.IGNORECASE):
return True, f"Sensitive keyword in filename: '{keyword}'"
return False, ""
# --- Main ---
input_data = parse_hook_input()
if not input_data:
sys.exit(0)
tool_name = input_data.get("tool_name", "")
file_path = input_data.get("tool_input", {}).get("file_path", "")
# Only check write-related tools
if tool_name not in ("write_file", "edit_file", "Edit", "Write"):
sys.exit(0)
if not file_path:
sys.exit(0)
blocked, reason = is_protected(file_path)
if blocked:
output_block(
f"Protected: {reason}\n"
f"File: {file_path}\n"
f"Edit manually outside Gemini CLI, or add an exception in file-protection.py."
)
# Return additionalContext for allowed files with hints
file_path_lower = file_path.lower()
if any(kw in file_path_lower for kw in ("config", "settings", "setup")):
output_json({
"hookSpecificOutput": {
"additionalContext": (
f"Note: {file_path} may contain configuration. "
"Ensure no secrets are included."
),
}
})
sys.exit(0)