Skip to content
Open
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
84 changes: 84 additions & 0 deletions src/dippy/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ class Config:
aliases: dict[str, str] = field(default_factory=dict)
"""Command aliases mapping source to target (e.g., ~/bin/gh -> gh)."""

web_rules: list[Rule] = field(default_factory=list)
"""WebSearch tool rules in load order."""

after_web_rules: list[Rule] = field(default_factory=list)
"""After-web rules for PostToolUse feedback on WebSearch."""

default: str = "ask" # 'allow' | 'ask'
log: Path | None = None # None = no logging
log_full: bool = False # log full command (requires log path)
Expand Down Expand Up @@ -122,6 +128,8 @@ def _merge_configs(base: Config, overlay: Config) -> Config:
after_mcp_rules=base.after_mcp_rules + overlay.after_mcp_rules,
# Aliases: overlay wins for conflicting keys
aliases={**base.aliases, **overlay.aliases},
web_rules=base.web_rules + overlay.web_rules,
after_web_rules=base.after_web_rules + overlay.after_web_rules,
# Settings: overlay wins if set
default=overlay.default if overlay.default != "ask" else base.default,
log=overlay.log if overlay.log is not None else base.log,
Expand All @@ -144,6 +152,10 @@ def _tag_rules(config: Config, source: str, scope: str) -> Config:
after_mcp_rules=[
replace(r, source=source, scope=scope) for r in config.after_mcp_rules
],
web_rules=[replace(r, source=source, scope=scope) for r in config.web_rules],
after_web_rules=[
replace(r, source=source, scope=scope) for r in config.after_web_rules
],
)


Expand Down Expand Up @@ -209,6 +221,8 @@ def parse_config(text: str, source: str | None = None) -> Config:
mcp_rules: list[Rule] = []
after_mcp_rules: list[Rule] = []
aliases: dict[str, str] = {}
web_rules: list[Rule] = []
after_web_rules: list[Rule] = []
settings: dict[str, bool | int | str | Path] = {}
prefix = f"{source}: " if source else ""

Expand Down Expand Up @@ -321,6 +335,28 @@ def parse_config(text: str, source: str | None = None) -> Config:
)
aliases[expanded_source] = alias_target

elif directive == "allow-web":
pattern = rest if rest else "*"
web_rules.append(Rule("allow", pattern))

elif directive == "ask-web":
if not rest:
raise ValueError("requires a pattern")
pattern, message = _extract_message(rest)
web_rules.append(Rule("ask", pattern, message=message))

elif directive == "deny-web":
if not rest:
raise ValueError("requires a pattern")
pattern, message = _extract_message(rest)
web_rules.append(Rule("deny", pattern, message=message))

elif directive == "after-web":
if not rest:
raise ValueError("requires a pattern")
pattern, message = _extract_message(rest)
after_web_rules.append(Rule("after", pattern, message=message))

elif directive == "set":
_apply_setting(settings, rest)

Expand All @@ -337,6 +373,8 @@ def parse_config(text: str, source: str | None = None) -> Config:
mcp_rules=mcp_rules,
after_mcp_rules=after_mcp_rules,
aliases=aliases,
web_rules=web_rules,
after_web_rules=after_web_rules,
default=settings.get("default", "ask"),
log=settings.get("log"),
log_full=settings.get("log_full", False),
Expand Down Expand Up @@ -873,6 +911,52 @@ def match_after_mcp(tool_name: str, config: Config) -> str | None:
return result


def match_web(query: str, config: Config) -> Match | None:
"""Match WebSearch query against web rules.

Simpler than command matching - just fnmatch against query string.
Last match wins.

Args:
query: WebSearch query string.
config: Loaded configuration.

Returns:
Match object for the last matching rule, or None if no match.
"""
result: Match | None = None
for rule in config.web_rules:
if fnmatch.fnmatch(query, rule.pattern):
result = Match(
decision=rule.decision,
pattern=rule.pattern,
message=rule.message,
source=rule.source,
scope=rule.scope,
)
return result


