Skip to content

fix(skill-required): address all audit findings#400

Draft
jack-nsheaps[bot] wants to merge 3 commits intomainfrom
fix/skill-required-audit-findings
Draft

fix(skill-required): address all audit findings#400
jack-nsheaps[bot] wants to merge 3 commits intomainfrom
fix/skill-required-audit-findings

Conversation

@jack-nsheaps
Copy link
Copy Markdown
Contributor

@jack-nsheaps jack-nsheaps Bot commented Apr 8, 2026

Summary

Addresses all findings from the skill-required plugin audit (2026-04-08).

  • Extract shared lib: lib/utils.sh with get_project_slug() and sanitize_skill_name() helpers, sourced from both hook scripts — eliminates duplicated project-slug generation
  • Fix colon sanitization: Skill names like scm-utils:commit now map to scm-utils--commit as cache filenames, preventing invalid path chars on some filesystems
  • Batch yq calls: check-skill-required.sh now reads all skill rules in one yq -o=json call, then uses jq for per-rule field extraction — reduces subprocess overhead from 4 yq calls per rule to 1 per loop iteration
  • Add mise.toml: Declares yq (mikefarah/yq) as an explicit plugin dependency
  • Add skill-required.settings.yaml: Shipped settings template with commented defaults and example rules, matching the pattern used by common-sense and agentic-behavior plugins
  • Convert to YAML arrays: required_before and command_pattern fields are now documented and templated as proper YAML arrays; legacy pipe-separated strings remain supported for backwards compatibility

Source

Test plan

  • Verify cache-skill-read.sh correctly sanitizes colon-containing skill names (scm-utils:commit → cache file scm-utils--commit.json)
  • Verify check-skill-required.sh reads the sanitized cache filename and correctly denies/allows based on skill-load state
  • Verify YAML-array config for required_before and command_pattern works the same as the old pipe-separated format
  • Verify legacy pipe-separated config still parses correctly (backwards compatibility)
  • Verify yq dependency is recognized by mise install from mise.toml
  • Confirm lib/utils.sh is sourced from both hooks without errors

Co-Authored-By: Jack Oat <jack-nsheaps[bot]@users.noreply.github.com>

- Extract shared lib: add lib/utils.sh with get_project_slug() and
  sanitize_skill_name() helpers; both hook scripts now source it
- Fix colon sanitization: skill names like scm-utils:commit now map to
  scm-utils--commit on the filesystem, preventing invalid path chars
- Batch yq calls: check-skill-required.sh reads all skill rules in one
  yq call (yq -o=json) then uses jq for per-rule field extraction,
  reducing subprocesses from 4x per rule to 1 per loop iteration
- Add mise.toml declaring yq as a required dependency
- Add skill-required.settings.yaml template with documented defaults
  and example rules using YAML arrays
- Convert required_before and command_pattern to YAML arrays in docs
  and settings template; legacy pipe-separated strings still supported
  for backwards compatibility
- Update README to reflect new config format and mise.toml dependency
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 8, 2026

Plugin Version Status

Versions are auto-bumped in PRs. Manual bumps to higher versions are preserved.

Plugin Base Current Action
skill-required 0.1.4 0.1.5 Auto-bumped

@jack-nsheaps jack-nsheaps Bot requested a review from nsheaps April 8, 2026 19:08
@nsheaps nsheaps added the request-review Request a one-time review from the Claude review bot (label is removed after review starts) label Apr 16, 2026
@henry-nsheaps henry-nsheaps Bot removed the request-review Request a one-time review from the Claude review bot (label is removed after review starts) label Apr 16, 2026
Copy link
Copy Markdown
Contributor

@henry-nsheaps henry-nsheaps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Solid audit cleanup — a few maintainability nits worth tightening before merge

⚠️ Array-vs-legacy-string parsing is duplicated ~40 lines across required_before and command_pattern — extract a helper
⚠️ jq is used everywhere but not declared in the new mise.toml (only yq is)
⚠️ PR body claims the settings template "matches" common-sense / agentic-behavior but the override file path diverges from their standard
ℹ️ Dead null/empty fallbacks after jq '// …' substitutions
ℹ️ sanitize_skill_name only covers : — either narrow the name or broaden the implementation
✅ Good: batched yq + jq reads, atomic cache writes, CLAUDE_PLUGIN_ROOT fallback, backwards-compat with legacy pipe strings, helper extraction into lib/utils.sh

🖱️ Click to expand for full details

Code Quality (78%)

