Files
shiro-neko/test/plugins-builtin.test.ts
T
Muhammad Zakir RamadhanandSisyphus 9e03512cb0 Add apply_patch for atomic multi-file edits
One envelope carrying Add, Update, Move, and Delete file markers, validated in full before anything is written: a failure on the fourth file leaves the first three untouched. Permission rules match every path a patch touches, and the write guard and secret checks cover it alongside the other write tools.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 16:41:44 +07:00

143 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHost } from '../src/plugins';
import { BUILTIN_PLUGINS, DEFAULT_ENABLED, formatPlugin, secretsPlugin } from '../src/plugins-builtin';
const cwd = process.cwd();
const check = (toolName: string, input: unknown) => secretsPlugin.beforeToolCall!({ toolName, input, cwd });
test('secrets is on by default, because an opt-in safety check is not one', () => {
expect(DEFAULT_ENABLED).toContain('secrets');
expect(DEFAULT_ENABLED).toContain('guard');
// Both of these act on their own rather than refusing, so they are opt-in.
expect(DEFAULT_ENABLED).not.toContain('bell');
expect(DEFAULT_ENABLED).not.toContain('format');
});
test('every builtin plugin has a unique name and a description', () => {
const names = BUILTIN_PLUGINS.map((p) => p.name);
expect(new Set(names).size).toBe(names.length);
for (const p of BUILTIN_PLUGINS) expect(p.description.length, p.name).toBeGreaterThan(5);
});
test('writing an env file is refused', async () => {
for (const path of ['.env', 'config/.env', 'app/.env.production', '.env.local']) {
expect(await check('write_file', { path }), path).toContain('refusing to write');
}
});
test('writing a key or certificate is refused', async () => {
for (const path of ['certs/server.pem', 'app.key', 'store.p12', 'keys/id_rsa', '.ssh/id_ed25519']) {
expect(await check('write_file', { path }), path).toContain('refusing to write');
}
});
test('writing a registry or credential file is refused', async () => {
for (const path of ['.npmrc', '.netrc', 'credentials.json', 'secrets.yaml', '.aws/config']) {
expect(await check('write_file', { path }), path).toContain('refusing to write');
}
});
test('every write tool is covered, including apply_patch', async () => {
for (const tool of ['write_file', 'edit_file', 'multi_edit']) {
expect(await check(tool, { path: '.env' }), tool).toContain('refusing to write');
}
// A patch carries its paths in markers, so a guard that only reads `path` is
// bypassed by the one tool that can touch several files at once.
const patch = '*** Update File: src/app.ts\n-a\n+b\n*** Add File: .env\n+SECRET=x';
expect(await check('apply_patch', { patch })).toContain('refusing to write');
expect(await check('apply_patch', { patch: '*** Move to: .env.production\n' })).toContain('refusing to write');
});
test('ordinary source files are untouched', async () => {
for (const path of ['src/app.ts', 'README.md', 'docs/env-vars.md', 'test/environment.test.ts', 'keychain.ts']) {
expect(await check('write_file', { path }), path).toBeUndefined();
}
});
test('an example env file may be written, since it holds no secret', async () => {
// The permission defaults allow *reading* .env.example for the same reason.
expect(await check('write_file', { path: '.env.example' })).toBeUndefined();
});
test('reading is not the secrets plugin′s business', async () => {
// Reads are refused by the permission defaults instead, so this must not also
// block them: two mechanisms refusing the same thing means one is dead code.
expect(await check('read_file', { path: '.env' })).toBeUndefined();
expect(await check('bash', { command: 'cat .env' })).toBeUndefined();
});
test('the appendix tells the model what to do instead', () => {
expect(secretsPlugin.appendix).toContain('let them write it');
});
test('the guard and secrets both run, and the first refusal wins', async () => {
const host = createHost(BUILTIN_PLUGINS.filter((p) => DEFAULT_ENABLED.includes(p.name)));
expect(await host.guard({ toolName: 'bash', input: { command: 'rm -rf /' }, cwd })).toContain('guard plugin');
expect(await host.guard({ toolName: 'write_file', input: { path: '.env' }, cwd })).toContain('secrets plugin');
expect(await host.guard({ toolName: 'write_file', input: { path: 'src/app.ts' }, cwd })).toBeUndefined();
});
function inTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const orig = process.cwd();
const dir = mkdtempSync(join(tmpdir(), 'shiro-fmt-'));
process.chdir(dir);
return fn(dir).finally(() => {
process.chdir(orig);
rmSync(dir, { recursive: true, force: true });
});
}
test('format runs the script the project already defines', async () =>
inTempDir(async (dir) => {
// A script that touches a file, so the assertion is that the formatter ran
// rather than that some formatter binary happens to be installed.
await Bun.write(join(dir, 'marker.js'), 'require("fs").writeFileSync("formatted.txt", "yes")');
await Bun.write(join(dir, 'package.json'), JSON.stringify({ scripts: { format: 'node marker.js' } }));
await formatPlugin.afterTurn!();
expect(await Bun.file(join(dir, 'formatted.txt')).text()).toBe('yes');
}), 30_000);
test('format does nothing when the project defines no formatter', async () =>
inTempDir(async (dir) => {
await Bun.write(join(dir, 'package.json'), JSON.stringify({ scripts: { test: 'bun test' } }));
// No throw and no side effect: a project without a format script gets nothing.
await formatPlugin.afterTurn!();
expect(await Bun.file(join(dir, 'formatted.txt')).exists()).toBe(false);
}), 30_000);
test('format survives a manifest it cannot parse', async () =>
inTempDir(async (dir) => {
await Bun.write(join(dir, 'package.json'), '{ not json');
await formatPlugin.afterTurn!();
expect(true).toBe(true);
}), 30_000);
test('format does nothing in a directory with no manifest at all', async () =>
inTempDir(async () => {
await formatPlugin.afterTurn!();
expect(true).toBe(true);
}), 30_000);
test('a throwing afterTurn does not stop the other plugins', async () => {
let ran = 0;
const host = createHost([
{
name: 'broken',
description: 'throws',
afterTurn: () => {
throw new Error('boom');
},
},
{ name: 'counter', description: 'counts', afterTurn: () => void ran++ },
]);
await host.afterTurn();
expect(ran).toBe(1);
});