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
15 changes: 12 additions & 3 deletions plugins/plugin-dev/agents/plugin-validator.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,12 @@ You are an expert plugin validator specializing in comprehensive validation of C
- Use the validate-hook-schema.sh utility from hook-development skill
- Or manually check:
- Valid JSON syntax
- Plugin wrapper format: optional `description` + required `hooks` object (or direct event map)
- Valid event names (PreToolUse, PostToolUse, Stop, etc.)
- Each hook has `matcher` and `hooks` array
- Each matcher group has a `hooks` array; `matcher` is optional
- Hook type is `command` or `prompt`
- Commands reference existing scripts with ${CLAUDE_PLUGIN_ROOT}
- No `${user_config.*}` in shell-form command strings (rejected since Claude Code v2.1.207)

8. **Validate MCP Configuration** (if `.mcp.json` or `mcpServers` in manifest):
- Check JSON syntax
Expand All @@ -120,18 +122,25 @@ You are an expert plugin validator specializing in comprehensive validation of C
- sse/http/ws: has `url` field
- Type-specific fields present
- Check ${CLAUDE_PLUGIN_ROOT} usage for portability
- `headersHelper` must not embed `${user_config.*}` in the command string; pass options via `env` instead

9. **Check File Organization**:
9. **Validate userConfig** (if present in `plugin.json`):
- Each option has `type`, `title`, and `description`
- Document that runtime options are read from user / `--settings` / managed settings only (not project `.claude/settings.json`, v2.1.207+)
- Prefer `$CLAUDE_PLUGIN_OPTION_<KEY>` or MCP `env` over shell-form interpolation

10. **Check File Organization**:
- README.md exists and is comprehensive
- No unnecessary files (node_modules, .DS_Store, etc.)
- .gitignore present if needed
- LICENSE file present

10. **Security Checks**:
11. **Security Checks**:
- No hardcoded credentials in any files
- MCP servers use HTTPS/WSS not HTTP/WS
- Hooks don't have obvious security issues
- No secrets in example files
- No shell-form `${user_config.*}` in hooks, monitors, or headersHelper (shell-injection surface)

**Quality Standards:**
- All validation errors include file path and specific issue
Expand Down
44 changes: 42 additions & 2 deletions plugins/plugin-dev/skills/hook-development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,18 +325,58 @@ Available in all command hooks:

- `$CLAUDE_PROJECT_DIR` - Project root path
- `$CLAUDE_PLUGIN_ROOT` - Plugin directory (use for portable paths)
- `$CLAUDE_PLUGIN_DATA` - Persistent plugin data directory (survives updates)
- `$CLAUDE_ENV_FILE` - SessionStart only: persist env vars here
- `$CLAUDE_CODE_REMOTE` - Set if running in remote context
- `$CLAUDE_PLUGIN_OPTION_<KEY>` - Values from the plugin's `userConfig` (enable-time options)

**Always use ${CLAUDE_PLUGIN_ROOT} in hook commands for portability:**
**Always use ${CLAUDE_PLUGIN_ROOT} in hook commands for portability.** Prefer **exec form** so paths need no shell quoting:

```json
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh"
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh"]
}
```

Shell form (no `args`) still works when you need pipes or `&&`:

```json
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}\"/scripts/validate.sh"
}
```

### Plugin options and shell-form (v2.1.207+)

As of Claude Code v2.1.207, `${user_config.*}` is **rejected** in shell-form plugin hook commands (shell-injection fix). Monitors and MCP `headersHelper` follow the same rule for their shell command strings.

| Approach | Status |
|----------|--------|
| Shell-form `"command": "... ${user_config.api_endpoint}"` | ❌ Rejected |
| Exec form with `args`, read option inside script via `$CLAUDE_PLUGIN_OPTION_*` | ✅ Preferred |
| Exec form that passes `${user_config.KEY}` only as an `args` element | ✅ Accepted (value is one argv, not shell-parsed) |

**Preferred pattern** — keep user-controlled values out of the command string:

```json
{
"type": "command",
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/notify.sh"]
}
```

