feat(mosaic): add secure skill registration CLI (#826)
All checks were successful
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #826.
This commit is contained in:
2026-07-17 23:45:35 +00:00
parent d3bf52898b
commit d801d6c4c8
18 changed files with 1240 additions and 26 deletions

View File

@@ -0,0 +1,419 @@
import {
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readlinkSync,
symlinkSync,
unlinkSync,
type Stats,
} from 'node:fs';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
import type { Command } from 'commander';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
export interface SkillPaths {
mosaicSkillsDir: string;
claudeSkillsDir: string;
}
export type SkillRegistrationStatus = 'registered' | 'already-registered' | 'repaired';
export type SkillUnregistrationStatus = 'unregistered' | 'already-unregistered';
export type SkillListStatus =
| 'registered'
| 'unregistered'
| 'dangling'
| 'foreign'
| 'foreign-dangling'
| 'misdirected';
export interface SkillRegistrationResult {
name: string;
status: SkillRegistrationStatus;
sourcePath: string;
linkPath: string;
}
export interface SkillUnregistrationResult {
name: string;
status: SkillUnregistrationStatus;
linkPath: string;
}
export interface SkillListEntry {
name: string;
status: SkillListStatus;
sourcePath?: string;
linkPath: string;
targetPath?: string;
}
export interface SkillSyncConflict {
name: string;
reason: string;
}
export interface SkillSyncResult {
registered: string[];
repaired: string[];
unchanged: string[];
conflicts: SkillSyncConflict[];
}
const SAFE_SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
export class SkillBridgeError extends Error {
public constructor(message: string) {
super(message);
this.name = 'SkillBridgeError';
}
}
/** Resolve the production bridge paths while keeping tests injectable. */
export function getDefaultSkillPaths(): SkillPaths {
const mosaicHome = process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME;
const claudeHome = process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude');
return {
mosaicSkillsDir: join(mosaicHome, 'skills'),
claudeSkillsDir: join(claudeHome, 'skills'),
};
}
/**
* Reject a user-supplied name before any filesystem operation.
* A skill name must identify one direct child in both managed roots.
*/
export function validateSkillName(name: string): void {
if (
name.length === 0 ||
name.startsWith('-') ||
name.endsWith('.') ||
name.includes('..') ||
name.includes('/') ||
name.includes('\\') ||
isAbsolute(name) ||
!SAFE_SKILL_NAME.test(name)
) {
throw new SkillBridgeError(
`Invalid skill name ${JSON.stringify(name)}: use letters, numbers, dots, underscores, or hyphens; start with a letter or number; and do not use paths, "..", or a leading "-".`,
);
}
}
function displaySkillName(name: string): string {
return SAFE_SKILL_NAME.test(name) ? name : JSON.stringify(name);
}
function lstatIfPresent(path: string): Stats | undefined {
try {
return lstatSync(path);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined;
throw error;
}
}
function assertNoSymlinkAncestors(path: string): void {
const absolute = resolve(path);
const pathRoot = parse(absolute).root;
let current = pathRoot;
for (const segment of relative(pathRoot, absolute).split(sep)) {
if (segment.length === 0) continue;
current = join(current, segment);
const entry = lstatIfPresent(current);
if (!entry) break;
if (entry.isSymbolicLink()) {
throw new SkillBridgeError(
`Refusing symlink ancestor at ${current}; managed skill roots must resolve without symlink traversal.`,
);
}
}
}
function assertManagedRoots(paths: SkillPaths): void {
assertNoSymlinkAncestors(paths.mosaicSkillsDir);
assertNoSymlinkAncestors(paths.claudeSkillsDir);
}
function directChild(root: string, name: string): string {
const resolvedRoot = resolve(root);
const child = resolve(resolvedRoot, name);
if (dirname(child) !== resolvedRoot) {
throw new SkillBridgeError(`Invalid skill name "${name}": resolved path escapes its root.`);
}
return child;
}
function resolveLinkTarget(linkPath: string): string {
return resolve(dirname(linkPath), readlinkSync(linkPath));
}
function isInsideSkillsRoot(targetPath: string, skillsRoot: string): boolean {
const rel = relative(resolve(skillsRoot), resolve(targetPath));
return rel.length > 0 && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
}
function isDangling(linkPath: string): boolean {
return !existsSync(linkPath);
}
function assertSourceSkill(name: string, paths: SkillPaths): string {
const sourcePath = directChild(paths.mosaicSkillsDir, name);
const source = lstatIfPresent(sourcePath);
if (!source?.isDirectory()) {
throw new SkillBridgeError(
`Canonical skill directory not found: ${sourcePath}. Add the skill under the Mosaic skills directory before registering it.`,
);
}
return sourcePath;
}
function foreignTargetError(linkPath: string): SkillBridgeError {
return new SkillBridgeError(
`Refusing to modify foreign entry at ${linkPath}; only symlinks pointing inside the Mosaic skills directory are managed.`,
);
}
/** Register one canonical skill with Claude Code without clobbering foreign entries. */
export function registerSkill(
name: string,
paths: SkillPaths = getDefaultSkillPaths(),
): SkillRegistrationResult {
validateSkillName(name);
assertManagedRoots(paths);
const sourcePath = assertSourceSkill(name, paths);
const linkPath = directChild(paths.claudeSkillsDir, name);
const existing = lstatIfPresent(linkPath);
if (!existing) {
mkdirSync(paths.claudeSkillsDir, { recursive: true });
symlinkSync(sourcePath, linkPath);
return { name, status: 'registered', sourcePath, linkPath };
}
if (!existing.isSymbolicLink()) throw foreignTargetError(linkPath);
const existingTarget = resolveLinkTarget(linkPath);
if (!isInsideSkillsRoot(existingTarget, paths.mosaicSkillsDir)) {
throw foreignTargetError(linkPath);
}
if (existingTarget === resolve(sourcePath) && !isDangling(linkPath)) {
return { name, status: 'already-registered', sourcePath, linkPath };
}
if (!isDangling(linkPath)) {
throw new SkillBridgeError(
`Refusing to replace live Mosaic skill symlink at ${linkPath}; it points to ${existingTarget}, not ${sourcePath}.`,
);
}
unlinkSync(linkPath);
symlinkSync(sourcePath, linkPath);
return { name, status: 'repaired', sourcePath, linkPath };
}
/** Unregister only a symlink owned by the canonical Mosaic skills root. */
export function unregisterSkill(
name: string,
paths: SkillPaths = getDefaultSkillPaths(),
): SkillUnregistrationResult {
validateSkillName(name);
assertManagedRoots(paths);
const linkPath = directChild(paths.claudeSkillsDir, name);
const existing = lstatIfPresent(linkPath);
if (!existing) return { name, status: 'already-unregistered', linkPath };
if (!existing.isSymbolicLink()) throw foreignTargetError(linkPath);
const targetPath = resolveLinkTarget(linkPath);
if (!isInsideSkillsRoot(targetPath, paths.mosaicSkillsDir)) throw foreignTargetError(linkPath);
const expectedTarget = resolve(directChild(paths.mosaicSkillsDir, name));
if (targetPath !== expectedTarget) {
throw new SkillBridgeError(
`Refusing to unregister misdirected Mosaic skill symlink at ${linkPath}; it points to ${targetPath}, not ${expectedTarget}.`,
);
}
unlinkSync(linkPath);
return { name, status: 'unregistered', linkPath };
}
function canonicalSkillNames(paths: SkillPaths): string[] {
assertNoSymlinkAncestors(paths.mosaicSkillsDir);
const root = lstatIfPresent(paths.mosaicSkillsDir);
if (!root) return [];
if (!root.isDirectory()) {
throw new SkillBridgeError(
`Canonical skills path is not a directory: ${paths.mosaicSkillsDir}`,
);
}
return readdirSync(paths.mosaicSkillsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}
function claudeEntryNames(paths: SkillPaths): string[] {
assertNoSymlinkAncestors(paths.claudeSkillsDir);
const root = lstatIfPresent(paths.claudeSkillsDir);
if (!root) return [];
if (!root.isDirectory()) {
throw new SkillBridgeError(`Claude skills path is not a directory: ${paths.claudeSkillsDir}`);
}
return readdirSync(paths.claudeSkillsDir)
.filter((name) => name.length > 0)
.sort();
}
/** Return a deterministic union of canonical skills and Claude bridge entries. */
export function listSkills(paths: SkillPaths = getDefaultSkillPaths()): SkillListEntry[] {
const canonicalNames = new Set(canonicalSkillNames(paths));
const names = new Set([...canonicalNames, ...claudeEntryNames(paths)]);
const entries: SkillListEntry[] = [];
for (const name of [...names].sort()) {
const sourcePath = canonicalNames.has(name)
? directChild(paths.mosaicSkillsDir, name)
: undefined;
const linkPath = directChild(paths.claudeSkillsDir, name);
const installed = lstatIfPresent(linkPath);
if (!installed) {
if (sourcePath) entries.push({ name, status: 'unregistered', sourcePath, linkPath });
continue;
}
if (!installed.isSymbolicLink()) {
entries.push({ name, status: 'foreign', sourcePath, linkPath });
continue;
}
const targetPath = resolveLinkTarget(linkPath);
const owned = isInsideSkillsRoot(targetPath, paths.mosaicSkillsDir);
const dangling = isDangling(linkPath);
if (!owned) {
entries.push({
name,
status: dangling ? 'foreign-dangling' : 'foreign',
sourcePath,
linkPath,
targetPath,
});
continue;
}
if (dangling) {
entries.push({ name, status: 'dangling', sourcePath, linkPath, targetPath });
continue;
}
entries.push({
name,
status: sourcePath && targetPath === resolve(sourcePath) ? 'registered' : 'misdirected',
sourcePath,
linkPath,
targetPath,
});
}
return entries;
}
/** Reconcile every canonical skill directory while preserving all foreign entries. */
export function syncClaudeSkills(paths: SkillPaths = getDefaultSkillPaths()): SkillSyncResult {
const result: SkillSyncResult = {
registered: [],
repaired: [],
unchanged: [],
conflicts: [],
};
for (const name of canonicalSkillNames(paths)) {
try {
const registration = registerSkill(name, paths);
if (registration.status === 'registered') result.registered.push(name);
if (registration.status === 'repaired') result.repaired.push(name);
if (registration.status === 'already-registered') result.unchanged.push(name);
} catch (error: unknown) {
result.conflicts.push({
name: displaySkillName(name),
reason: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
function reportCommandError(error: unknown): void {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
/** Register the `mosaic skill` command group. */
export function registerSkillCommand(
program: Command,
paths: SkillPaths = getDefaultSkillPaths(),
): void {
const skill = program
.command('skill')
.description('Manage Claude Code skill registrations')
.configureHelp({ sortSubcommands: true });
skill
.command('register <name>')
.description('Register a Mosaic skill with Claude Code')
.action((name: string) => {
try {
const result = registerSkill(name, paths);
if (result.status === 'already-registered') {
console.log(`${name}: already registered`);
} else if (result.status === 'repaired') {
console.log(`${name}: repaired dangling registration`);
} else {
console.log(`${name}: registered`);
}
} catch (error: unknown) {
reportCommandError(error);
}
});
skill
.command('unregister <name>')
.description('Unregister a Mosaic skill from Claude Code')
.action((name: string) => {
try {
const result = unregisterSkill(name, paths);
console.log(
result.status === 'already-unregistered'
? `${name}: already unregistered`
: `${name}: unregistered`,
);
} catch (error: unknown) {
reportCommandError(error);
}
});
skill
.command('list')
.description('List registered, dangling, foreign, and unregistered skills')
.action(() => {
try {
const entries = listSkills(paths);
if (entries.length === 0) {
console.log('No Mosaic or Claude Code skills found.');
return;
}
for (const entry of entries) {
console.log(`${entry.status.padEnd(17)} ${displaySkillName(entry.name)}`);
}
} catch (error: unknown) {
reportCommandError(error);
}
});
}