Skip to content
Open
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
2 changes: 1 addition & 1 deletion plans/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ modified while creating them.

| Plan | Title | Priority | Effort | Depends on | Status |
| -------------------------------------------------- | ------------------------------------------------------- | -------: | -----: | ---------- | ------ |
| [001](001-reject-option-like-skill-names.md) | Reject option-like skill names before invoking `skills` | P1 | S | — | TODO |
| [001](001-reject-option-like-skill-names.md) | Reject option-like skill names before invoking `skills` | P1 | S | — | DONE |
| [002](002-compare-sync-source-subpaths.md) | Detect source-subpath drift during sync | P1 | M | — | TODO |
| [003](003-block-credential-bearing-sources.md) | Block credential-bearing source references | P1 | S | — | TODO |
| [004](004-serialize-catalog-updates.md) | Serialize catalog updates | P1 | M | — | TODO |
Expand Down
6 changes: 6 additions & 0 deletions src/catalog/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
CATALOG_VERSION,
type Catalog,
type CatalogSkill,
assertSafeSkillName,
emptyCatalog,
normalizeCategory,
normalizeInstallName,
Expand Down Expand Up @@ -69,6 +70,11 @@ function validateSkill(value: unknown, key: string): CatalogSkill {
) {
invalid(`${field}.skill`, "expected a non-empty string.");
}
try {
assertSafeSkillName(value.skill);
} catch {
invalid(`${field}.skill`, "is not a valid install name.");
}
if (
typeof value.source !== "string" ||
!value.source.trim() ||
Expand Down
8 changes: 8 additions & 0 deletions src/catalog/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ const MAX_INSTALL_NAME_LENGTH = 255,
INVALID_CATEGORY_RUN = /[^a-z0-9]+/g,
CATEGORY_EDGE = /^-+|-+$/g;

export function assertSafeSkillName(value: string): void {
if (value.startsWith("-")) {
throw new ViblibError(
"Option-like skill names cannot be passed to skills@1.5.22."
);
}
}

export function normalizeInstallName(value: string): string {
const normalized = value
.toLowerCase()
Expand Down
4 changes: 4 additions & 0 deletions src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import prompts from "prompts";
import { loadCatalog } from "../catalog/io.js";
import {
type CatalogSkill,
assertSafeSkillName,
normalizeCategory,
normalizeInstallName,
} from "../catalog/types.js";
Expand Down Expand Up @@ -131,6 +132,9 @@ export async function applyCatalogInstall(
runner?: SkillsRunner;
}
): Promise<{ failed: string[]; installed: number }> {
for (const entry of entries) {
assertSafeSkillName(entry.skill);
}
const grouped = new Map<string, CatalogSkill[]>();
for (const entry of entries) {
grouped.set(entry.source, [...(grouped.get(entry.source) ?? []), entry]);
Expand Down
4 changes: 4 additions & 0 deletions src/skills/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createRequire } from "node:module";
import path from "node:path";
import { stripVTControlCharacters } from "node:util";

import { assertSafeSkillName } from "../catalog/types.js";
import { ViblibError } from "../util/errors.js";
import { sourceForCatalog } from "./source.js";
import type { InstalledSkill, SkillScope } from "./types.js";
Expand Down Expand Up @@ -164,6 +165,9 @@ export async function discoverSkills(
{ exitCode: 2 }
);
}
for (const skill of skills) {
assertSafeSkillName(skill.name);
}
return {
skills,
source: await sourceForCatalog(source, options.cwd),
Expand Down
90 changes: 89 additions & 1 deletion test/viblib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import {
} from "../src/catalog/types.js";
import { createProgram } from "../src/cli.js";
import { runAdd } from "../src/commands/add.js";
import { runInstall, selectCatalogSkills } from "../src/commands/install.js";
import {
applyCatalogInstall,
runInstall,
selectCatalogSkills,
} from "../src/commands/install.js";
import { runSync } from "../src/commands/sync.js";
import { runUninstall } from "../src/commands/uninstall.js";
import {
Expand Down Expand Up @@ -145,6 +149,33 @@ describe("catalog storage", () => {
})
).toThrow('skills."my-skill".categories[0]');
});

it("rejects option-like skill names without exposing them", () => {
const optionLikeName = "-unsafe-skill";
let thrown: unknown = undefined;
try {
validateCatalog({
skills: {
"unsafe-skill": {
categories: ["work"],
skill: optionLikeName,
source: "owner/repo",
},
},
version: 1,
});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(ViblibError);
expect(thrown).toMatchObject({
message:
'Invalid catalog at skills."unsafe-skill".skill: is not a valid install name.',
});
expect(thrown).toMatchObject({
message: expect.not.stringContaining(optionLikeName),
});
});
});

describe("catalog commands", () => {
Expand Down Expand Up @@ -226,6 +257,16 @@ describe("pinned skills adapter", () => {
discoverSkills("owner/repo", { runner: async () => success("changed") })
).rejects.toThrow("skills@1.5.22");
});

it("rejects option-like discovery names", async () => {
const output = DISCOVERY_OUTPUT.replace("Foo Skill", "-unsafe-skill");
await expect(
discoverSkills("owner/repo", { runner: async () => success(output) })
).rejects.toThrow(ViblibError);
await expect(
discoverSkills("owner/repo", { runner: async () => success(output) })
).rejects.toThrow("Option-like skill names");
});
});

describe("install and uninstall", () => {
Expand Down Expand Up @@ -284,6 +325,53 @@ describe("install and uninstall", () => {
]);
});

it("rejects option-like names before calling the installer", async () => {
const calls: string[][] = [],
install = () =>
applyCatalogInstall(
[
{
categories: [],
skill: "-unsafe-skill",
source: "owner/repo",
},
],
{
runner: async (args) => {
calls.push(args);
return success();
},
scope: "project",
}
);
await expect(install()).rejects.toThrow(ViblibError);
await expect(install()).rejects.toThrow("Option-like skill names");
expect(calls).toEqual([]);
});

it("keeps normal multiword names intact with the option-like guard", async () => {
const calls: string[][] = [];
await applyCatalogInstall(
[
{
categories: [],
skill: "Foo Skill",
source: "owner/repo",
},
],
{
runner: async (args) => {
calls.push(args);
return success();
},
scope: "project",
}
);
expect(calls).toEqual([
["add", "owner/repo", "--skill", "Foo Skill", "--yes"],
]);
});

it("expands uninstall --all to catalog names and all agents", async () => {
const calls: string[][] = [];
await runUninstall({
Expand Down