The refactor meaningfully improves the plugin — get_project_slug() / sanitize_skill_name() are now a single source of truth across both hooks, the yq -o=jsonjq batch reduces subprocess count per rule from ~4 to roughly 1, and the config template matches the shape of other plugins in the marketplace.

Two things dock the score:

  • DRY violation in check-skill-required.sh: the "is it a JSON array, or a legacy pipe-separated string?" branching is written twice, once for required_before and once for command_pattern. That's the largest block of duplicated logic in the file and the most likely place for future drift. A read_list_field() helper in lib/utils.sh collapses both call sites. Suggested implementation in the inline thread.
  • Dead fallback checks after jq '// 10' / jq '// empty'. Harmless, but they're remnants of the pre-jq path — worth cleaning up while you're here.

Simplicity (72%)

check-skill-required.sh grew from ~140 to ~200 lines. Most of the growth is the dual-path handling; pulling the helper out would bring it back closer to the original footprint while keeping the new YAML-array support.

Dependencies

The new mise.toml declares yq but not jq. Both are hard deps (scripts will crash without jq — there's no command -v jq guard, unlike yq). README lists both under Dependencies. For consistency and to make mise install actually set up the environment, jq should be declared too.

Config Pattern Alignment

The PR body advertises alignment with common-sense and agentic-behavior, but those plugins use the standard plugins.settings.yaml + shared plugin-config-read.sh resolution, whereas skill-required ships its own settings.skill-required.yaml path and parses inline. The template file accurately reflects what the scripts actually read — but the "matches the pattern" claim is only half true. Either adjust the PR description or treat the full migration as a follow-up (see inline thread).

Security (N/A)

No new attack surface. Regex patterns are applied via grep -qE (not eval), cache writes are atomic, and skill/tool names come from Claude Code's hook payload. sanitize_skill_name only handles : today but skill names are plugin-controlled.

Versioning

0.1.4 → 0.1.5 (auto-bumped). All changes are backwards compatible (legacy pipe-strings still parse), so patch is appropriate.

Test Plan

The PR body's test plan checklist is entirely unchecked. No visible evidence of manual verification for colon sanitization, YAML-array round-trip, or legacy-format backwards compat. Worth confirming those were run locally at least once before merge, given there aren't automated tests for the hook scripts.

How the scores were derived

  • Quality 78%: strong refactor core, offset by duplication + dead code + dep gap.
  • Security N/A: no security-relevant changes; config is trusted user input.
  • Simplicity 72%: dual-path logic roughly doubles the size of the rule-matching loop vs. what a shared helper would achieve.
  • Confidence 90%: shell code is straightforward, no framework indirection, I read both scripts end-to-end and cross-referenced with the reference plugins.

Recommended follow-ups (non-blocking):

  • Migrate skill-required onto shared/lib/plugin-config-read.sh and the standard plugins.settings.yaml override path, to genuinely match common-sense / agentic-behavior.
  • Add hook-script unit tests (even a simple bats suite) so the dual YAML-array/legacy-string paths don't silently regress.
  • Decide whether sanitize_skill_name should become a general path-safe sanitizer or be renamed to its actual (colon-only) scope.

Notes:1234

Footnotes

  1. Workflow Run: https://github.com/nsheaps/ai-mktpl/actions/runs/24538394948/attempts/1

  2. PR: nsheaps/ai-mktpl#400

  3. Reference plugin config pattern: common-sense.settings.yaml, agentic-behavior.settings.yaml

  4. Shared libs rule: .claude/rules/shared-libs.md

Comment on lines +121 to 185
# Check required_before — supports both YAML array and legacy pipe-separated string
# Build a newline-separated list of tool names
required_before_json="$(echo "$rule_json" | jq -c '.required_before // empty')"
if [ -z "$required_before_json" ] || [ "$required_before_json" = "null" ]; then
continue
fi

# Determine if value is a JSON array or a plain string
tool_matches=false
IFS='|' read -ra tool_list <<< "$required_before"
for t in "${tool_list[@]}"; do
t="$(echo "$t" | xargs)" # trim whitespace
if [ "$t" = "$tool_name" ]; then
tool_matches=true
break
fi
done
if echo "$required_before_json" | jq -e 'type == "array"' &>/dev/null; then
# YAML array: ["Bash", "Edit"]
while IFS= read -r t; do
if [ "$t" = "$tool_name" ]; then
tool_matches=true
break
fi
done < <(echo "$required_before_json" | jq -r '.[]')
else
# Legacy pipe-separated string: "Bash|Edit"
legacy_str="$(echo "$required_before_json" | jq -r '.')"
IFS='|' read -ra tool_list <<< "$legacy_str"
for t in "${tool_list[@]}"; do
t="$(echo "$t" | xargs)" # trim whitespace
if [ "$t" = "$tool_name" ]; then
tool_matches=true
break
fi
done
fi

if [ "$tool_matches" != "true" ]; then
continue
fi

# If there's a command_pattern, check it (only for Bash tool)
if [ -n "$command_pattern" ] && [ "$command_pattern" != "null" ] && [ "$tool_name" = "Bash" ]; then
command_pattern_json="$(echo "$rule_json" | jq -c '.command_pattern // empty')"
if [ -n "$command_pattern_json" ] && [ "$command_pattern_json" != "null" ] && [ "$tool_name" = "Bash" ]; then
command="$(echo "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
if [ -n "$command" ]; then
pattern_matches=false
IFS='|' read -ra patterns <<< "$command_pattern"
for p in "${patterns[@]}"; do
p="$(echo "$p" | xargs)"
if echo "$command" | grep -qE "$p"; then
pattern_matches=true
break
fi
done
if echo "$command_pattern_json" | jq -e 'type == "array"' &>/dev/null; then
# YAML array: ["git commit", "git push"]
while IFS= read -r p; do
if echo "$command" | grep -qE "$p"; then
pattern_matches=true
break
fi
done < <(echo "$command_pattern_json" | jq -r '.[]')
else
# Legacy pipe-separated string: "git commit|git push"
legacy_pat="$(echo "$command_pattern_json" | jq -r '.')"
IFS='|' read -ra patterns <<< "$legacy_pat"
for p in "${patterns[@]}"; do
p="$(echo "$p" | xargs)"
if echo "$command" | grep -qE "$p"; then
pattern_matches=true
break
fi
done
fi
if [ "$pattern_matches" != "true" ]; then
continue
fi
fi
fi
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ DRY violation — array-vs-legacy-string parsing duplicated

The required_before block (lines 121-149) and the command_pattern block (lines 156-184) implement essentially the same logic: check if the JSON is an array → iterate items; else treat as pipe-separated string → split + trim + iterate. About 40 lines of near-duplicate shell.

Consider extracting a helper into lib/utils.sh, e.g.:

# Emits one item per line from either a JSON array or a legacy pipe-separated string.
# Args: $1=json_value (compact JSON, e.g. '["a","b"]' or '"a|b"')
read_list_field() {
  local val="$1"
  if echo "$val" | jq -e 'type == "array"' &>/dev/null; then
    echo "$val" | jq -r '.[]'
  else
    # shellcheck disable=SC2046
    IFS='|' read -ra items <<< "$(echo "$val" | jq -r '.')"
    for item in "${items[@]}"; do
      echo "$item" | xargs
    done
  fi
}

Then each call site collapses to a single loop. Halves the size of this script and keeps the two code paths in lockstep — right now a future change to one branch can silently drift from the other.

Non-blocking, but worth doing since the audit was explicitly about maintainability findings.

[tools]
# yq (Go version: mikefarah/yq) is required for YAML config parsing in hook scripts.
# https://github.com/mikefarah/yq
yq = "latest"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ jq is a hard dependency too but isn't declared here

Both hook scripts call jq extensively (JSON parsing, atomic cache writes, etc.), and the README lists jq alongside yq under Dependencies. If the point of mise.toml is "declare tool dependencies so mise install sets them up", jq belongs here as well:

Suggested change
yq = "latest"
[tools]
# jq is required for JSON processing in hook scripts.
# https://github.com/jqlang/jq
jq = "latest"
# yq (Go version: mikefarah/yq) is required for YAML config parsing in hook scripts.
# https://github.com/mikefarah/yq
yq = "latest"

Otherwise a fresh environment that goes through mise install from this plugin's mise.toml still won't have jq guaranteed, and the scripts will crash with jq: command not found (there's no command -v jq guard like there is for yq).

Comment on lines +109 to +126
skill_name="$(echo "$rule_json" | jq -r '.name // empty')"
max_uses="$(echo "$rule_json" | jq -r '.max_tool_uses_before_reset // 10')"

# Validate max_uses is a number
if [ -z "$max_uses" ] || [ "$max_uses" = "null" ]; then
max_uses=10
fi

# Check if this rule applies to the current tool
if [ -z "$required_before" ] || [ "$required_before" = "null" ]; then
if [ -z "$skill_name" ] || [ "$skill_name" = "null" ]; then
continue
fi

# Check if tool name matches (pipe-separated list)
# Check required_before — supports both YAML array and legacy pipe-separated string
# Build a newline-separated list of tool names
required_before_json="$(echo "$rule_json" | jq -c '.required_before // empty')"
if [ -z "$required_before_json" ] || [ "$required_before_json" = "null" ]; then
continue
fi
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Dead fallback checks after jq '// …'

jq -r '.max_tool_uses_before_reset // 10' (line 110) already substitutes 10 for missing/null, so the if [ -z "$max_uses" ] || [ "$max_uses" = "null" ] (lines 113-115) can only fire if max_tool_uses_before_reset is a literal empty string — not a realistic YAML input.

Similarly, jq -c '.required_before // empty' (line 123) outputs an empty string (not the literal null) when the field is missing/null, so [ "$required_before_json" = "null" ] on line 124 is dead. Same for command_pattern_json on line 157.

Not a bug, just leftover from the pre-jq path. A one-line simplification on each:

max_uses="$(echo "$rule_json" | jq -r '.max_tool_uses_before_reset // 10')"

if [ -z "$skill_name" ]; then
  continue
fiif [ -z "$required_before_json" ]; then
  continue
fi

Non-blocking cleanup.

Comment on lines +1 to +4
# skill-required plugin configuration
#
# Override per-project via ${CLAUDE_PROJECT_DIR}/.claude/settings.skill-required.yaml
# or per-user via ~/.claude/settings.skill-required.yaml under the "skill-required" key.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Pattern diverges from reference plugins (PR body says "matching" them)

The PR description says this template "match[es] the pattern used by common-sense and agentic-behavior". It matches on having a <plugin>.settings.yaml default in the plugin root, but it diverges on where the override lives:

  • common-sense.settings.yaml:

    Override per-project via ${CLAUDE_PROJECT_DIR}/.claude/plugins.settings.yaml

  • This file:

    Override per-project via ${CLAUDE_PROJECT_DIR}/.claude/settings.skill-required.yaml

The reference plugins use the standard 3-tier plugins.settings.yaml resolution via the shared shared/lib/plugin-config-read.sh helper. This plugin ships its own bespoke file and its own YAML parsing inline.

Not a blocker for this PR (the scripts currently read settings.skill-required.yaml, and the template must match what the scripts read), but the audit's goal of "align with reference plugins" isn't fully met. Worth a follow-up to:

  1. Symlink shared/lib/plugin-config-read.sh into plugins/skill-required/lib/.
  2. Set PLUGIN_NAME="skill-required" in the hooks.
  3. Use plugin_is_enabled / plugin_get_config_json "skills" etc.
  4. Remove the bespoke settings.skill-required.yaml path in favor of plugins.settings.yaml + plugin key.

Or update the PR body to clarify that this PR keeps the plugin's existing settings-file location by design.

Comment on lines +24 to +32
# Sanitize a skill name for use as a filesystem path component.
# Replaces colons (invalid on some filesystems) with double-hyphens.
# Example: "scm-utils:commit" → "scm-utils--commit"
# Args: $1=skill_name
# Returns: sanitized name via stdout
sanitize_skill_name() {
local name="$1"
echo "$name" | sed 's|:|--|g'
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ sanitize_skill_name only handles :

The function name promises general filesystem safety, but only : is replaced. /, \, null bytes, leading . (dotfile), path traversal (..) etc. are passed through. Since skill names in practice are plugin-controlled (plugin:skill-name), this is low risk today, but the name of the function suggests broader guarantees than it provides.

Two options:

  1. Narrow the name to what it actually does, e.g. replace_skill_name_colons() — truthful, avoids false sense of safety.

  2. Broaden the implementation to also escape /, \, and other path-unsafe characters:

    sanitize_skill_name() {
      local name="$1"
      # Replace any character that isn't [A-Za-z0-9._-] with a single hyphen
      echo "$name" | sed 's|[^A-Za-z0-9._-]|-|g'
    }

    This would also cover : via the default class, so no behavior change for known inputs.

Non-blocking — just worth being explicit about scope in a helper that ends up in lib/.

Comment on lines +28 to +33
# Resolve plugin root (injected by claude-code as CLAUDE_PLUGIN_ROOT)
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "${_SCRIPT_DIR}/../.." && pwd)}"

# shellcheck source=../../lib/utils.sh
source "${_PLUGIN_ROOT}/lib/utils.sh"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Nice defensive fallback here (and in cache-skill-read.sh) — computing _PLUGIN_ROOT from BASH_SOURCE when CLAUDE_PLUGIN_ROOT is unset means the scripts are still runnable outside a Claude Code session (unit tests, ad-hoc invocation). Good hygiene.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant