@fontdata/ai-harness 2.6.0 → 3.0.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.
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "fontdata",
3
- "version": "2.6.0",
3
+ "version": "3.0.0",
4
+ "mcpServers": {
5
+ "banco_dados": { "command": "npx", "args": ["-y", "@fontdata/ai-harness@3.0.0", "banco-dados-mcp"] }
6
+ },
4
7
  "description": "Harness de engenharia com IA da Fontdata para Claude Code",
5
8
  "author": { "name": "Fontdata" },
6
9
  "commands": ["./templates/commands"],
package/README.md CHANGED
@@ -96,6 +96,7 @@ npx @fontdata/ai-harness init --comandos resolver-bug,nova-tarefa --sem-harness
96
96
  | `--sem-harness` | Não inclui o comando-mestre `/harness` |
97
97
  | `--sem-hooks` / `--sem-agents` | Não instala hooks / agents |
98
98
  | `--sem-comentarios` | Não instala a rule de comentários, o hook e a catraca do legado |
99
+ | `--sem-banco-dados` | Não registra o servidor MCP de banco no `.mcp.json` e lembra a recusa no config |
99
100
  | `--claude-md criar\|manter\|backup\|sobrescrever` | O que fazer com o `CLAUDE.md` (padrão: `criar`) |
100
101
  | `--conflito sobrescrever\|pular` | Arquivos que já existem (padrão: `pular`) |
101
102
  | `--yes` | Aplica; sem ele, só simula |
@@ -110,7 +111,8 @@ aprovação, inclusive no modo auto do Claude Code:
110
111
 
111
112
  - `permissions.allow`: o rastro, a checagem de conflitos, o `doctor` e a `memoria` do harness; `git add`,
112
113
  `git commit` e `git push` (o `/git-commit-push`); a abertura de sub-agents (`Agent`); as ferramentas `Read`,
113
- `Grep`, `Glob` e `Skill`; e consultas só de leitura
114
+ `Grep`, `Glob` e `Skill`; as quatro ferramentas do servidor MCP de banco, inclusive a `consultar`, que
115
+ devolve os dados pessoais mascarados; e consultas só de leitura
114
116
  (`git status`, `diff`, `log`, `show`, `blame`, `gh pr view`, `gh pr diff`, `npm outdated`...);
115
117
  - `permissions.deny`: o push forçado (`--force`, `-f`, `--force-with-lease` e refspec com `+`), que vence o `allow`.
116
118
 
@@ -119,6 +121,35 @@ Ficam de fora os comandos de teste e build, que variam por projeto, e os que reg
119
121
  Nos modos 1 e 2 nada é gravado no projeto: rode `npx @fontdata/ai-harness update --yes` num projeto já instalado,
120
122
  ou `init`, para receber as regras.
121
123
 
