295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
import { homedir } from 'node:os';
|
|||
|
|
import { join } from 'node:path';
|
||
|
|
import { z } from 'zod';
|
||
|
|
import type { Plugin } from './plugins';
|
||
|
|
import { parseSkill, type Skill } from './skills';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* External skills and plugins, fetched from an index over HTTPS.
|
||
|
|
*
|
||
|
|
* Two kinds, and they are not equally safe.
|
||
|
|
*
|
||
|
|
* A **skill** is prompt text. Installing one puts a stranger's words into the
|
||
|
|
* system prompt of every future session in this project, which is prompt injection
|
||
|
|
* by invitation. The command shows the body before writing it and the origin is
|
||
|
|
* recorded, so `/skills` always says where an instruction came from.
|
||
|
|
*
|
||
|
|
* A **plugin** is declarative: a name, a prompt appendix, and deny rules matched
|
||
|
|
* against tool input. Never code. Loading arbitrary TypeScript from a URL would let
|
||
|
|
* an entry read every file the agent can read and lie about blocking anything, so
|
||
|
|
* that is not offered at any price.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const MAX_INDEX_BYTES = 256 * 1024;
|
||
|
|
const MAX_BODY_BYTES = 64 * 1024;
|
||
|
|
/** A pattern from the internet runs on every tool call; a huge one is a denial of service. */
|
||
|
|
const MAX_PATTERN = 200;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* https only, with localhost allowed so the suite can serve a real index.
|
||
|
|
*
|
||
|
|
* `file:` would read a local path and `data:` would inline a payload, neither of
|
||
|
|
* which is what "fetch this from a registry" means to the person typing it.
|
||
|
|
*/
|
||
|
|
export function isFetchable(url: string): boolean {
|
||
|
|
let parsed: URL;
|
||
|
|
try {
|
||
|
|
parsed = new URL(url);
|
||
|
|
} catch {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (parsed.protocol === 'https:') return true;
|
||
|
|
return parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1');
|
||
|
|
}
|
||
|
|
|
||
|
|
const entrySchema = z.object({
|
||
|
|
name: z
|
||
|
|
.string()
|
||
|
|
.min(1)
|
||
|
|
.max(40)
|
||
|
|
// The name becomes a filename. Anything else is a path traversal.
|
||
|
|
.regex(/^[a-z0-9][a-z0-9-]*$/, 'lowercase letters, digits, and dashes only'),
|
||
|
|
description: z.string().min(1).max(300),
|
||
|
|
// z.url() accepts file: and data:, which would read a local path or inline a
|
||
|
|
// payload. Only https reaches the network the way the user expects.
|
||
|
|
url: z
|
||
|
|
.string()
|
||
|
|
.url()
|
||
|
|
.refine((u) => isFetchable(u), 'must be an https URL'),
|
||
|
|
author: z.string().max(80).optional(),
|
||
|
|
});
|
||
|
|
|
||
|
|
const indexSchema = z.object({
|
||
|
|
skills: z.array(entrySchema).max(500).optional(),
|
||
|
|
plugins: z.array(entrySchema).max(500).optional(),
|
||
|
|
});
|
||
|
|
|
||
|
|
export type RegistryKind = 'skill' | 'plugin';
|
||
|
|
export type RegistryEntry = z.infer<typeof entrySchema> & { kind: RegistryKind };
|
||
|
|
|
||
|
|
const denyRuleSchema = z
|
||
|
|
.object({
|
||
|
|
tools: z.array(z.string().max(60)).min(1).max(30),
|
||
|
|
pathPattern: z.string().max(MAX_PATTERN).optional(),
|
||
|
|
commandPattern: z.string().max(MAX_PATTERN).optional(),
|
||
|
|
reason: z.string().min(1).max(300),
|
||
|
|
})
|
||
|
|
.refine((r) => r.pathPattern !== undefined || r.commandPattern !== undefined, {
|
||
|
|
message: 'a deny rule needs pathPattern or commandPattern',
|
||
|
|
});
|
||
|
|
|
||
|
|
const manifestSchema = z.object({
|
||
|
|
name: z.string().min(1).max(40).regex(/^[a-z0-9][a-z0-9-]*$/),
|
||
|
|
description: z.string().min(1).max(300),
|
||
|
|
appendix: z.string().max(2000).optional(),
|
||
|
|
deny: z.array(denyRuleSchema).min(1).max(50),
|
||
|
|
});
|
||
|
|
|
||
|
|
export type PluginManifest = z.infer<typeof manifestSchema>;
|
||
|
|
|
||
|
|
export const DEFAULT_INDEX_URL =
|
||
|
|
'https://raw.githubusercontent.com/zakirkun/shiro-neko-registry/main/index.json';
|
||
|
|
|
||
|
|
const home = () => process.env['SHIRO_HOME'] ?? homedir();
|
||
|
|
|
||
|
|
export const skillsDir = () => join(home(), '.shiro-neko', 'registry', 'skills');
|
||
|
|
export const pluginsDir = () => join(home(), '.shiro-neko', 'registry', 'plugins');
|
||
|
|
|
||
|
|
async function fetchText(url: string, limit: number): Promise<string> {
|
||
|
|
if (!isFetchable(url)) throw new Error(`refusing a non-https URL: ${url}`);
|
||
|
|
const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(20_000) });
|
||
|
|
if (!res.ok) throw new Error(`${url} returned ${res.status} ${res.statusText}`);
|
||
|
|
|
||
|
|
const declared = Number(res.headers.get('content-length') ?? 0);
|
||
|
|
if (declared > limit) throw new Error(`${url} is ${declared} bytes, over the ${limit} byte limit`);
|
||
|
|
|
||
|
|
const text = await res.text();
|
||
|
|
if (text.length > limit) throw new Error(`${url} is over the ${limit} byte limit`);
|
||
|
|
return text;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Parses an index document. Exported so the shape can be tested without a network. */
|
||
|
|
export function parseIndex(source: string): RegistryEntry[] {
|
||
|
|
let raw: unknown;
|
||
|
|
try {
|
||
|
|
raw = JSON.parse(source);
|
||
|
|
} catch {
|
||
|
|
throw new Error('the registry index is not valid JSON');
|
||
|
|
}
|
||
|
|
|
||
|
|
const parsed = indexSchema.safeParse(raw);
|
||
|
|
if (!parsed.success) {
|
||
|
|
throw new Error(`the registry index is malformed: ${parsed.error.issues[0]?.message ?? 'unknown reason'}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const seen = new Set<string>();
|
||
|
|
const entries: RegistryEntry[] = [];
|
||
|
|
for (const kind of ['skill', 'plugin'] as const) {
|
||
|
|
for (const entry of parsed.data[`${kind}s`] ?? []) {
|
||
|
|
const key = `${kind}:${entry.name}`;
|
||
|
|
if (seen.has(key)) continue;
|
||
|
|
seen.add(key);
|
||
|
|
entries.push({ ...entry, kind });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return entries;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function fetchIndex(url = DEFAULT_INDEX_URL): Promise<RegistryEntry[]> {
|
||
|
|
return parseIndex(await fetchText(url, MAX_INDEX_BYTES));
|
||
|
|
}
|
||
|
|
|
||
|
|
export const searchEntries = (entries: RegistryEntry[], query: string): RegistryEntry[] => {
|
||
|
|
const needle = query.trim().toLowerCase();
|
||
|
|
if (!needle) return entries;
|
||
|
|
return entries.filter(
|
||
|
|
(e) => e.name.includes(needle) || e.description.toLowerCase().includes(needle),
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
/** Validates a plugin manifest, including that every pattern is a usable regex. */
|
||
|
|
export function parseManifest(source: string): PluginManifest {
|
||
|
|
let raw: unknown;
|
||
|
|
try {
|
||
|
|
raw = JSON.parse(source);
|
||
|
|
} catch {
|
||
|
|
throw new Error('the plugin manifest is not valid JSON');
|
||
|
|
}
|
||
|
|
|
||
|
|
const parsed = manifestSchema.safeParse(raw);
|
||
|
|
if (!parsed.success) {
|
||
|
|
throw new Error(`the plugin manifest is malformed: ${parsed.error.issues[0]?.message ?? 'unknown reason'}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const rule of parsed.data.deny) {
|
||
|
|
for (const pattern of [rule.pathPattern, rule.commandPattern]) {
|
||
|
|
if (pattern === undefined) continue;
|
||
|
|
try {
|
||
|
|
new RegExp(pattern, 'i');
|
||
|
|
} catch (e) {
|
||
|
|
throw new Error(`invalid pattern "${pattern}": ${(e as Error).message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return parsed.data;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A manifest as a Plugin.
|
||
|
|
*
|
||
|
|
* Rules are data, so the guard is the same code for every installed plugin: match
|
||
|
|
* the tool name, then the path or command against a compiled regex. Nothing from
|
||
|
|
* the manifest is ever evaluated.
|
||
|
|
*/
|
||
|
|
export function manifestToPlugin(manifest: PluginManifest): Plugin {
|
||
|
|
const rules = manifest.deny.map((rule) => ({
|
||
|
|
tools: new Set(rule.tools),
|
||
|
|
path: rule.pathPattern ? new RegExp(rule.pathPattern, 'i') : undefined,
|
||
|
|
command: rule.commandPattern ? new RegExp(rule.commandPattern, 'i') : undefined,
|
||
|
|
reason: rule.reason,
|
||
|
|
}));
|
||
|
|
|
||
|
|
return {
|
||
|
|
name: manifest.name,
|
||
|
|
description: `${manifest.description} (installed)`,
|
||
|
|
...(manifest.appendix ? { appendix: manifest.appendix } : {}),
|
||
|
|
beforeToolCall: ({ toolName, input }) => {
|
||
|
|
const o = (input ?? {}) as Record<string, unknown>;
|
||
|
|
const path = typeof o['path'] === 'string' ? o['path'] : '';
|
||
|
|
const command = typeof o['command'] === 'string' ? o['command'] : '';
|
||
|
|
|
||
|
|
for (const rule of rules) {
|
||
|
|
if (!rule.tools.has(toolName)) continue;
|
||
|
|
if (rule.path && path && rule.path.test(path)) return rule.reason;
|
||
|
|
if (rule.command && command && rule.command.test(command)) return rule.reason;
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export type Installed = { name: string; kind: RegistryKind; path: string };
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Downloads an entry and returns what would be written, without writing it.
|
||
|
|
*
|
||
|
|
* Separated from the write so the caller can show the user a skill body before it
|
||
|
|
* becomes part of every future prompt.
|
||
|
|
*/
|
||
|
|
export async function stage(entry: RegistryEntry): Promise<{ path: string; content: string; preview: string }> {
|
||
|
|
const body = await fetchText(entry.url, MAX_BODY_BYTES);
|
||
|
|
|
||
|
|
if (entry.kind === 'plugin') {
|
||
|
|
const manifest = parseManifest(body);
|
||
|
|
if (manifest.name !== entry.name) {
|
||
|
|
throw new Error(`the manifest calls itself "${manifest.name}" but the index calls it "${entry.name}"`);
|
||
|
|
}
|
||
|
|
const rules = manifest.deny
|
||
|
|
.map((r) => `- denies ${r.tools.join(', ')}: ${r.pathPattern ?? r.commandPattern}`)
|
||
|
|
.join('\n');
|
||
|
|
return {
|
||
|
|
path: join(pluginsDir(), `${entry.name}.json`),
|
||
|
|
content: JSON.stringify(manifest, null, 2),
|
||
|
|
preview: `${manifest.description}\n\n${rules}`,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const skill = parseSkill(body, 'registry');
|
||
|
|
if (!skill) throw new Error('that skill has no name/description frontmatter, so it cannot be loaded');
|
||
|
|
if (skill.name !== entry.name) {
|
||
|
|
throw new Error(`the skill calls itself "${skill.name}" but the index calls it "${entry.name}"`);
|
||
|
|
}
|
||
|
|
|
||
|
|
return { path: join(skillsDir(), `${entry.name}.md`), content: body, preview: skill.body };
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function install(entry: RegistryEntry): Promise<Installed> {
|
||
|
|
const { path, content } = await stage(entry);
|
||
|
|
await Bun.write(path, content);
|
||
|
|
return { name: entry.name, kind: entry.kind, path };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Removes an installed entry. Returns false when there was nothing to remove. */
|
||
|
|
export async function uninstall(kind: RegistryKind, name: string): Promise<boolean> {
|
||
|
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error(`"${name}" is not a valid entry name`);
|
||
|
|
const path = kind === 'plugin' ? join(pluginsDir(), `${name}.json`) : join(skillsDir(), `${name}.md`);
|
||
|
|
const file = Bun.file(path);
|
||
|
|
if (!(await file.exists())) return false;
|
||
|
|
await file.delete();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Declarative plugins from disk.
|
||
|
|
*
|
||
|
|
* A malformed file is reported and skipped rather than fatal: one bad install
|
||
|
|
* should not stop the agent from starting.
|
||
|
|
*/
|
||
|
|
export async function loadInstalledPlugins(): Promise<{ plugins: Plugin[]; errors: { plugin: string; message: string }[] }> {
|
||
|
|
const plugins: Plugin[] = [];
|
||
|
|
const errors: { plugin: string; message: string }[] = [];
|
||
|
|
|
||
|
|
let files: string[] = [];
|
||
|
|
try {
|
||
|
|
for await (const f of new Bun.Glob('*.json').scan({ cwd: pluginsDir(), onlyFiles: true })) files.push(f);
|
||
|
|
} catch {
|
||
|
|
return { plugins, errors };
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const file of files.sort()) {
|
||
|
|
const name = file.replace(/\.json$/, '');
|
||
|
|
try {
|
||
|
|
plugins.push(manifestToPlugin(parseManifest(await Bun.file(join(pluginsDir(), file)).text())));
|
||
|
|
} catch (e) {
|
||
|
|
errors.push({ plugin: name, message: e instanceof Error ? e.message : String(e) });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { plugins, errors };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Installed skills, for `/registry list` — the loader already merges them by name. */
|
||
|
|
export function installedSkillNames(skills: Skill[]): string[] {
|
||
|
|
return skills.filter((s) => s.origin === 'registry').map((s) => s.name);
|
||
|
|
}
|