Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
## Unreleased

- **A source that fails no longer drops its skills.** The crawl keeps what that source listed in the previous snapshot. The snapshot of 2026-11-10, built while claude-plugins.dev answered HTTP 402, had lost 35,750 skills; the one of 2026-11-04 is restored.
- **Content read from a URL is a warning sign.** A line where the skill fetches, reads or downloads something at a URL is reported (medium): what is served there can change after the review, and pinning the skill to a commit does not pin it.
- **Secret keys written in a skill are reported** (medium), cut to their first characters; placeholders such as `sk-xxxxxxxx` are not.
- **Look-alike names.** The audit, `show` and the web preview name the far more installed skill a skill could be taken for: a name, or an owner's name, one character away.
- **The update diff covers the whole folder.** The files an update adds, changes or removes are listed and shown next to the `SKILL.md`, and their new warning signs are reported with their file; `outdated_skills` returns them as `files_changed`.

## 0.30.0 — 2026-10-23

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,13 @@ The `SKILL.md` preview (`p`), `skills-atlas show` and the MCP server's `show_ski
| Level | Warning sign |
|---|---|
| high | downloads and runs code (`curl … \| sh`, `base64 -d \| sh`), reads or sends credential files (`~/.ssh`, `~/.aws`…), deletes broadly (`rm -rf ~`), asks the agent to hide things from the user or ignore its instructions |
| medium | needs a program missing from this machine, runs commands as administrator, reads secrets from the environment, sends data to a server |
| medium | needs a program missing from this machine, runs commands as administrator, reads secrets from the environment, sends data to a server, reads content from a URL (`curl https://…/llms.txt`, “fetch the rules at https://…”), holds what looks like a secret key (`sk-…`, `ghp_…`, `AKIA…`, a private key block; shown cut to its first characters), has a name close to a far more installed skill |
| info | installs or runs packages or bundled scripts, states its requirements |

The catalog already carries them for the 3,000 most installed skills, read at each crawl, with the day their folder last changed for the top 1,000: the tree's details show `needs gh, ✗ jq` (missing programs in red), the `compatibility` text and `updated 2026-09-30`; the web page shows the same as tags, and MCP's `search_skills` returns `requires` and `updated_at`. Prerequisites come from the frontmatter: the programs listed under `requires.bins` (OpenClaw metadata) are looked up on your `PATH`, and the spec's `compatibility` text (agent versions, tools, network) is shown as is. A missing program does not block the install, but the review screen and `install -y` name it before any command runs. Skills rarely declare either field, so no warning does not mean nothing is needed.

**Content read from a URL** is flagged because it can change after you reviewed the skill, and pinning the skill to a commit (`lock`) does not pin it: read what is served there, and expect it to differ later. A plain link after a word such as “read” is flagged too; a line that already downloads and runs code, or sends data, is reported under that sign only. **A name close to a far more installed skill** comes from the catalog, not from the skill's files: the name, or the owner's name for a skill of the same name, is one character away from one installed at least 1,000 times and 20 times more (`skil-creator` for `skill-creator`, `anthroplcs/skills` for `anthropics/skills`). It names that skill so that you can compare the two; honest forks match too.

It is a pattern check on the text, tuned on the 150 most installed skills: it points at lines worth reading, it does not prove a skill safe. `show` prints the warning signs on stderr, so that stdout stays the `SKILL.md` alone; `show --json` adds them as `risks`.

**The security audit** runs this check before every install and update, on the `SKILL.md` and on the other text files of the skill folder (scripts, references, configs: up to 30 files of 64 KB, listed with one GitHub call per repository), since a script the skill runs can hold what its `SKILL.md` does not show. Each finding names its file (`scripts/setup.sh:12`); binaries, larger files and files that could not be read are listed as *not audited*, never as passed. `show` and the MCP server's `show_skill` report the same. A skill **fails** it with at least one high warning sign; medium ones are shown and do not block.
Expand Down Expand Up @@ -258,7 +260,7 @@ Only `repository` (a GitHub repository URL) and `reason` are required. Without `
- **Pre-ticked**: installed skills are ticked and marked with their scope, `● project`, `● global` or `● project + global`, under their theme and in an **Already installed** group at the top. That group also holds skills the catalog does not know, such as home-made ones.
- **Uninstall**: unticking an installed skill marks it `✗ will be uninstalled`. On confirmation it is removed from every agent of every scope it was found in, with one `npx skills remove -s … -y [-g]` per scope, then checked on disk.
- **Same name, one folder**: the skills CLI installs by name, so a skill from another repository replaces an installed one of the same name. The review screen lists such replacements before anything runs; `install` and `import` print them.
- **Updates**: globally installed skills with a newer version on GitHub read `↑ update available`, and the status line counts them. Press `u` to mark them: they are reinstalled at their latest version for the agents that already have them. The check runs in the background, one GitHub request per repository, with `GITHUB_TOKEN`, `GH_TOKEN` or the GitHub CLI's token when there is one (without a token GitHub allows 60 requests an hour). Project skills are not checked: their lock records a hash GitHub cannot be compared with. A repository GitHub does not answer for (rate limit, timeout, private or deleted), a truncated tree or a skill folder it no longer has makes the check incomplete, never "up to date": the tree shows a warning, and `outdated` and `update` name each one on stderr and exit with 1 (`outdated --json` keeps its array on stdout; `update` still runs the confirmed updates). Before you update, `p` on a skill marked for update shows what changes in its `SKILL.md` (added lines green, removed red) and only the warning signs the new version brings. `skills-atlas update` prints each skill's `+added −removed` lines and those new warning signs before running anything, `--dry-run` included; the MCP server's `outdated_skills` returns `{ skills, unchecked }`, each skill with `lines_added`, `lines_removed` and `new_risks`, and `unchecked` listing what could not be checked (`source`, `reason`). Before each update, skills-atlas records the commit each skill was installed from, in `~/.config/skills-atlas/history.json`, five per skill, but only a commit it can prove: one a rollback installed while the lock still records the same folder hash (kept in `revisions.json` next to it), the installer's own ref, or the last commit touching the folder before the lock's install date whose folder hash on GitHub matches the lock's. A version that cannot be proven (offline, a folder hash that matches no such commit) is not recorded, and `update` says that skill has no way back. `skills-atlas rollback <id>` reinstalls the latest of them for the skill's own agents, audited at that revision; `--list` shows them and `--to <sha>` picks one. Skills updated before this version have no way back.
- **Updates**: globally installed skills with a newer version on GitHub read `↑ update available`, and the status line counts them. Press `u` to mark them: they are reinstalled at their latest version for the agents that already have them. The check runs in the background, one GitHub request per repository, with `GITHUB_TOKEN`, `GH_TOKEN` or the GitHub CLI's token when there is one (without a token GitHub allows 60 requests an hour). Project skills are not checked: their lock records a hash GitHub cannot be compared with. A repository GitHub does not answer for (rate limit, timeout, private or deleted), a truncated tree or a skill folder it no longer has makes the check incomplete, never "up to date": the tree shows a warning, and `outdated` and `update` name each one on stderr and exit with 1 (`outdated --json` keeps its array on stdout; `update` still runs the confirmed updates). Before you update, `p` on a skill marked for update shows what changes in its `SKILL.md` and in the other text files of its folder, each under its own `=== ~ scripts/run.sh +3 −1` line (added lines green, removed red), and only the warning signs the new version brings, each with its file: a script can change while the `SKILL.md` stays the same. `skills-atlas update` prints each skill's `+added −removed` lines for its `SKILL.md`, one line per other file added (`+`), changed (`~`) or removed (`-`), and those new warning signs before running anything, `--dry-run` included; the MCP server's `outdated_skills` returns `{ skills, unchecked }`, each skill with `lines_added`, `lines_removed`, `files_changed` and `new_risks`, and `unchecked` listing what could not be checked (`source`, `reason`). Before each update, skills-atlas records the commit each skill was installed from, in `~/.config/skills-atlas/history.json`, five per skill, but only a commit it can prove: one a rollback installed while the lock still records the same folder hash (kept in `revisions.json` next to it), the installer's own ref, or the last commit touching the folder before the lock's install date whose folder hash on GitHub matches the lock's. A version that cannot be proven (offline, a folder hash that matches no such commit) is not recorded, and `update` says that skill has no way back. `skills-atlas rollback <id>` reinstalls the latest of them for the skill's own agents, audited at that revision; `--list` shows them and `--to <sha>` picks one. Skills updated before this version have no way back.
- **Install**: ticking a skill that is not installed installs it. The scope is asked after the tree, pre-filled with your last choice, unless `-p`/`-g` sets it.
- **Questions**: the status line shows the balance `+N −M`. Scope, agents and method are only asked when there is something to install.

Expand Down
4 changes: 2 additions & 2 deletions src/agent-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AGENTS } from './agents.js';
import { querySkills } from './catalog/query.js';
import { auditSkills } from './audit.js';
import { auditSkills, withLookalikes } from './audit.js';
import { fetchSkillFiles, fetchSkillMarkdown, githubUrl } from './github.js';
import { parseSkillId } from './install/plan.js';
import { listInstalled } from './install/run.js';
Expand Down Expand Up @@ -65,7 +65,7 @@ export async function showSkill(catalog: Catalog, id: string): Promise<{ skill:
const skill = findSkill(catalog, id);
const markdown = await fetchSkillMarkdown(skill);
// The audit's view: the other files of the folder too, the SKILL.md read once.
const [audit] = await auditSkills([skill], async () => markdown, fetchSkillFiles);
const [audit] = withLookalikes(await auditSkills([skill], async () => markdown, fetchSkillFiles), catalog.skills);
return { skill: summarize(skill), markdown, risks: audit!.risks };
}

Expand Down
20 changes: 15 additions & 5 deletions src/audit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pLimit from 'p-limit';
import { fetchSkillFiles, fetchSkillMarkdown, type SkillFiles } from './github.js';
import { scanRisks, sortRisks, type Risk } from './risks.js';
import { scanRisks, sortRisks, unaudited, type Risk } from './risks.js';
import { lookalikeRisk } from './catalog/lookalike.js';
import type { Skill } from './types.js';

/**
Expand Down Expand Up @@ -64,10 +65,19 @@ export async function auditSkills(
);
}

const unaudited = (paths: string[]): Risk => ({
rule: 'unaudited', level: 'info', label: 'files not audited', line: 0, count: paths.length,
excerpt: paths.slice(0, 5).join(', ') + (paths.length > 5 ? ` (+${paths.length - 5})` : ''),
});
/**
* Adds, to each result, the far more installed skill its name could be taken for.
* The catalog tells it, not the skill's files: a result for a skill the catalog does
* not know is compared as one with no install.
*/
export function withLookalikes(results: AuditResult[], skills: Pick<Skill, 'id' | 'source' | 'name' | 'installs'>[]): AuditResult[] {
if (!skills.length) return results;
const known = new Map(skills.map((s) => [s.id.toLowerCase(), s]));
return results.map((r) => {
const risk = lookalikeRisk(known.get(r.id.toLowerCase()) ?? auditTarget(r.id), skills);
return risk ? { ...r, risks: sortRisks([...r.risks, risk]) } : r;
});
}

/** Whether to audit: on unless the config or the command line turns it off. */
export const auditEnabled = (config: { audit?: boolean }, flag?: boolean): boolean => flag !== false && config.audit !== false;
64 changes: 64 additions & 0 deletions src/catalog/lookalike.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Risk } from '../risks.js';
import type { Skill } from '../types.js';

/**
* A skill whose name, or whose owner's name, is one character away from a far more
* installed one: the usual shape of a copy that hopes to be taken for the original.
* It is a hint to compare the two, not a verdict: honest forks and plurals match too.
*/

/** The original must be installed at least this much, and this many times more than the look-alike. */
const MIN_INSTALLS = 1_000;
const RATIO = 20;
/** Shorter names are one character away from too many others (`figma` and `sigma`, `email` and `emails`). */
const MIN_LENGTH = 8;

/** Separators dropped, and the digits used in place of letters put back. */
const fold = (s: string): string => s.toLowerCase().replace(/[-_.\s]/g, '').replace(/0/g, 'o').replace(/1/g, 'l');

/** One substitution, insertion or deletion apart. */
function oneApart(a: string, b: string): boolean {
if (Math.abs(a.length - b.length) > 1) return false;
const [short, long] = a.length <= b.length ? [a, b] : [b, a];
let i = 0;
while (i < short.length && short[i] === long[i]) i++;
return short.length === long.length ? short.slice(i + 1) === long.slice(i + 1) : short.slice(i) === long.slice(i + 1);
}

/** Different names that read alike. */
export function near(a: string, b: string): boolean {
const x = a.toLowerCase();
const y = b.toLowerCase();
if (x === y) return false;
return fold(x) === fold(y) || (Math.min(x.length, y.length) >= MIN_LENGTH && oneApart(x, y));
}

const owner = (source: string): string => source.split('/')[0]!.toLowerCase();

type Named = Pick<Skill, 'id' | 'source' | 'name' | 'installs'>;

/** The most installed skill this one could be taken for, if any. */
export function lookalike(skill: Named, skills: Named[]): Named | undefined {
const floor = Math.max(MIN_INSTALLS, (skill.installs ?? 0) * RATIO);
const source = skill.source.toLowerCase();
const name = skill.name.toLowerCase();
let best: Named | undefined;
for (const other of skills) {
if ((other.installs ?? 0) < floor || (best && (best.installs ?? 0) >= (other.installs ?? 0))) continue;
if (other.source.toLowerCase() === source) continue;
const sameName = other.name.toLowerCase() === name;
// Another repository's skill with a close name, or the same skill name under a close owner name.
if (sameName ? near(owner(other.source), owner(source)) : near(other.name, name)) best = other;
}
return best;
}

/** The look-alike as a finding of the audit. */
export function lookalikeRisk(skill: Named, skills: Named[]): Risk | undefined {
const original = lookalike(skill, skills);
if (!original) return undefined;
return {
rule: 'lookalike', level: 'medium', label: 'name close to a far more installed skill', line: 0, count: 1, file: 'catalog',
excerpt: `${original.id} (${original.installs} installs)`,
};
}
14 changes: 8 additions & 6 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ import { readFileSync } from 'node:fs';
import { installedSkills, outdatedSkills, showSkill, skillsByAgent } from './agent-api.js';
import { serveMcp } from './mcp.js';
import { complete, completionScript, type CompletionContext } from './completion.js';
import { auditEnabled, auditSkills, auditTarget, failsAudit } from './audit.js';
import { auditEnabled, auditSkills, auditTarget, failsAudit, withLookalikes } from './audit.js';
import { lockSelection, projectRevisionDrift, type Revisions } from './revisions.js';
import { fetchSkillFiles, fetchSkillMarkdown } from './github.js';
import { riskWhere } from './risks.js';
import { updateDiff } from './install/diff.js';
import { riskNote, riskWhere } from './risks.js';
import { fileSummary, updateDiff } from './install/diff.js';
import { browseLoop } from './browse.js';
import { missingSkills, missingSkillsByAgent, parseSelection, parseSelectionFile, toSelection } from './selection.js';

Expand Down Expand Up @@ -473,9 +473,10 @@ async function auditGate(ids: string[], flags: InstallFlags, revisions?: Revisio
const catalog = await loadLatestCatalog({ offline: true }).catch(() => undefined);
const known = new Map(catalog?.skills.map((s) => [s.id.toLowerCase(), s]));
const left = new Set<string>();
for (const r of await auditSkills(ids.map((id) => auditTarget(id, known)), s => fetchSkillMarkdown(s, revisions?.[s.id.toLowerCase()]), s => fetchSkillFiles(s, revisions?.[s.id.toLowerCase()]))) {
const results = await auditSkills(ids.map((id) => auditTarget(id, known)), s => fetchSkillMarkdown(s, revisions?.[s.id.toLowerCase()]), s => fetchSkillFiles(s, revisions?.[s.id.toLowerCase()]));
for (const r of withLookalikes(results, catalog?.skills ?? [])) {
for (const x of r.risks.filter((x) => x.level !== 'info')) {
console.error(paint(x.level === 'high' ? 'red' : 'yellow', ` ${m.tree.riskLevels[x.level]} · ${m.tree.risks[x.rule]}${x.rule === 'missingBin' ? ` (${x.excerpt})` : ''} ${r.id} ${riskWhere(x)}`));
console.error(paint(x.level === 'high' ? 'red' : 'yellow', ` ${m.tree.riskLevels[x.level]} · ${m.tree.risks[x.rule]}${riskNote(x)} ${r.id} ${riskWhere(x)}`));
}
if (r.error) console.error(paint('yellow', ` ? ${r.id}: ${m.confirm.auditUnread}`));
if (r.error && revisions?.[r.id.toLowerCase()]) fail(`cannot audit pinned skill ${r.id}: ${r.error}`);
Expand Down Expand Up @@ -779,8 +780,9 @@ installOptions(program.command('update'))
for (const s of chosen) {
const diff = await updateDiff(auditTarget(s.id, known), local).catch(() => undefined);
console.log(` ${paint('pink', '↑')} ${s.id}${diff ? paint('dim', ` +${diff.added} −${diff.removed}`) : ''}`);
for (const f of diff?.files ?? []) console.log(paint('dim', ` ${fileSummary(f)}`));
for (const x of diff?.risks.filter((x) => x.level !== 'info') ?? []) {
console.log(paint(x.level === 'high' ? 'red' : 'yellow', ` ${m.tree.riskLevels[x.level]} · ${m.tree.risks[x.rule]} SKILL.md:${x.line} ${x.excerpt}`));
console.log(paint(x.level === 'high' ? 'red' : 'yellow', ` ${m.tree.riskLevels[x.level]} · ${m.tree.risks[x.rule]} ${riskWhere(x)} ${x.excerpt}`));
}
}
// A new version is new content: it is audited like an install.
Expand Down
2 changes: 2 additions & 0 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export interface SkillFiles {
}

const TEXT = /\.(sh|bash|zsh|fish|ps1|psm1|bat|cmd|py|js|mjs|cjs|ts|rb|pl|php|lua|md|txt|json|ya?ml|toml|ini|cfg|env)$|(^|\/)(Makefile|Dockerfile|[^./]+)$/i;
/** A file the audit reads: scripts, references, configs. */
export const isTextFile = (path: string): boolean => TEXT.test(path);
const MAX_BYTES = 64 * 1024;
const MAX_FILES = 30;
const FileTree = z.object({
Expand Down
Loading
Loading