124
+ ## Conexões de banco de dados
125
+
126
+ O Claude pode ler o banco do projeto (PostgreSQL, MariaDB ou SQLite) sem que ninguém cole credencial no chat. O
127
+ jeito recomendado para a equipe é a **conexão do projeto**: o `.ai-harness/config.json` (versionado) declara o nome
128
+ da conexão e o nome da variável de ambiente (`dsn_env`), e cada dev guarda o DSN nessa variável, no `env` do
129
+ `.claude/settings.local.json` (fora do git), no `.env` ou no ambiente:
130
+
131
+ ```bash
132
+ npx @fontdata/ai-harness banco-dados adicionar erp --projeto # pergunta os dados, testa e grava ERP_DATABASE_URL
133
+ npx @fontdata/ai-harness banco-dados testar
134
+ ```
135
+
136
+ O `adicionar --projeto` grava `{ "nome": "erp", "dsn_env": "ERP_DATABASE_URL" }` em `banco_dados.conexoes` e a variável
137
+ no `.claude/settings.local.json`. Faça commit do `config.json`; cada colega roda o mesmo comando, que grava só a
138
+ variável. `"producao": true` na entrada vale para a equipe toda. Quem prefere um cadastro por máquina
139
+ (`~/.config/ai-harness/conexoes.json`) usa `banco-dados adicionar erp-dev` sem `--projeto` e `banco-dados usar erp-dev` no
140
+ projeto.
141
+
142
+ O servidor MCP `banco_dados` entrega ao Claude `listar_conexoes`, `listar_tabelas`, `descrever_tabela` e
143
+ `consultar`, tudo somente leitura. Ele vem no plugin; sem plugin, o `init` e o `update` o registram no `.mcp.json`
144
+ (`--sem-banco-dados` pula). Conexão de produção nasce bloqueada. Use um usuário de banco só com leitura.
145
+
146
+ Os dados pessoais (CPF, CNPJ, e-mail, telefone, nome, endereço e afins) saem com **mascaramento LGPD**, feito no
147
+ servidor antes de a resposta chegar ao Claude; os filtros rodam sobre o dado real. Por isso a `consultar` fica
148
+ liberada nas permissões, sem pedir aprovação. Falsos positivos e colunas extras se ajustam em
149
+ `banco_dados.lgpd.liberar_colunas` e `banco_dados.lgpd.mascarar_colunas`; desligar, só no cadastro da máquina. Conexões do
150
+ projeto, cadastro, limites do filtro, produção, mascaramento e dúvidas frequentes estão no
151
+ [guia de banco de dados](docs/guias/banco-dados.md).
152
+
122
153
  ## Subcomandos do CLI
123
154
 
124
155
  | Subcomando | O que faz |
@@ -130,6 +161,8 @@ ou `init`, para receber as regras.
130
161
  | `memoria [caminho]` | Índice de quem mexeu em cada arquivo (`--arquivo <caminho>`, `--regressao <id>`) |
131
162
  | `evento <evento>` | Grava no rastro do trabalho (usado por fluxos e agents) |
132
163
  | `conflitos <id>` | Aponta trabalhos em execução que mexem nos mesmos arquivos; `--aguardar` espera liberar (usado pelo `/executar-plan`) |
164
+ | `banco-dados <subcomando>` | Conexões de banco somente leitura: `adicionar`, `listar`, `testar`, `usar`, `remover` |
165
+ | `banco-dados-mcp` | Servidor MCP de leitura do banco, iniciado pelo Claude Code |
133
166
  | `comentarios [caminho]` | Instala o hook do padrão de comentários com a catraca do legado (`--yes`) |
134
167
  | `list` | Lista o catálogo |
135
168
 