def match_after_web(query: str, config: Config) -> str | None:
"""Match WebSearch query against after-web rules for PostToolUse feedback.

Last matching rule wins. Empty string message means silent (no output).

Args:
query: WebSearch query string.
config: Loaded configuration.

Returns:
Message string if a rule with message matches, empty string if silent
rule matches, None if no rule matches.
"""
result: str | None = None
for rule in config.after_web_rules:
if fnmatch.fnmatch(query, rule.pattern):
result = rule.message if rule.message is not None else ""
return result


# === Logging ===


Expand Down
72 changes: 70 additions & 2 deletions src/dippy/dippy.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
load_config,
log_decision,
match_after_mcp,
match_after_web,
match_mcp,
match_web,
)
from dippy.core.analyzer import analyze

Expand Down Expand Up @@ -202,6 +204,16 @@ def check_command(command: str, config: Config, cwd: Path) -> dict:
return ask(result.reason)


def post_tool_response(message: str) -> dict:
"""Return PostToolUse response with feedback for Claude."""
return {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": f"🐤 {message}",
}
}


def handle_post_tool_use(command: str, config: Config, cwd: Path) -> None:
"""Handle PostToolUse hook - output feedback message if rule matches."""
from dippy.core.config import match_after
Expand All @@ -210,7 +222,7 @@ def handle_post_tool_use(command: str, config: Config, cwd: Path) -> None:
words = tokenize(command)
message = match_after(words, config, cwd)
if message: # non-empty string
print(f"🐤 {message}")
print(json.dumps(post_tool_response(message)))
# empty string or None = silent (no output)


Expand Down Expand Up @@ -249,7 +261,41 @@ def handle_mcp_post_tool_use(tool_name: str, config: Config) -> None:
"""Handle PostToolUse hook for MCP tools - output feedback if rule matches."""
message = match_after_mcp(tool_name, config)
if message: # non-empty string
print(f"🐤 {message}")
print(json.dumps(post_tool_response(message)))
# empty string or None = silent (no output)


# === WebSearch Tool Handling ===


def check_web_tool(query: str, config: Config) -> dict:
"""Check if a WebSearch tool should be approved based on config rules.

Args:
query: WebSearch query string.
config: Loaded configuration.

Returns:
Hook response dict, or empty dict if no rules match (defer to default).
"""
match = match_web(query, config)
if match is None:
return {} # No rules match - defer to Claude's default behavior
reason = match.message if match.message else f"[{match.pattern}]"
log_decision(match.decision, reason, rule=match.pattern)
if match.decision == "allow":
return approve(reason)
elif match.decision == "deny":
return deny(reason)
else:
return ask(reason)


def handle_web_post_tool_use(query: str, config: Config) -> None:
"""Handle PostToolUse hook for WebSearch - output feedback if rule matches."""
message = match_after_web(query, config)
if message: # non-empty string
print(json.dumps(post_tool_response(message)))
# empty string or None = silent (no output)


Expand Down Expand Up @@ -338,6 +384,28 @@ def main():
print(json.dumps(result))
return

# Check if this is a web tool (Claude: WebSearch/WebFetch, Gemini: google_web_search/web_fetch)
if tool_name in ("WebSearch", "WebFetch", "google_web_search", "web_fetch"):
# WebSearch/google_web_search use query, WebFetch/web_fetch use url
match_value = tool_input.get("query") or tool_input.get("url", "")
# Check for bypass permissions mode first
if hook_event != "PostToolUse":
permission_mode = input_data.get("permission_mode", "default")
if permission_mode in ("bypassPermissions", "dontAsk"):
logging.info(f"Bypass mode ({permission_mode}): {tool_name}")
log_decision("allow", permission_mode)
print(json.dumps(approve(permission_mode)))
return
# Handle WebSearch/WebFetch tool
if hook_event == "PostToolUse":
logging.info(f"PostToolUse {tool_name}: {match_value}")
handle_web_post_tool_use(match_value, config)
else:
logging.info(f"Checking {tool_name}: {match_value}")
result = check_web_tool(match_value, config)
print(json.dumps(result))
return

# Only handle shell/bash commands
if tool_name not in SHELL_TOOL_NAMES:
print(json.dumps({}))
Expand Down
Loading