From c45246b918eb5d448f07cbb1a2f29b13912e17d9 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 2 Jun 2026 00:17:45 +1000 Subject: [PATCH] feat(security): grow Yerin's toolset (pending_review, resource_exposure, token_audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ai/agent/tools/security/index.js | 6 +++ lib/ai/agent/tools/security/pending_review.js | 20 +++++++ .../agent/tools/security/resource_exposure.js | 14 +++++ lib/ai/agent/tools/security/token_audit.js | 13 +++++ lib/db/repos/resources.js | 11 ++++ tests/ai/security_tools.test.js | 54 +++++++++++++++++++ 6 files changed, 118 insertions(+) create mode 100644 lib/ai/agent/tools/security/pending_review.js create mode 100644 lib/ai/agent/tools/security/resource_exposure.js create mode 100644 lib/ai/agent/tools/security/token_audit.js diff --git a/lib/ai/agent/tools/security/index.js b/lib/ai/agent/tools/security/index.js index ede6c1f..865ae81 100644 --- a/lib/ai/agent/tools/security/index.js +++ b/lib/ai/agent/tools/security/index.js @@ -1,6 +1,9 @@ import { createRegistry } from '../../registry.js'; import { auditLogTool } from './audit_log.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 // 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(); securityRegistry.registerTool(auditLogTool); securityRegistry.registerTool(agentInventoryTool); +securityRegistry.registerTool(pendingReviewTool); +securityRegistry.registerTool(resourceExposureTool); +securityRegistry.registerTool(tokenAuditTool); diff --git a/lib/ai/agent/tools/security/pending_review.js b/lib/ai/agent/tools/security/pending_review.js new file mode 100644 index 0000000..733be2d --- /dev/null +++ b/lib/ai/agent/tools/security/pending_review.js @@ -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 }; + } +}; diff --git a/lib/ai/agent/tools/security/resource_exposure.js b/lib/ai/agent/tools/security/resource_exposure.js new file mode 100644 index 0000000..3e79a20 --- /dev/null +++ b/lib/ai/agent/tools/security/resource_exposure.js @@ -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 }; + } +}; diff --git a/lib/ai/agent/tools/security/token_audit.js b/lib/ai/agent/tools/security/token_audit.js new file mode 100644 index 0000000..4070162 --- /dev/null +++ b/lib/ai/agent/tools/security/token_audit.js @@ -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 }; + } +}; diff --git a/lib/db/repos/resources.js b/lib/db/repos/resources.js index 972e1df..251d46e 100644 --- a/lib/db/repos/resources.js +++ b/lib/db/repos/resources.js @@ -29,6 +29,17 @@ export async function listBySpace(space_id) { 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) { const before = await getById(id); const sets = [], vals = []; diff --git a/tests/ai/security_tools.test.js b/tests/ai/security_tools.test.js index c14bc34..91181e4 100644 --- a/tests/ai/security_tools.test.js +++ b/tests/ai/security_tools.test.js @@ -4,6 +4,9 @@ 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 @@ -26,6 +29,9 @@ describe('securityRegistry', () => { 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(); @@ -59,3 +65,51 @@ describe('agent_inventory tool', () => { 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 + }); +});