diff --git a/lib/ai/agent/tools/security/agent_inventory.js b/lib/ai/agent/tools/security/agent_inventory.js new file mode 100644 index 0000000..1e4f678 --- /dev/null +++ b/lib/ai/agent/tools/security/agent_inventory.js @@ -0,0 +1,23 @@ +import * as agents from '../../../../db/repos/agents.js'; + +// Privilege inventory: which agents exist and what each is allowed to do. +// Returns an explicit projection (never SELECT *) so token/secret columns can +// never leak through this view even if the agents schema grows. +export const agentInventoryTool = { + name: 'agent_inventory', + description: 'List every agent and its privilege level (capabilities + scopes). Use to audit who can read/suggest/write and to spot over-privileged agents. Never returns token material.', + input_schema: { type: 'object', properties: {} }, + async handler(_args, _ctx) { + const rows = await agents.list(); + const projected = rows.map(a => ({ + id: a.id, + slug: a.slug, + name: a.name, + kind: a.kind, + model: a.model, + capabilities: a.capabilities || {}, + scopes: a.scopes || {} + })); + return { agents: projected }; + } +}; diff --git a/lib/ai/agent/tools/security/audit_log.js b/lib/ai/agent/tools/security/audit_log.js new file mode 100644 index 0000000..251a251 --- /dev/null +++ b/lib/ai/agent/tools/security/audit_log.js @@ -0,0 +1,26 @@ +import * as audit from '../../../../db/repos/audit.js'; + +// Yerin's window into the audit trail. Read-only. The audit repo already +// redacts sensitive diff keys (token/password/api_key/secret/authorization) +// at write time, so entries are safe to surface. +export const auditLogTool = { + name: 'audit_log', + description: 'Review the security audit trail: who (which actor) did what, newest first. Filter by actor_kind (user/agent/cron/worker/system) and/or actor_id to investigate a specific principal.', + input_schema: { + type: 'object', + properties: { + actor_kind: { + type: 'string', + enum: ['user', 'agent', 'cron', 'worker', 'system'], + description: 'optional: only entries from this kind of actor' + }, + actor_id: { type: 'string', description: 'optional: only entries from this actor id (uuid)' }, + limit: { type: 'integer', description: 'max entries (default 50, max 200)' } + } + }, + async handler({ actor_kind, actor_id, limit } = {}, _ctx) { + const capped = Math.min(Math.max(Number(limit) || 50, 1), 200); + const entries = await audit.listByActor({ actor_kind, actor_id, limit: capped }); + return { entries }; + } +}; diff --git a/lib/ai/agent/tools/security/index.js b/lib/ai/agent/tools/security/index.js new file mode 100644 index 0000000..ede6c1f --- /dev/null +++ b/lib/ai/agent/tools/security/index.js @@ -0,0 +1,11 @@ +import { createRegistry } from '../../registry.js'; +import { auditLogTool } from './audit_log.js'; +import { agentInventoryTool } from './agent_inventory.js'; + +// Yerin's security toolset — read-only observability, kept in its own registry +// so the security agent gets security tools (not Dross's propose_change). A +// future MCP server can expose this registry the same way companion-stdio.js +// exposes companionRegistry. Roadmap for further tools: see docs/yerin-security-agent.md +export const securityRegistry = createRegistry(); +securityRegistry.registerTool(auditLogTool); +securityRegistry.registerTool(agentInventoryTool); diff --git a/tests/ai/security_tools.test.js b/tests/ai/security_tools.test.js new file mode 100644 index 0000000..c14bc34 --- /dev/null +++ b/tests/ai/security_tools.test.js @@ -0,0 +1,61 @@ +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 { securityRegistry } from '../../lib/ai/agent/tools/security/index.js'; +import * as agentsRepo from '../../lib/db/repos/agents.js'; +import { recordAudit } from '../../lib/db/repos/audit.js'; + +// Yerin's security toolset: read-only observability over the audit trail and +// the agent privilege inventory. No mutations, no secret values. + +const owner = { kind: 'user', id: null }; +let watchedAgent; + +beforeAll(async () => { + await resetDb(); + await migrateUp(); + watchedAgent = await agentsRepo.create({ + slug: `sec-watch-${Date.now()}`, name: 'Watched', kind: 'claude', model: 'sonnet', + capabilities: { read: true, suggest: true }, scopes: {} + }, owner); +}); + +describe('securityRegistry', () => { + it('registers read-only tools with valid descriptors', () => { + const names = securityRegistry.listTools().map(t => t.name).sort(); + expect(names).toContain('audit_log'); + expect(names).toContain('agent_inventory'); + for (const t of securityRegistry.listTools()) { + expect(t.name).toBeTruthy(); + expect(t.description).toBeTruthy(); + expect(t.input_schema.type).toBe('object'); + expect(typeof t.handler).toBe('function'); + } + }); +}); + +describe('audit_log tool', () => { + it('returns recent audit entries, newest first, filterable by actor_kind', async () => { + await recordAudit({ kind: 'agent', id: watchedAgent.id }, 'create', 'task', null, null, { title: 'x' }); + const tool = securityRegistry.getTool('audit_log'); + const { entries } = await tool.handler({ actor_kind: 'agent', limit: 5 }, {}); + expect(Array.isArray(entries)).toBe(true); + expect(entries.length).toBeGreaterThan(0); + expect(entries.every(e => e.actor_kind === 'agent')).toBe(true); + }); +}); + +describe('agent_inventory tool', () => { + it('lists agents with their privilege level and never exposes token material', async () => { + const tool = securityRegistry.getTool('agent_inventory'); + const { agents } = await tool.handler({}, {}); + const found = agents.find(a => a.id === watchedAgent.id); + expect(found).toBeTruthy(); + expect(found.capabilities).toEqual({ read: true, suggest: true }); + // defense-in-depth: no secret/token field may leak through this view + const blob = JSON.stringify(agents).toLowerCase(); + expect(blob).not.toContain('token_hash'); + expect(blob).not.toContain('password'); + }); +});