diff --git a/plugins/plugin-dev/agents/plugin-validator.md b/plugins/plugin-dev/agents/plugin-validator.md index cf977e4933..0549c75c8a 100644 --- a/plugins/plugin-dev/agents/plugin-validator.md +++ b/plugins/plugin-dev/agents/plugin-validator.md @@ -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 @@ -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_` 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 diff --git a/plugins/plugin-dev/skills/hook-development/SKILL.md b/plugins/plugin-dev/skills/hook-development/SKILL.md index d1c0c199c7..ae544a4bab 100644 --- a/plugins/plugin-dev/skills/hook-development/SKILL.md +++ b/plugins/plugin-dev/skills/hook-development/SKILL.md @@ -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_` - 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`: diff --git a/plugins/plugin-dev/skills/hook-development/scripts/README.md b/plugins/plugin-dev/skills/hook-development/scripts/README.md index 02a556fdbd..d987e3cda9 100644 --- a/plugins/plugin-dev/skills/hook-development/scripts/README.md +++ b/plugins/plugin-dev/skills/hook-development/scripts/README.md @@ -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 diff --git a/plugins/plugin-dev/skills/hook-development/scripts/validate-hook-schema.sh b/plugins/plugin-dev/skills/hook-development/scripts/validate-hook-schema.sh index fed0a1f1d4..797c23ed2b 100755 --- a/plugins/plugin-dev/skills/hook-development/scripts/validate-hook-schema.sh +++ b/plugins/plugin-dev/skills/hook-development/scripts/validate-hook-schema.sh @@ -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 @@ -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 @@ -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 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_ 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 diff --git a/plugins/plugin-dev/skills/mcp-integration/references/authentication.md b/plugins/plugin-dev/skills/mcp-integration/references/authentication.md index 1d4ff3840f..753a3b98d1 100644 --- a/plugins/plugin-dev/skills/mcp-integration/references/authentication.md +++ b/plugins/plugin-dev/skills/mcp-integration/references/authentication.md @@ -257,6 +257,47 @@ cat <` +- 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_` 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} diff --git a/plugins/plugin-dev/skills/plugin-structure/references/manifest-reference.md b/plugins/plugin-dev/skills/plugin-structure/references/manifest-reference.md index 40c9c2f363..fc94746d4e 100644 --- a/plugins/plugin-dev/skills/plugin-structure/references/manifest-reference.md +++ b/plugins/plugin-dev/skills/plugin-structure/references/manifest-reference.md @@ -329,6 +329,114 @@ MCP server configuration location or inline definition. - Complex plugins: External `.mcp.json` file - Multiple servers: Always use external file +#### userConfig + +**Type**: Object +**Default**: none +**Example**: see below + +Declares values Claude Code prompts for when the plugin is enabled. Prefer this over asking users to hand-edit `settings.json`. + +```json +{ + "userConfig": { + "api_endpoint": { + "type": "string", + "title": "API endpoint", + "description": "Your team's API endpoint" + }, + "api_token": { + "type": "string", + "title": "API token", + "description": "API authentication token", + "sensitive": true + } + } +} +``` + +**Option fields**: + +| Field | Required | Description | +|-------|----------|-------------| +| `type` | Yes | `string`, `number`, `boolean`, `directory`, or `file` | +| `title` | Yes | Label in the configuration dialog | +| `description` | Yes | Help text under the field | +| `sensitive` | No | If `true`, mask input and store in secure storage | +| `required` | No | Fail validation when empty | +| `default` | No | Value when the user provides nothing | +| `multiple` | No | For `string`, allow an array of strings | +| `min` / `max` | No | Bounds for `number` | + +**How values are exposed**: + +- Exported to plugin subprocesses as `CLAUDE_PLUGIN_OPTION_` (uppercase key) +- Substitutable as `${user_config.KEY}` in MCP/LSP server configs (including `env` blocks) and in **exec-form** hook handlers +- Non-sensitive values may also appear in skill/agent content + +**Storage scopes (Claude Code v2.1.207+)**: + +Non-sensitive values are stored under `pluginConfigs[].options` in settings. Only these layers are **read** at runtime: + +1. User settings (`~/.claude/settings.json`) +2. Inline `--settings` +3. Managed/enterprise settings + +Project-level `.claude/settings.json` is **not** a supported source for `pluginConfigs`. Do not document team plugin options as something to commit under project settings—they will be ignored. Sensitive values use the system keychain (or `~/.claude/.credentials.json` where keychain is unavailable). + +**Shell-form restriction (Claude Code v2.1.207+, shell-injection fix)**: + +`${user_config.*}` is **rejected** in shell-form command strings for: + +- Plugin hook `command` handlers when `args` is omitted +- Plugin monitor `command` fields (monitors are always shell-form) +- MCP `headersHelper` command strings + +| Component | Safe migration | +|-----------|----------------| +| Hooks | Prefer exec form: `"command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/run.js"]` and read `$CLAUDE_PLUGIN_OPTION_` inside the script; or pass the option only via `args` | +| Monitors | Do **not** interpolate `${user_config.*}` into `command`. Read `$CLAUDE_PLUGIN_OPTION_` (or a config file) inside the monitor script | +| MCP `headersHelper` | Keep the helper path free of user options; pass options through the server's `env` block as `${user_config.KEY}` / `$CLAUDE_PLUGIN_OPTION_` and read them inside the helper | + +**Unsafe (rejected since v2.1.207)**: +```json +{ + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/poll.sh ${user_config.api_endpoint}" +} +``` + +**Safe — read env inside the script**: +```json +{ + "type": "command", + "command": "bash", + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/poll.sh"] +} +``` + +```bash +# scripts/poll.sh +endpoint="${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:?api_endpoint not configured}" +# use "$endpoint" as data — never eval it +``` + +**Safe — MCP env block (not shell-form)**: +```json +{ + "mcpServers": { + "api": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/server.js"], + "env": { + "API_ENDPOINT": "${user_config.api_endpoint}", + "API_TOKEN": "${user_config.api_token}" + } + } + } +} +``` + ## Path Resolution ### Relative Path Rules