```bash
# scripts/notify.sh
endpoint="${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:?missing api_endpoint}"
# treat "$endpoint" as data only — do not eval
```

The hook schema validator flags shell-form `${user_config.*}` as an error.

## Plugin Hook Configuration

In plugins, define hooks in `hooks/hooks.json`:
Expand Down
4 changes: 3 additions & 1 deletion plugins/plugin-dev/skills/hook-development/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ Validates `hooks.json` configuration files for correct structure and common issu

**Checks:**
- Valid JSON syntax
- Required fields present
- Plugin wrapper format (`description` + `hooks`) or direct event map
- Required fields present (`hooks` array; `matcher` optional)
- Valid hook event names
- Proper hook types (command/prompt)
- Timeout values in valid ranges
- Hardcoded path detection
- Prompt hook event compatibility
- Shell-form `${user_config.*}` rejected (Claude Code v2.1.207 shell-injection fix)

**Example:**
```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ if [ $# -eq 0 ]; then
echo ""
echo "Validates hook configuration file for:"
echo " - Valid JSON syntax"
echo " - Plugin wrapper format (optional description + hooks)"
echo " - Required fields"
echo " - Hook type validity"
echo " - Matcher patterns"
echo " - Timeout ranges"
echo " - Shell-form \${user_config.*} (rejected since Claude Code v2.1.207)"
exit 1
fi

Expand All @@ -36,11 +38,29 @@ fi
echo "✅ Valid JSON"

# Check 2: Root structure
# Plugin hooks.json uses {"description"?: string, "hooks": { Event: [...] }}
# Settings-style files place events at the top level.
echo ""
echo "Checking root structure..."

if jq -e 'type == "object" and has("hooks") and (.hooks | type == "object")' "$HOOKS_FILE" >/dev/null 2>&1; then
HOOKS_JSON=$(jq -c '.hooks' "$HOOKS_FILE")
echo "✅ Plugin wrapper format detected (hooks key)"

# Unknown top-level keys other than description/hooks are worth a note
for key in $(jq -r 'keys[]' "$HOOKS_FILE"); do
if [ "$key" != "hooks" ] && [ "$key" != "description" ]; then
echo "⚠️ Unknown top-level field: $key (expected description and/or hooks)"
fi
done
else
HOOKS_JSON=$(jq -c '.' "$HOOKS_FILE")
echo "✅ Direct event-map format detected"
fi

VALID_EVENTS=("PreToolUse" "PostToolUse" "UserPromptSubmit" "Stop" "SubagentStop" "SessionStart" "SessionEnd" "PreCompact" "Notification")

for event in $(jq -r 'keys[]' "$HOOKS_FILE"); do
for event in $(echo "$HOOKS_JSON" | jq -r 'keys[]'); do
found=false
for valid_event in "${VALID_EVENTS[@]}"; do
if [ "$event" = "$valid_event" ]; then
Expand All @@ -62,83 +82,103 @@ echo "Validating individual hooks..."
error_count=0
warning_count=0

for event in $(jq -r 'keys[]' "$HOOKS_FILE"); do
hook_count=$(jq -r ".\"$event\" | length" "$HOOKS_FILE")
for event in $(echo "$HOOKS_JSON" | jq -r 'keys[]'); do
hook_count=$(echo "$HOOKS_JSON" | jq -r ".\"$event\" | length")

for ((i=0; i<hook_count; i++)); do
# Check matcher exists
matcher=$(jq -r ".\"$event\"[$i].matcher // empty" "$HOOKS_FILE")
# matcher is optional: omit or "*" matches every occurrence of the event
matcher=$(echo "$HOOKS_JSON" | jq -r ".\"$event\"[$i].matcher // empty")
if [ -z "$matcher" ]; then
echo "❌ $event[$i]: Missing 'matcher' field"
((error_count++))
continue
echo "💡 $event[$i]: No matcher (fires on every $event occurrence)"
fi

# Check hooks array exists
hooks=$(jq -r ".\"$event\"[$i].hooks // empty" "$HOOKS_FILE")
hooks=$(echo "$HOOKS_JSON" | jq -r ".\"$event\"[$i].hooks // empty")
if [ -z "$hooks" ] || [ "$hooks" = "null" ]; then
echo "❌ $event[$i]: Missing 'hooks' array"
((error_count++))
error_count=$((error_count + 1))
continue
fi

# Validate each hook in the array
hook_array_count=$(jq -r ".\"$event\"[$i].hooks | length" "$HOOKS_FILE")
hook_array_count=$(echo "$HOOKS_JSON" | jq -r ".\"$event\"[$i].hooks | length")

for ((j=0; j<hook_array_count; j++)); do
hook_type=$(jq -r ".\"$event\"[$i].hooks[$j].type // empty" "$HOOKS_FILE")
hook_type=$(echo "$HOOKS_JSON" | jq -r ".\"$event\"[$i].hooks[$j].type // empty")

if [ -z "$hook_type" ]; then
echo "❌ $event[$i].hooks[$j]: Missing 'type' field"
((error_count++))
error_count=$((error_count + 1))
continue
fi

if [ "$hook_type" != "command" ] && [ "$hook_type" != "prompt" ]; then
echo "❌ $event[$i].hooks[$j]: Invalid type '$hook_type' (must be 'command' or 'prompt')"
((error_count++))
error_count=$((error_count + 1))
continue
fi

# Check type-specific fields
if [ "$hook_type" = "command" ]; then
command=$(jq -r ".\"$event\"[$i].hooks[$j].command // empty" "$HOOKS_FILE")
command=$(echo "$HOOKS_JSON" | jq -r ".\"$event\"[$i].hooks[$j].command // empty")
if [ -z "$command" ]; then
echo "❌ $event[$i].hooks[$j]: Command hooks must have 'command' field"
((error_count++))
error_count=$((error_count + 1))
else
# Check for hardcoded paths
if [[ "$command" == /* ]] && [[ "$command" != *'${CLAUDE_PLUGIN_ROOT}'* ]]; then
echo "⚠️ $event[$i].hooks[$j]: Hardcoded absolute path detected. Consider using \${CLAUDE_PLUGIN_ROOT}"
((warning_count++))
warning_count=$((warning_count + 1))
fi

# As of Claude Code v2.1.207, ${user_config.*} is rejected in shell-form
# plugin hook commands (shell-injection fix). Detect shell form (no args)
# and warn authors to migrate.
has_args=$(echo "$HOOKS_JSON" | jq -r ".\"$event\"[$i].hooks[$j] | has(\"args\")")
if echo "$command" | grep -q '\${user_config\.'; then
if [ "$has_args" = "true" ]; then
echo "💡 $event[$i].hooks[$j]: \${user_config.*} in exec form (args present) is OK; prefer \$CLAUDE_PLUGIN_OPTION_<KEY> inside the script"
else
echo "❌ $event[$i].hooks[$j]: \${user_config.*} in shell-form command is rejected since Claude Code v2.1.207 (shell-injection fix)"
echo " Fix: use exec form with \"args\", or read \$CLAUDE_PLUGIN_OPTION_<KEY> inside the script"
error_count=$((error_count + 1))
fi
fi

# Also scan each args element for documentation completeness
if [ "$has_args" = "true" ]; then
args_joined=$(echo "$HOOKS_JSON" | jq -r ".\"$event\"[$i].hooks[$j].args // [] | join(\" \")")
if echo "$args_joined" | grep -q '\${user_config\.'; then
echo "💡 $event[$i].hooks[$j]: Passing \${user_config.*} via args is accepted; ensure the value is treated as data, not re-evaluated by a shell"
fi
fi
fi
elif [ "$hook_type" = "prompt" ]; then
prompt=$(jq -r ".\"$event\"[$i].hooks[$j].prompt // empty" "$HOOKS_FILE")
prompt=$(echo "$HOOKS_JSON" | jq -r ".\"$event\"[$i].hooks[$j].prompt // empty")
if [ -z "$prompt" ]; then
echo "❌ $event[$i].hooks[$j]: Prompt hooks must have 'prompt' field"
((error_count++))
error_count=$((error_count + 1))
fi

# Check if prompt-based hooks are used on supported events
if [ "$event" != "Stop" ] && [ "$event" != "SubagentStop" ] && [ "$event" != "UserPromptSubmit" ] && [ "$event" != "PreToolUse" ]; then
echo "⚠️ $event[$i].hooks[$j]: Prompt hooks may not be fully supported on $event (best on Stop, SubagentStop, UserPromptSubmit, PreToolUse)"
((warning_count++))
warning_count=$((warning_count + 1))
fi
fi

# Check timeout
timeout=$(jq -r ".\"$event\"[$i].hooks[$j].timeout // empty" "$HOOKS_FILE")
timeout=$(echo "$HOOKS_JSON" | jq -r ".\"$event\"[$i].hooks[$j].timeout // empty")
if [ -n "$timeout" ] && [ "$timeout" != "null" ]; then
if ! [[ "$timeout" =~ ^[0-9]+$ ]]; then
echo "❌ $event[$i].hooks[$j]: Timeout must be a number"
((error_count++))
error_count=$((error_count + 1))
elif [ "$timeout" -gt 600 ]; then
echo "⚠️ $event[$i].hooks[$j]: Timeout $timeout seconds is very high (max 600s)"
((warning_count++))
warning_count=$((warning_count + 1))
elif [ "$timeout" -lt 5 ]; then
echo "⚠️ $event[$i].hooks[$j]: Timeout $timeout seconds is very low"
((warning_count++))
warning_count=$((warning_count + 1))
fi
fi
done
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,47 @@ cat <<EOF
EOF
```

