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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ These run **when a session begins**:
These run **before** a tool call is executed:

- `mcp__.*very-good-cli__.*` matcher → `check-vgv-cli.sh` — reads `tool_name` from the payload and exits 0 unless the caller is a Very Good CLI tool, so a host that does not apply the matcher cannot have the decision land on an unrelated tool; for its own tools it auto-approves the call by returning a PreToolUse `allow` decision, so it is always permitted regardless of run mode (interactive, headless, or `skipAutoPermissionPrompt`) and never dead-ends when the tool isn't on `permissions.allow`; denies with an install/upgrade message if the CLI is missing or < 1.5.0, and stands aside when the CLI is present but its version cannot be read (`dart` missing from `PATH`), leaving normal permission handling to apply. The `.*` in the matcher covers both the bare `mcp__very-good-cli__*` server (repo-root `.mcp.json`) and the plugin-namespaced `mcp__plugin_<plugin>_very-good-cli__*` form used when installed from a marketplace
- `Bash` matcher → `block-cli-workarounds.sh` — prevents direct CLI bypass of VGV CLI commands through the host's shell tool (`Bash`, or `Shell` on other hosts); exits 0 when the payload names a non-shell tool, so an unrelated tool carrying a `command` argument is never inspected; when the CLI is present but cannot run because `dart` is missing from `PATH`, the denial says so rather than redirecting to an MCP server that cannot start; every denial also says the whole shell call was refused, so the agent re-runs any command chained with the blocked one instead of assuming it ran; exits 2 on failure (blocking)
- `Bash` matcher → `block-cli-workarounds.sh` — denies `flutter`/`dart` `test`/`create` and `very_good test`/`create`/`packages` in the shell and points to the MCP tool. Quoted text is data unless `eval`, `sh -c` or `$( )` runs it. A heredoc body with a quoted delimiter (`<<'EOF'`) is data too, unless the opening line feeds it to a shell (`bash <<'EOF'`, `cat <<'EOF' | sh`), which runs it; an unquoted `<<EOF` expands, so its body is always scanned. Every adjacent word pair is checked, so wrappers (`fvm`, `melos`, `sudo`, `timeout`) and `/path/to/flutter` need no list. The denial names the match and says the whole call was refused. Pinned gaps: `"flutter" test` and `$F test` pass; an unquoted pair is denied wherever it sits, including as an argument (`echo flutter test`, `man flutter test`); narrowing the match to a command position would need the wrapper list the pair rule exists to avoid

The first two PreToolUse hooks are plugin-level (defined in `hooks.json`) and share common utilities
from `vgv-cli-common.sh`. The following hook is **agent-scoped** — it is declared in the
Expand All @@ -35,7 +35,7 @@ These run **after** a tool call completes:
- `Edit|Write` matcher → `analyze.sh` — runs `dart analyze` on the modified `.dart` file; exits 2 on failure (blocking — Claude must fix the issue)
- `Edit|Write` matcher → `format.sh` — runs `dart format` on the modified `.dart` file; always exits 0 (non-blocking)

All hook scripts require **jq** to parse the hook payload (they skip gracefully if `jq` is not installed).
All hook scripts require **jq** to parse the hook payload (they skip gracefully if `jq` is not installed). `block-cli-workarounds.sh` also requires **tr**, and exits 1 without either, since standing aside would drop enforcement silently.

### Hook scoping

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ This plugin includes SessionStart, PreToolUse, and PostToolUse hooks that valida
| ---- | ------- | -------- |
| **Warn Missing MCP** (`warn-missing-mcp.sh`) | SessionStart | Warns if the Very Good CLI is missing, older than 1.5.0, or present but unable to run because `dart` is not on the `PATH` hooks inherit; non-blocking |
| **Check VGV CLI** (`check-vgv-cli.sh`) | PreToolUse (`mcp__.*very-good-cli__.*`) | Verifies from the payload that the caller is a Very Good CLI tool and stands aside otherwise, so the decision never lands on an unrelated tool; for its own tools, auto-approves the call in every run mode via a PreToolUse `allow` decision, so they never dead-end when the tool isn't on `permissions.allow` (including under `skipAutoPermissionPrompt`); denies with an install/upgrade message if the CLI is missing or < 1.5.0 |
| **Block CLI Workarounds** (`block-cli-workarounds.sh`) | PreToolUse (`Bash`) | Blocks direct CLI bypass of Very Good CLI commands through the host's shell tool; inspects the command only when the payload identifies a shell call, so an unrelated tool carrying a `command` argument is left alone; when the CLI is present but cannot run because `dart` is missing from `PATH`, the denial says so instead of redirecting to an MCP server that cannot start; exits 2 on failure (blocking) |
| **Block CLI Workarounds** (`block-cli-workarounds.sh`) | PreToolUse (`Bash`) | Denies `flutter`/`dart` `test`/`create` and `very_good test`/`create`/`packages` in the shell and points to the matching MCP tool. Quoted text is not read as a command, so searching for those strings is fine. A denial refuses the whole shell call, so anything chained with the blocked command has to be run again |
| **Allow Read-only Git** (`allow-readonly-git.sh`) | PreToolUse (`Bash`, `flutter-reviewer` agent only) | Restricts the `flutter-reviewer` agent's Bash to single-line `git diff`/`git status`, without the `--output` or `--ext-diff` options, and denies anything else (blocking). Scoped via the agent's frontmatter, not `hooks.json` |
| **Analyze** (`analyze.sh`) | PostToolUse (`Edit`/`Write`) | Runs `dart analyze` on the modified `.dart` file; exits 2 on failure (blocking — Claude must fix issues before continuing) |
| **Format** (`format.sh`) | PostToolUse (`Edit`/`Write`) | Runs `dart format` on the modified `.dart` file; always exits 0 (non-blocking — formatting is applied silently) |
Expand All @@ -80,6 +80,7 @@ This plugin includes SessionStart, PreToolUse, and PostToolUse hooks that valida

- **Dart SDK** — must be available on your `PATH`
- **jq** — used to parse the hook payload; hooks are skipped gracefully if `jq` is not installed
- **tr** — used by `block-cli-workarounds.sh` to split a command on shell operators; the hook refuses to run without it, rather than letting every shell call through unchecked

The Very Good CLI is resolved from `PATH`, falling back to `$PUB_CACHE/bin` (default
`~/.pub-cache/bin`) for hosts that launch hooks without an interactive shell's `PATH`. The
Expand Down
8 changes: 6 additions & 2 deletions config/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"Codex",
"compgen",
"CSPRNG",
"cupertino",
"Cupertino",
"cupertino",
"dartdoc",
"dismissable",
"elemento",
Expand All @@ -33,6 +33,8 @@
"GHSA",
"goldens",
"gradeable",
"gsub",
"heredocs",
"hoverable",
"icontains",
"idgetbook",
Expand All @@ -49,6 +51,7 @@
"mocktail",
"monorepo",
"Mundo",
"nohup",
"opencode",
"pasteable",
"pipefail",
Expand All @@ -65,14 +68,15 @@
"subagent",
"subagents",
"subclassing",
"subshell",
"tappable",
"tooltipped",
"unmirrored",
"unrouted",
"vsync",
"WCAG",
"widgetbook",
"Widgetbook",
"widgetbook",
"xhigh",
"xxlg"
],
Expand Down
234 changes: 193 additions & 41 deletions hooks/scripts/block-cli-workarounds.sh
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
#!/bin/bash
# PreToolUse hook: block Bash commands that bypass MCP tools.
# Denies flutter create, dart create, very_good create, very_good test,
# very_good packages, flutter test, dart test.
# PreToolUse hook: deny shell calls to CLI commands the MCP tools cover.
# To block another command, add a hint line below and a test case.

