feat(security): seed Yerin agent + registry-selectable MCP server
- migration 011_yerin.sql: seed read-only 'yerin' agent ({read:true}, kind claude,
model NULL = server default; switch to local Ollama via agents.model anytime)
- companion-stdio.js: select the toolset from VOID_TOOL_REGISTRY ('security' →
Yerin's securityRegistry; default → Dross's companionRegistry)
- tests/mcp/registry_select.test.js
Remaining for Yerin (left for review): an entry point (route or cron) + persona
prompt — see docs/yerin-security-agent.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
10
lib/db/migrations/011_yerin.sql
Normal file
10
lib/db/migrations/011_yerin.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Seed Yerin, the security agent. READ-ONLY by capability (no suggest/write):
|
||||
-- she investigates and reports; remediation goes through you. Runs on the same
|
||||
-- claude CLI subscription path as Dross (model NULL = server default); switch to
|
||||
-- a local Ollama model later by setting agents.model. See docs/yerin-security-agent.md.
|
||||
INSERT INTO agents (slug, name, kind, model, capabilities)
|
||||
VALUES (
|
||||
'yerin', 'Yerin', 'claude', NULL,
|
||||
'{"read":true,"suggest":false,"write":false}'::jsonb
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
@@ -20,8 +20,16 @@ import {
|
||||
CallToolRequestSchema
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { companionRegistry } from '../ai/agent/tools/index.js';
|
||||
import { securityRegistry } from '../ai/agent/tools/security/index.js';
|
||||
import { buildCtxFromEnv } from './context.js';
|
||||
|
||||
// Which toolset this stdio server exposes, selected at launch via
|
||||
// VOID_TOOL_REGISTRY (default: Dross's companion tools; 'security' = Yerin's).
|
||||
const REGISTRIES = { companion: companionRegistry, security: securityRegistry };
|
||||
function resolveRegistry(env = process.env) {
|
||||
return REGISTRIES[env.VOID_TOOL_REGISTRY] || companionRegistry;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transport-free helpers (exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -30,8 +38,8 @@ import { buildCtxFromEnv } from './context.js';
|
||||
* Returns the list of MCP tool descriptors (no handler) from the companion registry.
|
||||
* @returns {{ name: string, description: string, input_schema: object }[]}
|
||||
*/
|
||||
export function listMcpTools() {
|
||||
return companionRegistry.listTools().map(({ name, description, input_schema }) => ({
|
||||
export function listMcpTools(env = process.env) {
|
||||
return resolveRegistry(env).listTools().map(({ name, description, input_schema }) => ({
|
||||
name,
|
||||
description,
|
||||
input_schema
|
||||
@@ -45,8 +53,8 @@ export function listMcpTools() {
|
||||
* @param {object} ctx – tool context {agent, space_id, view, actor}
|
||||
* @returns {Promise<object>} raw handler result
|
||||
*/
|
||||
export async function callMcpTool(name, args, ctx) {
|
||||
const tool = companionRegistry.getTool(name);
|
||||
export async function callMcpTool(name, args, ctx, env = process.env) {
|
||||
const tool = resolveRegistry(env).getTool(name);
|
||||
if (!tool) {
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
|
||||
40
tests/mcp/registry_select.test.js
Normal file
40
tests/mcp/registry_select.test.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { pool } from '../../lib/db/pool.js';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
import { listMcpTools, callMcpTool } from '../../lib/mcp/companion-stdio.js';
|
||||
|
||||
// The stdio MCP server must be able to expose Yerin's securityRegistry instead
|
||||
// of Dross's companionRegistry, selected by VOID_TOOL_REGISTRY at launch.
|
||||
|
||||
describe('MCP registry selection', () => {
|
||||
it('defaults to the companion registry when VOID_TOOL_REGISTRY is unset', () => {
|
||||
const names = listMcpTools({}).map(t => t.name).sort();
|
||||
expect(names).toEqual(['context', 'propose_change', 'read', 'search']);
|
||||
});
|
||||
|
||||
it('selects the security registry when VOID_TOOL_REGISTRY=security', () => {
|
||||
const names = listMcpTools({ VOID_TOOL_REGISTRY: 'security' }).map(t => t.name).sort();
|
||||
expect(names).toContain('audit_log');
|
||||
expect(names).toContain('agent_inventory');
|
||||
expect(names).not.toContain('propose_change'); // Yerin cannot propose mutations
|
||||
});
|
||||
|
||||
it('callMcpTool routes to the selected registry', async () => {
|
||||
await resetDb(); await migrateUp();
|
||||
const out = await callMcpTool('agent_inventory', {}, {}, { VOID_TOOL_REGISTRY: 'security' });
|
||||
expect(Array.isArray(out.agents)).toBe(true);
|
||||
// an unknown tool for the default registry must not be reachable in security mode
|
||||
await expect(callMcpTool('propose_change', {}, {}, { VOID_TOOL_REGISTRY: 'security' }))
|
||||
.rejects.toThrow(/unknown tool/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration 011 seeds Yerin', () => {
|
||||
it('creates a read-only yerin agent', async () => {
|
||||
await resetDb(); await migrateUp();
|
||||
const { rows: [y] } = await pool.query(`SELECT * FROM agents WHERE slug='yerin'`);
|
||||
expect(y).toBeTruthy();
|
||||
expect(y.capabilities).toEqual({ read: true, suggest: false, write: false });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user