feat(doctor): check the vendored external skills
lib/doctor-vendored.sh check_vendored_skills: every curl-pinned lock entry has its files under skills-external/ (list, dict, single-path shapes), every link.sh EXTERNAL_SKILLS name is symlinked into ~/.claude/skills when the active profile lists it, parked names reported not failed, hints make plugin / make link. Lock shape-validated (warn, never a traceback), profile and item names allowlisted before becoming paths. Suite: 11 cases.
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib/tests/doctor-vendored.test.sh — lib/doctor-vendored.sh's
|
||||
# check_vendored_skills(): a fixture repo under mktemp with a fake
|
||||
# plugins.lock.json (single-path shape, list shape, dict shape with a
|
||||
# references/ file), a fake link.sh holding a multi-line
|
||||
# EXTERNAL_SKILLS=(...) array, a fake <claude_home>/skills dir and a fake
|
||||
# profile file. Cases: every file present + linked (ALL_PRESENT), a
|
||||
# list-shape skill missing its SKILL.md (FILE_MISSING), a dict-shape
|
||||
# skill missing one of two files — only that file is named
|
||||
# (DICT_FILES_COMPLETE), a profile-listed name with no symlink
|
||||
# (SYMLINK_MISSING_ACTIVE) or a symlink to the wrong target
|
||||
# (SYMLINK_WRONG_TARGET), a name absent from the profile reported parked
|
||||
# rather than failed (SYMLINK_PARKED), the same name treated as
|
||||
# expected-linked (fail, not parked) when no profile file is passed at
|
||||
# all (NO_PROFILE_EXPECTS_LINK), an unreadable lock file degrading to a
|
||||
# warn instead of a fail (LOCK_UNREADABLE, rc 0), a lock entry whose
|
||||
# "skills" is neither null/list/dict degrading the same way with no
|
||||
# Python traceback leaking (LOCK_MALFORMED_ENTRY, rc 0), the
|
||||
# profile-name allowlist rejecting a path-traversal value
|
||||
# (REJECTS_BAD_PROFILE_NAME), and the item-name allowlist rejecting a
|
||||
# link.sh entry with a ".." segment — warned and skipped, not failed
|
||||
# (REJECTS_BAD_NAME).
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
LIB="$ROOT/lib/doctor-vendored.sh"
|
||||
pass=0; fail=0
|
||||
|
||||
check_bool() {
|
||||
local name="$1" ok="$2"
|
||||
if [ "$ok" = 1 ]; then pass=$((pass+1)); echo "PASS $name"
|
||||
else fail=$((fail+1)); echo "FAIL $name"; fi
|
||||
}
|
||||
|
||||
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
|
||||
REPO="$WORK/repo"
|
||||
CLAUDE_HOME="$WORK/claude_home"
|
||||
mkdir -p "$REPO/skills-external" "$CLAUDE_HOME/skills"
|
||||
|
||||
# ── Lock: "ok-skill" mirrors the emil-design-eng single-file "path"
|
||||
# shape (the key IS the name); "list-entry" mirrors the agent-skills
|
||||
# bare-list shape (5 names, each defaults to SKILL.md); "dict-entry"
|
||||
# mirrors the mengto-skills explicit shape (dict-skill needs SKILL.md +
|
||||
# references/notes.md, and only the latter is ever missing below).
|
||||
cat > "$REPO/plugins.lock.json" <<'JSON'
|
||||
{
|
||||
"ok-skill": {
|
||||
"path": "skills/ok-skill/SKILL.md",
|
||||
"managed_by": "curl"
|
||||
},
|
||||
"list-entry": {
|
||||
"managed_by": "curl",
|
||||
"skills": [
|
||||
"missing-skill", "active-nolink-skill", "active-wronglink-skill",
|
||||
"parked-skill", "noprofile-skill"
|
||||
]
|
||||
},
|
||||
"dict-entry": {
|
||||
"managed_by": "curl",
|
||||
"skills": {"dict-skill": ["SKILL.md", "references/notes.md"]}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
# ── link.sh: a multi-line EXTERNAL_SKILLS array, tolerance-tested.
|
||||
cat > "$REPO/link.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
EXTERNAL_SKILLS=(ok-skill missing-skill dict-skill
|
||||
active-nolink-skill active-wronglink-skill
|
||||
parked-skill noprofile-skill)
|
||||
SH
|
||||
|
||||
# ── skills-external/ tree: every name's SKILL.md present, except
|
||||
# missing-skill (nothing at all) and dict-skill's references/notes.md.
|
||||
for n in ok-skill dict-skill active-nolink-skill active-wronglink-skill \
|
||||
parked-skill noprofile-skill; do
|
||||
mkdir -p "$REPO/skills-external/$n"
|
||||
echo "v1" > "$REPO/skills-external/$n/SKILL.md"
|
||||
done
|
||||
|
||||
# ── claude_home symlinks: ok-skill correct, active-wronglink-skill
|
||||
# points elsewhere, active-nolink-skill and noprofile-skill have none.
|
||||
ln -sf "$REPO/skills-external/ok-skill" "$CLAUDE_HOME/skills/ok-skill"
|
||||
mkdir -p "$WORK/elsewhere"
|
||||
ln -sf "$WORK/elsewhere" "$CLAUDE_HOME/skills/active-wronglink-skill"
|
||||
|
||||
# ── active.profile: lists everything EXCEPT parked-skill and
|
||||
# noprofile-skill (both proven absent from it).
|
||||
cat > "$REPO/active.profile" <<'PROF'
|
||||
# DESC: fixture profile
|
||||
ok-skill external
|
||||
missing-skill external
|
||||
dict-skill external
|
||||
active-nolink-skill external
|
||||
active-wronglink-skill external
|
||||
PROF
|
||||
|
||||
# shellcheck source=../doctor-vendored.sh disable=SC1091
|
||||
source "$LIB"
|
||||
|
||||
# ── With the active profile passed ──────────────────────────────────
|
||||
out1="$(check_vendored_skills "$REPO" "$CLAUDE_HOME" \
|
||||
"$REPO/active.profile" 2>&1)"
|
||||
|
||||
check_bool ALL_PRESENT \
|
||||
"$(printf '%s' "$out1" | grep -qF 'ok-skill: vendored + linked' \
|
||||
&& echo 1 || echo 0)"
|
||||
|
||||
check_bool FILE_MISSING \
|
||||
"$(printf '%s' "$out1" | \
|
||||
grep -qF 'missing-skill: skills-external/missing-skill/SKILL.md missing' \
|
||||
&& echo 1 || echo 0)"
|
||||
|
||||
check_bool DICT_FILES_COMPLETE \
|
||||
"$(printf '%s' "$out1" | grep -qF \
|
||||
'dict-skill: skills-external/dict-skill/references/notes.md missing' \
|
||||
&& ! printf '%s' "$out1" | grep -qF \
|
||||
'dict-skill: skills-external/dict-skill/SKILL.md missing' \
|
||||
&& echo 1 || echo 0)"
|
||||
|
||||
check_bool SYMLINK_MISSING_ACTIVE \
|
||||
"$(printf '%s' "$out1" | \
|
||||
grep -qF 'active-nolink-skill: symlink missing/wrong' && echo 1 || echo 0)"
|
||||
|
||||
check_bool SYMLINK_WRONG_TARGET \
|
||||
"$(printf '%s' "$out1" | \
|
||||
grep -qF 'active-wronglink-skill: symlink missing/wrong' \
|
||||
&& echo 1 || echo 0)"
|
||||
|
||||
check_bool SYMLINK_PARKED \
|
||||
"$(printf '%s' "$out1" | grep -qF 'parked-skill: parked by profile active' \
|
||||
&& ! printf '%s' "$out1" | \
|
||||
grep -qF 'parked-skill: symlink missing/wrong' \
|
||||
&& echo 1 || echo 0)"
|
||||
|
||||
# ── No profile file passed at all: noprofile-skill (absent from
|
||||
# active.profile, parked above) must now be treated as expected-linked.
|
||||
out2="$(check_vendored_skills "$REPO" "$CLAUDE_HOME" 2>&1)"
|
||||
check_bool NO_PROFILE_EXPECTS_LINK \
|
||||
"$(printf '%s' "$out2" | \
|
||||
grep -qF 'noprofile-skill: symlink missing/wrong' && echo 1 || echo 0)"
|
||||
|
||||
# ── LOCK_UNREADABLE — a second, minimal fixture with an invalid
|
||||
# plugins.lock.json: check_vendored_skills degrades to a warn (not a
|
||||
# fail) and still returns 0.
|
||||
BROKEN="$WORK/broken"
|
||||
mkdir -p "$BROKEN/skills-external/solo-skill"
|
||||
echo "v1" > "$BROKEN/skills-external/solo-skill/SKILL.md"
|
||||
echo "not valid json" > "$BROKEN/plugins.lock.json"
|
||||
cat > "$BROKEN/link.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
EXTERNAL_SKILLS=(solo-skill)
|
||||
SH
|
||||
out3="$(check_vendored_skills "$BROKEN" "$CLAUDE_HOME" 2>&1)"
|
||||
rc3=$?
|
||||
check_bool LOCK_UNREADABLE \
|
||||
"$([ "$rc3" -eq 0 ] && printf '%s' "$out3" | \
|
||||
grep -qF 'plugins.lock.json unreadable' && echo 1 || echo 0)"
|
||||
|
||||
# ── LOCK_MALFORMED_ENTRY — a valid-JSON lock whose "skills" is a bare
|
||||
# number (neither null, list nor dict): degrades to the same warn as an
|
||||
# unreadable lock, rc 0, and no Python traceback text anywhere in the
|
||||
# captured output.
|
||||
MALFORMED="$WORK/malformed"
|
||||
mkdir -p "$MALFORMED/skills-external/bad-entry"
|
||||
echo "v1" > "$MALFORMED/skills-external/bad-entry/SKILL.md"
|
||||
cat > "$MALFORMED/plugins.lock.json" <<'JSON'
|
||||
{
|
||||
"bad-entry": {
|
||||
"managed_by": "curl",
|
||||
"skills": 42
|
||||
}
|
||||
}
|
||||
JSON
|
||||
cat > "$MALFORMED/link.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
EXTERNAL_SKILLS=(bad-entry)
|
||||
SH
|
||||
out4="$(check_vendored_skills "$MALFORMED" "$CLAUDE_HOME" 2>&1)"
|
||||
rc4=$?
|
||||
check_bool LOCK_MALFORMED_ENTRY \
|
||||
"$([ "$rc4" -eq 0 ] \
|
||||
&& printf '%s' "$out4" | grep -qF 'plugins.lock.json unreadable' \
|
||||
&& ! printf '%s' "$out4" | grep -qi 'traceback' \
|
||||
&& ! printf '%s' "$out4" | grep -q 'Error:' \
|
||||
&& echo 1 || echo 0)"
|
||||
|
||||
# ── REJECTS_BAD_PROFILE_NAME — the profile-name allowlist helper
|
||||
# rejects a path-traversal value and accepts a plain one.
|
||||
check_bool REJECTS_BAD_PROFILE_NAME \
|
||||
"$( { _dv_valid_profile_name "full" \
|
||||
&& ! _dv_valid_profile_name "../x" \
|
||||
&& ! _dv_valid_profile_name "a/b" \
|
||||
&& ! _dv_valid_profile_name "a.b"; } && echo 1 || echo 0)"
|
||||
|
||||
# ── REJECTS_BAD_NAME — a link.sh EXTERNAL_SKILLS entry with a ".."
|
||||
# segment: warned and skipped, never reaches _dv_check_files/
|
||||
# _dv_check_link (no fail line names it), rc 0.
|
||||
BADNAME="$WORK/badname"
|
||||
mkdir -p "$BADNAME/skills-external"
|
||||
echo '{}' > "$BADNAME/plugins.lock.json"
|
||||
cat > "$BADNAME/link.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
EXTERNAL_SKILLS=(../evil)
|
||||
SH
|
||||
out5="$(check_vendored_skills "$BADNAME" "$CLAUDE_HOME" 2>&1)"
|
||||
rc5=$?
|
||||
check_bool REJECTS_BAD_NAME \
|
||||
"$([ "$rc5" -eq 0 ] \
|
||||
&& printf '%s' "$out5" | \
|
||||
grep -qF '"../evil" rejected by the item-name allowlist' \
|
||||
&& ! printf '%s' "$out5" | grep -qF 'skills-external/../evil' \
|
||||
&& ! printf '%s' "$out5" | grep -qF '../evil: symlink' \
|
||||
&& echo 1 || echo 0)"
|
||||
|
||||
echo "PASS=$pass FAIL=$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
Reference in New Issue
Block a user