if ! command -v jq &>/dev/null; then
echo "jq is required for block-cli-workarounds hook but not found" >&2
exit 1
fi
# Fail loudly rather than stand aside: a missing tool here would let every shell call
# through unchecked, and silently.
for tool in jq tr; do
if ! command -v "$tool" &>/dev/null; then
echo "$tool is required for block-cli-workarounds hook but not found" >&2
exit 1
fi
done

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/vgv-cli-common.sh"
Expand Down Expand Up @@ -34,54 +37,203 @@ fi
# assumes the chained command's side effect happened and carries on without it.
WHOLE_CALL_REFUSED="This whole shell call was refused, so none of it ran: run any other commands it chained in a call of their own."

# Deny with an install/upgrade message when the CLI is missing or outdated, with a PATH
# message when it is present but cannot run, and otherwise redirect to the MCP tool.
# #60 and #147 were both reports of a denial naming an action the operator never took.
# Saying where the match can come from turns a surprising denial into a legible one.
# Deliberately not phrased as "quote it and it will pass": `"flutter" test` still runs
# the command, so printing that remedy would hand out a bypass with every denial.
PAIR_MAY_BE_AN_ARGUMENT="The match is on an adjacent command and subcommand anywhere in the call, so an argument that reads like one is refused too."

# Deny with a reason that fits the CLI status.
deny_with_cli_check() {
local mcp_hint="$1"
local cli_status
local mcp_hint="$1" matched="$2"
local cli_status reason
cli_status=$(check_vgv_cli)
case "$cli_status" in
not_installed)
deny "Very Good CLI is required but was not found. Install with: dart pub global activate very_good_cli. $WHOLE_CALL_REFUSED"
reason="Very Good CLI is required but was not found. Install with: dart pub global activate very_good_cli."
;;
outdated:*)
local version="${cli_status#outdated:}"
deny "Very Good CLI ${version} is too old (requires >= ${MIN_VERSION}). Update with: dart pub global activate very_good_cli. $WHOLE_CALL_REFUSED"
reason="Very Good CLI ${cli_status#outdated:} is too old (requires >= ${MIN_VERSION}). Update with: dart pub global activate very_good_cli."
;;
unverifiable)
# Redirecting to the MCP tool here would be a dead end: the server starts through
# the same very_good shim, which cannot exec dart from this PATH either.
deny "Very Good CLI was found but could not run: dart is not on the PATH available to hooks, so the very_good_cli MCP server cannot start either. Add the Dart SDK bin directory to PATH for non-interactive shells (e.g. in ~/.zprofile) and start a new session. $WHOLE_CALL_REFUSED"
reason="Very Good CLI was found but could not run: dart is not on the PATH available to hooks, so the very_good_cli MCP server cannot start either. Add the Dart SDK bin directory to PATH for non-interactive shells (e.g. in ~/.zprofile) and start a new session."
;;
*)
deny "$mcp_hint $WHOLE_CALL_REFUSED"
reason="$mcp_hint"
;;
esac
deny "$reason $WHOLE_CALL_REFUSED Matched: $matched. $PAIR_MAY_BE_AN_ARGUMENT"
}

# --- what the shell would treat as a command ---------------------------------
#
# No lexer, and no list of wrappers to maintain. Quoted text counts as data, except
# where the shell would still run what is inside it, and every adjacent word pair is
# tested against its basename.

INERT="_" # stands in for text the shell will not run
ANY_QUOTE="[\"']"

# A backslash-escaped quote, $ or ` is literal text, not a quote or an expansion.
escape_literals() {
printf '%s' "${1//\\[\"\'\$\`]/$INERT}"
}

