feat(security): grow Yerin's toolset (pending_review, resource_exposure, token_audit)
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>
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import { createRegistry } from '../../registry.js';
|
import { createRegistry } from '../../registry.js';
|
||||||
import { auditLogTool } from './audit_log.js';
|
import { auditLogTool } from './audit_log.js';
|
||||||
import { agentInventoryTool } from './agent_inventory.js';
|
import { agentInventoryTool } from './agent_inventory.js';
|
||||||
|
import { pendingReviewTool } from './pending_review.js';
|
||||||
|
import { resourceExposureTool } from './resource_exposure.js';
|
||||||
|
import { tokenAuditTool } from './token_audit.js';
|
||||||
|
|
||||||
// Yerin's security toolset — read-only observability, kept in its own registry
|
// 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
|
// so the security agent gets security tools (not Dross's propose_change). A
|
||||||
@@ -9,3 +12,6 @@ import { agentInventoryTool } from './agent_inventory.js';
|
|||||||
export const securityRegistry = createRegistry();
|
export const securityRegistry = createRegistry();
|
||||||
securityRegistry.registerTool(auditLogTool);
|
securityRegistry.registerTool(auditLogTool);
|
||||||
securityRegistry.registerTool(agentInventoryTool);
|
securityRegistry.registerTool(agentInventoryTool);
|
||||||
|
securityRegistry.registerTool(pendingReviewTool);
|
||||||
|
securityRegistry.registerTool(resourceExposureTool);
|
||||||
|
securityRegistry.registerTool(tokenAuditTool);
|
||||||
|
|||||||
20
lib/ai/agent/tools/security/pending_review.js
Normal file
20
lib/ai/agent/tools/security/pending_review.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import * as pendingChanges from '../../../../db/repos/pending_changes.js';
|
||||||
|
|
||||||
|
// The queue of agent-proposed mutations awaiting owner approval. This is exactly
|
||||||
|
// where a prompt-injected or misbehaving agent's intent surfaces, so it's a
|
||||||
|
// primary security-review surface.
|
||||||
|
export const pendingReviewTool = {
|
||||||
|
name: 'pending_review',
|
||||||
|
description: 'List pending (unapproved) agent-proposed changes awaiting owner approval — the queue where a misbehaving or injected agent\'s intent shows up. Review these for anything unexpected.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: { type: 'integer', description: 'max rows (default 50, max 200)' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async handler({ limit } = {}, _ctx) {
|
||||||
|
const capped = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||||
|
const pending = await pendingChanges.listPending({ limit: capped });
|
||||||
|
return { pending };
|
||||||
|
}
|
||||||
|
};
|
||||||
14
lib/ai/agent/tools/security/resource_exposure.js
Normal file
14
lib/ai/agent/tools/security/resource_exposure.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import * as resources from '../../../../db/repos/resources.js';
|
||||||
|
|
||||||
|
// Attack-surface inventory: every resource's host/url/status across all spaces.
|
||||||
|
// Backed by resources.listExposure (scalar columns only — no monitoring/metadata
|
||||||
|
// JSON, no credentials).
|
||||||
|
export const resourceExposureTool = {
|
||||||
|
name: 'resource_exposure',
|
||||||
|
description: 'Inventory of all resources (services/hosts) with their host, url and status — the reachable attack surface. Use to spot exposed or unexpected services. Never includes secrets or monitoring config.',
|
||||||
|
input_schema: { type: 'object', properties: {} },
|
||||||
|
async handler(_args, _ctx) {
|
||||||
|
const resourceList = await resources.listExposure();
|
||||||
|
return { resources: resourceList };
|
||||||
|
}
|
||||||
|
};
|
||||||
13
lib/ai/agent/tools/security/token_audit.js
Normal file
13
lib/ai/agent/tools/security/token_audit.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import * as agents from '../../../../db/repos/agents.js';
|
||||||
|
|
||||||
|
// Agent credential hygiene: which tokens exist, when last used, whether revoked.
|
||||||
|
// Backed by agents.listTokenMeta — token_hash is never selected.
|
||||||
|
export const tokenAuditTool = {
|
||||||
|
name: 'token_audit',
|
||||||
|
description: 'List agent API tokens with label, last_used and revoked status (never the secret) so you can spot stale, unused, or unexpected credentials. Recommend revoking anything dormant.',
|
||||||
|
input_schema: { type: 'object', properties: {} },
|
||||||
|
async handler(_args, _ctx) {
|
||||||
|
const tokens = await agents.listTokenMeta();
|
||||||
|
return { tokens };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -29,6 +29,17 @@ export async function listBySpace(space_id) {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attack-surface inventory across all spaces. Scalar columns only — never the
|
||||||
|
// monitoring/metadata JSON blobs (which can hold connection hints / vault_path).
|
||||||
|
export async function listExposure() {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, space_id, slug, name, runtime_type, host, url, version, status,
|
||||||
|
last_check, maintenance_until
|
||||||
|
FROM resources ORDER BY name`
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
export async function update(id, patch, actor) {
|
export async function update(id, patch, actor) {
|
||||||
const before = await getById(id);
|
const before = await getById(id);
|
||||||
const sets = [], vals = [];
|
const sets = [], vals = [];
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { resetDb } from '../helpers/db.js';
|
|||||||
import { migrateUp } from '../../lib/db/migrate.js';
|
import { migrateUp } from '../../lib/db/migrate.js';
|
||||||
import { securityRegistry } from '../../lib/ai/agent/tools/security/index.js';
|
import { securityRegistry } from '../../lib/ai/agent/tools/security/index.js';
|
||||||
import * as agentsRepo from '../../lib/db/repos/agents.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';
|
import { recordAudit } from '../../lib/db/repos/audit.js';
|
||||||
|
|
||||||
// Yerin's security toolset: read-only observability over the audit trail and
|
// Yerin's security toolset: read-only observability over the audit trail and
|
||||||
@@ -26,6 +29,9 @@ describe('securityRegistry', () => {
|
|||||||
const names = securityRegistry.listTools().map(t => t.name).sort();
|
const names = securityRegistry.listTools().map(t => t.name).sort();
|
||||||
expect(names).toContain('audit_log');
|
expect(names).toContain('audit_log');
|
||||||
expect(names).toContain('agent_inventory');
|
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()) {
|
for (const t of securityRegistry.listTools()) {
|
||||||
expect(t.name).toBeTruthy();
|
expect(t.name).toBeTruthy();
|
||||||
expect(t.description).toBeTruthy();
|
expect(t.description).toBeTruthy();
|
||||||
@@ -59,3 +65,51 @@ describe('agent_inventory tool', () => {
|
|||||||
expect(blob).not.toContain('password');
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user