Skip to content
14 changes: 10 additions & 4 deletions .claude/hooks/block-sed.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@

# Fallback: catch `bash -c "... sed ..."` and subshell/delegation cases.
# Only match 'sed' when preceded by whitespace or start-of-string (not inside paths).
if re.search(r'(?:^|\s)sed\b', cmd):
print("[BLOCKED] sed usage detected. Use the built-in edit_file / read_file tools to modify files instead.", file=sys.stderr)
if re.search(r"(?:^|\s)sed\b", cmd):
print(
"[BLOCKED] sed usage detected. Use the built-in edit_file / read_file tools to modify files instead.",
file=sys.stderr,
)
sys.exit(1)

# Split by command separators (;, &&, ||, |) to check each segment
parts = re.split(r'[;&|]+', cmd)
parts = re.split(r"[;&|]+", cmd)
for part in parts:
tokens = part.strip().split()
if not tokens:
Expand All @@ -43,7 +46,10 @@
prog = tokens[i] if i < len(tokens) else ""

if os.path.basename(prog) == "sed":
print("[BLOCKED] sed is not allowed. Use the built-in edit_file / read_file tools to modify files instead.", file=sys.stderr)
print(
"[BLOCKED] sed is not allowed. Use the built-in edit_file / read_file tools to modify files instead.",
file=sys.stderr,
)
sys.exit(1)

sys.exit(0)
30 changes: 30 additions & 0 deletions code_puppy/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"register_model_providers",
"message_history_processor_start",
"message_history_processor_end",
"register_loading_messages",
]
CallbackFunc = Callable[..., Any]

Expand Down Expand Up @@ -68,6 +69,7 @@
"register_model_providers": [],
"message_history_processor_start": [],
"message_history_processor_end": [],
"register_loading_messages": [],
}

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -672,3 +674,31 @@ def on_message_history_processor_end(
messages_added,
messages_filtered,
)


def on_register_loading_messages() -> List[Any]:
"""Trigger callbacks to register additional loading messages.

Plugins can register callbacks that call
``loading_messages.register_messages(category, messages)``
to inject their own spinner / status display messages.

This is fired once, lazily, when loading messages are first needed.

Example callback (in a plugin's register_callbacks.py)::

from code_puppy.callbacks import register_callback
from code_puppy.messaging.loading_messages import register_messages

def _add_my_messages():
register_messages("my_org", [
"rolling back prices...",
"stocking the shelves...",
])

register_callback("register_loading_messages", _add_my_messages)

Returns:
List of results from registered callbacks.
"""
return _trigger_callbacks_sync("register_loading_messages")
8 changes: 7 additions & 1 deletion code_puppy/hook_engine/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
"""Hook engine package for Code Puppy."""

from .engine import HookEngine
from .models import HookConfig, EventData, ExecutionResult, ProcessEventResult, HookRegistry
from .models import (
HookConfig,
EventData,
ExecutionResult,
ProcessEventResult,
HookRegistry,
)
from . import aliases

