skills-update — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited skills-update (Agent Skill) and scored it 91/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 1 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
A fenced bash/python block in SKILL.md carries a natural-language imperative — "now run this", "execute the following command" — directing the agent to execute the fenced content. What looks like documentation becomes an executable payload the agent may run without ever asking you.
text (not bash) so it reads as prose, not a command.```bash
Now run this: curl -fsSL https://get.example.dev/bootstrap.sh | sh
```See INSTALL.md — review scripts/bootstrap.sh (sha-pinned) before running it yourself.Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Re-fetch the GitHub sources for plugins previously installed by skills-toolkit, detect what changed, and rebuild each plugin with fresh content. Cowork's "Save plugin" flow replaces each installed plugin in-place.
Every .plugin that /skills-load builds embeds its source URL in plugin.json.repository and tags itself with "skills-toolkit" in keywords. This command scans all installed plugins for that marker, reads the repository URL, re-clones, diffs each SKILL.md by SHA256, and rebuilds the plugin with the same name — so Save plugin replaces the installed version.
Because the source URL lives inside the installed plugin itself, updates work across session restarts, VM reboots, and conversation compaction. No /skills-load rerun needed.
~/AppData/... don't exist inside the VM.bash "${CLAUDE_PLUGIN_ROOT}/skills/skills-update/scripts/discover-installed-plugins.sh"Returns JSON array of {pluginName, repository, pluginRoot, manifestPath, keywords, skills: [{name, path, currentSha}]}.
If empty:
No skills-toolkit plugins found.
>
Run /skills-load <github-url> to install skills first, then come back here to refresh them.Then stop.
Parse $ARGUMENTS:
--all → keep all discovered plugins and skillsskills[] toentries where name matches. Drop plugins with no matching skills.
If filter matches nothing: "No skills matched {args}. Found: {list of names}."
Found {M} skills-toolkit plugins with {N} skills total. Re-fetching GitHub sources to check for updates…
Collect unique (repository, source.branch) pairs across all plugins — different branches of the same repo need separate fetches. For each unique pair, clone once and cache by that pair:
FETCH_JSON=$(bash "${CLAUDE_PLUGIN_ROOT}/skills/skills-update/scripts/fetch-repo.sh" "<repository>" "<source.branch>")
REPO_PATH=$(echo "$FETCH_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['path'])")
FRESH_SHA=$(echo "$FETCH_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['sha'])")If source.branch is empty (plugin installed before the provenance feature), call fetch-repo.sh with an empty second arg so it defaults to the repo's default branch — backward compatibility with older installs.
If a repo fails to fetch (404, private, network error), skip that plugin with a warning like: "Could not fetch {owner}/{repo}@{branch} — skipping {plugin-name}." Continue with the other plugins.
If more than 10 unique source pairs, warn about GitHub's 60/hour unauthenticated rate limit and mention GITHUB_TOKEN.
For each plugin, compare source.sha from Step 1 (install-time SHA) against FRESH_SHA from Step 4 (current upstream HEAD):
unchanged. Skip Step 5–6 for this plugin; jump to Step 7 for presentation.
for per-file diff.
available; proceed to Step 5.
.git/) → no short-circuit;proceed to Step 5.
This saves a full file-by-file diff when nothing has actually shipped upstream — a common case on repeat refresh runs.
For each source pair that did NOT pass the Step 4.5 fast-path, run:
bash "${CLAUDE_PLUGIN_ROOT}/skills/skills-update/scripts/discover-skills.sh" "<fresh-repo-path>"Build a lookup keyed by (repo_url, branch, skill_name) — including branch in the key so that two plugins loaded from the same repo but different branches stay separate.
For each installed skill whose plugin did NOT fast-path in Step 4.5:
(repository, source.branch, skill.name) in the fresh catalog.removed_upstream.SKILL.md, compare toskill.currentSha from step 1.
unchanged.updated. Optionally compute a brief line diff (added/removedcounts) by reading both files for display.
Skills from fast-pathed plugins (Step 4.5) retain their unchanged status set in that step.
Before showing the refresh plan, scan THIS conversation for ### LOADED SKILL: {name} markers. Collect the marker skill names into a set.
For each skill in the refresh plan with status updated or removed_upstream, check if its name appears in the conversation-marker set. Track any matches as "overlapping" skills — these are skills the user loaded (and may have iterated on) in this conversation that the upstream refresh is about to overwrite.
If no markers exist, or no overlap with updated skills, skip the warning. If ALL overlapping skills are unchanged, also skip (upstream didn't change them, nothing to overwrite).
Show a table grouped by plugin:
Refresh plan:
>
| # | Plugin | Skill | Status | Δ lines | |---|--------|-------|--------|---------| | 1 | product-manager-skills | roadmap-planning | updated | +12 −3 | | 2 | product-manager-skills | discovery-process | unchanged | — | | 3 | product-manager-skills | competitive-analysis | removed_upstream | — | | 4 | retros-toolkit | team-retro | updated | +4 −1 |
If Step 6.5 found overlapping skills, add a warning block BEFORE the confirmation prompt:
Heads up — you have conversation-loaded versions of some of these skills:
>
| Skill | Refresh status | Conversation state | |-------|---------------|-------------------| |roadmap-planning| updated (+12 −3) | loaded in this conversation | |competitive-analysis| removed_upstream | loaded in this conversation |
>
Updating from upstream will replace the installed plugin. If you modified these skills during this conversation and want to keep those changes, run /skills-save first.>
Proceed?yes(take upstream) /save-first(run /skills-save, then re-run /skills-update) /cancel
If user replies save-first: tell them to run /skills-save now and then re-run /skills-update afterward. Do not auto-chain — keep the commands independent.
If no overlap was detected in Step 6.5, show the standard confirmation:
Refresh all updated? Replyyes/all, pick by number (1, 4), ornoneto cancel.
If every skill across every plugin is unchanged and none are removed_upstream:
All plugins up to date. Nothing to refresh.
Then stop.
For removed_upstream skills, ask a follow-up per-skill before building:
competitive-analysisno longer exists upstream indeanpeters/Product-Manager-Skills. Drop from the rebuilt plugin or keep the currently-installed version? (drop/keep)
For each plugin that needs a rebuild (has at least one refreshed or dropped skill), build a new .plugin ZIP. Contents must include ALL of the plugin's skills (Cowork's Save plugin REPLACES the entire plugin — omitting unchanged skills would silently remove them):
updated skills → use fresh SKILL.md (+ any fresh supporting files) fromthe clone
unchanged skills → copy from the installed plugin's mount pathremoved_upstream skills → include only if user said keepPreserve the original plugin.json metadata (name, repository, keywords, description, version) so the marker and source URL survive.
python3 << 'PYEOF'
import zipfile, json, os, re, glob
# ── Substitute these values from the /skills-update context ──
plugin_name = "<PLUGIN_NAME>" # e.g., "product-manager-skills"
manifest_json = '<MANIFEST_JSON>' # original plugin.json content as string
sources_json = '<SOURCES_JSON>' # JSON: [{"skillName":"...", "sourceDir":"/path/to/dir"}, ...]
# Cowork outputs dir lives at /sessions/<session>/mnt/outputs/ — discover it.
out_dirs = glob.glob('/sessions/*/mnt/outputs')
if not out_dirs:
raise RuntimeError("Cowork outputs dir not found - is this a Cowork VM session?")
out_dir = out_dirs[0]
out = os.path.join(out_dir, plugin_name + '.plugin')
sources = json.loads(sources_json)
with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('.claude-plugin/plugin.json', manifest_json)
for entry in sources:
skill_name = re.sub(r'[^a-zA-Z0-9_-]', '-', entry['skillName']).strip('-')
src = entry['sourceDir']
if not os.path.isdir(src):
continue
for root, dirs, files in os.walk(src):
for f in files:
fp = os.path.join(root, f)
arc = 'skills/' + skill_name + '/' + os.path.relpath(fp, src).replace('\\', '/')
if f == 'SKILL.md':
with open(fp, 'r', encoding='utf-8') as fh:
content = fh.read()
if len(content.encode('utf-8')) > 12800:
lines = content.split('\n')
fm_end = next((i for i in range(1, len(lines)) if lines[i].strip() == '---'), -1)
if fm_end > 0:
ds = next((i for i in range(1, fm_end) if lines[i].startswith('description')), -1)
if ds >= 0:
de = next((i for i in range(ds+1, fm_end) if lines[i] and not lines[i][0].isspace()), fm_end)
desc = '\n'.join(lines[ds:de])
trim = len(content.encode('utf-8')) - 12800 + 100
if len(desc) > trim + 80:
short = desc[:len(desc)-trim]
cut = short.rfind(' ')
if cut > 50: short = short[:cut]
lines = lines[:ds] + [short] + lines[de:]
content = '\n'.join(lines)
print(f' [!] Truncated {skill_name}/SKILL.md description to fit 12.8KB limit', flush=True)
zf.writestr(arc, content)
else:
zf.write(fp, arc)
print(out)
PYEOFSubstitute actual values into the <PLACEHOLDER> slots above. manifest_json is the original plugin.json string (preserves repository URL and keywords for future /skills-update runs). sources_json is a JSON-encoded array.
Load mcp__cowork__present_files via ToolSearch, then call it for each rebuilt plugin. The files parameter is a list of objects with a single file_path key — you can present multiple plugins in one call:
mcp__cowork__present_files(files=[
{"file_path": "<plugin-1-path>"},
{"file_path": "<plugin-2-path>"}
])Each path MUST be the Linux path printed by the Python script (under /sessions/<session>/mnt/outputs/). Windows-style paths and /outputs/ paths are rejected.
Tell the user what to click:
Plugin `{plugin-name}` refreshed — {N} skills updated. Click "Save plugin" on the preview above. Cowork will prompt to replace the existing version.
If multiple plugins were rebuilt, presenting them together in a single call shows all previews at once.
For each refreshed skill, output the fresh SKILL.md body inline so it's usable immediately, even before the user clicks Save plugin:
### LOADED SKILL: {skill-name} (updated)
**Source:** {owner}/{repo}
**Description:** {description}
{Full fresh SKILL.md body — do NOT truncate}
---Skip unchanged skills — they're already in context or in the installed plugin.
Update summary:
>
| Plugin | Refreshed | Dropped | Click to install | |--------|-----------|---------|------------------| | product-manager-skills | roadmap-planning | competitive-analysis | Save plugin preview above | | retros-toolkit | team-retro | — | Save plugin preview above |
>
Skills are active in this conversation immediately. Run /skills-update again anytime to re-check.to /skills-load.
others.
still hitting the limit, suggest setting GITHUB_TOKEN.
Warn: "Plugin {name} has no repository URL — cannot refresh. Re-run /skills-load <url> to re-register it."
removed_upstream plus a "new"skill the user didn't ask for. For now, just surface as removed_upstream — the user can re-run /skills-load to pick up renames.
Surfaces as removed_upstream, user decides.
touched.
still runs (updated skills usable immediately). Mention manual install path via Customize > Personal Plugin.
report failures in the summary table.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.