mirror of
https://github.com/trailofbits/skills.git
synced 2026-09-14 14:28:48 +08:00
43d7af8064
* gh-cli: intercept GitHub fetches from MCP fetch tools The fetch hook only matched WebFetch, so a GitHub URL fetched through an MCP fetch tool bypassed it entirely. Match `mcp__.*[Ff]etch` as well, which covers Exa's `web_fetch_exa` and equivalents from other MCP servers. A matcher alone is not enough: WebFetch passes a single `url`, while MCP fetch tools pass a `urls` array and batch several pages into one call. Read both shapes. A tool call is atomic, so one GitHub URL anywhere in a batch denies the whole call, and each offending URL is labeled with its own suggestion. Single-URL calls keep the message they had. Split the URL classification into suggest_api, suggest_raw, and suggest_github_com behind a suggest_for_url dispatcher so it can run per URL in a loop, and collapse eight verbatim copies of the clone hint into one helper. Behavior is unchanged; blob and tree merge into one branch because they emitted identical text. The plugin README's interception table now also lists the pull, issues, releases, and gist patterns the hook already handled but never documented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * gh-cli: address review on the MCP fetch interceptor Test the matcher. Nothing exercised the regex that decides whether the hook runs at all, so a matcher firing on nothing would still pass every other suite here. matcher.bats reads it out of hooks.json and checks it against real tool names, and refuses to run against an empty matcher, which would make every match assertion pass vacuously. Widen the matcher to fetch, scrape, crawl, and extract. Firecrawl's scrape and Tavily's extract retrieve a URL like any fetch tool but carry no "fetch" in the name, so they bypassed the hook while the README promised "any MCP fetch tool". The README now names what matches. Check a string-valued `urls`. A server declaring `urls: string | string[]` sent a bare string, `arrays` dropped it, and the fetch went out unauthenticated. Listing the field twice keeps it under both shapes. `prompt` is still not scanned, so a prompt mentioning a GitHub URL does not false-deny. Give the closing note its own line when several URLs are denied; it previously trailed only the last entry. Guard assert_suggestion_starts_with against an empty reason and an empty prefix, either of which let it pass while inspecting nothing. Document the api.github.com contents, releases, and actions rows. The generic `gh api` row was actively wrong for /contents/, pointing readers at the anti-pattern the shim exists to block. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
134 lines
4.4 KiB
Bash
134 lines
4.4 KiB
Bash
#!/usr/bin/env bats
|
|
# Tests for the PreToolUse matchers in hooks.json.
|
|
#
|
|
# The hook scripts are well covered, but nothing exercised the matcher that
|
|
# decides whether they run at all — so a matcher that fires on nothing would
|
|
# still pass every other suite in this directory. These tests read the matcher
|
|
# out of hooks.json and check it against real tool names.
|
|
#
|
|
# Claude Code evaluates a matcher containing regex metacharacters as an
|
|
# unanchored JavaScript regular expression. `grep -E` is likewise unanchored,
|
|
# and these patterns use only constructs common to both.
|
|
|
|
setup() {
|
|
HOOKS_JSON="${BATS_TEST_DIRNAME}/hooks.json"
|
|
FETCH_MATCHER=$(jq -r '.hooks.PreToolUse[]
|
|
| select(.hooks[].command | contains("intercept-github-fetch.sh"))
|
|
| .matcher' "$HOOKS_JSON")
|
|
BASH_MATCHER=$(jq -r '.hooks.PreToolUse[]
|
|
| select(.hooks[].command | contains("intercept-github-curl.sh"))
|
|
| .matcher' "$HOOKS_JSON")
|
|
}
|
|
|
|
# A matcher that failed to parse would be empty, and an empty pattern matches
|
|
# every tool name — which would make every assertion below pass vacuously.
|
|
assert_matcher_nonempty() {
|
|
if [[ -z "$1" ]]; then
|
|
echo "Matcher not found in $HOOKS_JSON — every match assertion would pass vacuously"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
assert_matches() {
|
|
local pattern="$1" tool="$2"
|
|
assert_matcher_nonempty "$pattern" || return 1
|
|
if ! printf '%s\n' "$tool" | grep -Eq "$pattern"; then
|
|
echo "Expected matcher '$pattern' to match tool '$tool'"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
refute_matches() {
|
|
local pattern="$1" tool="$2"
|
|
assert_matcher_nonempty "$pattern" || return 1
|
|
if printf '%s\n' "$tool" | grep -Eq "$pattern"; then
|
|
echo "Expected matcher '$pattern' NOT to match tool '$tool'"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# The matchers are present at all
|
|
# =============================================================================
|
|
|
|
@test "matcher: hooks.json is valid JSON" {
|
|
run jq -e . "$HOOKS_JSON"
|
|
[[ $status -eq 0 ]]
|
|
}
|
|
|
|
@test "matcher: fetch hook has a matcher" {
|
|
assert_matcher_nonempty "$FETCH_MATCHER"
|
|
}
|
|
|
|
@test "matcher: bash hook has a matcher" {
|
|
assert_matcher_nonempty "$BASH_MATCHER"
|
|
}
|
|
|
|
# =============================================================================
|
|
# Built-in tools
|
|
# =============================================================================
|
|
|
|
@test "matcher: fetch matcher matches WebFetch" {
|
|
assert_matches "$FETCH_MATCHER" "WebFetch"
|
|
}
|
|
|
|
@test "matcher: bash matcher matches Bash" {
|
|
assert_matches "$BASH_MATCHER" "Bash"
|
|
}
|
|
|
|
@test "matcher: fetch matcher does not match Bash" {
|
|
refute_matches "$FETCH_MATCHER" "Bash"
|
|
}
|
|
|
|
@test "matcher: fetch matcher does not match Read" {
|
|
refute_matches "$FETCH_MATCHER" "Read"
|
|
}
|
|
|
|
@test "matcher: fetch matcher does not match WebSearch" {
|
|
refute_matches "$FETCH_MATCHER" "WebSearch"
|
|
}
|
|
|
|
# =============================================================================
|
|
# MCP tools that retrieve a URL — these are the ones the hook exists for
|
|
# =============================================================================
|
|
|
|
@test "matcher: fetch matcher matches Exa web_fetch_exa" {
|
|
assert_matches "$FETCH_MATCHER" "mcp__exa__web_fetch_exa"
|
|
}
|
|
|
|
@test "matcher: fetch matcher matches a bare fetch server" {
|
|
assert_matches "$FETCH_MATCHER" "mcp__fetch__fetch"
|
|
}
|
|
|
|
@test "matcher: fetch matcher matches Firecrawl scrape" {
|
|
assert_matches "$FETCH_MATCHER" "mcp__firecrawl__firecrawl_scrape"
|
|
}
|
|
|
|
@test "matcher: fetch matcher matches Firecrawl batch scrape" {
|
|
assert_matches "$FETCH_MATCHER" "mcp__firecrawl__firecrawl_batch_scrape"
|
|
}
|
|
|
|
@test "matcher: fetch matcher matches a crawl tool" {
|
|
assert_matches "$FETCH_MATCHER" "mcp__exa__crawling_exa"
|
|
}
|
|
|
|
@test "matcher: fetch matcher matches Tavily extract" {
|
|
assert_matches "$FETCH_MATCHER" "mcp__tavily__tavily_extract"
|
|
}
|
|
|
|
# =============================================================================
|
|
# MCP tools that do not retrieve a URL — no reason to run the hook
|
|
# =============================================================================
|
|
|
|
@test "matcher: fetch matcher does not match Exa search" {
|
|
refute_matches "$FETCH_MATCHER" "mcp__exa__web_search_exa"
|
|
}
|
|
|
|
@test "matcher: fetch matcher does not match context7 docs query" {
|
|
refute_matches "$FETCH_MATCHER" "mcp__context7__query-docs"
|
|
}
|
|
|
|
@test "matcher: fetch matcher does not match a Slack reader" {
|
|
refute_matches "$FETCH_MATCHER" "mcp__slack__slack_read_channel"
|
|
}
|