# A heredoc whose delimiter is quoted (<<'X', <<-"X") expands nothing, so its body is
# data. An unquoted <<X does expand $( ), so that body is left in place to be scanned.
#
# A quoted delimiter stops expansion, not execution: `bash <<'X'` and `cat <<'X' | sh`
# feed the body to a shell, which runs it line by line. When the opening line invokes a
# shell the body is a script, so it stays in place and is scanned like any other command.
strip_quoted_heredocs() {
local quoted_delimiter='<<-?[[:space:]]*('"'"'[^'"'"']+'"'"'|"[^"]+")'
Comment thread
ryzizub marked this conversation as resolved.
local feeds_a_shell='(^|[^[:alnum:]_])(eval|sh|bash|zsh)([^[:alnum:]_]|$)'
local kept="" delimiter="" line trimmed

while IFS= read -r line || [ -n "$line" ]; do
if [ -n "$delimiter" ]; then
# Drop the body. <<- may indent the terminator, and the delimiter is compared
# as text so nothing in it is ever read as a pattern.
trimmed=${line#"${line%%[![:space:]]*}"}
trimmed=${trimmed%"${trimmed##*[![:space:]]}"}
[ "$trimmed" = "$delimiter" ] && delimiter=""
continue
fi

kept+="$line"$'\n'

# The shell test runs first on purpose: BASH_REMATCH holds the last match, so the
# delimiter has to be captured by the test that runs last.
if ! [[ $line =~ $feeds_a_shell ]] && [[ $line =~ $quoted_delimiter ]]; then
delimiter=${BASH_REMATCH[1]}
delimiter=${delimiter//\'/}
delimiter=${delimiter//\"/}
fi
done <<< "$1"

printf '%s' "$kept"
}

# Split on shell operators and check the first two tokens of each subcommand.
# This avoids false positives from file paths (.dart) or quoted strings.
BLOCKED=$(echo "$COMMAND" | awk '{
n = split($0, parts, /[;&|]+/)
for (i = 1; i <= n; i++) {
gsub(/^[[:space:]]+/, "", parts[i])
split(parts[i], w, /[[:space:]]+/)
b = w[1]; s = w[2]
if ((b == "flutter" || b == "dart") && s == "create") { print "create"; exit }
if ((b == "flutter" || b == "dart") && s == "test") { print "test"; exit }
if (b == "very_good" && s == "create") { print "vg_create"; exit }
if (b == "very_good" && s == "test") { print "vg_test"; exit }
if (b == "very_good" && s == "packages") { print "vg_packages"; exit }
}
}')

case "$BLOCKED" in
create) deny_with_cli_check "Do not use 'flutter create' or 'dart create'. Use the very_good_cli MCP 'create' tool instead." ;;
test) deny_with_cli_check "Do not use 'flutter test' or 'dart test'. Use the very_good_cli MCP 'test' tool instead." ;;
vg_create) deny_with_cli_check "Do not use 'very_good create' via shell. Use the very_good_cli MCP 'create' tool instead." ;;
vg_test) deny_with_cli_check "Do not use 'very_good test' via shell. Use the very_good_cli MCP 'test' tool instead." ;;
vg_packages) deny_with_cli_check "Do not use 'very_good packages' via shell. Use the very_good_cli MCP 'packages_get' or 'packages_check_licenses' tool instead." ;;
esac