### Plugin options and headersHelper (v2.1.207+)

As of Claude Code v2.1.207, `${user_config.*}` is **rejected** inside MCP `headersHelper` command strings (same shell-injection fix as plugin hooks/monitors).

**Do not** write:

```json
{
"headersHelper": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/get-headers.sh ${user_config.api_token}"
}
```

**Do** pass options through the server's `env` block (substitution is allowed there) and read them inside the helper:

```json
{
"api": {
"type": "http",
"url": "https://api.example.com/mcp",
"headersHelper": "${CLAUDE_PLUGIN_ROOT}/scripts/get-headers.sh",
"env": {
"API_TOKEN": "${user_config.api_token}",
"API_ENDPOINT": "${user_config.api_endpoint}"
}
}
}
```

```bash
#!/bin/bash
# Prefer env from the MCP server config; CLAUDE_PLUGIN_OPTION_* is also set
TOKEN="${API_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:?api_token not configured}}"
cat <<EOF
{
"Authorization": "Bearer ${TOKEN}"
}
EOF
```

`${user_config.KEY}` remains valid in non-shell MCP fields such as `env`, static `headers`, and similar structured config—only shell-form helper/command strings are restricted.

### Use Cases for Dynamic Headers

- Short-lived tokens that need refresh
Expand Down
14 changes: 14 additions & 0 deletions plugins/plugin-dev/skills/plugin-structure/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,20 @@ hooks/

**Usage**: MCP servers integrate seamlessly with Claude Code's tool system

### User configuration (`userConfig`)

Plugins can declare `userConfig` in `plugin.json` so Claude Code prompts for options at enable time (API URLs, tokens, paths). Values are available as:

- Environment variables: `CLAUDE_PLUGIN_OPTION_<KEY>`
- Substitutions: `${user_config.KEY}` in MCP/LSP configs and **exec-form** hooks

**Important (Claude Code v2.1.207+)**:

1. **No shell-form interpolation** of `${user_config.*}` in plugin hook commands, monitor commands, or MCP `headersHelper` strings (shell-injection fix). Use exec form (`args`) or read `$CLAUDE_PLUGIN_OPTION_<KEY>` inside the script.
2. **`pluginConfigs` are not read from project** `.claude/settings.json`. Only user, `--settings`, and managed settings supply plugin option values.

See `references/manifest-reference.md` for the full schema and safe examples.

## Portable Path References

### ${CLAUDE_PLUGIN_ROOT}
Expand Down
Loading