Three more read-only tools on securityRegistry: - pending_review: agent-proposed changes awaiting approval (injection surface) - resource_exposure: host/url/status attack-surface inventory (resources.listExposure, scalar cols only — no monitoring/metadata/credentials) - token_audit: token label/last_used/revoked, never the hash Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
116 lines
5.0 KiB
JavaScript
116 lines
5.0 KiB
JavaScript
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 * as resourcesRepo from '../../lib/db/repos/resources.js';
|
|
import * as pendingRepo from '../../lib/db/repos/pending_changes.js';
|
|
import * as spacesRepo from '../../lib/db/repos/spaces.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');
|
|
expect(names).toContain('pending_review');
|
|
expect(names).toContain('resource_exposure');
|
|
expect(names).toContain('token_audit');
|
|
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');
|
|
});
|
|
});
|
|
|
|
describe('pending_review tool', () => {
|
|
it('lists pending (unapproved) agent-proposed changes', async () => {
|
|
await pendingRepo.create({
|
|
agent_id: watchedAgent.id, entity_type: 'task', entity_id: null,
|
|
action: 'create', payload: { title: 'proposed' }, reason: 'test'
|
|
});
|
|
const tool = securityRegistry.getTool('pending_review');
|
|
const { pending } = await tool.handler({}, {});
|
|
expect(Array.isArray(pending)).toBe(true);
|
|
expect(pending.some(p => p.agent_id === watchedAgent.id && p.action === 'create')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('resource_exposure tool', () => {
|
|
it('lists resources (host/url/status) without the monitoring/metadata blobs', async () => {
|
|
const space = await spacesRepo.create({ slug: `sec-sp-${Date.now()}`, name: 'SP' }, owner);
|
|
await resourcesRepo.create({
|
|
space_id: space.id, slug: 'exposed', name: 'Exposed', runtime_type: 'lxc',
|
|
host: '192.168.1.99', url: 'http://192.168.1.99:8080',
|
|
monitoring: { secret: 'x' }, metadata: { vault_path: 'env:DB_PASS' }
|
|
}, owner);
|
|
const tool = securityRegistry.getTool('resource_exposure');
|
|
const { resources } = await tool.handler({}, {});
|
|
const found = resources.find(r => r.slug === 'exposed');
|
|
expect(found).toBeTruthy();
|
|
expect(found.host).toBe('192.168.1.99');
|
|
expect(found.url).toBe('http://192.168.1.99:8080');
|
|
const blob = JSON.stringify(resources).toLowerCase();
|
|
expect(blob).not.toContain('vault_path');
|
|
expect(blob).not.toContain('monitoring');
|
|
});
|
|
});
|
|
|
|
describe('token_audit tool', () => {
|
|
it('lists token metadata (label/last_used/revoked) and never the hash', async () => {
|
|
const { id: tokenId } = await agentsRepo.createToken(watchedAgent.id, 'ci-token');
|
|
const tool = securityRegistry.getTool('token_audit');
|
|
const { tokens } = await tool.handler({}, {});
|
|
const found = tokens.find(t => t.id === tokenId);
|
|
expect(found).toBeTruthy();
|
|
expect(found.label).toBe('ci-token');
|
|
expect(found).toHaveProperty('revoked_at');
|
|
const blob = JSON.stringify(tokens).toLowerCase();
|
|
expect(blob).not.toContain('token_hash');
|
|
expect(blob).not.toContain('$2b$'); // no bcrypt hash material
|
|
});
|
|
});
|