@fontdata/ai-harness 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ import { agruparChavesEstrangeiras, semPontoEVirgulaFinal, validarLimite, validarTimeout, } from './tipos.js';
2
+ async function criarConexaoMysql2(config) {
3
+ const { default: mysql } = await import('mysql2');
4
+ return mysql.createConnection(config);
5
+ }
6
+ function origensDosCampos(campos) {
7
+ if (!campos.every((c) => typeof c.orgName === 'string'))
8
+ return undefined;
9
+ return campos.map((c) => (c.orgTable && c.orgName ? { tabela: c.orgTable, coluna: c.orgName } : null));
10
+ }
11
+ export function paraUriMysql(dsn) {
12
+ return dsn.replace(/^mariadb:\/\//i, 'mysql://');
13
+ }
14
+ export function criarDriverMariadb(dsn, opcoes = {}) {
15
+ const criarConexao = opcoes.criarConexao ?? criarConexaoMysql2;
16
+ let conexao = null;
17
+ async function obterConexao() {
18
+ if (!conexao) {
19
+ const nova = await criarConexao({ uri: paraUriMysql(dsn), multipleStatements: false });
20
+ nova.on?.('error', () => {
21
+ if (conexao === nova)
22
+ conexao = null;
23
+ });
24
+ conexao = nova;
25
+ }
26
+ return conexao;
27
+ }
28
+ function destruir(alvo) {
29
+ if (conexao === alvo)
30
+ conexao = null;
31
+ alvo.destroy();
32
+ }
33
+ async function executar(sql, valores = []) {
34
+ const atual = await obterConexao();
35
+ return new Promise((resolve, reject) => {
36
+ atual.query(sql, valores, (erro, linhas) => {
37
+ if (!erro)
38
+ return resolve(linhas);
39
+ if (erro.fatal && conexao === atual)
40
+ conexao = null;
41
+ reject(erro);
42
+ });
43
+ });
44
+ }
45
+ return {
46
+ async testar() {
47
+ await executar('SELECT 1');
48
+ },
49
+ async listarTabelas() {
50
+ const linhas = await executar(`SELECT table_name AS nome, table_type AS tipo
51
+ FROM information_schema.tables
52
+ WHERE table_schema = DATABASE()
53
+ ORDER BY table_name`);
54
+ return linhas.map((t) => ({ nome: t.nome, tipo: /VIEW/i.test(t.tipo) ? 'view' : 'tabela' }));
55
+ },
56
+ async descreverTabela(nome) {
57
+ const colunas = await executar(`SELECT column_name AS nome, column_type AS tipo, is_nullable AS nulo, column_default AS padrao
58
+ FROM information_schema.columns
59
+ WHERE table_schema = DATABASE() AND table_name = ?
60
+ ORDER BY ordinal_position`, [nome]);
61
+ if (colunas.length === 0)
62
+ throw new Error(`Tabela ${nome} não encontrada`);
63
+ const primaria = await executar(`SELECT column_name AS coluna
64
+ FROM information_schema.key_column_usage
65
+ WHERE table_schema = DATABASE() AND table_name = ? AND constraint_name = 'PRIMARY'
66
+ ORDER BY ordinal_position`, [nome]);
67
+ const estrangeiras = await executar(`SELECT constraint_name AS restricao, column_name AS coluna,
68
+ referenced_table_name AS tabela_ref, referenced_column_name AS coluna_ref
69
+ FROM information_schema.key_column_usage
70
+ WHERE table_schema = DATABASE() AND table_name = ? AND referenced_table_name IS NOT NULL
71
+ ORDER BY constraint_name, ordinal_position`, [nome]);
72
+ return {
73
+ colunas: colunas.map((c) => ({ nome: c.nome, tipo: c.tipo, nulo: c.nulo === 'YES', padrao: c.padrao })),
74
+ chave_primaria: primaria.map((p) => p.coluna),
75
+ chaves_estrangeiras: agruparChavesEstrangeiras(estrangeiras),
76
+ };
77
+ },
78
+ async consultar(sql, { limite, timeoutSegundos }) {
79
+ const timeout = validarTimeout(timeoutSegundos);
80
+ const maximo = validarLimite(limite);
81
+ await executar(`SET SESSION max_statement_time = ${timeout}`);
82
+ await executar('START TRANSACTION READ ONLY');
83
+ const atual = await obterConexao();
84
+ const colunas = [];
85
+ let origens;
86
+ const linhas = [];
87
+ let truncado = false;
88
+ try {
89
+ const consulta = atual.query({ sql: semPontoEVirgulaFinal(sql), rowsAsArray: true });
90
+ consulta.on?.('error', () => { });
91
+ const fluxo = consulta.stream();
92
+ fluxo.once('fields', (campos) => {
93
+ colunas.push(...campos.map((c) => c.name));
94
+ origens = origensDosCampos(campos);
95
+ });
96
+ for await (const linha of fluxo) {
97
+ if (linhas.length === maximo) {
98
+ truncado = true;
99
+ break;
100
+ }
101
+ linhas.push(linha);
102
+ }
103
+ }
104
+ finally {
105
+ if (truncado) {
106
+ destruir(atual);
107
+ }
108
+ else {
109
+ await executar('ROLLBACK').catch(() => destruir(atual));
110
+ }
111
+ }
112
+ return { colunas, linhas, truncado, ...(origens ? { origens } : {}) };
113
+ },
114
+ async fechar() {
115
+ const atual = conexao;
116
+ conexao = null;
117
+ if (atual)
118
+ await new Promise((resolve) => atual.end(() => resolve()));
119
+ },
120
+ };
121
+ }
@@ -0,0 +1,157 @@
1
+ import { agruparChavesEstrangeiras, semPontoEVirgulaFinal, validarLimite, validarTimeout, } from './tipos.js';
2
+ const CURSOR = 'ai_harness_cursor';
3
+ async function criarClientePg(dsn) {
4
+ const { default: pg } = await import('pg');
5
+ return new pg.Client({ connectionString: dsn });
6
+ }
7
+ function semComentariosIniciais(sql) {
8
+ let resto = sql;
9
+ for (;;) {
10
+ const sem = resto.replace(/^\s*(--[^\n]*(\n|$)|\/\*[\s\S]*?\*\/)/, '');
11
+ if (sem === resto)
12
+ return resto.trimStart();
13
+ resto = sem;
14
+ }
15
+ }
16
+ function separarNome(nome) {
17
+ const ponto = nome.indexOf('.');
18
+ return ponto < 0 ? { schema: null, tabela: nome } : { schema: nome.slice(0, ponto), tabela: nome.slice(ponto + 1) };
19
+ }
20
+ /** Uma consulta ao catálogo cobre todos os pares; falha nela deixa as origens ausentes sem derrubar o resultado. */
21
+ async function resolverOrigens(conexao, campos) {
22
+ if (!campos.every((c) => typeof c.tableID === 'number' && typeof c.columnID === 'number'))
23
+ return undefined;
24
+ const chave = (tabela, coluna) => `${tabela}:${coluna}`;
25
+ const pares = new Map();
26
+ for (const c of campos) {
27
+ if (c.tableID)
28
+ pares.set(chave(c.tableID, c.columnID), [c.tableID, c.columnID]);
29
+ }
30
+ const nomes = new Map();
31
+ if (pares.size > 0) {
32
+ try {
33
+ const { rows } = await conexao.query({
34
+ text: `SELECT a.attrelid AS tabela_id, a.attnum AS coluna_id, c.relname AS tabela, a.attname AS coluna
35
+ FROM pg_attribute a
36
+ JOIN pg_class c ON c.oid = a.attrelid
37
+ WHERE (a.attrelid, a.attnum) IN (SELECT * FROM unnest($1::oid[], $2::int2[]))`,
38
+ values: [[...pares.values()].map((p) => p[0]), [...pares.values()].map((p) => p[1])],
39
+ });
40
+ for (const linha of rows) {
41
+ nomes.set(chave(Number(linha.tabela_id), Number(linha.coluna_id)), { tabela: linha.tabela, coluna: linha.coluna });
42
+ }
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ return campos.map((c) => (c.tableID ? (nomes.get(chave(c.tableID, c.columnID)) ?? null) : null));
49
+ }
50
+ export function criarDriverPostgres(dsn, opcoes = {}) {
51
+ const criarCliente = opcoes.criarCliente ?? criarClientePg;
52
+ let cliente = null;
53
+ async function obterCliente() {
54
+ if (!cliente) {
55
+ const novo = await criarCliente(dsn);
56
+ await novo.connect();
57
+ cliente = novo;
58
+ }
59
+ return cliente;
60
+ }
61
+ async function descartarCliente() {
62
+ const atual = cliente;
63
+ cliente = null;
64
+ await atual?.end().catch(() => { });
65
+ }
66
+ async function linhas(text, values = []) {
67
+ const conexao = await obterCliente();
68
+ return (await conexao.query({ text, values })).rows;
69
+ }
70
+ return {
71
+ async testar() {
72
+ await linhas('SELECT 1');
73
+ },
74
+ async listarTabelas() {
75
+ const resultado = await linhas(`SELECT table_schema AS schema, table_name AS nome, table_type AS tipo
76
+ FROM information_schema.tables
77
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
78
+ ORDER BY table_schema, table_name`);
79
+ return resultado.map((t) => ({ schema: t.schema, nome: t.nome, tipo: t.tipo === 'VIEW' ? 'view' : 'tabela' }));
80
+ },
81
+ async descreverTabela(nome) {
82
+ const { schema, tabela } = separarNome(nome);
83
+ const filtro = (alias) => `${alias}table_name = $1 AND ${alias}table_schema = COALESCE($2, current_schema())`;
84
+ const valores = [tabela, schema];
85
+ const colunas = await linhas(`SELECT table_schema AS schema, column_name AS nome, data_type AS tipo, is_nullable AS nulo, column_default AS padrao
86
+ FROM information_schema.columns
87
+ WHERE ${filtro('')}
88
+ ORDER BY ordinal_position`, valores);
89
+ if (colunas.length === 0)
90
+ throw new Error(`Tabela ${nome} não encontrada`);
91
+ const primaria = await linhas(`SELECT kcu.column_name AS coluna
92
+ FROM information_schema.table_constraints tc
93
+ JOIN information_schema.key_column_usage kcu
94
+ ON kcu.constraint_schema = tc.constraint_schema AND kcu.constraint_name = tc.constraint_name
95
+ WHERE tc.constraint_type = 'PRIMARY KEY' AND ${filtro('tc.')}
96
+ ORDER BY kcu.ordinal_position`, valores);
97
+ const estrangeiras = await linhas(`SELECT kcu.constraint_name AS restricao, kcu.column_name AS coluna,
98
+ ref.table_schema AS schema_ref, ref.table_name AS tabela_ref, ref.column_name AS coluna_ref
99
+ FROM information_schema.key_column_usage kcu
100
+ JOIN information_schema.referential_constraints rc
101
+ ON rc.constraint_schema = kcu.constraint_schema AND rc.constraint_name = kcu.constraint_name
102
+ JOIN information_schema.key_column_usage ref
103
+ ON ref.constraint_schema = rc.unique_constraint_schema AND ref.constraint_name = rc.unique_constraint_name
104
+ AND ref.ordinal_position = kcu.position_in_unique_constraint
105
+ WHERE ${filtro('kcu.')}
106
+ ORDER BY kcu.constraint_name, kcu.ordinal_position`, valores);
107
+ const schemaTabela = colunas[0]?.schema;
108
+ return {
109
+ colunas: colunas.map((c) => ({ nome: c.nome, tipo: c.tipo, nulo: c.nulo === 'YES', padrao: c.padrao })),
110
+ chave_primaria: primaria.map((p) => p.coluna),
111
+ chaves_estrangeiras: agruparChavesEstrangeiras(estrangeiras.map((e) => ({
112
+ ...e,
113
+ tabela_ref: e.schema_ref === schemaTabela ? e.tabela_ref : `${e.schema_ref}.${e.tabela_ref}`,
114
+ }))),
115
+ };
116
+ },
117
+ async consultar(sql, { limite, timeoutSegundos }) {
118
+ const timeout = validarTimeout(timeoutSegundos);
119
+ const maximo = validarLimite(limite);
120
+ const texto = semPontoEVirgulaFinal(sql);
121
+ const direto = /^(explain|show)\b/i.test(semComentariosIniciais(texto));
122
+ const conexao = await obterCliente();
123
+ let resultado;
124
+ let origens;
125
+ await conexao.query('BEGIN READ ONLY');
126
+ try {
127
+ await conexao.query(`SET LOCAL statement_timeout = ${timeout * 1000}`);
128
+ if (direto) {
129
+ resultado = await conexao.query({ text: texto, rowMode: 'array', queryMode: 'extended' });
130
+ }
131
+ else {
132
+ await conexao.query({ text: `DECLARE ${CURSOR} NO SCROLL CURSOR FOR ${texto}`, queryMode: 'extended' });
133
+ resultado = await conexao.query({ text: `FETCH ${maximo + 1} FROM ${CURSOR}`, rowMode: 'array', queryMode: 'extended' });
134
+ }
135
+ origens = await resolverOrigens(conexao, resultado.fields ?? []);
136
+ }
137
+ finally {
138
+ try {
139
+ await conexao.query('ROLLBACK');
140
+ }
141
+ catch {
142
+ await descartarCliente();
143
+ }
144
+ }
145
+ const todas = resultado.rows;
146
+ return {
147
+ colunas: (resultado.fields ?? []).map((f) => f.name),
148
+ linhas: todas.slice(0, maximo),
149
+ truncado: todas.length > maximo,
150
+ ...(origens ? { origens } : {}),
151
+ };
152
+ },
153
+ async fechar() {
154
+ await descartarCliente();
155
+ },
156
+ };
157
+ }
@@ -0,0 +1,92 @@
1
+ import { validarLimite, } from './tipos.js';
2
+ async function carregarSqlite() {
3
+ try {
4
+ return await import('node:sqlite');
5
+ }
6
+ catch {
7
+ throw new Error('SQLite exige Node 22.13 ou mais recente');
8
+ }
9
+ }
10
+ function lerLinhas(instrucao, maximo) {
11
+ const opcional = instrucao;
12
+ const descritas = typeof opcional.columns === 'function' ? opcional.columns.call(instrucao) : null;
13
+ const emArrays = descritas !== null && typeof opcional.setReturnArrays === 'function';
14
+ if (emArrays)
15
+ opcional.setReturnArrays?.(true);
16
+ let colunas = descritas?.map((c) => c.name) ?? null;
17
+ const origens = descritas?.map((c) => (c.table && c.column ? { tabela: c.table, coluna: c.column } : null));
18
+ const linhas = [];
19
+ let truncado = false;
20
+ const fonte = typeof opcional.iterate === 'function' ? opcional.iterate.call(instrucao) : instrucao.all();
21
+ for (const linha of fonte) {
22
+ if (linhas.length === maximo) {
23
+ truncado = true;
24
+ break;
25
+ }
26
+ if (emArrays) {
27
+ linhas.push(linha);
28
+ }
29
+ else {
30
+ const objeto = linha;
31
+ colunas ??= Object.keys(objeto);
32
+ linhas.push(colunas.map((c) => objeto[c]));
33
+ }
34
+ }
35
+ return { colunas: colunas ?? [], linhas, truncado, ...(origens ? { origens } : {}) };
36
+ }
37
+ export async function criarDriverSqlite(caminho) {
38
+ const { DatabaseSync: Banco } = await carregarSqlite();
39
+ let banco = null;
40
+ function abrir() {
41
+ banco ??= new Banco(caminho, { readOnly: true });
42
+ return banco;
43
+ }
44
+ function todas(sql, ...valores) {
45
+ return abrir().prepare(sql).all(...valores);
46
+ }
47
+ return {
48
+ async testar() {
49
+ todas('SELECT count(*) FROM sqlite_master');
50
+ },
51
+ async listarTabelas() {
52
+ const linhas = todas(`SELECT name AS nome, type AS tipo FROM sqlite_master
53
+ WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'
54
+ ORDER BY name`);
55
+ return linhas.map((t) => ({ nome: t.nome, tipo: t.tipo === 'view' ? 'view' : 'tabela' }));
56
+ },
57
+ async descreverTabela(nome) {
58
+ const colunas = todas('SELECT name AS nome, type AS tipo, "notnull" AS nao_nulo, dflt_value AS padrao, pk FROM pragma_table_info(?) ORDER BY cid', nome);
59
+ if (colunas.length === 0)
60
+ throw new Error(`Tabela ${nome} não encontrada`);
61
+ const estrangeiras = todas('SELECT id, "table" AS tabela_ref, "from" AS coluna, "to" AS coluna_ref FROM pragma_foreign_key_list(?) ORDER BY id, seq', nome);
62
+ const chaves = new Map();
63
+ for (const fk of estrangeiras) {
64
+ let chave = chaves.get(fk.id);
65
+ if (!chave) {
66
+ chave = { colunas: [], tabela_ref: fk.tabela_ref, colunas_ref: [] };
67
+ chaves.set(fk.id, chave);
68
+ }
69
+ chave.colunas.push(fk.coluna);
70
+ if (fk.coluna_ref !== null)
71
+ chave.colunas_ref.push(fk.coluna_ref);
72
+ }
73
+ for (const chave of chaves.values()) {
74
+ if (chave.colunas_ref.length === 0) {
75
+ chave.colunas_ref = todas('SELECT name AS nome FROM pragma_table_info(?) WHERE pk > 0 ORDER BY pk', chave.tabela_ref).map((c) => c.nome);
76
+ }
77
+ }
78
+ return {
79
+ colunas: colunas.map((c) => ({ nome: c.nome, tipo: c.tipo, nulo: c.nao_nulo === 0, padrao: c.padrao })),
80
+ chave_primaria: colunas.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk).map((c) => c.nome),
81
+ chaves_estrangeiras: [...chaves.values()],
82
+ };
83
+ },
84
+ async consultar(sql, { limite }) {
85
+ return lerLinhas(abrir().prepare(sql), validarLimite(limite));
86
+ },
87
+ async fechar() {
88
+ banco?.close();
89
+ banco = null;
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,29 @@
1
+ /** Os valores entram no SQL como literais, então só inteiros dentro da faixa passam. */
2
+ export function validarTimeout(timeoutSegundos) {
3
+ if (typeof timeoutSegundos !== 'number' || !Number.isInteger(timeoutSegundos) || timeoutSegundos < 1 || timeoutSegundos > 600) {
4
+ throw new Error(`timeoutSegundos deve ser um inteiro de 1 a 600 (recebido: ${String(timeoutSegundos)})`);
5
+ }
6
+ return timeoutSegundos;
7
+ }
8
+ export function validarLimite(limite) {
9
+ if (typeof limite !== 'number' || !Number.isInteger(limite) || limite < 1) {
10
+ throw new Error(`limite deve ser um inteiro positivo (recebido: ${String(limite)})`);
11
+ }
12
+ return limite;
13
+ }
14
+ export function semPontoEVirgulaFinal(sql) {
15
+ return sql.trim().replace(/;\s*$/, '').trimEnd();
16
+ }
17
+ export function agruparChavesEstrangeiras(linhas) {
18
+ const grupos = new Map();
19
+ for (const linha of linhas) {
20
+ let grupo = grupos.get(linha.restricao);
21
+ if (!grupo) {
22
+ grupo = { colunas: [], tabela_ref: linha.tabela_ref, colunas_ref: [] };
23
+ grupos.set(linha.restricao, grupo);
24
+ }
25
+ grupo.colunas.push(linha.coluna);
26
+ grupo.colunas_ref.push(linha.coluna_ref);
27
+ }
28
+ return [...grupos.values()];
29
+ }
@@ -0,0 +1,241 @@
1
+ import { readConfig } from '../config/config.js';
2
+ import { ocultarSenha } from './cadastro.js';
3
+ import { escolherConexao, resolverConexoes } from './conexao.js';
4
+ import { criarDriver } from './drivers/index.js';
5
+ import { categoriaDaColuna, mascararResultado, mascararTexto } from './lgpd/aplicar.js';
6
+ import { validarLeitura } from './somente-leitura.js';
7
+ const CONEXAO = {
8
+ type: 'string',
9
+ description: 'Nome da conexão do projeto; sem ele vale a primeira de banco.conexoes.',
10
+ };
11
+ export const FERRAMENTAS = [
12
+ {
13
+ name: 'listar_conexoes',
14
+ description: 'Lista as conexões de banco do projeto (nome, tipo, se é produção, origem e, nas do projeto, a variável e a fonte), sem credenciais.',
15
+ inputSchema: { type: 'object', properties: { conexao: CONEXAO }, additionalProperties: false },
16
+ },
17
+ {
18
+ name: 'listar_tabelas',
19
+ description: 'Lista as tabelas e views do banco, com o schema no Postgres.',
20
+ inputSchema: { type: 'object', properties: { conexao: CONEXAO }, additionalProperties: false },
21
+ },
22
+ {
23
+ name: 'descrever_tabela',
24
+ description: 'Descreve uma tabela: colunas (nome, tipo, nulo, padrão), chave primária e chaves estrangeiras.',
25
+ inputSchema: {
26
+ type: 'object',
27
+ properties: {
28
+ tabela: { type: 'string', description: 'Nome da tabela, com o schema quando preciso (schema.tabela).' },
29
+ conexao: CONEXAO,
30
+ },
31
+ required: ['tabela'],
32
+ additionalProperties: false,
33
+ },
34
+ },
35
+ {
36
+ name: 'consultar',
37
+ description: 'Executa uma única consulta somente leitura (SELECT, WITH, EXPLAIN, SHOW, DESCRIBE) e devolve colunas, linhas e se o resultado foi truncado.',
38
+ inputSchema: {
39
+ type: 'object',
40
+ properties: {
41
+ sql: { type: 'string', description: 'Consulta de leitura; selecione só as colunas necessárias.' },
42
+ limite: {
43
+ type: 'integer',
44
+ minimum: 1,
45
+ description: 'Máximo de linhas devolvidas; nunca passa de banco.limite_linhas do projeto.',
46
+ },
47
+ conexao: CONEXAO,
48
+ },
49
+ required: ['sql'],
50
+ additionalProperties: false,
51
+ },
52
+ },
53
+ ];
54
+ export const INSTRUCOES = [
55
+ 'Use estas ferramentas para ler o banco de dados do projeto; o acesso é somente leitura.',
56
+ 'Nunca peça nem repita credenciais, senhas ou DSNs no chat.',
57
+ 'As conexões vêm do cadastro da máquina ou do projeto: uma conexão do projeto é declarada em banco.conexoes do .ai-harness/config.json com dsn_env (o nome da variável), e o valor da variável fica no .claude/settings.local.json, no .env ou no ambiente.',
58
+ 'Sem conexão configurada, oriente o usuário a rodar no terminal `npx @fontdata/ai-harness banco adicionar <nome>` e, no projeto, `npx @fontdata/ai-harness banco usar <nome>`.',
59
+ 'Se a variável de uma conexão do projeto não estiver definida, oriente a defini-la no .claude/settings.local.json, no .env ou no ambiente.',
60
+ 'Antes de consultar, rode listar_tabelas e descrever_tabela para conhecer o esquema.',
61
+ 'Em consultar, selecione só as colunas necessárias e filtre as linhas; evite SELECT *.',
62
+ 'Somente leitura: comandos que alteram dados ou esquema são recusados.',
63
+ 'Os dados pessoais (CPF, e-mail, telefone, nome e afins) vêm mascarados, e o rodapé de consultar diz o que foi mascarado. Não tente contornar o mascaramento: não concatene, fatie, converta nem compare valores para reconstruir o original. Peça só as colunas necessárias.',
64
+ ].join('\n');
65
+ const ORIENTACAO_USAR = 'rode npx @fontdata/ai-harness banco usar <nome> no projeto';
66
+ function orientarAdicionar(nome) {
67
+ return `Conexão ${nome} não encontrada nesta máquina; rode npx @fontdata/ai-harness banco adicionar ${nome}`;
68
+ }
69
+ function orientarVariavel(nome, variavel) {
70
+ return `A variável ${variavel} da conexão ${nome} não está definida; defina-a no .claude/settings.local.json, no .env ou no ambiente`;
71
+ }
72
+ function paraJson(dados) {
73
+ return JSON.stringify(dados, function (chave, valor) {
74
+ const original = this[chave];
75
+ if (typeof original === 'bigint')
76
+ return original.toString();
77
+ if (original instanceof Uint8Array)
78
+ return Buffer.from(original).toString('hex');
79
+ if (original instanceof Date)
80
+ return Number.isNaN(original.getTime()) ? String(original) : original.toISOString();
81
+ return valor;
82
+ }, 2);
83
+ }
84
+ function plural(n, singular, plural) {
85
+ return `${n} ${n === 1 ? singular : plural}`;
86
+ }
87
+ function rodapeLgpd({ colunasMascaradas, valoresEmTexto }) {
88
+ const partes = [];
89
+ if (colunasMascaradas.length > 0) {
90
+ const lista = colunasMascaradas.map((c) => `${c.coluna} → ${c.categoria}`).join(', ');
91
+ partes.push(`${plural(colunasMascaradas.length, 'coluna', 'colunas')} (${lista})`);
92
+ }
93
+ if (valoresEmTexto > 0) {
94
+ partes.push(plural(valoresEmTexto, 'valor detectado pelo conteúdo', 'valores detectados pelo conteúdo'));
95
+ }
96
+ return partes.length === 0 ? '' : `\n\nMascaramento LGPD: ${partes.join(' e ')}.`;
97
+ }
98
+ const TAMANHO_MINIMO_SEGREDO = 8;
99
+ /** Oculta a senha das conexões e dos segredos; o segredo inteiro só a partir de 8 caracteres, para não estragar textos com valores curtos. */
100
+ function limpar(texto, conexoes, segredos = []) {
101
+ let resultado = [...segredos]
102
+ .filter((segredo) => segredo.length >= TAMANHO_MINIMO_SEGREDO)
103
+ .sort((a, b) => b.length - a.length)
104
+ .reduce((parcial, segredo) => parcial.replaceAll(segredo, '***'), texto);
105
+ for (const segredo of segredos)
106
+ resultado = ocultarSenha(resultado, { tipo: 'postgres', dsn: segredo });
107
+ return conexoes.reduce((parcial, conexao) => ocultarSenha(parcial, conexao), resultado);
108
+ }
109
+ function erro(texto) {
110
+ return { content: [{ type: 'text', text: texto }], isError: true };
111
+ }
112
+ function argumentoTexto(args, nome, obrigatorio) {
113
+ const valor = args[nome];
114
+ if (valor === undefined && !obrigatorio)
115
+ return undefined;
116
+ if (typeof valor !== 'string' || !valor.trim())
117
+ throw new Error(`O argumento ${nome} deve ser um texto não vazio.`);
118
+ return valor;
119
+ }
120
+ function lerLimite(valor, banco) {
121
+ if (valor === undefined)
122
+ return banco.limite_linhas;
123
+ if (typeof valor !== 'number' || !Number.isInteger(valor) || valor < 1) {
124
+ throw new Error('O argumento limite deve ser um inteiro positivo.');
125
+ }
126
+ return Math.min(valor, banco.limite_linhas);
127
+ }
128
+ /** Sem nome, vale a primeira declarada em banco.conexoes; se ela não estiver disponível, o erro é dela, nunca outra conexão. */
129
+ function escolher(resolvidas, banco, nome) {
130
+ const alvo = nome ?? banco.conexoes[0];
131
+ if (alvo !== undefined) {
132
+ if (resolvidas.faltando.includes(alvo))
133
+ throw new Error(orientarAdicionar(alvo));
134
+ const ausente = resolvidas.variaveisAusentes.find((item) => item.nome === alvo);
135
+ if (ausente)
136
+ throw new Error(orientarVariavel(ausente.nome, ausente.variavel));
137
+ const descartada = resolvidas.descartadas.find((item) => item.nome === alvo);
138
+ if (descartada)
139
+ throw new Error(`Conexão ${descartada.nome} descartada: ${descartada.motivo}.`);
140
+ }
141
+ return escolherConexao(resolvidas.conexoes, alvo);
142
+ }
143
+ function descreverConexao({ nome, tipo, producao, origem, variavel, fonte }) {
144
+ const dados = { nome, tipo, producao, origem };
145
+ if (origem === 'projeto')
146
+ Object.assign(dados, { dsn_env: variavel, fonte });
147
+ return dados;
148
+ }
149
+ async function comDriver(conexao, contexto, acao) {
150
+ const fabrica = contexto.criarDriver ?? criarDriver;
151
+ const driver = await fabrica(conexao.tipo, conexao.dsn ?? conexao.caminho ?? '');
152
+ try {
153
+ return await acao(driver);
154
+ }
155
+ finally {
156
+ await driver.fechar().catch(() => { });
157
+ }
158
+ }
159
+ async function executar(nome, args, contexto, banco, resolvidas) {
160
+ if (nome === 'listar_conexoes') {
161
+ const { conexoes, faltando, variaveisAusentes, descartadas } = resolvidas;
162
+ const dados = { conexoes: conexoes.map(descreverConexao) };
163
+ if (faltando.length > 0) {
164
+ dados.faltando = faltando.map((n) => ({ nome: n, orientacao: orientarAdicionar(n) }));
165
+ }
166
+ if (variaveisAusentes.length > 0) {
167
+ dados.variaveisAusentes = variaveisAusentes.map(({ nome: n, variavel }) => ({
168
+ nome: n,
169
+ dsn_env: variavel,
170
+ orientacao: orientarVariavel(n, variavel),
171
+ }));
172
+ }
173
+ if (descartadas.length > 0)
174
+ dados.descartadas = descartadas;
175
+ if (conexoes.length + faltando.length + variaveisAusentes.length + descartadas.length === 0) {
176
+ dados.orientacao = `Nenhuma conexão configurada; rode npx @fontdata/ai-harness banco adicionar <nome> e ${ORIENTACAO_USAR}`;
177
+ }
178
+ if (resolvidas.problemas.length > 0)
179
+ dados.problemas = resolvidas.problemas;
180
+ return { content: [{ type: 'text', text: paraJson(dados) }] };
181
+ }
182
+ if (!['listar_tabelas', 'descrever_tabela', 'consultar'].includes(nome))
183
+ return erro(`Ferramenta desconhecida: ${nome}`);
184
+ const conexao = escolher(resolvidas, banco, argumentoTexto(args, 'conexao', false));
185
+ if (conexao.producao && banco.producao === 'bloqueio') {
186
+ return erro(`Conexão ${conexao.nome} é de produção e está bloqueada (banco.producao: "bloqueio"). Use uma cópia ou troque banco.producao para "aviso" em .ai-harness/config.json de propósito.`);
187
+ }
188
+ let dados;
189
+ let rodape = '';
190
+ if (nome === 'listar_tabelas') {
191
+ dados = { conexao: conexao.nome, tabelas: await comDriver(conexao, contexto, (driver) => driver.listarTabelas()) };
192
+ }
193
+ else if (nome === 'descrever_tabela') {
194
+ const tabela = argumentoTexto(args, 'tabela', true) ?? '';
195
+ const descricao = await comDriver(conexao, contexto, (driver) => driver.descreverTabela(tabela));
196
+ const colunas = descricao.colunas.map((coluna) => {
197
+ const categoria = conexao.mascaramento ? categoriaDaColuna(tabela, coluna.nome, banco.lgpd) : null;
198
+ return categoria === null ? coluna : { ...coluna, mascarada: categoria };
199
+ });
200
+ dados = { conexao: conexao.nome, tabela, ...descricao, colunas };
201
+ }
202
+ else {
203
+ const sql = argumentoTexto(args, 'sql', true) ?? '';
204
+ const limite = lerLimite(args.limite, banco);
205
+ const validacao = validarLeitura(sql, conexao.tipo);
206
+ if (!validacao.ok)
207
+ return erro(`Consulta recusada: ${validacao.motivo}`);
208
+ const bruto = await comDriver(conexao, contexto, (driver) => driver.consultar(validacao.sql, { limite, timeoutSegundos: banco.timeout_segundos }));
209
+ const mascarado = mascararResultado(bruto, {
210
+ sql: validacao.sql,
211
+ dialeto: conexao.tipo,
212
+ regras: banco.lgpd,
213
+ ativo: conexao.mascaramento,
214
+ });
215
+ const { resultado } = mascarado;
216
+ rodape = rodapeLgpd(mascarado);
217
+ dados = { conexao: conexao.nome, colunas: resultado.colunas, linhas: resultado.linhas, truncado: resultado.truncado };
218
+ }
219
+ const aviso = conexao.producao ? `⚠ PRODUÇÃO — conexão ${conexao.nome} é de produção; confira antes de agir.\n` : '';
220
+ return { content: [{ type: 'text', text: `${aviso}${paraJson(dados)}${rodape}` }] };
221
+ }
222
+ /** Nunca lança: falhas viram `isError`, e todo texto sai sem a senha dos DSNs e sem os valores lidos de variáveis; erros passam pela detecção LGPD por valor. */
223
+ export async function executarFerramenta(nome, args, contexto) {
224
+ let conexoes = [];
225
+ let segredos = [];
226
+ try {
227
+ const { config } = readConfig(contexto.raiz);
228
+ const resolvidas = resolverConexoes(contexto.raiz, contexto.cadastro ? { cadastro: contexto.cadastro } : {});
229
+ conexoes = resolvidas.conexoes;
230
+ segredos = resolvidas.segredos;
231
+ const argumentos = typeof args === 'object' && args !== null && !Array.isArray(args) ? args : {};
232
+ const resultado = await executar(nome, argumentos, contexto, config.banco, resolvidas);
233
+ const texto = limpar(resultado.content[0].text, conexoes, segredos);
234
+ resultado.content[0].text = resultado.isError ? mascararTexto(texto) : texto;
235
+ return resultado;
236
+ }
237
+ catch (falha) {
238
+ const mensagem = falha instanceof Error ? falha.message : String(falha);
239
+ return erro(mascararTexto(limpar(mensagem, conexoes, segredos)));
240
+ }
241
+ }