@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.
- package/.claude-plugin/plugin.json +4 -1
- package/README.md +34 -1
- package/dist/banco/ambiente.js +98 -0
- package/dist/banco/cadastro.js +179 -0
- package/dist/banco/command.js +432 -0
- package/dist/banco/conexao.js +127 -0
- package/dist/banco/drivers/index.js +13 -0
- package/dist/banco/drivers/mariadb.js +121 -0
- package/dist/banco/drivers/postgres.js +157 -0
- package/dist/banco/drivers/sqlite.js +92 -0
- package/dist/banco/drivers/tipos.js +29 -0
- package/dist/banco/ferramentas.js +241 -0
- package/dist/banco/lgpd/aplicar.js +168 -0
- package/dist/banco/lgpd/detectar.js +210 -0
- package/dist/banco/lgpd/mascarar.js +114 -0
- package/dist/banco/mcp.js +101 -0
- package/dist/banco/somente-leitura.js +221 -0
- package/dist/cli.js +15 -0
- package/dist/config/config.js +206 -2
- package/dist/doctor/checks.js +107 -2
- package/dist/init.js +25 -1
- package/dist/install/flags.js +4 -1
- package/dist/install/mcp.js +66 -0
- package/dist/install/plan.js +19 -0
- package/dist/install/settings.js +7 -1
- package/dist/update/update.js +78 -11
- package/package.json +4 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
const COMANDOS_INICIAIS = new Set(['select', 'with', 'explain', 'show', 'describe', 'desc']);
|
|
2
|
+
const PALAVRAS_PROIBIDAS = new Set([
|
|
3
|
+
'insert', 'update', 'delete', 'merge', 'drop', 'alter', 'create', 'truncate', 'grant', 'revoke',
|
|
4
|
+
'copy', 'call', 'lock', 'vacuum', 'attach', 'detach', 'into', 'outfile',
|
|
5
|
+
]);
|
|
6
|
+
const FUNCOES_PROIBIDAS = [
|
|
7
|
+
'pg_terminate_backend', 'pg_cancel_backend', 'pg_reload_conf', 'pg_rotate_logfile', 'set_config',
|
|
8
|
+
'pg_read_file', 'pg_read_binary_file', 'pg_ls_dir', 'pg_ls_logdir', 'pg_ls_waldir', 'pg_stat_file',
|
|
9
|
+
'pg_switch_wal', 'pg_promote', 'pg_notify', 'lo_import', 'lo_export', 'dblink', 'dblink_exec',
|
|
10
|
+
'load_file', 'sys_exec', 'sys_eval', 'query_to_xml', 'query_to_xmlschema', 'query_to_xml_and_xmlschema',
|
|
11
|
+
'cursor_to_xml', 'cursor_to_xmlschema', 'ts_stat', 'crosstab', 'crosstab2', 'crosstab3', 'crosstab4',
|
|
12
|
+
'connectby',
|
|
13
|
+
];
|
|
14
|
+
const INICIO_PALAVRA = /[\p{L}_]/u;
|
|
15
|
+
const CONTINUA_PALAVRA = /[\p{L}\p{N}_$]/u;
|
|
16
|
+
const DIGITO = /[0-9]/;
|
|
17
|
+
const ESPACO = /\s/;
|
|
18
|
+
const TAG_DOLAR = /^\$([\p{L}_][\p{L}\p{N}_]*)?\$/u;
|
|
19
|
+
export function tokenizar(sql, dialeto) {
|
|
20
|
+
return tokenizarCom(sql, dialeto, { barraInvertida: dialeto === 'mariadb' });
|
|
21
|
+
}
|
|
22
|
+
function tokenizarCom(sql, dialeto, regras) {
|
|
23
|
+
const tokens = [];
|
|
24
|
+
let i = 0;
|
|
25
|
+
const n = sql.length;
|
|
26
|
+
const push = (tipo, fim, valor) => {
|
|
27
|
+
tokens.push({ tipo, valor: valor ?? sql.slice(i, fim), inicio: i });
|
|
28
|
+
i = fim;
|
|
29
|
+
};
|
|
30
|
+
const fecharCitado = (aspa, escapa) => {
|
|
31
|
+
let j = i + 1;
|
|
32
|
+
while (j < n) {
|
|
33
|
+
const c = sql[j];
|
|
34
|
+
if (escapa && c === '\\') {
|
|
35
|
+
j += 2;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (c === aspa) {
|
|
39
|
+
if (sql[j + 1] === aspa) {
|
|
40
|
+
j += 2;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
return j + 1;
|
|
44
|
+
}
|
|
45
|
+
j++;
|
|
46
|
+
}
|
|
47
|
+
throw new Error(aspa === "'" || (dialeto === 'mariadb' && aspa === '"') ? 'string sem fechamento' : 'identificador sem fechamento');
|
|
48
|
+
};
|
|
49
|
+
const citado = (aspa) => {
|
|
50
|
+
const fim = fecharCitado(aspa, false);
|
|
51
|
+
push('identificador-citado', fim, sql.slice(i + 1, fim - 1).split(aspa + aspa).join(aspa).toLowerCase());
|
|
52
|
+
};
|
|
53
|
+
while (i < n) {
|
|
54
|
+
const c = sql[i];
|
|
55
|
+
const prox = sql[i + 1];
|
|
56
|
+
if (ESPACO.test(c)) {
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (c === '/' && prox === '*') {
|
|
61
|
+
const resto = sql.slice(i + 2, i + 4);
|
|
62
|
+
if (resto.startsWith('!') || /^m!/i.test(resto)) {
|
|
63
|
+
throw new Error('comentário executável /*! ou /*M! não permitido');
|
|
64
|
+
}
|
|
65
|
+
push('comentario', fecharComentarioBloco(sql, i, dialeto === 'postgres'));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const comentarioLinha = (c === '-' && prox === '-' && (dialeto !== 'mariadb' || espacoOuFim(sql, i + 2))) ||
|
|
69
|
+
(c === '#' && dialeto === 'mariadb');
|
|
70
|
+
if (comentarioLinha) {
|
|
71
|
+
const quebra = sql.indexOf('\n', i);
|
|
72
|
+
push('comentario', quebra === -1 ? n : quebra);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (dialeto === 'postgres') {
|
|
76
|
+
if ((c === 'e' || c === 'E') && prox === "'") {
|
|
77
|
+
throw new Error("prefixo E' não permitido");
|
|
78
|
+
}
|
|
79
|
+
if ((c === 'u' || c === 'U') && prox === '&' && (sql[i + 2] === "'" || sql[i + 2] === '"')) {
|
|
80
|
+
throw new Error('prefixo U& não permitido');
|
|
81
|
+
}
|
|
82
|
+
if (c === '$') {
|
|
83
|
+
if (prox !== undefined && DIGITO.test(prox)) {
|
|
84
|
+
let j = i + 1;
|
|
85
|
+
while (j < n && DIGITO.test(sql[j]))
|
|
86
|
+
j++;
|
|
87
|
+
push('simbolo', j);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const tag = TAG_DOLAR.exec(sql.slice(i));
|
|
91
|
+
if (tag) {
|
|
92
|
+
const fim = sql.indexOf(tag[0], i + tag[0].length);
|
|
93
|
+
if (fim === -1)
|
|
94
|
+
throw new Error('string sem fechamento');
|
|
95
|
+
push('string', fim + tag[0].length);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (c === "'") {
|
|
101
|
+
push('string', fecharCitado("'", regras.barraInvertida));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (c === '"') {
|
|
105
|
+
if (dialeto === 'mariadb')
|
|
106
|
+
push('string', fecharCitado('"', regras.barraInvertida));
|
|
107
|
+
else
|
|
108
|
+
citado('"');
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (c === '`' && dialeto !== 'postgres') {
|
|
112
|
+
citado('`');
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (c === '[' && dialeto === 'sqlite') {
|
|
116
|
+
const fim = sql.indexOf(']', i + 1);
|
|
117
|
+
if (fim === -1)
|
|
118
|
+
throw new Error('identificador sem fechamento');
|
|
119
|
+
push('identificador-citado', fim + 1, sql.slice(i + 1, fim).toLowerCase());
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (INICIO_PALAVRA.test(c) || (c === '$' && dialeto === 'mariadb')) {
|
|
123
|
+
let j = i + 1;
|
|
124
|
+
while (j < n && CONTINUA_PALAVRA.test(sql[j]))
|
|
125
|
+
j++;
|
|
126
|
+
push('palavra', j, sql.slice(i, j).toLowerCase());
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (DIGITO.test(c)) {
|
|
130
|
+
let j = i + 1;
|
|
131
|
+
while (j < n && /[0-9.]/.test(sql[j]))
|
|
132
|
+
j++;
|
|
133
|
+
push('simbolo', j);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
push('simbolo', i + 1);
|
|
137
|
+
}
|
|
138
|
+
return tokens;
|
|
139
|
+
}
|
|
140
|
+
function espacoOuFim(sql, j) {
|
|
141
|
+
const c = sql[j];
|
|
142
|
+
return c === undefined || ESPACO.test(c);
|
|
143
|
+
}
|
|
144
|
+
function fecharComentarioBloco(sql, inicio, aninhado) {
|
|
145
|
+
let profundidade = 1;
|
|
146
|
+
let j = inicio + 2;
|
|
147
|
+
while (j < sql.length) {
|
|
148
|
+
if (aninhado && sql[j] === '/' && sql[j + 1] === '*') {
|
|
149
|
+
profundidade++;
|
|
150
|
+
j += 2;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (sql[j] === '*' && sql[j + 1] === '/') {
|
|
154
|
+
profundidade--;
|
|
155
|
+
j += 2;
|
|
156
|
+
if (profundidade === 0)
|
|
157
|
+
return j;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
j++;
|
|
161
|
+
}
|
|
162
|
+
throw new Error('comentário sem fechamento');
|
|
163
|
+
}
|
|
164
|
+
function funcaoProibida(sql) {
|
|
165
|
+
const minusculo = sql.toLowerCase();
|
|
166
|
+
return FUNCOES_PROIBIDAS.find((nome) => new RegExp(`(?<![a-z0-9_])${nome}(?![a-z0-9_])`).test(minusculo));
|
|
167
|
+
}
|
|
168
|
+
function validarTokens(sql, tokens) {
|
|
169
|
+
const uteis = tokens.filter((t) => t.tipo !== 'comentario');
|
|
170
|
+
const primeiro = uteis[0];
|
|
171
|
+
if (!primeiro)
|
|
172
|
+
return { ok: false, motivo: 'consulta vazia' };
|
|
173
|
+
const pontos = uteis.filter((t) => t.tipo === 'simbolo' && t.valor === ';');
|
|
174
|
+
const ultimo = uteis[uteis.length - 1];
|
|
175
|
+
if (pontos.length > 1 || (pontos.length === 1 && pontos[0] !== ultimo)) {
|
|
176
|
+
return { ok: false, motivo: 'mais de um comando: ";" só é aceito no fim' };
|
|
177
|
+
}
|
|
178
|
+
if (primeiro.tipo !== 'palavra' || !COMANDOS_INICIAIS.has(primeiro.valor)) {
|
|
179
|
+
const texto = primeiro.tipo === 'palavra' ? primeiro.valor.toUpperCase() : sql.slice(primeiro.inicio).split(/\s/)[0];
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
motivo: `comando ${texto} não permitido: só SELECT, WITH, EXPLAIN, SHOW, DESCRIBE ou DESC`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const proibida = uteis.find((t) => t.tipo === 'palavra' && PALAVRAS_PROIBIDAS.has(t.valor));
|
|
186
|
+
if (proibida) {
|
|
187
|
+
return { ok: false, motivo: `palavra ${proibida.valor.toUpperCase()} não permitida em consulta de leitura` };
|
|
188
|
+
}
|
|
189
|
+
const ponto = pontos[0];
|
|
190
|
+
const final = ponto ? sql.slice(0, ponto.inicio) + sql.slice(ponto.inicio + 1) : sql;
|
|
191
|
+
return { ok: true, sql: final };
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Filtro léxico de somente leitura. Falha fechado: qualquer dúvida do tokenizador vira recusa.
|
|
195
|
+
* No MariaDB a consulta precisa passar com e sem escape por `\`, cobrindo `NO_BACKSLASH_ESCAPES`.
|
|
196
|
+
*/
|
|
197
|
+
export function validarLeitura(sql, dialeto) {
|
|
198
|
+
const funcao = funcaoProibida(sql);
|
|
199
|
+
if (funcao)
|
|
200
|
+
return { ok: false, motivo: `função ${funcao} não permitida` };
|
|
201
|
+
const modos = dialeto === 'mariadb' ? [{ barraInvertida: true }, { barraInvertida: false }] : [{ barraInvertida: false }];
|
|
202
|
+
let resultado = { ok: false, motivo: 'consulta vazia' };
|
|
203
|
+
for (const regras of modos) {
|
|
204
|
+
let tokens;
|
|
205
|
+
try {
|
|
206
|
+
tokens = tokenizarCom(sql, dialeto, regras);
|
|
207
|
+
}
|
|
208
|
+
catch (erro) {
|
|
209
|
+
return { ok: false, motivo: erro.message };
|
|
210
|
+
}
|
|
211
|
+
resultado = validarTokens(sql, tokens);
|
|
212
|
+
if (!resultado.ok)
|
|
213
|
+
return resultado;
|
|
214
|
+
}
|
|
215
|
+
return resultado;
|
|
216
|
+
}
|
|
217
|
+
export function identificadoresDoSql(sql, dialeto) {
|
|
218
|
+
return tokenizar(sql, dialeto)
|
|
219
|
+
.filter((t) => t.tipo === 'palavra' || t.tipo === 'identificador-citado')
|
|
220
|
+
.map((t) => t.valor);
|
|
221
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,8 @@ const RESERVED = new Set([
|
|
|
14
14
|
'conflitos',
|
|
15
15
|
'comentarios',
|
|
16
16
|
'memoria',
|
|
17
|
+
'banco',
|
|
18
|
+
'banco-mcp',
|
|
17
19
|
'list',
|
|
18
20
|
'run',
|
|
19
21
|
'help',
|
|
@@ -47,12 +49,15 @@ Gerenciar:
|
|
|
47
49
|
init [caminho] Instala os slash commands em .claude/commands do projeto (interativo)
|
|
48
50
|
doctor [caminho] Diagnostica a instalação (arquivos, lock, skills, agents, hooks, Node)
|
|
49
51
|
update [caminho] Atualiza o que foi instalado: --check só lista, --yes aplica, --forcar sobrescreve conflitos
|
|
52
|
+
--sem-banco: não registra o MCP de banco no .mcp.json e lembra a recusa no config
|
|
50
53
|
migrar [caminho] Move docs/specs, plans, bugs e investigacoes para docs/trabalhos/<id>/ (--yes aplica)
|
|
51
54
|
--reclassificar: planos já migrados em em-execucao viram concluido ou legado
|
|
52
55
|
comentarios [caminho] Instala o hook de comentários e a catraca do legado (--yes aplica)
|
|
53
56
|
memoria [caminho] Índice de quem mexeu em cada arquivo (--arquivo <caminho>, --regressao <id do bug>)
|
|
54
57
|
evento <evento> Grava no rastro do trabalho (--trabalho, --task, --nome, --resultado, --dados '{json}')
|
|
55
58
|
conflitos <id> Aponta trabalhos em execução que mexem nos mesmos arquivos (--aguardar espera liberar)
|
|
59
|
+
banco <subcomando> Conexões de banco somente leitura: adicionar, listar, testar, usar, remover
|
|
60
|
+
banco-mcp Servidor MCP de leitura do banco, usado pelo Claude Code (stdio)
|
|
56
61
|
list Lista todos os comandos disponíveis no catálogo
|
|
57
62
|
|
|
58
63
|
Init sem perguntas (CI):
|
|
@@ -61,6 +66,7 @@ Init sem perguntas (CI):
|
|
|
61
66
|
--sem-hooks não instala os hooks do harness
|
|
62
67
|
--sem-agents não instala os agents somente leitura
|
|
63
68
|
--sem-comentarios não instala a rule, o hook e a catraca de comentários
|
|
69
|
+
--sem-banco não registra o servidor MCP de banco no .mcp.json
|
|
64
70
|
--claude-md criar|manter|backup|sobrescrever (padrão: criar)
|
|
65
71
|
--conflito sobrescrever|pular (padrão: pular)
|
|
66
72
|
--yes aplica; sem ele o init só simula
|
|
@@ -146,6 +152,15 @@ async function main() {
|
|
|
146
152
|
const { runConflictsCommand } = await import('./trabalhos/conflitos.js');
|
|
147
153
|
process.exit(await runConflictsCommand(argv.slice(1)));
|
|
148
154
|
}
|
|
155
|
+
if (cmd === 'banco') {
|
|
156
|
+
const { runBancoCommand } = await import('./banco/command.js');
|
|
157
|
+
process.exit(await runBancoCommand(argv.slice(1)));
|
|
158
|
+
}
|
|
159
|
+
if (cmd === 'banco-mcp') {
|
|
160
|
+
const { iniciarServidorMcp } = await import('./banco/mcp.js');
|
|
161
|
+
await iniciarServidorMcp({ entrada: process.stdin, saida: process.stdout });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
149
164
|
if (cmd === 'update') {
|
|
150
165
|
const { runUpdateCommand } = await import('./update/update.js');
|
|
151
166
|
process.exit(runUpdateCommand(argv.slice(1)));
|
package/dist/config/config.js
CHANGED
|
@@ -11,6 +11,8 @@ export const DEFAULT_HOOK_MODES = {
|
|
|
11
11
|
'proteger-contratos': 'aviso',
|
|
12
12
|
'sem-jargao': 'aviso',
|
|
13
13
|
};
|
|
14
|
+
export const MODOS_PRODUCAO = ['aviso', 'bloqueio'];
|
|
15
|
+
export const TIPOS_CONEXAO_PROJETO = ['postgres', 'mariadb', 'sqlite'];
|
|
14
16
|
function isObject(value) {
|
|
15
17
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
18
|
}
|
|
@@ -20,8 +22,139 @@ export function defaultConfig() {
|
|
|
20
22
|
hooks: { ...DEFAULT_HOOK_MODES },
|
|
21
23
|
rastro: { versionar: false },
|
|
22
24
|
bloqueios: { dias_para_aviso: 7 },
|
|
25
|
+
banco: {
|
|
26
|
+
conexoes: [],
|
|
27
|
+
producao: 'bloqueio',
|
|
28
|
+
limite_linhas: 200,
|
|
29
|
+
timeout_segundos: 30,
|
|
30
|
+
registrar_mcp: true,
|
|
31
|
+
lgpd: { mascarar_colunas: [], liberar_colunas: [] },
|
|
32
|
+
projeto: {},
|
|
33
|
+
declarado: false,
|
|
34
|
+
},
|
|
23
35
|
};
|
|
24
36
|
}
|
|
37
|
+
function inteiroEntre(value, min, max) {
|
|
38
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;
|
|
39
|
+
}
|
|
40
|
+
const SO_NO_CADASTRO = 'O mascaramento LGPD só pode ser desligado no cadastro da máquina (mascaramento: false na conexão)';
|
|
41
|
+
const REGRA_DE_COLUNA = /^[^.\s]+(\.[^.\s]+){0,2}$/;
|
|
42
|
+
function lerLgpd(raw, lgpd, problems) {
|
|
43
|
+
if (!isObject(raw)) {
|
|
44
|
+
problems.push(`banco.lgpd deve ser um objeto com mascarar_colunas e liberar_colunas em ${CONFIG_PATH}.`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if ('desligar' in raw)
|
|
48
|
+
problems.push(`${SO_NO_CADASTRO}; banco.lgpd.desligar em ${CONFIG_PATH} foi ignorado.`);
|
|
49
|
+
for (const chave of ['mascarar_colunas', 'liberar_colunas']) {
|
|
50
|
+
if (!(chave in raw))
|
|
51
|
+
continue;
|
|
52
|
+
const lista = raw[chave];
|
|
53
|
+
if (!Array.isArray(lista)) {
|
|
54
|
+
problems.push(`banco.lgpd.${chave} deve ser uma lista de colunas em ${CONFIG_PATH}.`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
lgpd[chave] = lista.filter((regra) => {
|
|
58
|
+
if (typeof regra === 'string' && REGRA_DE_COLUNA.test(regra))
|
|
59
|
+
return true;
|
|
60
|
+
problems.push(`Entrada ${JSON.stringify(regra)} ignorada em banco.lgpd.${chave} de ${CONFIG_PATH}; use coluna, tabela.coluna ou schema.tabela.coluna.`);
|
|
61
|
+
return false;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export const REGRA_NOME_CONEXAO = /^[a-z0-9][a-z0-9_-]*$/;
|
|
66
|
+
export const REGRA_DSN_ENV = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
67
|
+
const CHAVES_CONEXAO_PROJETO = ['nome', 'dsn_env', 'tipo', 'producao'];
|
|
68
|
+
/** Devolve o motivo de a conexão ser inválida, ou null se ela puder ser usada. */
|
|
69
|
+
function motivoConexaoInvalida(entrada) {
|
|
70
|
+
if (typeof entrada.nome !== 'string' || !REGRA_NOME_CONEXAO.test(entrada.nome)) {
|
|
71
|
+
return 'nome deve usar só minúsculas, dígitos, - e _, começando por minúscula ou dígito';
|
|
72
|
+
}
|
|
73
|
+
if (typeof entrada.dsn_env !== 'string' || !REGRA_DSN_ENV.test(entrada.dsn_env)) {
|
|
74
|
+
return 'dsn_env deve ser um nome de variável de ambiente (letras, dígitos e _, sem começar por dígito)';
|
|
75
|
+
}
|
|
76
|
+
if ('tipo' in entrada && !TIPOS_CONEXAO_PROJETO.includes(entrada.tipo)) {
|
|
77
|
+
return 'tipo deve ser postgres, mariadb ou sqlite';
|
|
78
|
+
}
|
|
79
|
+
if ('producao' in entrada && typeof entrada.producao !== 'boolean')
|
|
80
|
+
return 'producao deve ser true ou false';
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
function lerConexoes(conexoes, banco, problems) {
|
|
84
|
+
banco.declarado = !(Array.isArray(conexoes) && conexoes.length === 0);
|
|
85
|
+
if (!Array.isArray(conexoes)) {
|
|
86
|
+
problems.push(`banco.conexoes deve ser uma lista de nomes ou de objetos { nome, dsn_env } em ${CONFIG_PATH}.`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const entrada of conexoes) {
|
|
90
|
+
const citada = JSON.stringify(entrada);
|
|
91
|
+
let nome;
|
|
92
|
+
if (typeof entrada === 'string' && entrada.trim() !== '')
|
|
93
|
+
nome = entrada;
|
|
94
|
+
else if (isObject(entrada)) {
|
|
95
|
+
const motivo = motivoConexaoInvalida(entrada);
|
|
96
|
+
if (motivo) {
|
|
97
|
+
problems.push(`Entrada ${citada} descartada em banco.conexoes de ${CONFIG_PATH}: ${motivo}.`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
nome = entrada.nome;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
problems.push(`Entrada ${citada} descartada em banco.conexoes de ${CONFIG_PATH}; use um nome (texto não vazio) ou um objeto { nome, dsn_env }.`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (banco.conexoes.includes(nome)) {
|
|
107
|
+
problems.push(`Entrada ${citada} descartada em banco.conexoes de ${CONFIG_PATH}: o nome "${nome}" já foi declarado antes.`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
banco.conexoes.push(nome);
|
|
111
|
+
if (typeof entrada === 'string' || !isObject(entrada))
|
|
112
|
+
continue;
|
|
113
|
+
for (const chave of Object.keys(entrada).filter((chave) => !CHAVES_CONEXAO_PROJETO.includes(chave))) {
|
|
114
|
+
problems.push(chave === 'mascaramento'
|
|
115
|
+
? `${SO_NO_CADASTRO}; mascaramento da entrada ${citada} em banco.conexoes de ${CONFIG_PATH} foi ignorado.`
|
|
116
|
+
: `Chave desconhecida ${chave} ignorada na entrada ${citada} de banco.conexoes em ${CONFIG_PATH}.`);
|
|
117
|
+
}
|
|
118
|
+
banco.projeto[nome] = {
|
|
119
|
+
nome,
|
|
120
|
+
dsn_env: entrada.dsn_env,
|
|
121
|
+
...(entrada.tipo === undefined ? {} : { tipo: entrada.tipo }),
|
|
122
|
+
producao: entrada.producao === true,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function lerBanco(raw, banco, problems) {
|
|
127
|
+
if ('conexoes' in raw)
|
|
128
|
+
lerConexoes(raw.conexoes, banco, problems);
|
|
129
|
+
if ('producao' in raw) {
|
|
130
|
+
if (MODOS_PRODUCAO.includes(raw.producao))
|
|
131
|
+
banco.producao = raw.producao;
|
|
132
|
+
else
|
|
133
|
+
problems.push(`Modo "${String(raw.producao)}" inválido para banco.producao em ${CONFIG_PATH}; use aviso ou bloqueio.`);
|
|
134
|
+
}
|
|
135
|
+
if ('limite_linhas' in raw) {
|
|
136
|
+
if (inteiroEntre(raw.limite_linhas, 1, 5000))
|
|
137
|
+
banco.limite_linhas = raw.limite_linhas;
|
|
138
|
+
else
|
|
139
|
+
problems.push(`banco.limite_linhas deve ser um inteiro de 1 a 5000 em ${CONFIG_PATH}.`);
|
|
140
|
+
}
|
|
141
|
+
if ('timeout_segundos' in raw) {
|
|
142
|
+
if (inteiroEntre(raw.timeout_segundos, 1, 600))
|
|
143
|
+
banco.timeout_segundos = raw.timeout_segundos;
|
|
144
|
+
else
|
|
145
|
+
problems.push(`banco.timeout_segundos deve ser um inteiro de 1 a 600 em ${CONFIG_PATH}.`);
|
|
146
|
+
}
|
|
147
|
+
if ('registrar_mcp' in raw) {
|
|
148
|
+
if (typeof raw.registrar_mcp === 'boolean')
|
|
149
|
+
banco.registrar_mcp = raw.registrar_mcp;
|
|
150
|
+
else
|
|
151
|
+
problems.push(`banco.registrar_mcp deve ser true ou false em ${CONFIG_PATH}.`);
|
|
152
|
+
}
|
|
153
|
+
if ('mascaramento' in raw)
|
|
154
|
+
problems.push(`${SO_NO_CADASTRO}; banco.mascaramento em ${CONFIG_PATH} foi ignorado.`);
|
|
155
|
+
if ('lgpd' in raw)
|
|
156
|
+
lerLgpd(raw.lgpd, banco.lgpd, problems);
|
|
157
|
+
}
|
|
25
158
|
/** Lê a config sobre os padrões; valores inválidos são ignorados e viram problema, nunca erro. */
|
|
26
159
|
export function readConfig(root) {
|
|
27
160
|
const config = defaultConfig();
|
|
@@ -59,17 +192,88 @@ export function readConfig(root) {
|
|
|
59
192
|
else
|
|
60
193
|
problems.push(`bloqueios.dias_para_aviso deve ser um inteiro positivo em ${CONFIG_PATH}.`);
|
|
61
194
|
}
|
|
195
|
+
if (isObject(raw.banco))
|
|
196
|
+
lerBanco(raw.banco, config.banco, problems);
|
|
62
197
|
return { config, problems };
|
|
63
198
|
}
|
|
64
|
-
/** Cria a config com os padrões; retorna false se já existia (nunca sobrescreve). */
|
|
199
|
+
/** Cria a config com os padrões, sem a seção banco; retorna false se já existia (nunca sobrescreve). */
|
|
65
200
|
export function writeDefaultConfig(root) {
|
|
66
201
|
const file = join(root, CONFIG_PATH);
|
|
67
202
|
if (existsSync(file))
|
|
68
203
|
return false;
|
|
69
204
|
mkdirSync(dirname(file), { recursive: true });
|
|
70
|
-
|
|
205
|
+
const { banco: _banco, ...gravada } = defaultConfig();
|
|
206
|
+
writeFileSync(file, `${JSON.stringify(gravada, null, 2)}\n`);
|
|
71
207
|
return true;
|
|
72
208
|
}
|
|
209
|
+
function lerJsonParaEscrita(file, chave) {
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
parsed = undefined;
|
|
216
|
+
}
|
|
217
|
+
if (!isObject(parsed))
|
|
218
|
+
throw new Error(`O ${CONFIG_PATH} está com JSON inválido; corrija-o antes de gravar ${chave}.`);
|
|
219
|
+
return parsed;
|
|
220
|
+
}
|
|
221
|
+
/** Lança o mesmo erro de `gravarConexaoProjeto` se a config existir com JSON inválido; nunca escreve. */
|
|
222
|
+
export function validarConfigParaEscrita(root) {
|
|
223
|
+
const file = join(root, CONFIG_PATH);
|
|
224
|
+
if (existsSync(file))
|
|
225
|
+
lerJsonParaEscrita(file, 'banco.conexoes');
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Grava a conexão em `banco.conexoes`, preservando o resto do JSON; sem config, cria uma com os padrões.
|
|
229
|
+
* Se o nome já existir (objeto ou string), lança, ou troca a entrada na mesma posição com `substituir`.
|
|
230
|
+
* JSON inválido é recusado sem tocar no arquivo.
|
|
231
|
+
*/
|
|
232
|
+
export function gravarConexaoProjeto(root, conexao, { substituir = false } = {}) {
|
|
233
|
+
const motivo = motivoConexaoInvalida({ ...conexao });
|
|
234
|
+
if (motivo)
|
|
235
|
+
throw new Error(`Conexão ${JSON.stringify(conexao.nome)} inválida: ${motivo}.`);
|
|
236
|
+
const file = join(root, CONFIG_PATH);
|
|
237
|
+
const raw = existsSync(file) ? lerJsonParaEscrita(file, 'banco.conexoes') : undefined;
|
|
238
|
+
const banco = raw && isObject(raw.banco) ? raw.banco : {};
|
|
239
|
+
const conexoes = banco.conexoes === undefined ? [] : banco.conexoes;
|
|
240
|
+
if (!Array.isArray(conexoes))
|
|
241
|
+
throw new Error(`banco.conexoes em ${CONFIG_PATH} não é uma lista; corrija-o antes de gravar a conexão ${conexao.nome}.`);
|
|
242
|
+
const posicao = conexoes.findIndex((entrada) => (isObject(entrada) ? entrada.nome : entrada) === conexao.nome);
|
|
243
|
+
if (posicao !== -1 && !substituir)
|
|
244
|
+
throw new Error(`A conexão ${conexao.nome} já está em banco.conexoes de ${CONFIG_PATH}.`);
|
|
245
|
+
const entrada = {
|
|
246
|
+
nome: conexao.nome,
|
|
247
|
+
dsn_env: conexao.dsn_env,
|
|
248
|
+
...(conexao.tipo === undefined ? {} : { tipo: conexao.tipo }),
|
|
249
|
+
...(conexao.producao === true ? { producao: true } : {}),
|
|
250
|
+
};
|
|
251
|
+
const proximas = posicao === -1 ? [...conexoes, entrada] : conexoes.map((atual, i) => (i === posicao ? entrada : atual));
|
|
252
|
+
if (!raw)
|
|
253
|
+
writeDefaultConfig(root);
|
|
254
|
+
const base = raw ?? lerJsonParaEscrita(file, 'banco.conexoes');
|
|
255
|
+
writeFileSync(file, `${JSON.stringify({ ...base, banco: { ...banco, conexoes: proximas } }, null, 2)}\n`);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Grava só `banco.registrar_mcp`, preservando o resto do JSON; sem config, cria uma com os padrões
|
|
259
|
+
* e essa chave. JSON inválido é recusado sem tocar no arquivo.
|
|
260
|
+
*/
|
|
261
|
+
export function gravarRegistrarMcp(root, valor) {
|
|
262
|
+
const file = join(root, CONFIG_PATH);
|
|
263
|
+
let raw;
|
|
264
|
+
if (existsSync(file)) {
|
|
265
|
+
raw = lerJsonParaEscrita(file, 'banco.registrar_mcp');
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
const { banco: _banco, ...padrao } = defaultConfig();
|
|
269
|
+
raw = padrao;
|
|
270
|
+
}
|
|
271
|
+
const banco = isObject(raw.banco) ? raw.banco : {};
|
|
272
|
+
if (banco.registrar_mcp === valor)
|
|
273
|
+
return;
|
|
274
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
275
|
+
writeFileSync(file, `${JSON.stringify({ ...raw, banco: { ...banco, registrar_mcp: valor } }, null, 2)}\n`);
|
|
276
|
+
}
|
|
73
277
|
const BLOCK_START = '# ai-harness (gerenciado pelo harness; não edite entre estas linhas)';
|
|
74
278
|
const BLOCK_END = '# /ai-harness';
|
|
75
279
|
/**
|
package/dist/doctor/checks.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { lerEnvSettingsLocal, lerVariavel, SETTINGS_LOCAL_PATH } from '../banco/ambiente.js';
|
|
5
|
+
import { caminhoCadastro, lerCadastro } from '../banco/cadastro.js';
|
|
4
6
|
import { allCommands, loadCatalog } from '../catalog.js';
|
|
5
7
|
import { CONFIG_PATH, readConfig } from '../config/config.js';
|
|
6
8
|
import { readArtifact, validateAllWorks, WORKS_DIR } from '../contratos/artefatos.js';
|
|
@@ -396,6 +398,108 @@ function checkConfig(problems) {
|
|
|
396
398
|
correcao: `Corrija o ${CONFIG_PATH}; enquanto isso, valem os padrões (segurança em bloqueio, demais hooks em aviso).`,
|
|
397
399
|
}));
|
|
398
400
|
}
|
|
401
|
+
const LINHAS_IGNORE_SETTINGS_LOCAL = new Set(['.claude/settings.local.json', '.claude/', '.claude', 'settings.local.json']);
|
|
402
|
+
function settingsLocalIgnorado(root, env) {
|
|
403
|
+
const opcoes = { cwd: root, env: { ...process.env, ...env }, stdio: 'ignore', timeout: 5000 };
|
|
404
|
+
const repositorio = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], opcoes);
|
|
405
|
+
if (!repositorio.error && repositorio.status === 0) {
|
|
406
|
+
return spawnSync('git', ['check-ignore', '-q', SETTINGS_LOCAL_PATH], opcoes).status === 0;
|
|
407
|
+
}
|
|
408
|
+
const gitignore = join(root, '.gitignore');
|
|
409
|
+
if (!existsSync(gitignore))
|
|
410
|
+
return false;
|
|
411
|
+
return readFileSync(gitignore, 'utf8')
|
|
412
|
+
.split(/\r?\n/)
|
|
413
|
+
.map((linha) => linha.trim().replace(/^\//, '').replace(/^\*\*\//, ''))
|
|
414
|
+
.some((linha) => LINHAS_IGNORE_SETTINGS_LOCAL.has(linha));
|
|
415
|
+
}
|
|
416
|
+
function checkBancoProjeto(root, config, env) {
|
|
417
|
+
const conexoes = config.banco.conexoes.flatMap((nome) => config.banco.projeto[nome] ?? []);
|
|
418
|
+
if (!conexoes.length)
|
|
419
|
+
return [];
|
|
420
|
+
const findings = [];
|
|
421
|
+
for (const { nome, dsn_env } of conexoes) {
|
|
422
|
+
if (lerVariavel(dsn_env, root, env))
|
|
423
|
+
continue;
|
|
424
|
+
findings.push({
|
|
425
|
+
id: 'banco-variavel-ausente',
|
|
426
|
+
severidade: 'aviso',
|
|
427
|
+
mensagem: `A conexão ${nome}, declarada em banco.conexoes de ${join(root, CONFIG_PATH)}, lê o DSN da variável ${dsn_env}, que não está definida no ambiente, em ${SETTINGS_LOCAL_PATH} nem no .env.`,
|
|
428
|
+
correcao: `Defina ${dsn_env} com \`npx @fontdata/ai-harness banco adicionar ${nome} --projeto\` ou no ambiente do terminal.`,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const envLocal = lerEnvSettingsLocal(root);
|
|
432
|
+
const variaveis = [...new Set(conexoes.map(({ dsn_env }) => dsn_env).filter((variavel) => envLocal[variavel]))];
|
|
433
|
+
if (!variaveis.length)
|
|
434
|
+
return findings;
|
|
435
|
+
const caminho = join(root, SETTINGS_LOCAL_PATH);
|
|
436
|
+
const lista = variaveis.join(', ');
|
|
437
|
+
if (!settingsLocalIgnorado(root, env)) {
|
|
438
|
+
findings.push({
|
|
439
|
+
id: 'banco-settings-local-versionado',
|
|
440
|
+
severidade: 'aviso',
|
|
441
|
+
mensagem: `O ${caminho} guarda o DSN de ${lista} e não está ignorado pelo git; a senha pode ir para o repositório.`,
|
|
442
|
+
correcao: `Acrescente ao .gitignore da raiz a linha \`.claude/settings.local.json\`. Se o arquivo já foi commitado, rode também \`git rm --cached .claude/settings.local.json\`.`,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
const modo = process.platform !== 'win32' ? statSync(caminho).mode & 0o777 : 0;
|
|
446
|
+
if (modo & 0o077) {
|
|
447
|
+
findings.push({
|
|
448
|
+
id: 'banco-settings-local-permissao',
|
|
449
|
+
severidade: 'aviso',
|
|
450
|
+
mensagem: `O ${caminho} guarda o DSN de ${lista} e pode ser lido por outros usuários desta máquina (modo ${modo.toString(8)}).`,
|
|
451
|
+
correcao: `Restrinja ao seu usuário: \`chmod 600 ${caminho}\`.`,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
return findings;
|
|
455
|
+
}
|
|
456
|
+
/** Confere o cadastro desta máquina e as variáveis das conexões do projeto contra `banco.conexoes`, sem abrir conexão. */
|
|
457
|
+
export function checkBanco(root, config, env = process.env) {
|
|
458
|
+
if (!config.banco.conexoes.length)
|
|
459
|
+
return [];
|
|
460
|
+
const findings = checkBancoProjeto(root, config, env);
|
|
461
|
+
const doCadastro = config.banco.conexoes.filter((nome) => !(nome in config.banco.projeto));
|
|
462
|
+
if (!doCadastro.length)
|
|
463
|
+
return findings;
|
|
464
|
+
const caminho = caminhoCadastro(env);
|
|
465
|
+
const modo = process.platform !== 'win32' && existsSync(caminho) ? statSync(caminho).mode & 0o777 : 0;
|
|
466
|
+
if (modo & 0o077) {
|
|
467
|
+
findings.push({
|
|
468
|
+
id: 'banco-cadastro-permissao',
|
|
469
|
+
severidade: 'aviso',
|
|
470
|
+
mensagem: `O cadastro de conexões ${caminho} pode ser lido por outros usuários desta máquina (modo ${modo.toString(8)}).`,
|
|
471
|
+
correcao: `Restrinja ao seu usuário: \`chmod 600 ${caminho}\`.`,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
const { conexoes, problemas } = lerCadastro(caminho);
|
|
475
|
+
for (const mensagem of problemas) {
|
|
476
|
+
findings.push({
|
|
477
|
+
id: 'banco-cadastro-invalido',
|
|
478
|
+
severidade: 'aviso',
|
|
479
|
+
mensagem,
|
|
480
|
+
correcao: `Corrija o JSON de ${caminho} (formato {"versao": 1, "conexoes": {...}}) ou recadastre as conexões.`,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
if (problemas.length)
|
|
484
|
+
return findings;
|
|
485
|
+
for (const nome of doCadastro.filter((nome) => !(nome in conexoes))) {
|
|
486
|
+
findings.push({
|
|
487
|
+
id: 'banco-conexao-ausente',
|
|
488
|
+
severidade: 'aviso',
|
|
489
|
+
mensagem: `A conexão ${nome}, citada em banco.conexoes de ${join(root, CONFIG_PATH)}, não está no cadastro desta máquina (${caminho}).`,
|
|
490
|
+
correcao: `Cadastre-a com \`npx @fontdata/ai-harness banco adicionar ${nome}\`.`,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
for (const nome of doCadastro.filter((nome) => conexoes[nome]?.mascaramento === false)) {
|
|
494
|
+
findings.push({
|
|
495
|
+
id: 'banco-mascaramento-desligado',
|
|
496
|
+
severidade: 'aviso',
|
|
497
|
+
mensagem: `A conexão ${nome}, usada por este projeto, está com o mascaramento LGPD desligado no cadastro desta máquina (${caminho}).`,
|
|
498
|
+
correcao: `Os dados pessoais da conexão ${nome} chegam sem máscara ao Claude. Se não for de propósito, remova "mascaramento": false dela em ${caminho}.`,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return findings;
|
|
502
|
+
}
|
|
399
503
|
/** Diagnostica a instalação do harness no projeto; nunca altera arquivos. */
|
|
400
504
|
export function runDoctor(root, options = {}) {
|
|
401
505
|
const { findings: lockFindings, lock } = checkLock(root);
|
|
@@ -410,6 +514,7 @@ export function runDoctor(root, options = {}) {
|
|
|
410
514
|
...checkSettings(root),
|
|
411
515
|
...checkGitIgnore(root),
|
|
412
516
|
...checkConfig(configProblems),
|
|
517
|
+
...checkBanco(root, config),
|
|
413
518
|
...checkWorks(root),
|
|
414
519
|
...checkStaleBlocks(root, options.now ?? new Date(), options.staleBlockDays ?? config.bloqueios.dias_para_aviso),
|
|
415
520
|
...checkEventLogs(root),
|