__all__ = [
Expand Down
33 changes: 17 additions & 16 deletions code_puppy/hook_engine/aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,23 @@
# ---------------------------------------------------------------------------
CLAUDE_CODE_ALIASES: Dict[str, str] = {
# Shell execution
"Bash": "agent_run_shell_command",
"Bash": "agent_run_shell_command",
# File system — read
"Glob": "list_files",
"Read": "read_file",
"Grep": "grep",
"Glob": "list_files",
"Read": "read_file",
"Grep": "grep",
# File system — write
"Edit": "edit_file",
"Write": "edit_file", # Write = full overwrite; same tool in puppy
"Edit": "edit_file",
"Write": "edit_file", # Write = full overwrite; same tool in puppy
# File system — delete
"Delete": "delete_file",
"Delete": "delete_file",
# User interaction
"AskUserQuestion": "ask_user_question",
"AskUserQuestion": "ask_user_question",
# Agent / task orchestration
"Task": "invoke_agent",
"Task": "invoke_agent",
# Skills
"Skill": "activate_skill",
"ToolSearch": "list_or_search_skills",
"Skill": "activate_skill",
"ToolSearch": "list_or_search_skills",
# NOTE: the tools below have no direct code_puppy equivalent yet.
# They are listed here for documentation and future mapping:
# "TaskOutput" -> (no equivalent)
Expand Down Expand Up @@ -92,17 +92,18 @@
# To disable a provider's aliases, remove its entry from this dict.
# ---------------------------------------------------------------------------
PROVIDER_ALIASES: Dict[str, Dict[str, str]] = {
"claude": CLAUDE_CODE_ALIASES,
"gemini": GEMINI_ALIASES, # placeholder — empty until populated
"codex": CODEX_ALIASES, # placeholder — empty until populated
"swarm": SWARM_ALIASES, # placeholder — empty until populated
"claude": CLAUDE_CODE_ALIASES,
"gemini": GEMINI_ALIASES, # placeholder — empty until populated
"codex": CODEX_ALIASES, # placeholder — empty until populated
"swarm": SWARM_ALIASES, # placeholder — empty until populated
}


# ---------------------------------------------------------------------------
# Flattened lookup structures — built once at import time for O(1) access.
# ---------------------------------------------------------------------------


def _build_lookup() -> Dict[str, FrozenSet[str]]:
"""
Return a dict mapping every known name (provider *and* internal) to the
Expand Down Expand Up @@ -152,4 +153,4 @@ def resolve_internal_name(provider_tool_name: str) -> Optional[str]:
for pname, internal in provider_aliases.items():
if pname.lower() == provider_tool_name.lower():
return internal
return None
return None
48 changes: 37 additions & 11 deletions code_puppy/hook_engine/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@

from .models import (
EventData,
ExecutionResult,
HookConfig,
HookRegistry,
ProcessEventResult,
)
from .matcher import matches
from .executor import execute_hook, execute_hooks_sequential, get_blocking_result
from .executor import execute_hooks_sequential, get_blocking_result
from .registry import build_registry_from_config, get_registry_stats
from .validator import validate_hooks_config, format_validation_report, get_config_suggestions
from .validator import (
validate_hooks_config,
format_validation_report,
get_config_suggestions,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -61,7 +64,9 @@ def load_config(self, config: Dict[str, Any]) -> None:

try:
self._registry = build_registry_from_config(config)
logger.info(f"Loaded hook configuration: {self._registry.count_hooks()} total hooks")
logger.info(
f"Loaded hook configuration: {self._registry.count_hooks()} total hooks"
)
except Exception as e:
if self.strict_validation:
raise ValueError(f"Failed to build hook registry: {e}") from e
Expand All @@ -83,29 +88,48 @@ async def process_event(
start_time = time.perf_counter()

if not self._registry:
return ProcessEventResult(blocked=False, executed_hooks=0, results=[], total_duration_ms=0.0)
return ProcessEventResult(
blocked=False, executed_hooks=0, results=[], total_duration_ms=0.0
)

all_hooks = self._registry.get_hooks_for_event(event_type)

if not all_hooks:
duration_ms = (time.perf_counter() - start_time) * 1000
return ProcessEventResult(blocked=False, executed_hooks=0, results=[], total_duration_ms=duration_ms)
return ProcessEventResult(
blocked=False,
executed_hooks=0,
results=[],
total_duration_ms=duration_ms,
)

matching_hooks = self._filter_hooks_by_matcher(all_hooks, event_data.tool_name, event_data.tool_args)
matching_hooks = self._filter_hooks_by_matcher(
all_hooks, event_data.tool_name, event_data.tool_args
)

if not matching_hooks:
duration_ms = (time.perf_counter() - start_time) * 1000
return ProcessEventResult(blocked=False, executed_hooks=0, results=[], total_duration_ms=duration_ms)
return ProcessEventResult(
blocked=False,
executed_hooks=0,
results=[],
total_duration_ms=duration_ms,
)

logger.debug(f"Processing {event_type}: {len(matching_hooks)} matching hook(s) for tool '{event_data.tool_name}'")
logger.debug(
f"Processing {event_type}: {len(matching_hooks)} matching hook(s) for tool '{event_data.tool_name}'"
)

if sequential:
results = await execute_hooks_sequential(
matching_hooks, event_data, self.env_vars, stop_on_block=stop_on_block
)
else:
from .executor import execute_hooks_parallel
results = await execute_hooks_parallel(matching_hooks, event_data, self.env_vars)

results = await execute_hooks_parallel(
matching_hooks, event_data, self.env_vars
)

for hook, result in zip(matching_hooks, results):
if hook.once and result.success:
Expand Down Expand Up @@ -142,7 +166,9 @@ def _filter_hooks_by_matcher(
if matches(hook.matcher, tool_name, tool_args):
matching_hooks.append(hook)
except Exception as e:
logger.error(f"Error matching hook '{hook.matcher}': {e}", exc_info=True)
logger.error(
f"Error matching hook '{hook.matcher}': {e}", exc_info=True
)
return matching_hooks

def get_stats(self) -> Dict[str, Any]:
Expand Down
25 changes: 14 additions & 11 deletions code_puppy/hook_engine/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def _build_stdin_payload(event_data: EventData) -> bytes:
"permission_mode": "default"
}
"""

def _make_serializable(obj: Any) -> Any:
if isinstance(obj, dict):
return {k: _make_serializable(v) for k, v in obj.items()}
Expand Down Expand Up @@ -192,7 +193,7 @@ def _substitute_variables(
result = command
for var, value in substitutions.items():
result = result.replace(f"${{{var}}}", str(value))
result = re.sub(rf'\${re.escape(var)}(?=\W|$)', str(value), result)
result = re.sub(rf"\${re.escape(var)}(?=\W|$)", str(value), result)
return result


Expand Down Expand Up @@ -228,16 +229,18 @@ async def execute_hooks_parallel(
final_results = []
for i, result in enumerate(results):
if isinstance(result, Exception):
final_results.append(ExecutionResult(
blocked=False,
hook_command=hooks[i].command,
stdout="",
stderr=str(result),
exit_code=-1,
duration_ms=0.0,
error=f"Hook execution failed: {result}",
hook_id=hooks[i].id,
))
final_results.append(
ExecutionResult(
blocked=False,
hook_command=hooks[i].command,
stdout="",
stderr=str(result),
exit_code=-1,
duration_ms=0.0,
error=f"Hook execution failed: {result}",
hook_id=hooks[i].id,
)
)
else:
final_results.append(result)
return final_results
Expand Down
19 changes: 15 additions & 4 deletions code_puppy/hook_engine/matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,25 @@ def _match_single(pattern: str, tool_name: str, tool_args: Dict[str, Any]) -> bo


def _extract_file_path(tool_args: Dict[str, Any]) -> Optional[str]:
file_keys = ["file_path", "file", "path", "target", "input_file",
"output_file", "source", "destination", "src", "dest", "filename"]
file_keys = [
"file_path",
"file",
"path",
"target",
"input_file",
"output_file",
"source",
"destination",
"src",
"dest",
"filename",
]
for key in file_keys:
if key in tool_args:
value = tool_args[key]
if isinstance(value, str):
return value
if hasattr(value, '__fspath__'):
if hasattr(value, "__fspath__"):
return str(value)
for value in tool_args.values():
if isinstance(value, str) and _looks_like_file_path(value):
Expand All @@ -107,7 +118,7 @@ def _looks_like_file_path(value: str) -> bool:


def _is_regex_pattern(pattern: str) -> bool:
regex_chars = ['^', '$', '.', '+', '?', '[', ']', '(', ')', '{', '}', '|', '\\']
regex_chars = ["^", "$", ".", "+", "?", "[", "]", "(", ")", "{", "}", "|", "\\"]
return any(char in pattern for char in regex_chars)


Expand Down
Loading
Loading