@@ -0,0 +1,98 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ export const SETTINGS_LOCAL_PATH = '.claude/settings.local.json';
4
+ function ehObjeto(valor) {
5
+ return typeof valor === 'object' && valor !== null && !Array.isArray(valor);
6
+ }
7
+ function lerSettings(caminho) {
8
+ if (!existsSync(caminho))
9
+ return { existe: false };
10
+ let dados;
11
+ try {
12
+ dados = JSON.parse(readFileSync(caminho, 'utf8'));
13
+ }
14
+ catch {
15
+ return { existe: true, erro: `${caminho} não é um JSON válido` };
16
+ }
17
+ if (!ehObjeto(dados))
18
+ return { existe: true, erro: `${caminho} precisa ter um objeto JSON na raiz` };
19
+ if (dados.env !== undefined && !ehObjeto(dados.env)) {
20
+ return { existe: true, erro: `${caminho}: a chave env precisa ser um objeto` };
21
+ }
22
+ return { existe: true, dados };
23
+ }
24
+ export function lerEnvSettingsLocal(raiz) {
25
+ const leitura = lerSettings(join(raiz, SETTINGS_LOCAL_PATH));
26
+ if (!('dados' in leitura) || !ehObjeto(leitura.dados.env))
27
+ return {};
28
+ const env = {};
29
+ for (const [chave, valor] of Object.entries(leitura.dados.env)) {
30
+ if (typeof valor === 'string')
31
+ env[chave] = valor;
32
+ }
33
+ return env;
34
+ }
35
+ function semAspas(valor) {
36
+ const aspas = /^(["'])(.*)\1$/.exec(valor);
37
+ if (aspas)
38
+ return aspas[2] ?? '';
39
+ return valor.replace(/\s+#.*$/, '');
40
+ }
41
+ /** Lê a variável do .env da raiz; vale a última ocorrência e valor vazio conta como ausente. */
42
+ export function lerVariavelDotEnv(raiz, nome) {
43
+ const arquivo = join(raiz, '.env');
44
+ if (!existsSync(arquivo))
45
+ return null;
46
+ const nomeEscapado = nome.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
47
+ const padrao = new RegExp(`^\\s*(?:export\\s+)?${nomeEscapado}\\s*=\\s*(.*?)\\s*$`);
48
+ let valor = null;
49
+ for (const linha of readFileSync(arquivo, 'utf8').split(/\r?\n/)) {
50
+ const encontrado = padrao.exec(linha);
51
+ if (encontrado)
52
+ valor = semAspas(encontrado[1] ?? '');
53
+ }
54
+ return valor || null;
55
+ }
56
+ /** Procura a variável no processo, no env do settings.local.json e no .env, nessa ordem. */
57
+ export function lerVariavel(nome, raiz, env = process.env) {
58
+ const doProcesso = env[nome];
59
+ if (doProcesso)
60
+ return { valor: doProcesso, fonte: 'processo' };
61
+ const doSettings = lerEnvSettingsLocal(raiz)[nome];
62
+ if (doSettings)
63
+ return { valor: doSettings, fonte: 'settings.local.json' };
64
+ const doDotEnv = lerVariavelDotEnv(raiz, nome);
65
+ if (doDotEnv)
66
+ return { valor: doDotEnv, fonte: '.env' };
67
+ return null;
68
+ }
69
+ export function validarSettingsLocal(raiz) {
70
+ const leitura = lerSettings(join(raiz, SETTINGS_LOCAL_PATH));
71
+ if ('erro' in leitura)
72
+ throw new Error(leitura.erro);
73
+ }
74
+ /** Grava a variável no env do settings.local.json de forma atômica, preservando as outras chaves e o modo do arquivo. */
75
+ export function gravarVariavelSettingsLocal(raiz, nome, valor) {
76
+ const caminho = join(raiz, SETTINGS_LOCAL_PATH);
77
+ const leitura = lerSettings(caminho);
78
+ if ('erro' in leitura)
79
+ throw new Error(leitura.erro);
80
+ const dados = leitura.existe ? leitura.dados : {};
81
+ const envAtual = ehObjeto(dados.env) ? dados.env : {};
82
+ if (envAtual[nome] === valor)
83
+ return { changed: false };
84
+ const novo = { ...dados, env: { ...envAtual, [nome]: valor } };
85
+ const modo = leitura.existe ? statSync(caminho).mode & 0o777 : 0o600;
86
+ mkdirSync(dirname(caminho), { recursive: true });
87
+ const temporario = `${caminho}.${process.pid}.${Date.now()}.tmp`;
88
+ try {
89
+ writeFileSync(temporario, `${JSON.stringify(novo, null, 2)}\n`, { mode: modo });
90
+ chmodSync(temporario, modo);
91
+ renameSync(temporario, caminho);
92
+ }
93
+ catch (erro) {
94
+ rmSync(temporario, { force: true });
95
+ throw erro;
96
+ }
97
+ return { changed: true };
98
+ }
@@ -0,0 +1,179 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ export const TIPOS_BANCO = ['postgres', 'mariadb', 'sqlite'];
5
+ export const VERSAO_CADASTRO = 1;
6
+ const NOME_VALIDO = /^[a-z0-9][a-z0-9_-]*$/;
7
+ function isObject(value) {
8
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
9
+ }
10
+ export function caminhoCadastro(env = process.env, home = homedir()) {
11
+ if (env.AI_HARNESS_CONEXOES)
12
+ return env.AI_HARNESS_CONEXOES;
13
+ const base = env.XDG_CONFIG_HOME || join(home, '.config');
14
+ return join(base, 'ai-harness', 'conexoes.json');
15
+ }
16
+ /** Devolve a conexão normalizada, com o que foi descartado dela, ou o motivo de ser inválida. */
17
+ function validarConexao(valor) {
18
+ if (!isObject(valor))
19
+ return 'não é um objeto';
20
+ const tipo = valor.tipo;
21
+ if (typeof tipo !== 'string' || !TIPOS_BANCO.includes(tipo)) {
22
+ return `tipo deve ser ${TIPOS_BANCO.join(', ')}`;
23
+ }
24
+ if (valor.producao !== undefined && typeof valor.producao !== 'boolean')
25
+ return 'producao deve ser true ou false';
26
+ const conexao = { tipo: tipo };
27
+ if (tipo === 'sqlite') {
28
+ if (typeof valor.caminho !== 'string' || !valor.caminho)
29
+ return 'sqlite exige caminho';
30
+ conexao.caminho = valor.caminho;
31
+ }
32
+ else {
33
+ if (typeof valor.dsn !== 'string' || !valor.dsn)
34
+ return `${tipo} exige dsn`;
35
+ conexao.dsn = valor.dsn;
36
+ }
37
+ if (valor.producao !== undefined)
38
+ conexao.producao = valor.producao;
39
+ const descartes = [];
40
+ if (typeof valor.mascaramento === 'boolean')
41
+ conexao.mascaramento = valor.mascaramento;
42
+ else if (valor.mascaramento !== undefined)
43
+ descartes.push('mascaramento deve ser true ou false; vale true');
44
+ return { conexao, descartes };
45
+ }
46
+ /** Lê o arquivo bruto; lança se o JSON ou o formato estiverem quebrados. */
47
+ function lerBruto(caminho) {
48
+ if (!existsSync(caminho))
49
+ return {};
50
+ let dados;
51
+ try {
52
+ dados = JSON.parse(readFileSync(caminho, 'utf8'));
53
+ }
54
+ catch (erro) {
55
+ throw new Error(`${caminho}: JSON inválido (${erro.message})`);
56
+ }
57
+ if (!isObject(dados) || (dados.conexoes !== undefined && !isObject(dados.conexoes))) {
58
+ throw new Error(`${caminho}: formato inválido, esperado {"versao": 1, "conexoes": {...}}`);
59
+ }
60
+ return dados.conexoes ?? {};
61
+ }
62
+ /** Lê o cadastro sem lançar; conexões inválidas são descartadas e viram problema. */
63
+ export function lerCadastro(caminho = caminhoCadastro()) {
64
+ let brutas;
65
+ try {
66
+ brutas = lerBruto(caminho);
67
+ }
68
+ catch (erro) {
69
+ return { conexoes: {}, problemas: [erro.message] };
70
+ }
71
+ const conexoes = {};
72
+ const problemas = [];
73
+ for (const [nome, valor] of Object.entries(brutas)) {
74
+ const resultado = validarConexao(valor);
75
+ if (typeof resultado === 'string') {
76
+ problemas.push(`${caminho}: conexão ${nome} ignorada, ${resultado}`);
77
+ continue;
78
+ }
79
+ for (const descarte of resultado.descartes)
80
+ problemas.push(`${caminho}: conexão ${nome}, ${descarte}`);
81
+ conexoes[nome] = { ...resultado.conexao, mascaramento: resultado.conexao.mascaramento ?? true };
82
+ }
83
+ return { conexoes, problemas };
84
+ }
85
+ function gravarAtomico(caminho, conexoes) {
86
+ const pasta = dirname(caminho);
87
+ if (!existsSync(pasta)) {
88
+ mkdirSync(pasta, { recursive: true, mode: 0o700 });
89
+ chmodSync(pasta, 0o700);
90
+ }
91
+ const temporario = `${caminho}.${process.pid}.${Date.now()}.tmp`;
92
+ try {
93
+ writeFileSync(temporario, `${JSON.stringify({ versao: VERSAO_CADASTRO, conexoes }, null, 2)}\n`, { mode: 0o600 });
94
+ chmodSync(temporario, 0o600);
95
+ renameSync(temporario, caminho);
96
+ }
97
+ catch (erro) {
98
+ rmSync(temporario, { force: true });
99
+ throw erro;
100
+ }
101
+ }
102
+ export function salvarConexao(nome, conexao, opcoes = {}) {
103
+ if (!NOME_VALIDO.test(nome)) {
104
+ throw new Error(`Nome de conexão inválido: "${nome}". Use letras minúsculas, números, _ e -, começando por letra ou número.`);
105
+ }
106
+ const validada = validarConexao(conexao);
107
+ const motivo = typeof validada === 'string' ? validada : validada.descartes[0];
108
+ if (typeof validada === 'string' || motivo !== undefined)
109
+ throw new Error(`Conexão ${nome} inválida: ${motivo}.`);
110
+ const caminho = opcoes.caminho ?? caminhoCadastro();
111
+ const brutas = lerBruto(caminho);
112
+ if (nome in brutas && !opcoes.substituir) {
113
+ throw new Error(`A conexão ${nome} já existe em ${caminho}. Use a opção de substituir para trocá-la.`);
114
+ }
115
+ gravarAtomico(caminho, { ...brutas, [nome]: validada.conexao });
116
+ }
117
+ /** Devolve true quando a conexão existia e foi removida. */
118
+ export function removerConexao(nome, caminho = caminhoCadastro()) {
119
+ const brutas = lerBruto(caminho);
120
+ if (!(nome in brutas))
121
+ return false;
122
+ const { [nome]: _removida, ...restantes } = brutas;
123
+ gravarAtomico(caminho, restantes);
124
+ return true;
125
+ }
126
+ function lerUrl(dsn) {
127
+ try {
128
+ return new URL(dsn);
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
133
+ }
134
+ export function resumirConexao(conexao) {
135
+ if (conexao.tipo === 'sqlite')
136
+ return `sqlite ${conexao.caminho ?? ''}`.trimEnd();
137
+ const url = lerUrl(conexao.dsn ?? '');
138
+ if (!url || !url.hostname)
139
+ return `${conexao.tipo} (dsn não reconhecido)`;
140
+ const porta = url.port ? `:${url.port}` : '';
141
+ return `${conexao.tipo} ${url.hostname}${porta}${url.pathname === '/' ? '' : url.pathname}`;
142
+ }
143
+ function decodificar(texto) {
144
+ try {
145
+ return decodeURIComponent(texto);
146
+ }
147
+ catch {
148
+ return texto;
149
+ }
150
+ }
151
+ /** Troca `usuario:senha` e a senha do DSN por *** em qualquer texto, nas formas codificada e decodificada. */
152
+ export function ocultarSenha(texto, conexao) {
153
+ if (!conexao.dsn)
154
+ return texto;
155
+ const url = lerUrl(conexao.dsn);
156
+ const credencial = url
157
+ ? { usuario: url.username, senha: url.password }
158
+ : /^[a-z][a-z0-9+.-]*:\/\/(?<usuario>[^:@/]*):(?<senha>[^@]*)@/i.exec(conexao.dsn)?.groups;
159
+ if (!credencial?.senha)
160
+ return texto;
161
+ const usuario = credencial.usuario ?? '';
162
+ const senha = credencial.senha;
163
+ const trechos = new Set();
164
+ const pares = [
165
+ [usuario, senha],
166
+ [decodificar(usuario), decodificar(senha)],
167
+ ];
168
+ for (const [u, s] of pares) {
169
+ if (u)
170
+ trechos.add(`${u}:${s}`);
171
+ trechos.add(s);
172
+ }
173
+ let resultado = texto;
174
+ for (const trecho of [...trechos].sort((a, b) => b.length - a.length)) {
175
+ if (trecho)
176
+ resultado = resultado.replaceAll(trecho, '***');
177
+ }
178
+ return resultado;
179
+ }