From 281799fad2c03f3e40e4a170b297a6237d3a27bf Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Tue, 7 Apr 2026 03:59:19 +0100 Subject: [PATCH 1/4] =?UTF-8?q?feat(diagnostics):=20adicionar=2016=20ferra?= =?UTF-8?q?mentas=20de=20diagn=C3=B3stico=20DB=20Paperclip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14 tools read-only para inspecção da BD Paperclip directamente via MCP, eliminando psql nos skills /clip-*. 2 tools write (rotation, cancel issue). - src/db.ts: pool PG singleton + COMPANY_ID + helper query() parametrizado - src/tools/diagnostics.ts: 16 tools, 100% queries com $1,$2 (zero injection) - src/tools/index.ts: registo ...diagnosticsTools - package.json: pg ^8.13.1 + @types/pg ^8.11.10 - CHANGELOG.md: changelog completo Fix: diag_agents_without_membership cast a.id::text + filtros principal_type='agent' AND status='active'. Validado: - 14/14 read tools testadas contra BD real (CEO=19 runs, 65 agentes, Reality Checker sem heartbeat/membership, 2 routines next_run_at NULL) - npm audit: 0 vulnerabilidades - grep '\${' em SQL: zero matches Refs: Desk #2041 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 43 ++++ package.json | 2 + src/db.ts | 33 +++ src/tools/diagnostics.ts | 444 +++++++++++++++++++++++++++++++++++++++ src/tools/index.ts | 2 + 5 files changed, 524 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 src/db.ts create mode 100644 src/tools/diagnostics.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6f04280 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +## [Unreleased] - feature/diagnostics-db + +### Added +- 16 ferramentas de diagnóstico read-only/write para inspecção da base de dados Paperclip directamente via MCP, eliminando necessidade de psql nos skills `/clip-*`. +- Pool PostgreSQL singleton (`src/db.ts`) com export de `COMPANY_ID` e helper `query()` parametrizado. +- Variável de ambiente `PAPERCLIP_DB_URL` (default: `postgres://paperclip:paperclip@localhost:54329/paperclip`). +- Dependências `pg ^8.13.1` e `@types/pg ^8.11.10`. + +### Diagnostic tools (read-only — 14) +1. `diag_agents_by_status` — contagem por status +2. `diag_agent_hierarchy` — árvore reports_to completa +3. `diag_agents_missing_permissions` — agentes sem entradas permissions +4. `diag_agents_missing_heartbeat` — agentes activos sem runs heartbeat +5. `diag_agents_without_membership` — agentes sem entrada activa em company_memberships +6. `diag_budget_orphans` — budget_policies/incidents a referenciar entidades inexistentes +7. `diag_routine_triggers_broken` — routine_triggers schedule sem next_run_at +8. `diag_heartbeat_token_usage(hours)` — token burn por agent últimas N horas +9. `diag_prompt_too_long_errors(hours)` — erros "prompt too long" últimas N horas +10. `diag_false_blockers` — issues blocked sem motivo real +11. `diag_stuck_routines(hours)` — routines presas há N horas +12. `diag_zombie_parents` — agentes com parent archived +13. `diag_company_skills_summary` — agregado company_skills por source_type +14. `diag_agent_full_context(agent_name)` — contexto completo de 1 agente (config + runs + membership + perms + tokens 24h) + +### Diagnostic tools (write — 2) +15. `force_session_rotation` — rotação forçada de sessão (validação posterior) +16. `cancel_stuck_routine_issue` — cancelar issue de routine presa (validação posterior) + +### Security +- 100% das queries SQL parametrizadas (`$1`, `$2`...). Zero string interpolation em SQL — verificado por grep. +- `npm audit`: 0 vulnerabilidades. + +### Fixed +- `diag_agents_without_membership`: corrigido erro `operator does not exist: text = uuid` adicionando cast `a.id::text` e filtros `principal_type='agent' AND status='active'`. + +### Migration +- 17 blocos psql substituídos por chamadas MCP em 5 skills `/clip-*`: clip, clip-agent, clip-health, clip-org, clip-routine. Skills clip-instructions/issue/skill mantêm psql (CRUD específico sem equivalente diag_*). + +### Refs +- Desk CRM Task #2041 +- Plano: `memory/mcp-paperclip-diagnostics-plan.md` diff --git a/package.json b/package.json index 2f278f8..2789cfd 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,14 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "dotenv": "^16.6.1", + "pg": "^8.13.1", "winston": "^3.19.0", "zod": "^3.25.76" }, "devDependencies": { "@types/jest": "^30.0.0", "@types/node": "^22.19.17", + "@types/pg": "^8.11.10", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.57.1", diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..3139287 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,33 @@ +import pg from 'pg'; +import { logger } from './utils/logger.js'; + +const { Pool } = pg; + +let pool: pg.Pool | null = null; + +export function getPool(): pg.Pool { + if (pool) return pool; + const connectionString = process.env.PAPERCLIP_DB_URL; + if (!connectionString) { + throw new Error('PAPERCLIP_DB_URL não definido — diagnostics tools requerem acesso BD'); + } + pool = new Pool({ + connectionString, + max: 5, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }); + pool.on('error', (err) => logger.error('pg pool error:', err)); + return pool; +} + +export async function query>( + text: string, + params: unknown[] = [] +): Promise { + const result = await getPool().query(text, params); + return result.rows as T[]; +} + +export const COMPANY_ID = + process.env.PAPERCLIP_COMPANY_ID ?? 'ebe10308-efd7-453f-86ab-13e6fe84004f'; diff --git a/src/tools/diagnostics.ts b/src/tools/diagnostics.ts new file mode 100644 index 0000000..b519424 --- /dev/null +++ b/src/tools/diagnostics.ts @@ -0,0 +1,444 @@ +import { PaperclipTool, ToolResponse } from '../types.js'; +import { query, COMPANY_ID } from '../db.js'; + +// Helper para construir resposta JSON consistente. +function ok(rows: unknown): ToolResponse { + return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] }; +} + +// Coercão simples e segura de horas (default + clamp). +function hoursParam(args: Record, def = 24, max = 720): number { + const raw = args.hours; + const n = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isFinite(n) || n <= 0) return def; + return Math.min(Math.floor(n), max); +} + +export const diagnosticsTools: PaperclipTool[] = [ + // 1 + { + name: 'diag_agents_by_status', + description: 'Contagem de agentes por status (active, paused, etc) para a empresa.', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT status, COUNT(*)::int AS total + FROM agents + WHERE company_id = $1 + GROUP BY status + ORDER BY total DESC`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 2 + { + name: 'diag_agent_hierarchy', + description: 'Hierarquia completa de agentes com reports_to e role (para org chart).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT a.id, a.name, a.role, a.status, + a.reports_to, p.name AS reports_to_name, p.role AS reports_to_role + FROM agents a + LEFT JOIN agents p ON p.id = a.reports_to + WHERE a.company_id = $1 + ORDER BY COALESCE(p.name, ''), a.name`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 3 + { + name: 'diag_agents_missing_permissions', + description: + 'Agentes sem dangerouslySkipPermissions=true em adapter_config (check 11 health).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT id, name, role, status + FROM agents + WHERE company_id = $1 + AND COALESCE(adapter_config->>'dangerouslySkipPermissions','false') <> 'true' + ORDER BY name`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 4 + { + name: 'diag_agents_missing_heartbeat', + description: 'Agentes sem runtime_config.heartbeat.enabled=true (check 12 health).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT id, name, role, status + FROM agents + WHERE company_id = $1 + AND COALESCE(runtime_config->'heartbeat'->>'enabled','false') <> 'true' + ORDER BY name`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 5 + { + name: 'diag_agents_without_membership', + description: 'Agentes sem entrada activa em company_memberships.', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT a.id, a.name, a.role + FROM agents a + WHERE a.company_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM company_memberships m + WHERE m.company_id = a.company_id + AND m.principal_type = 'agent' + AND m.status = 'active' + AND m.principal_id = a.id::text + ) + ORDER BY a.name`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 6 + { + name: 'diag_budget_orphans', + description: + 'budget_policies/budget_incidents a referenciar agents/projects inexistentes (check 8).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT 'budget_policies' AS tabela, bp.id::text, bp.scope_type, bp.scope_id::text + FROM budget_policies bp + WHERE bp.company_id = $1 + AND ((bp.scope_type = 'agent' AND bp.scope_id NOT IN (SELECT id FROM agents)) + OR (bp.scope_type = 'project' AND bp.scope_id NOT IN (SELECT id FROM projects))) + UNION ALL + SELECT 'budget_incidents', bi.id::text, bi.scope_type, bi.scope_id::text + FROM budget_incidents bi + WHERE bi.company_id = $1 + AND bi.scope_type = 'agent' + AND bi.scope_id NOT IN (SELECT id FROM agents)`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 7 + { + name: 'diag_routine_triggers_broken', + description: 'routine_triggers com kind!=schedule ou next_run_at NULL (check 13).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT rt.id, r.title AS routine, rt.kind, rt.next_run_at, rt.enabled + FROM routine_triggers rt + JOIN routines r ON r.id = rt.routine_id + WHERE r.company_id = $1 + AND (rt.kind <> 'schedule' OR rt.next_run_at IS NULL) + ORDER BY r.title`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 8 + { + name: 'diag_heartbeat_token_usage', + description: + 'Agregação de cache_read_input_tokens por agente nas últimas N horas (default 24).', + inputSchema: { + type: 'object', + properties: { + hours: { type: 'number', description: 'Janela em horas (default 24, max 720)' }, + }, + }, + handler: async (args) => { + const h = hoursParam(args, 24); + const rows = await query( + `SELECT a.name, + ROUND(SUM(COALESCE((hr.usage_json->>'cache_read_input_tokens')::numeric,0))/1000,0) + AS cache_read_k, + COUNT(*)::int AS runs + FROM heartbeat_runs hr + JOIN agents a ON a.id = hr.agent_id + WHERE a.company_id = $1 + AND hr.started_at > NOW() - ($2 || ' hours')::interval + GROUP BY a.name + ORDER BY cache_read_k DESC`, + [COMPANY_ID, String(h)] + ); + return ok(rows); + }, + }, + + // 9 + { + name: 'diag_prompt_too_long_errors', + description: 'heartbeat_runs com erro "Prompt is too long" nas últimas N horas (default 24).', + inputSchema: { + type: 'object', + properties: { hours: { type: 'number' } }, + }, + handler: async (args) => { + const h = hoursParam(args, 24); + const rows = await query( + `SELECT a.name, + COUNT(*)::int AS falhas_prompt_longo, + MAX(hr.started_at)::timestamp(0) AS ultima_falha + FROM heartbeat_runs hr + JOIN agents a ON a.id = hr.agent_id + WHERE a.company_id = $1 + AND hr.error ILIKE '%Prompt is too long%' + AND hr.started_at > NOW() - ($2 || ' hours')::interval + GROUP BY a.name + ORDER BY falhas_prompt_longo DESC`, + [COMPANY_ID, String(h)] + ); + return ok(rows); + }, + }, + + // 10 + { + name: 'diag_false_blockers', + description: 'Issues blocked com sub-tasks ainda in_progress/todo (check 18).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT i.identifier, i.title, a.name AS assignee, + (SELECT COUNT(*)::int FROM issues sub + WHERE sub.parent_id = i.id + AND sub.status IN ('in_progress','running','todo')) AS sub_tasks_activas + FROM issues i + LEFT JOIN agents a ON a.id = i.assignee_agent_id + WHERE i.company_id = $1 + AND i.status = 'blocked' + AND EXISTS ( + SELECT 1 FROM issues sub + WHERE sub.parent_id = i.id + AND sub.status IN ('in_progress','running','todo') + ) + ORDER BY i.identifier`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 11 + { + name: 'diag_stuck_routines', + description: + 'Routine issues in_progress há mais de N horas via routine_runs.linked_issue_id (check 20).', + inputSchema: { + type: 'object', + properties: { hours: { type: 'number', description: 'Default 24' } }, + }, + handler: async (args) => { + const h = hoursParam(args, 24); + const rows = await query( + `SELECT i.identifier, LEFT(i.title,60) AS title, r.title AS routine, + i.status, i.updated_at::timestamp(0) AS updated_at + FROM issues i + JOIN routine_runs rr ON rr.linked_issue_id = i.id + JOIN routines r ON r.id = rr.routine_id + WHERE i.company_id = $1 + AND i.status IN ('in_progress','running') + AND i.updated_at < NOW() - ($2 || ' hours')::interval + ORDER BY i.updated_at`, + [COMPANY_ID, String(h)] + ); + return ok(rows); + }, + }, + + // 12 + { + name: 'diag_zombie_parents', + description: + 'Issues pai abertas mas com 100% das sub-tasks done/cancelled (check 21).', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT p.identifier, p.title, p.status, a.name AS assignee, + (SELECT COUNT(*)::int FROM issues sub WHERE sub.parent_id = p.id) AS total_sub, + (SELECT COUNT(*)::int FROM issues sub + WHERE sub.parent_id = p.id + AND sub.status IN ('done','cancelled')) AS sub_concluidas + FROM issues p + LEFT JOIN agents a ON a.id = p.assignee_agent_id + WHERE p.company_id = $1 + AND p.status NOT IN ('done','cancelled') + AND (SELECT COUNT(*) FROM issues sub WHERE sub.parent_id = p.id) > 0 + AND (SELECT COUNT(*) FROM issues sub + WHERE sub.parent_id = p.id + AND sub.status NOT IN ('done','cancelled')) = 0 + ORDER BY p.identifier`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 13 + { + name: 'diag_company_skills_summary', + description: 'Resumo de company_skills agrupado por source_type.', + inputSchema: { type: 'object', properties: {} }, + handler: async () => { + const rows = await query( + `SELECT source_type, COUNT(*)::int AS total + FROM company_skills + WHERE company_id = $1 + GROUP BY source_type + ORDER BY total DESC`, + [COMPANY_ID] + ); + return ok(rows); + }, + }, + + // 14 + { + name: 'diag_agent_full_context', + description: + 'Megaquery: agente + reports_to + últimas heartbeat_runs + permissions + membership + tokens.', + inputSchema: { + type: 'object', + properties: { + agent_name: { type: 'string', description: 'Nome exacto do agente' }, + }, + required: ['agent_name'], + }, + handler: async (args) => { + const name = String(args.agent_name ?? ''); + if (!name) throw new Error('agent_name obrigatório'); + + const [agent] = await query( + `SELECT id, name, role, title, status, reports_to, + runtime_config, adapter_config + FROM agents + WHERE company_id = $1 AND name = $2`, + [COMPANY_ID, name] + ); + if (!agent) return ok({ error: `Agente '${name}' não encontrado` }); + + const agentId = (agent as Record).id as string; + + const [reportsTo, runs, membership, permissions, tokens] = await Promise.all([ + query( + `SELECT name, role FROM agents WHERE id = $1`, + [(agent as Record).reports_to] + ), + query( + `SELECT status, started_at::timestamp(0) AS started_at, + finished_at::timestamp(0) AS finished_at, LEFT(error, 120) AS erro + FROM heartbeat_runs + WHERE agent_id = $1 + ORDER BY started_at DESC + LIMIT 10`, + [agentId] + ), + query( + `SELECT status, membership_role + FROM company_memberships + WHERE company_id = $1 AND principal_id = $2`, + [COMPANY_ID, agentId] + ), + query( + `SELECT permission_key + FROM principal_permission_grants + WHERE principal_id = $1`, + [agentId] + ), + query( + `SELECT ROUND(SUM(COALESCE((usage_json->>'cache_read_input_tokens')::numeric,0))/1000,0) + AS cache_read_k_24h, + COUNT(*)::int AS runs_24h + FROM heartbeat_runs + WHERE agent_id = $1 + AND started_at > NOW() - INTERVAL '24 hours'`, + [agentId] + ), + ]); + + return ok({ + agent, + reports_to: reportsTo[0] ?? null, + recent_runs: runs, + membership: membership[0] ?? null, + permissions, + tokens_24h: tokens[0] ?? null, + }); + }, + }, + + // 15 — WRITE + { + name: 'force_session_rotation', + description: + 'DESTRUTIVO: apaga agent_task_sessions de um agente (fix prompt-too-long, check 19).', + inputSchema: { + type: 'object', + properties: { + agent_id: { type: 'string', description: 'UUID do agente' }, + }, + required: ['agent_id'], + }, + handler: async (args) => { + const agentId = String(args.agent_id ?? ''); + if (!agentId) throw new Error('agent_id obrigatório'); + const rows = await query( + `DELETE FROM agent_task_sessions + WHERE agent_id = $1 + AND company_id = $2 + RETURNING id`, + [agentId, COMPANY_ID] + ); + return ok({ deleted: rows.length, ids: rows }); + }, + }, + + // 16 — WRITE + { + name: 'cancel_stuck_routine_issue', + description: + 'DESTRUTIVO: marca issue como cancelled (fix stuck routine, check 20). Identifier ex: ACI-1234.', + inputSchema: { + type: 'object', + properties: { + identifier: { type: 'string', description: 'Identifier da issue (ex: ACI-1234)' }, + }, + required: ['identifier'], + }, + handler: async (args) => { + const identifier = String(args.identifier ?? ''); + if (!identifier) throw new Error('identifier obrigatório'); + const rows = await query( + `UPDATE issues + SET status = 'cancelled', updated_at = NOW() + WHERE identifier = $1 + AND company_id = $2 + RETURNING id, identifier, status`, + [identifier, COMPANY_ID] + ); + return ok({ updated: rows.length, rows }); + }, + }, +]; diff --git a/src/tools/index.ts b/src/tools/index.ts index 38a0272..6fe74e5 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -23,6 +23,7 @@ import { pluginBridgeTools } from './plugin-bridge.js'; import { assetTools } from './assets.js'; import { settingsTools } from './settings.js'; import { accessTools } from './access.js'; +import { diagnosticsTools } from './diagnostics.js'; export const allTools: PaperclipTool[] = [ ...healthTools, @@ -49,4 +50,5 @@ export const allTools: PaperclipTool[] = [ ...assetTools, ...settingsTools, ...accessTools, + ...diagnosticsTools, ]; From 53ab2b99a6c3170ced440cbcba70868a1c5d7843 Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Tue, 7 Apr 2026 04:31:31 +0100 Subject: [PATCH 2/4] fix(routines): create_routine_trigger schema alinhado com API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API exige cronExpression/timezone (não schedule.cron). Schema do MCP agora reflecte os campos reais e tem kind como discriminator obrigatório. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tools/routines.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/tools/routines.ts b/src/tools/routines.ts index a791a49..eb4d10b 100644 --- a/src/tools/routines.ts +++ b/src/tools/routines.ts @@ -101,15 +101,19 @@ export const routineTools: PaperclipTool[] = [ }, { name: 'create_routine_trigger', - description: 'Criar um gatilho para uma rotina', + description: 'Criar um gatilho para uma rotina. Para schedule passar kind="schedule" + schedule={cron,tz} + enabled + label.', inputSchema: { type: 'object', properties: { routine_id: { type: 'string', description: 'ID da rotina' }, - type: { type: 'string', description: 'Tipo de gatilho' }, - config: { type: 'object', description: 'Configuracao do gatilho' }, + kind: { type: 'string', description: 'schedule|webhook|api', enum: ['schedule', 'webhook', 'api'] }, + cronExpression: { type: 'string', description: 'Para kind=schedule: cron 5-field (ex: "0 9 * * 1")' }, + timezone: { type: 'string', description: 'Para kind=schedule: ex Europe/Lisbon' }, + webhookSecret: { type: 'string', description: 'Para kind=webhook' }, + enabled: { type: 'boolean', description: 'Estado activo (default true)' }, + label: { type: 'string', description: 'Label legivel' }, }, - required: ['routine_id'], + required: ['routine_id', 'kind'], }, handler: async (args) => { const { routine_id, ...body } = args as { routine_id: string; [k: string]: unknown }; From 469628cd0a5012ab6b7afe4613e82302ccf7aa77 Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Tue, 7 Apr 2026 04:38:11 +0100 Subject: [PATCH 3/4] feat(diag): write tools ensure_agent_membership + grant_agent_permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ensure_agent_membership: idempotente, fix achado #2 (Reality Checker membership) - grant_agent_permission: baseline RBAC em principal_permission_grants - diag_agents_missing_permissions: descrição clarificada (é flag Claude CLI, não RBAC) - create_routine_trigger: schema cronExpression/timezone alinhado com API Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tools/diagnostics.ts | 83 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/src/tools/diagnostics.ts b/src/tools/diagnostics.ts index b519424..c916d20 100644 --- a/src/tools/diagnostics.ts +++ b/src/tools/diagnostics.ts @@ -56,7 +56,7 @@ export const diagnosticsTools: PaperclipTool[] = [ { name: 'diag_agents_missing_permissions', description: - 'Agentes sem dangerouslySkipPermissions=true em adapter_config (check 11 health).', + 'Agentes sem dangerouslySkipPermissions=true em adapter_config (NOTA: NÃO é RBAC — é flag Claude Code/CLI para correr sem prompts. Aplicar conscientemente por agent, não em massa).', inputSchema: { type: 'object', properties: {} }, handler: async () => { const rows = await query( @@ -441,4 +441,85 @@ export const diagnosticsTools: PaperclipTool[] = [ return ok({ updated: rows.length, rows }); }, }, + + // 17 — WRITE + { + name: 'ensure_agent_membership', + description: + 'WRITE: garante membership active em company_memberships para um agent (idempotente). Fix achado #2 sessão 5.', + inputSchema: { + type: 'object', + properties: { + agent_id: { type: 'string', description: 'UUID do agente' }, + membership_role: { type: 'string', description: 'Default "member"' }, + }, + required: ['agent_id'], + }, + handler: async (args) => { + const agentId = String(args.agent_id ?? ''); + const role = String(args.membership_role ?? 'member'); + if (!agentId) throw new Error('agent_id obrigatório'); + const existing = await query( + `SELECT id, status, membership_role FROM company_memberships + WHERE company_id = $1 AND principal_type = 'agent' AND principal_id = $2`, + [COMPANY_ID, agentId] + ); + if (existing.length > 0) { + const rows = await query( + `UPDATE company_memberships + SET status = 'active', membership_role = $1, updated_at = NOW() + WHERE id = $2 + RETURNING id, status, membership_role`, + [role, existing[0].id] + ); + return ok({ action: 'updated', row: rows[0] }); + } + const rows = await query( + `INSERT INTO company_memberships + (company_id, principal_type, principal_id, status, membership_role) + VALUES ($1, 'agent', $2, 'active', $3) + RETURNING id, status, membership_role`, + [COMPANY_ID, agentId, role] + ); + return ok({ action: 'inserted', row: rows[0] }); + }, + }, + + // 18 — WRITE + { + name: 'grant_agent_permission', + description: + 'WRITE: insere row em principal_permission_grants para um agent (idempotente por permission_key). Baseline RBAC. Fix achado #4.', + inputSchema: { + type: 'object', + properties: { + agent_id: { type: 'string', description: 'UUID do agente' }, + permission_key: { type: 'string', description: 'Ex: tasks:assign, agents:create, runs:read' }, + scope: { type: 'string', description: 'Scope opcional (default null)' }, + }, + required: ['agent_id', 'permission_key'], + }, + handler: async (args) => { + const agentId = String(args.agent_id ?? ''); + const permissionKey = String(args.permission_key ?? ''); + const scope = args.scope ? String(args.scope) : null; + if (!agentId || !permissionKey) throw new Error('agent_id e permission_key obrigatórios'); + const existing = await query( + `SELECT id FROM principal_permission_grants + WHERE company_id = $1 AND principal_type = 'agent' AND principal_id = $2 AND permission_key = $3`, + [COMPANY_ID, agentId, permissionKey] + ); + if (existing.length > 0) { + return ok({ action: 'noop', existing: existing[0] }); + } + const rows = await query( + `INSERT INTO principal_permission_grants + (company_id, principal_type, principal_id, permission_key, scope, granted_by_user_id) + VALUES ($1, 'agent', $2, $3, $4, 'mcp-bootstrap') + RETURNING id, permission_key, scope`, + [COMPANY_ID, agentId, permissionKey, scope] + ); + return ok({ action: 'inserted', row: rows[0] }); + }, + }, ]; From d61edd5f2d184a669499243a006c9ad6fa12a33e Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Tue, 7 Apr 2026 04:39:56 +0100 Subject: [PATCH 4/4] =?UTF-8?q?refactor(diag):=20rename=20diag=5Fagents=5F?= =?UTF-8?q?missing=5Fpermissions=20=E2=86=92=20diag=5Fagents=5Fwithout=5Fs?= =?UTF-8?q?kip=5Fpermissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nome anterior era enganador (sugeria RBAC, mas verifica flag Claude CLI dangerouslySkipPermissions). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tools/diagnostics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/diagnostics.ts b/src/tools/diagnostics.ts index c916d20..e02dd42 100644 --- a/src/tools/diagnostics.ts +++ b/src/tools/diagnostics.ts @@ -54,7 +54,7 @@ export const diagnosticsTools: PaperclipTool[] = [ // 3 { - name: 'diag_agents_missing_permissions', + name: 'diag_agents_without_skip_permissions', description: 'Agentes sem dangerouslySkipPermissions=true em adapter_config (NOTA: NÃO é RBAC — é flag Claude Code/CLI para correr sem prompts. Aplicar conscientemente por agent, não em massa).', inputSchema: { type: 'object', properties: {} },