Files

133 lines
5.6 KiB
TypeScript

import { afterEach, beforeEach, expect, test } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadExternalPlugins, loadExternalTools, parseToolManifest, manifestToTool } from '../src/autoload';
let cwd: string;
let home: string;
let origCwd: string;
let origHome: string | undefined;
beforeEach(() => {
origCwd = process.cwd();
origHome = process.env['SHIRO_HOME'];
cwd = mkdtempSync(join(tmpdir(), 'shiro-auto-'));
home = mkdtempSync(join(tmpdir(), 'shiro-autohome-'));
process.chdir(cwd);
process.env['SHIRO_HOME'] = home;
});
afterEach(() => {
process.chdir(origCwd);
if (origHome === undefined) delete process.env['SHIRO_HOME'];
else process.env['SHIRO_HOME'] = origHome;
rmSync(cwd, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
const noGuard = async () => undefined;
const denyAll = async () => 'not allowed';
test('a tool manifest validates its kind-specific field', () => {
expect(() => parseToolManifest('{"name":"x","description":"d","kind":"shell"}')).toThrow(/needs a command/);
expect(() => parseToolManifest('{"name":"x","description":"d","kind":"http"}')).toThrow(/needs a url/);
expect(() => parseToolManifest('{"name":"x","description":"d","kind":"read"}')).toThrow(/needs a path/);
expect(parseToolManifest('{"name":"x","description":"d","kind":"shell","command":"echo hi"}').name).toBe('x');
});
test('a malformed manifest is a descriptive error, not a crash', () => {
expect(() => parseToolManifest('not json')).toThrow(/not valid JSON/);
expect(() => parseToolManifest('{"name":"x"}')).toThrow(/malformed/);
});
test('an external read tool returns a workspace file, jailed', async () => {
await Bun.write('note.txt', 'hello workspace');
const t = manifestToTool(parseToolManifest('{"name":"rd","description":"d","kind":"read","path":"note.txt"}'), noGuard);
const out = (await t.execute!({ arg: '' }, { toolCallId: 't', messages: [] } as never)) as string;
expect(out).toBe('hello workspace');
});
test('a read tool cannot escape the workspace', async () => {
const t = manifestToTool(
parseToolManifest('{"name":"rd","description":"d","kind":"read","path":"../../secret"}'),
noGuard,
);
await expect(t.execute!({ arg: '' }, { toolCallId: 't', messages: [] } as never)).rejects.toThrow();
});
test('an external shell tool substitutes {arg} and runs it', async () => {
const t = manifestToTool(
parseToolManifest('{"name":"hi","description":"d","kind":"shell","command":"echo got-{arg}"}'),
noGuard,
);
const out = (await t.execute!({ arg: 'there' }, { toolCallId: 't', messages: [] } as never)) as string;
expect(out).toBe('got-there');
});
test('a shell tool is stopped by the guard before it runs', async () => {
const t = manifestToTool(
parseToolManifest('{"name":"bad","description":"d","kind":"shell","command":"echo should-not-run"}'),
denyAll,
);
await expect(t.execute!({ arg: '' }, { toolCallId: 't', messages: [] } as never)).rejects.toThrow(/refused/);
});
test('an http tool refuses a non-https URL', async () => {
const t = manifestToTool(
parseToolManifest('{"name":"w","description":"d","kind":"http","url":"http://insecure.example/x"}'),
noGuard,
);
await expect(t.execute!({ arg: '' }, { toolCallId: 't', messages: [] } as never)).rejects.toThrow(/https/);
});
test('loadExternalTools auto-registers a project tool and marks it auto-approved', async () => {
await Bun.write(join('.shiro', 'tools', 'greet.json'), '{"name":"greet","description":"d","kind":"shell","command":"echo hi"}');
const { tools, autoApprove, errors } = await loadExternalTools(cwd, noGuard);
expect(Object.keys(tools)).toContain('greet');
expect(autoApprove).toContain('greet');
expect(errors).toHaveLength(0);
});
test('a tool with autoApprove:false is registered but not auto-approved', async () => {
await Bun.write(
join('.shiro', 'tools', 'deploy.json'),
'{"name":"deploy","description":"d","kind":"shell","command":"echo deploy","autoApprove":false}',
);
const { tools, autoApprove } = await loadExternalTools(cwd, noGuard);
expect(Object.keys(tools)).toContain('deploy');
expect(autoApprove).not.toContain('deploy');
});
test('a bad tool file is reported and skipped, never fatal', async () => {
await Bun.write(join('.shiro', 'tools', 'broken.json'), '{ not json');
await Bun.write(join('.shiro', 'tools', 'good.json'), '{"name":"good","description":"d","kind":"read","path":"x.txt"}');
const { tools, errors } = await loadExternalTools(cwd, noGuard);
expect(Object.keys(tools)).toContain('good');
expect(errors).toHaveLength(1);
expect(errors[0]!.name).toBe('broken');
});
test('an external plugin manifest auto-loads and blocks a matching call', async () => {
await Bun.write(
join('.shiro', 'plugins', 'no-drop.json'),
JSON.stringify({
name: 'no-drop',
description: 'never drop a table',
deny: [{ tools: ['bash'], commandPattern: 'DROP TABLE', reason: 'drops data' }],
}),
);
const { plugins, errors } = await loadExternalPlugins(cwd);
expect(errors).toHaveLength(0);
expect(plugins.map((p) => p.name)).toContain('no-drop');
const blocked = await plugins[0]!.beforeToolCall!({ toolName: 'bash', input: { command: 'DROP TABLE users' }, cwd });
expect(blocked).toBe('drops data');
});
test('a bad plugin file is reported and skipped', async () => {
await Bun.write(join('.shiro', 'plugins', 'bad.json'), '{"name":"bad"}');
const { plugins, errors } = await loadExternalPlugins(cwd);
expect(plugins).toHaveLength(0);
expect(errors).toHaveLength(1);
});