# Not a blocked command — allow
# Replaces every quoted span, walking the call from left to right. Taking the first
# quote and then its partner is the shell's own rule, so an apostrophe inside "..."
# can never open a span of its own and hide everything after it.
#
# Usage: collapse_quotes <text> <keep-single> <keep-double>. A kept span keeps its
# contents, because the shell would still run them; every other span becomes INERT.
collapse_quotes() {
local rest=$1 keep_single=$2 keep_double=$3
local out="" before quote body keep

while [ -n "$rest" ]; do
before=${rest%%$ANY_QUOTE*}
if [ "$before" = "$rest" ]; then # nothing quoted is left
out+=$rest
break
fi

out+=$before
rest=${rest#"$before"}
quote=${rest:0:1}
rest=${rest:1}

if [[ $rest != *"$quote"* ]]; then # unbalanced: leave the remainder alone
out+="$quote$rest"
break
fi

body=${rest%%"$quote"*}
rest=${rest#"$body$quote"}

if [ "$quote" = "'" ]; then keep=$keep_single; else keep=$keep_double; fi
if [ "$keep" = 1 ]; then out+=$body; else out+=$INERT; fi
done

printf '%s' "$out"
}

# The three readings of one call, in the order they are tried.
as_written() { collapse_quotes "$1" 0 0; } # quotes are data
as_substitution() { collapse_quotes "$1" 0 1; } # but "$( )" runs
as_eval_target() { collapse_quotes "$1" 1 1; } # and eval "" runs

runs_substitution() {
case "$1" in *'$('* | *'`'*) return 0 ;; esac
return 1
}

runs_its_argument() {
local re='(^|[^[:alnum:]_])(eval|sh|bash|zsh) '
[[ $1 =~ $re ]]
}

# --- matching ----------------------------------------------------------------

# Prints the first "<command> <subcommand>" pair the MCP tools cover, or nothing.
# The command is matched on its basename, so /usr/local/bin/flutter counts as flutter
# and wrappers (fvm, melos, sudo, timeout) need no list of their own.
blocked_pair() {
local -a words
local line i pair

# tr, not ${1//$OPERATOR/ }: bash 3.2 runs a bracket-class // over a long string in
# quadratic time, which costs seconds on a command a few kilobytes long. Operators
# become line breaks, and a pair is only ever read within one line, so no pair can
# bridge the operator that separates two commands.
while IFS= read -r line || [ -n "$line" ]; do # the last line carries no newline
read -ra words <<< "$line"

for ((i = 0; i < ${#words[@]} - 1; i++)); do
pair="${words[i]##*/} ${words[i + 1]}"

case "$pair" in
"flutter test" | "dart test" | "flutter create" | "dart create" | \
"very_good dart" | "very_good test" | "very_good create" | "very_good packages")
printf '%s' "$pair"
return 0
;;
esac
done
done < <(printf '%s' "$1" | tr ';&|(){}`' '\n')

return 1
}

hint_for() {
case "$1" in
"flutter test" | "dart test")
printf '%s' "Do not use 'flutter test' or 'dart test'. Use the very_good_cli MCP 'test' tool instead." ;;
"flutter create" | "dart create")
printf '%s' "Do not use 'flutter create' or 'dart create'. Use the very_good_cli MCP 'create' tool instead." ;;
# Scanning left to right reaches this before the `dart test` inside it, so the
# denial names the command that was actually run. `very_good dart` has one subcommand.
"very_good dart")
printf '%s' "Do not use 'very_good dart test' via shell. Use the very_good_cli MCP 'test' tool instead." ;;
"very_good test")
printf '%s' "Do not use 'very_good test' via shell. Use the very_good_cli MCP 'test' tool instead." ;;
"very_good create")
printf '%s' "Do not use 'very_good create' via shell. Use the very_good_cli MCP 'create' tool instead." ;;
"very_good packages")
printf '%s' "Do not use 'very_good packages' via shell. Use the very_good_cli MCP 'packages_get' or 'packages_check_licenses' tool instead." ;;
esac
}

CALL=$(escape_literals "$COMMAND")
CALL=$(strip_quoted_heredocs "$CALL")

PAIR=$(blocked_pair "$(as_written "$CALL")")

if [ -z "$PAIR" ] && runs_substitution "$CALL"; then
PAIR=$(blocked_pair "$(as_substitution "$CALL")")
fi

if [ -z "$PAIR" ] && runs_its_argument "$CALL"; then
PAIR=$(blocked_pair "$(as_eval_target "$CALL")")
fi

if [ -n "$PAIR" ]; then
deny_with_cli_check "$(hint_for "$PAIR")" "$PAIR"
fi

exit 0
Loading
Loading