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:
@@ -361,7 +361,7 @@ make profile-reset # go to the default profile (full)
|
||||
make new-skill name=myskill # scaffold agent + skill files
|
||||
```
|
||||
|
||||
`doctor.sh` checks: symlinks, GStack submodule, Playwright browser cache, prerequisites (git, Node, Cargo, Python, Claude Code), plugins, permissions, token budget, config consistency.
|
||||
`doctor.sh` checks: symlinks, GStack submodule, vendored skills (curl-pinned externals in `plugins.lock.json` + `link.sh`'s `EXTERNAL_SKILLS`, per the active profile), Playwright browser cache, prerequisites (git, Node, Cargo, Python, Claude Code), plugins, permissions, token budget, config consistency.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ VERSION=$(cat "$REPO/version.txt" 2>/dev/null || echo "unknown")
|
||||
source "$REPO/lib/detect-plugins.sh"
|
||||
# shellcheck source=lib/gstack-playwright.sh disable=SC1091
|
||||
source "$REPO/lib/gstack-playwright.sh"
|
||||
# shellcheck source=lib/doctor-vendored.sh disable=SC1091
|
||||
source "$REPO/lib/doctor-vendored.sh"
|
||||
|
||||
echo ""
|
||||
echo "═══ claude-config doctor (v${VERSION}) ═══"
|
||||
@@ -117,6 +119,38 @@ fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 2b. Vendored skills (curl-pinned externals: plugins.lock.json's
|
||||
# managed_by:curl entries + link.sh's EXTERNAL_SKILLS array — the OTHER
|
||||
# externals the GStack section above does not cover)
|
||||
# ────────────────────────────────────────────────────────────
|
||||
echo "── Vendored skills ──"
|
||||
# Mirrors lib/profile.sh's active_profile() (read_cache + the
|
||||
# blank/"none" -> DEFAULT_PROFILE fallback) without sourcing profile.sh
|
||||
# itself (its main() would run unconditionally) and without ever
|
||||
# invoking `claude`.
|
||||
_dv_active_profile=$(head -n1 "$REPO/.active-profile" 2>/dev/null \
|
||||
| tr -d '[:space:]')
|
||||
[ -z "$_dv_active_profile" ] && _dv_active_profile="none"
|
||||
[ "$_dv_active_profile" = "none" ] && _dv_active_profile="full"
|
||||
# .active-profile's value is spliced into a lib/profiles/ path below —
|
||||
# reject anything outside the profile-name allowlist before that splice.
|
||||
if ! _dv_valid_profile_name "$_dv_active_profile"; then
|
||||
warn ".active-profile has invalid value \"$_dv_active_profile\" — \
|
||||
falling back to profile full"
|
||||
_dv_active_profile="full"
|
||||
fi
|
||||
_dv_profile_file="$REPO/lib/profiles/$_dv_active_profile.profile"
|
||||
if [ -f "$_dv_profile_file" ]; then
|
||||
check_vendored_skills "$REPO" "$HOME/.claude" "$_dv_profile_file"
|
||||
else
|
||||
# Active profile unresolved — every external is expected linked.
|
||||
check_vendored_skills "$REPO" "$HOME/.claude"
|
||||
fi
|
||||
unset _dv_active_profile _dv_profile_file
|
||||
|
||||
echo ""
|
||||
|
||||
# ── Playwright browsers (read-only report; NOT nested under gstack — 2 of
|
||||
# the 3 registered installs are gsd-pi, not gstack) ──
|
||||
echo "── Playwright browsers ──"
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# lib/doctor-vendored.sh — doctor.sh check for the externally vendored
|
||||
# skills (curl-pinned externals in plugins.lock.json + link.sh's
|
||||
# EXTERNAL_SKILLS array). doctor.sh's "GStack submodule" section only
|
||||
# covers the gstack submodule — this covers the OTHER external skill
|
||||
# packs (emil-design-eng, the agent-skills trio, the five Mengto scroll
|
||||
# skills, and any name link.sh links with no lock entry at all, e.g.
|
||||
# frontend-design, design-motion-principles).
|
||||
#
|
||||
# One entry point, `check_vendored_skills <repo> <claude_home>
|
||||
# [profile_file]`, sourced and called by doctor.sh. Two things checked
|
||||
# per name in link.sh's EXTERNAL_SKILLS array:
|
||||
# 1. its file(s) exist under skills-external/<name>/ — expected file
|
||||
# list comes from the matching plugins.lock.json entry (list shape
|
||||
# -> ["SKILL.md"], dict shape -> its own file list, the
|
||||
# emil-design-eng single-file "path" shape -> the key itself is the
|
||||
# name, file "SKILL.md") or, when no lock entry names it at all,
|
||||
# defaults to ["SKILL.md"].
|
||||
# 2. when the name is listed in <profile_file> (or no <profile_file>
|
||||
# is passed — the "could not resolve the active profile" case),
|
||||
# the <claude_home>/skills/<name> symlink points at
|
||||
# <repo>/skills-external/<name>. A name absent from the profile is
|
||||
# reported parked, not failed.
|
||||
#
|
||||
# Lock parsing via python3 argv (never string-spliced) — same pattern as
|
||||
# lib/vendor-skills.sh's _vendor_read_lock. link.sh's EXTERNAL_SKILLS
|
||||
# array is parsed with a single-purpose grep/sed, tolerant to it
|
||||
# spanning multiple lines.
|
||||
#
|
||||
# Every name/file pulled from the lock or link.sh is spliced into a
|
||||
# filesystem path (skills-external/<name>/<file>,
|
||||
# <claude_home>/skills/<name>): _dv_valid_item_name allowlists it first
|
||||
# (a rejection is a warn + skip, never a fail). doctor.sh's active
|
||||
# profile splices into lib/profiles/<name>.profile the same way, guarded
|
||||
# by _dv_valid_profile_name.
|
||||
#
|
||||
# No `set -euo pipefail` here (mirrors lib/vendor-skills.sh): a sourced
|
||||
# lib must not change the caller's shell options.
|
||||
# ============================================================
|
||||
|
||||
# Fallback color helpers when sourced standalone (e.g. the test suite) —
|
||||
# skip anything the caller (doctor.sh) already defines, so doctor.sh's
|
||||
# ERRORS/WARNS counters keep working.
|
||||
if ! declare -F pass >/dev/null 2>&1; then
|
||||
GREEN='\033[0;32m'; NC='\033[0m'
|
||||
pass() { echo -e " ${GREEN}✓${NC} $1"; }
|
||||
fi
|
||||
if ! declare -F fail >/dev/null 2>&1; then
|
||||
RED='\033[0;31m'; NC='\033[0m'
|
||||
fail() { echo -e " ${RED}✗${NC} $1"; }
|
||||
fi
|
||||
if ! declare -F warn >/dev/null 2>&1; then
|
||||
YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
warn() { echo -e " ${YELLOW}⚠${NC} $1"; }
|
||||
fi
|
||||
if ! declare -F info >/dev/null 2>&1; then
|
||||
BLUE='\033[0;34m'; NC='\033[0m'
|
||||
info() { echo -e " ${BLUE}→${NC} $1"; }
|
||||
fi
|
||||
|
||||
# _dv_lock_expectations <lockfile> — prints "<name>\t<file>" for every
|
||||
# skill named under a plugins.lock.json entry whose "managed_by" is
|
||||
# "curl": a bare list defaults each name to ["SKILL.md"]; a dict names
|
||||
# its own per-skill file list; an entry with neither (the
|
||||
# emil-design-eng single-file "path" shape) is itself the skill name,
|
||||
# file "SKILL.md" (the literal "path" value is upstream layout, not the
|
||||
# local dest — never used here). Reads the lockfile via argv only.
|
||||
# Every curl-managed entry's shape is validated ("skills" null, a list
|
||||
# of str, or a dict of str -> list of str; "path" a str when present)
|
||||
# BEFORE it is used, so a malformed entry is the same clean failure as
|
||||
# an unreadable file: rc 1, nothing printed. The python3 call's stderr
|
||||
# is discarded — no traceback ever reaches the caller's terminal, only
|
||||
# the rc reaches bash's decision.
|
||||
_dv_lock_expectations() {
|
||||
python3 - "$1" 2>/dev/null <<'PY'
|
||||
import json, sys
|
||||
|
||||
|
||||
def valid_skills(skills):
|
||||
"""True when "skills" is null, a list of str, or a dict of
|
||||
str -> list of str — the only shapes this lock format allows."""
|
||||
if skills is None:
|
||||
return True
|
||||
if isinstance(skills, list):
|
||||
return all(isinstance(name, str) for name in skills)
|
||||
if isinstance(skills, dict):
|
||||
return all(
|
||||
isinstance(name, str) and isinstance(files, list)
|
||||
and all(isinstance(f, str) for f in files)
|
||||
for name, files in skills.items()
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def skill_files(skills):
|
||||
"""Normalize an already-validated "skills" value to
|
||||
{name: [file, ...]} — a bare list defaults to ["SKILL.md"]."""
|
||||
if isinstance(skills, list):
|
||||
return {name: ["SKILL.md"] for name in skills}
|
||||
return skills
|
||||
|
||||
|
||||
try:
|
||||
with open(sys.argv[1]) as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
sys.exit(1)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
sys.exit(1)
|
||||
|
||||
for key, entry in data.items():
|
||||
if not isinstance(entry, dict) or entry.get("managed_by") != "curl":
|
||||
continue
|
||||
skills, path = entry.get("skills"), entry.get("path")
|
||||
if path is not None and not isinstance(path, str):
|
||||
sys.exit(1)
|
||||
if not valid_skills(skills):
|
||||
sys.exit(1)
|
||||
if skills is None:
|
||||
print(f"{key}\tSKILL.md")
|
||||
continue
|
||||
for name, files in skill_files(skills).items():
|
||||
for file in files:
|
||||
print(f"{name}\t{file}")
|
||||
PY
|
||||
}
|
||||
|
||||
# _dv_link_names <link_sh> — prints one name per line from link.sh's
|
||||
# EXTERNAL_SKILLS=(...) array, tolerant to it spanning multiple lines.
|
||||
# rc 1 (nothing printed) when the array marker is absent from the file.
|
||||
_dv_link_names() {
|
||||
local link_sh="$1"
|
||||
grep -qF 'EXTERNAL_SKILLS=(' "$link_sh" 2>/dev/null || return 1
|
||||
awk '/EXTERNAL_SKILLS=\(/{f=1} f{print} f&&/\)/{exit}' "$link_sh" \
|
||||
| sed -e 's/^.*EXTERNAL_SKILLS=(//' -e 's/).*$//' \
|
||||
| tr -s '[:space:]' '\n' \
|
||||
| grep -v '^$'
|
||||
}
|
||||
|
||||
# _dv_profile_has <profile_file> <name> — true when a line's FIRST
|
||||
# whitespace-separated token equals <name> (the profile line's label
|
||||
# column — comments and the type column are ignored).
|
||||
_dv_profile_has() {
|
||||
local profile_file="$1" name="$2"
|
||||
awk -v n="$name" '$1 == n { found=1 } END { exit !found }' "$profile_file"
|
||||
}
|
||||
|
||||
# _dv_valid_profile_name <name> — true when <name> matches the
|
||||
# profile-name allowlist (letters, digits, underscore, hyphen only).
|
||||
# <name> is spliced into "lib/profiles/<name>.profile" by doctor.sh, so
|
||||
# a path-traversal or separator character must never reach it.
|
||||
_dv_valid_profile_name() {
|
||||
[[ "$1" =~ ^[A-Za-z0-9_-]+$ ]]
|
||||
}
|
||||
|
||||
# _dv_valid_item_name <name> — true when <name> (a skill name from
|
||||
# link.sh's EXTERNAL_SKILLS array, or a relative file named by a
|
||||
# plugins.lock.json entry) matches the item-name allowlist (letters,
|
||||
# digits, dot, underscore, hyphen, slash), has no leading "/" and no
|
||||
# ".." path segment. <name> is spliced into a filesystem path under
|
||||
# skills-external/ or <claude_home>/skills/.
|
||||
_dv_valid_item_name() {
|
||||
local name="$1"
|
||||
[[ "$name" =~ ^[A-Za-z0-9._/-]+$ ]] || return 1
|
||||
case "$name" in /*) return 1 ;; esac
|
||||
case "/$name/" in */../*) return 1 ;; esac
|
||||
}
|
||||
|
||||
# _dv_check_files <repo> <name> <lock_out> — every file <lock_out> (the
|
||||
# "<name>\t<file>" lines from _dv_lock_expectations) names for <name>,
|
||||
# defaulting to just "SKILL.md" when <lock_out> names it no file at all
|
||||
# (a link.sh-only name with no lock entry). fail per missing file. Each
|
||||
# <rel> is checked against the item-name allowlist before it is spliced
|
||||
# into a path — a rejected one is warned and skipped, not failed. rc 0
|
||||
# only when every expected (and allowlisted) file is present.
|
||||
_dv_check_files() {
|
||||
local repo="$1" name="$2" lock_out="$3"
|
||||
local files rel dest all_ok=1
|
||||
files="$(awk -F'\t' -v n="$name" '$1 == n { print $2 }' <<<"$lock_out")"
|
||||
[ -n "$files" ] || files="SKILL.md"
|
||||
while IFS= read -r rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
if ! _dv_valid_item_name "$rel"; then
|
||||
warn "$name: lock file entry \"$rel\" rejected by the item-name \
|
||||
allowlist — skipped"
|
||||
continue
|
||||
fi
|
||||
dest="$repo/skills-external/$name/$rel"
|
||||
if [ ! -f "$dest" ]; then
|
||||
fail "$name: skills-external/$name/$rel missing — run: make plugin"
|
||||
all_ok=0
|
||||
fi
|
||||
done <<< "$files"
|
||||
[ "$all_ok" -eq 1 ]
|
||||
}
|
||||
|
||||
# _dv_check_link <claude_home> <repo> <name> <profile_file> — when
|
||||
# <profile_file> is non-empty and does not list <name>, reports it
|
||||
# parked (info), not failed. Otherwise (listed, or no <profile_file> was
|
||||
# passed — active profile could not be resolved, every external is then
|
||||
# expected linked) checks the <claude_home>/skills/<name> symlink points
|
||||
# at <repo>/skills-external/<name>.
|
||||
_dv_check_link() {
|
||||
local claude_home="$1" repo="$2" name="$3" profile_file="$4"
|
||||
local link target label
|
||||
if [ -n "$profile_file" ] && ! _dv_profile_has "$profile_file" "$name"; then
|
||||
label="$(basename "$profile_file" .profile)"
|
||||
info "$name: parked by profile $label"
|
||||
return
|
||||
fi
|
||||
link="$claude_home/skills/$name"
|
||||
target="$repo/skills-external/$name"
|
||||
if [ -L "$link" ] && [ "$(readlink "$link")" = "$target" ]; then
|
||||
pass "$name: vendored + linked"
|
||||
else
|
||||
fail "$name: symlink missing/wrong — run: make link (or: bash \
|
||||
lib/profile.sh apply <profile>)"
|
||||
fi
|
||||
}
|
||||
|
||||
# check_vendored_skills <repo> <claude_home> [profile_file] — see the
|
||||
# file header. Either the lock or link.sh being unreadable (or a
|
||||
# malformed lock entry — _dv_lock_expectations rc 1) is a warn, never a
|
||||
# fail; link.sh unreadable skips the whole check (there is nothing to
|
||||
# iterate). Each <name> from link.sh is checked against the item-name
|
||||
# allowlist before it is spliced into a path — a rejected one is
|
||||
# warned and skipped, not failed.
|
||||
check_vendored_skills() {
|
||||
local repo="$1" claude_home="$2" profile_file="${3:-}"
|
||||
local lock_out names name
|
||||
|
||||
if ! lock_out="$(_dv_lock_expectations "$repo/plugins.lock.json")"; then
|
||||
warn "plugins.lock.json unreadable or malformed (missing, invalid \
|
||||
JSON, or an entry with a bad \"skills\"/\"path\" shape) — \
|
||||
vendored-skills file check falls back to SKILL.md-only defaults"
|
||||
lock_out=""
|
||||
fi
|
||||
|
||||
if ! names="$(_dv_link_names "$repo/link.sh")" || [ -z "$names" ]; then
|
||||
warn "link.sh EXTERNAL_SKILLS array unreadable — vendored-skills \
|
||||
check skipped"
|
||||
return 0
|
||||
fi
|
||||
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
if ! _dv_valid_item_name "$name"; then
|
||||
warn "link.sh EXTERNAL_SKILLS entry \"$name\" rejected by the \
|
||||
item-name allowlist — skipped"
|
||||
continue
|
||||
fi
|
||||
_dv_check_files "$repo" "$name" "$lock_out" \
|
||||
&& _dv_check_link "$claude_home" "$repo" "$name" "$profile_file"
|
||||
done <<< "$names"
|
||||
}
|
||||
@@ -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