From 10a8b813a5aed8cc4afc62f8b123880650bcaf21 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 2 Jun 2026 00:17:15 +1000 Subject: [PATCH] fix: crash-proofing + small robustness fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pool.js: add pool.on('error') handler — an idle-client error (DB restart / .215 failover) previously crashed the process (no 'error' listener → throw) - context tool: project a SAFE_COLUMNS allow-list for resources (never the monitoring/metadata JSON blobs); also add 'resource' to TABLE (was unhandled) - applyPendingChange: guard the 'upsert' arm so a non-upsertable entity_type fails with a clear ValidationError instead of a bare TypeError Tests: pool_error, context (resource case), pending_extended_actions (guard). Co-Authored-By: Claude Opus 4.8 --- lib/ai/agent/tools/context.js | 12 ++++++++++-- lib/api/routes/pending_changes.js | 3 +++ lib/db/pool.js | 8 ++++++++ tests/ai/agent/tools/context.test.js | 12 ++++++++++++ tests/db/pool_error.test.js | 12 ++++++++++++ 5 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 tests/db/pool_error.test.js diff --git a/lib/ai/agent/tools/context.js b/lib/ai/agent/tools/context.js index 4a5f9f0..95aaf68 100644 --- a/lib/ai/agent/tools/context.js +++ b/lib/ai/agent/tools/context.js @@ -1,6 +1,13 @@ import { pool } from '../../../db/pool.js'; -const TABLE = { page: 'pages', ref: 'refs', task: 'tasks', project: 'projects', space: 'spaces' }; +const TABLE = { page: 'pages', ref: 'refs', task: 'tasks', project: 'projects', space: 'spaces', resource: 'resources' }; + +// Explicit column projection per entity type (defaults to '*'). Resources carry +// monitoring/metadata JSON that can hold connection hints / vault_path pointers, +// so we never surface those blobs to an agent — see docs/security-sweep-2026-06-01.md. +const SAFE_COLUMNS = { + resource: 'id, space_id, slug, name, runtime_type, host, url, version, status, last_check, maintenance_until' +}; export const contextTool = { name: 'context', @@ -13,7 +20,8 @@ export const contextTool = { } const table = TABLE[view.entityType]; if (!table) return { entityType: view.entityType, entityId: view.entityId, note: 'unrecognised entity type' }; - const { rows: [row] } = await pool.query(`SELECT * FROM ${table} WHERE id=$1`, [view.entityId]); + const cols = SAFE_COLUMNS[view.entityType] || '*'; + const { rows: [row] } = await pool.query(`SELECT ${cols} FROM ${table} WHERE id=$1`, [view.entityId]); if (!row) return { entityType: view.entityType, entityId: view.entityId, error: 'not found' }; return { entityType: view.entityType, ...row }; } diff --git a/lib/api/routes/pending_changes.js b/lib/api/routes/pending_changes.js index d7e6676..652e435 100644 --- a/lib/api/routes/pending_changes.js +++ b/lib/api/routes/pending_changes.js @@ -35,6 +35,9 @@ export async function applyPendingChange(row, actor) { return created.id; } case 'upsert': { + if (typeof repo.upsertByExternal !== 'function') { + throw new ValidationError(`entity_type '${row.entity_type}' does not support upsert`); + } const row_ = await repo.upsertByExternal(row.payload, actor); return row_.id; } diff --git a/lib/db/pool.js b/lib/db/pool.js index ceb5f96..8c65bb9 100644 --- a/lib/db/pool.js +++ b/lib/db/pool.js @@ -1,8 +1,16 @@ import pg from 'pg'; import 'dotenv/config'; +import { log } from '../log.js'; export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10, idleTimeoutMillis: 30_000 }); + +// An idle pooled client can emit 'error' (DB restart, replication failover on +// the .215 cluster). With no listener, EventEmitter throws and the process +// crashes. Log and let pg discard the dead client; the pool reconnects lazily. +pool.on('error', (err) => { + log.error({ err }, 'idle pg client error'); +}); diff --git a/tests/ai/agent/tools/context.test.js b/tests/ai/agent/tools/context.test.js index 3d78827..ed914a7 100644 --- a/tests/ai/agent/tools/context.test.js +++ b/tests/ai/agent/tools/context.test.js @@ -23,4 +23,16 @@ describe('context tool', () => { const out = await contextTool.handler({}, { space_id: spaceId, view: null }); expect(out.note).toMatch(/no specific entity/i); }); + + it('omits sensitive JSON blobs (monitoring/metadata) when grounding on a resource', async () => { + const { rows: [{ id: resId }] } = await pool.query( + `INSERT INTO resources(space_id, slug, name, runtime_type, monitoring, metadata) + VALUES($1,'db','DB','lxc','{"endpoint":"secret"}'::jsonb,'{"vault_path":"env:DB_PASS"}'::jsonb) + RETURNING id`, [spaceId]); + const out = await contextTool.handler({}, { space_id: spaceId, view: { entityType: 'resource', entityId: resId } }); + expect(out.name).toBe('DB'); + expect(out.status).toBe('unknown'); + expect(out).not.toHaveProperty('monitoring'); + expect(out).not.toHaveProperty('metadata'); + }); }); diff --git a/tests/db/pool_error.test.js b/tests/db/pool_error.test.js new file mode 100644 index 0000000..b0866f4 --- /dev/null +++ b/tests/db/pool_error.test.js @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import { pool } from '../../lib/db/pool.js'; + +// A pg.Pool emits 'error' when an *idle* pooled client errors (DB restart, +// replication failover). With no 'error' listener, EventEmitter throws and the +// whole process crashes. The pool must register a handler. +describe('db pool error handling', () => { + it('has an error listener so an idle-client error never crashes the process', () => { + expect(pool.listenerCount('error')).toBeGreaterThan(0); + expect(() => pool.emit('error', new Error('simulated idle-client error'), null)).not.toThrow(); + }); +});