The compaction bug, which is the important one:
beta.2 taught the pruner to drop any assistant part whose reasoning item it had
removed. That was right about the 400 and wrong about everything else. On a
reasoning model every tool call carries a provider itemId, so past the threshold
the model could no longer see what it had already run, and re-ran the same tools
until maxSteps ended the turn. Reproduced at 12 model calls for a job needing 4,
with nothing but the user message reaching the wire.
The dependency is not the part, it is the itemId. A part carrying one is
serialised as `{ type: 'item_reference', id }`, a pointer to an item stored
provider-side that depends on its reasoning item. Without the itemId the same
content goes out inline and carries no dependency at all. Verified against the
provider's own serialiser: `text` with an itemId becomes item_reference, the
identical part without one becomes output_text.
So `dropOrphanedItems` becomes `detachOrphanedItems`: strip the itemId, keep the
content. Compaction may shorten the history; it must not blank it. The new test
asserts behaviour rather than shape — the loop must end because the model chose
to, and every call after the first must still carry the earlier exchange. A shape
assertion passed the whole time the model was losing its memory.
Registry, via `/registry [list|search|add|remove|installed]`:
Skills and plugins are treated differently on purpose. A skill is prompt text, so
installing one puts a stranger's words into the system prompt of every future
session in this project; the install shows the body first and the origin is
recorded, so /skills always says where an instruction came from. A plugin is a
JSON manifest of deny rules, evaluated by compiled code identical for every
install. Loading TypeScript from a URL is declined outright: a plugin that can
block tool calls could otherwise lie about blocking them.
Validated before anything is written: https only (file: and data: rejected), name
matched against ^[a-z0-9][a-z0-9-]*$ so it cannot escape its directory, size
caps on index and body, every regex compiled, pattern length capped since it runs
on every tool call, and the body's own name checked against the index. Installed
skills rank below your own, so an install can never shadow a skill you wrote.
Interface:
- Context is a percentage of the compaction threshold, amber from two thirds and
red at 90. A turn about to lose history now says so beforehand.
- Aligned command menu and registry tables; /skills and /plugins name origins.
538 tests, up from 488. The registry is tested against a real local HTTP server,
and the guard is proven to refuse a .env write end to end rather than assumed to.
296 lines
11 KiB
TypeScript
296 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);
|
|
}
|