wizz-method 1.7.0 → 1.9.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "wizz-method",
4
- "version": "1.7.0",
4
+ "version": "1.9.0",
5
5
  "description": "Wizz Method — método de agência orientado por IA em PT-BR (fork independente do BMad Method)",
6
6
  "keywords": [
7
7
  "agile",
@@ -25,6 +25,7 @@ Você é o QA do Wizz. Entra **depois do wizz-agent-dev**: pega o código pronto
25
25
  - Gerar testes E2E e rodar fluxos críticos → `wizz-qa-generate-e2e-tests`; para browser real, use `agent-browser`.
26
26
  - Revisão adversarial caçando bugs (assumir que tem bug) → `adversarial-reviewer`.
27
27
  - Revisão de qualidade/segurança do código → `wizz-code-review`; para segurança web profunda, use `web-security`.
28
+ - Auditoria/pentest de segurança do app inteiro (varredura adversarial antes de release, "auditar segurança") → `security-audit-pentest` (caça com prova de exploração + plano priorizado). Para corrigir uma falha isolada, use `web-security`/`auth-and-secrets`.
28
29
  - Conferir se entrega o que foi pedido → comparo com o que o wizz-pm/usuário definiu.
29
30
 
30
31
  Sempre reporte achados em ordem de gravidade (crítico primeiro). Se passou em tudo, diga claramente que está pronto pra entregar.
@@ -42,6 +42,7 @@ Evita que um atacante descubra quais e-mails existem na base (login, signup, res
42
42
  - Auth gerenciada pelo Clerk: nunca reimplementar flows de auth manualmente
43
43
  - Toda rota protegida, Server Action e route handler chama `auth()` do Clerk no servidor. Nunca confiar em estado de sessão vindo do cliente
44
44
  - Clerk webhook (`svix`) verificado por assinatura antes de processar: não confiar no body sem verificar
45
+ - Webhook de pagamento/provedor externo: além de verificar a assinatura, **nunca confie em valor de status, dinheiro ou permissão que veio no corpo**. Confirme com o provedor de origem (ex: re-buscar a sessão no Stripe pelo id) antes de liberar acesso ou creditar saldo. Assinatura válida só prova que o evento veio do provedor, não que o payload não foi remontado num replay.
45
46
  - Supabase RLS: toda tabela de domínio deve ter RLS ativo; queries devem filtrar por `workspace_id`/`user_id`
46
47
  - `SUPABASE_SERVICE_ROLE_KEY` bypassa RLS: só em server-side (API routes, jobs), nunca exposta no frontend
47
48
  - `NEXT_PUBLIC_*` = seguro expor no cliente; tudo sem `NEXT_PUBLIC_` = server-only
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: security-audit-pentest
3
+ description: >
4
+ Auditoria de segurança adversarial em 5 estágios com saída padronizada (tabela markdown, régua de corte,
5
+ payload concreto por achado). Use quando o pedido for auditar/pentestar um app inteiro, revisar segurança
6
+ antes de release, "auditoria de segurança", "pentest", "encontrar vulnerabilidades", ou rodar uma varredura
7
+ completa de mass assignment, IDOR/RLS, injeção, rate limit/abuso por volume, e vazamento por resposta.
8
+ Diferente de web-security/auth-and-secrets (que corrigem): esta CAÇA, com prova de exploração, e no fim funde
9
+ os achados num plano priorizado ("as três de hoje"). Use para revisão de correção de uma falha isolada as skills
10
+ web-security e auth-and-secrets.
11
+ ---
12
+
13
+ # Security Audit (Pentest adversarial)
14
+
15
+ Cinco estágios independentes de caça a vulnerabilidade, cada um com **prova de exploração concreta** e **régua de corte** (sem payload real, a linha não entra). No fim, um sexto passo funde os cinco relatórios num plano único priorizado.
16
+
17
+ Cada estágio é um agente com um prompt fechado em `references/`. Rode-os em paralelo (subagentes), um por estágio, e depois o passo de fusão sobre os cinco resultados.
18
+
19
+ ## Premissa comum a todos os estágios
20
+
21
+ Validação no cliente não conta. O atacante fala direto com a API. Considere apenas verificação no servidor. O campo "Como se explora" de cada achado tem que conter o **payload concreto** que o atacante enviaria, não a categoria da vulnerabilidade. "Poderia ser melhor" e "considere adicionar" não são achados. Prefira dez linhas reais a cinquenta de boa prática. Sem achado que passe na régua num estágio: responda só `NENHUM ACHADO NESTA ETAPA`.
22
+
23
+ Formato de saída de cada estágio, tabela markdown, sem texto antes ou depois:
24
+
25
+ ```
26
+ | # | Risco | Onde (arquivo:linha) | Como se explora | O que impede | Gravidade |
27
+ ```
28
+
29
+ Gravidade: CRÍTICO, ALTO, MÉDIO.
30
+
31
+ ## Os 5 estágios (rodar em paralelo)
32
+
33
+ | Estágio | Caça | Prompt |
34
+ |---|------|--------|
35
+ | 1 | **Validação de entrada** — mass assignment (prioridade máxima), injeção SQL/NoSQL/shell, renderização insegura, schema ausente / texto sem limite | `references/stage-1-validacao-entrada.md` |
36
+ | 2 | **Falha de autorização** — 3 camadas: app (identidade do cliente vs token; posse na query), banco (RLS, policy permissiva `USING(true)`, tabela nova sem policy), máquina (webhook sem assinatura, confia em status/dinheiro do body) | `references/stage-2-autorizacao.md` |
37
+ | 3 | **Abuso por volume** — rate limit em auth (por IP **e** por conta), custo por chamada (IA/SMS/e-mail × 10k), extração por paginação, origem do IP atrás de proxy/CDN | `references/stage-3-abuso-volume.md` |
38
+ | 4 | **Vazamento por resposta** — detalhe técnico em prod, divergência na auth (mensagem/status/tempo), existência de recurso (404 vs 403), secret em log | `references/stage-4-vazamento-resposta.md` |
39
+ | 5 | **Fusão** — funde os 4 relatórios: dedup por origem cruzada, ordena por esforço÷gravidade, identifica cadeias, fecha com "as três de hoje" | `references/stage-5-fusao-plano.md` |
40
+
41
+ > Estágios 1-4 são de caça e independentes. O estágio 5 recebe a saída dos outros quatro. (O prompt original tinha o estágio de validação duplicado; aqui é um só.)
42
+
43
+ ## Como rodar
44
+
45
+ 1. Dispare os estágios 1-4 como quatro subagentes em paralelo, cada um carregando seu prompt de `references/`.
46
+ 2. Colete as quatro tabelas.
47
+ 3. Rode o estágio 5 (fusão) colando as quatro tabelas onde o prompt indica.
48
+ 4. Entregue o plano de fusão como resultado. As tabelas por estágio ficam como anexo.
49
+
50
+ ## Relação com as outras skills de segurança
51
+
52
+ - **Esta skill CAÇA** (encontra + prova). Saída = lista de achados + plano.
53
+ - **`web-security` e `auth-and-secrets` CORRIGEM** (padrão certo + exemplo de código). Ao fechar um achado desta auditoria, abra a skill de correção correspondente citada na coluna "O que impede".
@@ -0,0 +1,50 @@
1
+ # Estágio 1 — Validação de entrada
2
+
3
+ Você é um analista de validação de entrada.
4
+
5
+ Premissa: validação no cliente não conta. Um atacante fala
6
+ direto com a API. Considere apenas verificação no servidor.
7
+
8
+ Procure, nesta ordem de prioridade:
9
+
10
+ 1. ATRIBUIÇÃO EM MASSA — prioridade máxima
11
+ Todo endpoint de escrita onde o corpo da requisição é
12
+ repassado inteiro para o banco (spread do objeto, update
13
+ com o payload completo, create com todos os campos).
14
+ Para cada um: liste os campos da tabela que um atacante
15
+ conseguiria setar e que não estão no formulário.
16
+ Dê atenção especial a campos de papel, permissão, plano,
17
+ saldo, status de pagamento e id de dono.
18
+
19
+ 2. INJEÇÃO
20
+ Consultas montadas por concatenação ou template string
21
+ com valor vindo do usuário. Inclua SQL, NoSQL e comandos
22
+ de shell.
23
+
24
+ 3. RENDERIZAÇÃO INSEGURA
25
+ Conteúdo de usuário chegando em innerHTML,
26
+ dangerouslySetInnerHTML, v-html ou equivalente
27
+ sem sanitização no caminho.
28
+
29
+ 4. SCHEMA AUSENTE
30
+ Endpoints sem validação de tipo, formato ou tamanho.
31
+ Sinalize campos de texto sem limite máximo — são vetor
32
+ de exaustão de recurso.
33
+
34
+ Para cada achado, o campo "Como se explora" deve conter
35
+ o payload concreto que um atacante enviaria, não a categoria
36
+ da vulnerabilidade.
37
+
38
+ Formato de saída — tabela markdown, sem texto antes ou depois:
39
+
40
+ | # | Risco | Onde (arquivo:linha) | Como se explora | O que impede | Gravidade |
41
+
42
+ Gravidade: CRÍTICO, ALTO, MÉDIO.
43
+
44
+ REGRA DE CORTE: se você não conseguir preencher "Como se explora"
45
+ com uma ação concreta e específica, NÃO inclua a linha.
46
+ "Poderia ser melhor" não é achado. "Considere adicionar" não é achado.
47
+ Prefiro dez linhas reais a cinquenta linhas de boa prática.
48
+
49
+ Se não houver nenhum achado que passe nessa régua, responda
50
+ apenas: NENHUM ACHADO NESTA ETAPA.
@@ -0,0 +1,61 @@
1
+ # Estágio 2 — Falha de autorização
2
+
3
+ Você é um pentester especializado em falha de autorização.
4
+
5
+ Distinção que governa toda essa análise:
6
+ AUTENTICAÇÃO responde "quem é você". AUTORIZAÇÃO responde
7
+ "você pode acessar ISSO". Estar logado não é permissão.
8
+
9
+ Analise as três camadas.
10
+
11
+ CAMADA 1 — APLICAÇÃO
12
+ Para cada endpoint que lê ou escreve um recurso pertencente
13
+ a um usuário, responda:
14
+ a) De onde vem a identidade de QUEM está pedindo?
15
+ Do token/sessão no servidor, ou de algo enviado pelo cliente
16
+ (body, query, header, param)?
17
+ b) A consulta ao banco amarra o id do recurso E o dono
18
+ na mesma condição?
19
+ Marque CRÍTICO todo lugar onde um user_id, org_id, tenant_id
20
+ ou equivalente é lido do que o cliente enviou e usado para
21
+ determinar propriedade.
22
+
23
+ CAMADA 2 — BANCO
24
+ Leia os arquivos de migration, schema e policy.
25
+ a) Liste as tabelas e diga, para cada uma, se há RLS
26
+ habilitada nos arquivos versionados.
27
+ b) Marque CRÍTICO toda policy permissiva —
28
+ USING (true), WITH CHECK (true) ou equivalente.
29
+ Uma policy permissiva é PIOR que policy nenhuma,
30
+ porque o painel mostra a trava como ativa.
31
+ c) Sinalize qualquer tabela criada em migration recente
32
+ que não tenha policy correspondente.
33
+
34
+ CAMADA 3 — MÁQUINA
35
+ Para cada webhook e endpoint que recebe evento externo:
36
+ a) Há validação de assinatura ou token compartilhado?
37
+ b) O fluxo confia em valor de status, dinheiro ou permissão
38
+ vindo no corpo, sem confirmar com o provedor de origem?
39
+
40
+ LIMITE DA SUA ANÁLISE — obrigatório:
41
+ Você está lendo arquivos. RLS de verdade é estado do banco,
42
+ não arquivo. Termine com uma seção "NÃO VERIFICÁVEL AQUI"
43
+ listando tudo que precisa ser confirmado no banco ao vivo
44
+ ou no painel do provedor. Não afirme que está seguro
45
+ o que você não conseguiu ver.
46
+
47
+ Formato de saída — tabela markdown, sem texto antes ou depois:
48
+
49
+ | # | Risco | Onde (arquivo:linha) | Como se explora | O que impede | Gravidade |
50
+
51
+ Gravidade: CRÍTICO, ALTO, MÉDIO.
52
+
53
+ REGRA DE CORTE: se você não conseguir preencher "Como se explora"
54
+ com uma ação concreta e específica, NÃO inclua a linha.
55
+ "Poderia ser melhor" não é achado. "Considere adicionar" não é achado.
56
+ Prefiro dez linhas reais a cinquenta linhas de boa prática.
57
+
58
+ Se não houver nenhum achado que passe nessa régua, responda
59
+ apenas: NENHUM ACHADO NESTA ETAPA.
60
+
61
+ (Após a tabela, inclua a seção obrigatória "NÃO VERIFICÁVEL AQUI".)
@@ -0,0 +1,49 @@
1
+ # Estágio 3 — Abuso por volume
2
+
3
+ Você é um analista de abuso por volume.
4
+
5
+ Enumere TODA rota de entrada do sistema e classifique cada uma:
6
+ TEM LIMITADOR / NÃO TEM LIMITADOR / NÃO DETERMINADO.
7
+
8
+ Depois, marque as rotas nestas categorias:
9
+
10
+ A) AUTENTICAÇÃO
11
+ Login, cadastro, recuperação de senha, verificação de código.
12
+ Sem limitador aqui significa força bruta e teste de
13
+ credenciais vazadas. Marque CRÍTICO.
14
+ Verifique também: o limitador conta por IP apenas, ou também
15
+ por conta alvo? Contagem só por IP é contornada distribuindo
16
+ o ataque.
17
+
18
+ B) CUSTO POR CHAMADA
19
+ Rotas que gastam dinheiro real a cada execução:
20
+ chamada a modelo de linguagem, disparo de SMS, envio de e-mail,
21
+ processamento de arquivo, chamada a API paga de terceiro.
22
+ Para cada uma estime o custo unitário e o custo de
23
+ dez mil chamadas em loop.
24
+ Rota de agente de IA exposta sem limitador é CRÍTICO,
25
+ mesmo que não vaze nenhum dado.
26
+
27
+ C) EXTRAÇÃO
28
+ Rotas que devolvem um registro por vez e que, chamadas em
29
+ sequência, entregam a base inteira.
30
+
31
+ D) ORIGEM DO IP
32
+ Se o sistema roda atrás de proxy ou CDN, verifique de qual
33
+ cabeçalho o IP do cliente é lido. Se estiver lendo do
34
+ socket direto, o mundo inteiro conta como um IP só
35
+ e o limitador não existe na prática.
36
+
37
+ Formato de saída — tabela markdown, sem texto antes ou depois:
38
+
39
+ | # | Risco | Onde (arquivo:linha) | Como se explora | O que impede | Gravidade |
40
+
41
+ Gravidade: CRÍTICO, ALTO, MÉDIO.
42
+
43
+ REGRA DE CORTE: se você não conseguir preencher "Como se explora"
44
+ com uma ação concreta e específica, NÃO inclua a linha.
45
+ "Poderia ser melhor" não é achado. "Considere adicionar" não é achado.
46
+ Prefiro dez linhas reais a cinquenta linhas de boa prática.
47
+
48
+ Se não houver nenhum achado que passe nessa régua, responda
49
+ apenas: NENHUM ACHADO NESTA ETAPA.
@@ -0,0 +1,52 @@
1
+ # Estágio 4 — Vazamento por resposta
2
+
3
+ Você é um analista de vazamento por resposta.
4
+
5
+ Premissa: toda resposta de erro é informação. Você está
6
+ procurando o que o sistema conta a quem falhou.
7
+
8
+ 1. DETALHE TÉCNICO EM PRODUÇÃO
9
+ Stack trace, caminho de arquivo do servidor, nome e versão
10
+ de framework ou biblioteca, fragmento de consulta SQL,
11
+ nome de tabela ou coluna chegando na resposta ao cliente.
12
+ Confirme se existe separação real entre comportamento de
13
+ desenvolvimento e de produção, ou se é o mesmo caminho.
14
+
15
+ 2. DIVERGÊNCIA NA AUTENTICAÇÃO — prioridade máxima
16
+ Compare as respostas de: e-mail inexistente, e-mail existente
17
+ com senha errada, e conta bloqueada.
18
+ Divergem em mensagem? Em código de status? Em campo do corpo?
19
+ E divergem em TEMPO de resposta, porque o caminho do e-mail
20
+ existente executa a verificação de hash e o outro não?
21
+ Qualquer divergência transforma o login numa API de consulta
22
+ da base de clientes. Marque CRÍTICO.
23
+ O mesmo vale para cadastro ("e-mail já em uso") e recuperação
24
+ de senha.
25
+
26
+ 3. EXISTÊNCIA DE RECURSO
27
+ Quando o usuário pede um recurso que existe mas não é dele,
28
+ a resposta é diferente de quando o recurso não existe?
29
+ Se for, dá para mapear os ids válidos contando respostas.
30
+ O correto é responder "não encontrado" nos dois casos.
31
+
32
+ 4. LOG
33
+ Senha, token, cabeçalho de autorização, dado de cartão ou
34
+ documento sendo gravado em log.
35
+
36
+ Para cada achado, o campo "Como se explora" deve conter
37
+ o payload concreto que um atacante enviaria, não a categoria
38
+ da vulnerabilidade.
39
+
40
+ Formato de saída — tabela markdown, sem texto antes ou depois:
41
+
42
+ | # | Risco | Onde (arquivo:linha) | Como se explora | O que impede | Gravidade |
43
+
44
+ Gravidade: CRÍTICO, ALTO, MÉDIO.
45
+
46
+ REGRA DE CORTE: se você não conseguir preencher "Como se explora"
47
+ com uma ação concreta e específica, NÃO inclua a linha.
48
+ "Poderia ser melhor" não é achado. "Considere adicionar" não é achado.
49
+ Prefiro dez linhas reais a cinquenta linhas de boa prática.
50
+
51
+ Se não houver nenhum achado que passe nessa régua, responda
52
+ apenas: NENHUM ACHADO NESTA ETAPA.
@@ -0,0 +1,27 @@
1
+ # Estágio 5 — Fusão num plano único
2
+
3
+ Vou colar abaixo os relatórios das quatro etapas de auditoria
4
+ (validação de entrada, autorização, abuso por volume, vazamento
5
+ por resposta).
6
+
7
+ Funda os quatro num plano único de correção:
8
+
9
+ 1. Remova duplicatas. O mesmo problema aparece em mais de uma
10
+ etapa com nomes diferentes — por exemplo, uma chave vazada
11
+ aparece no MAPA e o efeito dela aparece no ACESSO.
12
+ Trate como um item só, citando as duas origens.
13
+
14
+ 2. Reordene por ESFORÇO DE CORREÇÃO dividido por GRAVIDADE.
15
+ Primeiro o que é crítico e se resolve em minutos.
16
+ Por último o que é médio e exige refatoração.
17
+
18
+ 3. Identifique CADEIAS: onde dois achados de gravidade média
19
+ se combinam num caminho crítico. Exemplo: enumeração de
20
+ usuário no login mais ausência de limitador na mesma rota
21
+ é uma cadeia, e vale mais que a soma das partes.
22
+
23
+ 4. Termine com "AS TRÊS DE HOJE": os três itens que devem ser
24
+ corrigidos antes de qualquer outra coisa, com a justificativa
25
+ em uma linha cada.
26
+
27
+ [colar os 4 relatórios abaixo]
@@ -17,14 +17,21 @@ As que mais aparecem numa auditoria, com a correção direta. Use como triagem r
17
17
  |---|-------|-----------|----------|------|
18
18
  | 1 | SQL Injection | 🔴 Crítica (parada de linha) | Query parametrizada / query-builder, nunca interpolar string | `references/owasp-top5-detalhado.md` §3 |
19
19
  | 2 | IDOR (recurso por ID sem checar dono) | 🔴 Crítica (parada de linha) | Validar posse do recurso no servidor a cada request | `references/owasp-top5-detalhado.md` §1 |
20
- | 3 | Rate limit ausente (brute force grátis) | 🟠 Alta | Limite por IP **e** por conta + lockout/CAPTCHA no login | `references/headers-rate-limit-cors.md` |
21
- | 4 | CORS refletindo o `Origin` | 🟠 Alta | Allowlist de origens, nunca ecoar o `Origin` recebido | `references/headers-rate-limit-cors.md` |
22
- | 5 | PII/dado demais na resposta (até hash de senha) | 🟠 Alta | Retornar os campos necessários (allowlist de saída) | `references/headers-rate-limit-cors.md` |
23
- | 6 | JWT na URL / token vivo pós-logout | 🟠 Alta | Token no header `Authorization` + revogar no logout | skill `auth-and-secrets` |
24
- | 7 | Enumeração de usuário (erro revela se e-mail existe) | 🟡 Média | Mensagem genérica + resposta em tempo constante | skill `auth-and-secrets` |
25
- | 8 | Clickjacking (sem header) | 🟡 Média (1 min) | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` | `references/headers-rate-limit-cors.md` |
26
-
27
- > As duas primeiras (SQLi e IDOR) são **parada de linha**: apareceu, corrige antes de qualquer coisa.
20
+ | 3 | Mass assignment (body inteiro vai pro banco) | 🔴 Crítica (parada de linha) | Allowlist de campos: grave os campos do formulário, nunca `spread` do body | `references/owasp-top5-detalhado.md` §6 |
21
+ | 4 | RLS permissiva (`USING(true)`) ou tabela sem policy | 🔴 Crítica | Policy que amarra `auth.uid()` ao dono; tabela nova sempre com policy | `references/owasp-top5-detalhado.md` §1 |
22
+ | 5 | Rate limit ausente (brute force grátis) | 🟠 Alta | Limite por IP **e** por conta + lockout/CAPTCHA no login | `references/headers-rate-limit-cors.md` |
23
+ | 6 | Origem do IP lida do socket atrás de CDN | 🟠 Alta | Ler IP do header certo (`x-forwarded-for` confiável do proxy), senão o rate limit não existe | `references/headers-rate-limit-cors.md` |
24
+ | 7 | Custo por chamada sem limitador (IA/SMS/e-mail) | 🟠 Alta | Rate limit + quota por conta em toda rota que gasta dinheiro real | `references/headers-rate-limit-cors.md` |
25
+ | 8 | Extração por paginação (base inteira registro a registro) | 🟠 Alta | Filtrar por dono na query + limite de página + detecção de varredura | `references/headers-rate-limit-cors.md` |
26
+ | 9 | CORS refletindo o `Origin` | 🟠 Alta | Allowlist de origens, nunca ecoar o `Origin` recebido | `references/headers-rate-limit-cors.md` |
27
+ | 10 | PII/dado demais na resposta (até hash de senha) | 🟠 Alta | Retornar os campos necessários (allowlist de saída) | `references/headers-rate-limit-cors.md` |
28
+ | 11 | JWT na URL / token vivo pós-logout | 🟠 Alta | Token no header `Authorization` + revogar no logout | skill `auth-and-secrets` |
29
+ | 12 | Enumeração de usuário (erro revela se e-mail existe) | 🟡 Média | Mensagem genérica + resposta em tempo constante | skill `auth-and-secrets` |
30
+ | 13 | Clickjacking (sem header) | 🟡 Média (1 min) | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` | `references/headers-rate-limit-cors.md` |
31
+
32
+ > As quatro primeiras (SQLi, IDOR, mass assignment e RLS permissiva) são **parada de linha**: apareceu, corrige antes de qualquer coisa.
33
+ >
34
+ > Para uma auditoria adversarial completa (caça com prova de exploração + plano priorizado), use a skill `security-audit-pentest`. Esta skill aqui é para **corrigir** uma falha específica da tabela.
28
35
 
29
36
  ## References (load on demand)
30
37
 
@@ -56,6 +56,17 @@ async headers() {
56
56
  - Comparações de token/secret: `crypto.timingSafeEqual`, nunca `!==` (detalhe em auth-and-secrets)
57
57
  - Sem rate limit = brute force de graça. Somar lockout/CAPTCHA após N falhas no login
58
58
 
59
+ ### Origem do IP atrás de proxy/CDN
60
+ Se o app roda atrás de Vercel/Cloudflare/qualquer proxy, o IP do socket é o do proxy, não o do cliente. Ler o socket direto faz **o mundo inteiro contar como um IP só** e o rate limit deixa de existir na prática.
61
+ - Vercel: use `x-forwarded-for` (ou `req.ip` no runtime que já resolve). Pegue o **primeiro** IP da lista, mas só confie na cadeia que vem do seu proxy.
62
+ - Nunca confie em `x-forwarded-for` cru vindo de origem não-proxy (o cliente falsifica o header). Configure a lib de rate limit para o número certo de proxies à frente.
63
+
64
+ ### Custo por chamada (rota que gasta dinheiro real)
65
+ Toda rota que chama modelo de linguagem, dispara SMS, envia e-mail, processa arquivo ou bate em API paga de terceiro precisa de rate limit **e** quota por conta. Rota de agente de IA exposta sem limitador é crítica mesmo sem vazar dado nenhum: 10.000 chamadas em loop viram conta de centenas a milhares de reais/dólares. Estime o custo unitário e multiplique por 10k ao avaliar a rota.
66
+
67
+ ### Extração por paginação
68
+ Rota que devolve um registro por vez e, chamada em sequência, entrega a base inteira. Barre com: filtro por dono na query (o mesmo do IDOR), limite máximo de página, e detecção de varredura (muitos ids sequenciais do mesmo cliente).
69
+
59
70
  ## CORS
60
71
  - **Nunca reflita o header `Origin` recebido** de volta em `Access-Control-Allow-Origin` (isso libera qualquer site a chamar sua API com credenciais)
61
72
  - Use uma **allowlist explícita** de origens: compare o `Origin` recebido contra a lista e só então ecoe o valor exato daquela origem
@@ -28,6 +28,38 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
28
28
  }
29
29
  ```
30
30
 
31
+ ### RLS: policy permissiva é pior que policy nenhuma
32
+ Ler os arquivos de migration/policy versionados e checar:
33
+ - Toda tabela de domínio tem `ENABLE ROW LEVEL SECURITY`?
34
+ - Nenhuma policy é permissiva: `USING (true)` ou `WITH CHECK (true)` libera geral, mas o painel do Supabase mostra a trava como "ativa". Amarre sempre ao dono: `USING (auth.uid() = user_id)`.
35
+ - Tabela criada em migration recente **sem** policy correspondente = exposta.
36
+
37
+ ```sql
38
+ -- ERRADO: mostra RLS ativo no painel, mas libera qualquer um
39
+ create policy "read" on projects for select using (true);
40
+
41
+ -- CERTO: amarra o dono
42
+ alter table projects enable row level security;
43
+ create policy "own rows" on projects
44
+ for select using (auth.uid() = user_id);
45
+ ```
46
+
47
+ > RLS de verdade é estado do banco, não arquivo. O que está no repo é ponto de partida: confirme o estado real no dashboard/`\d+` do Postgres.
48
+
49
+ ## 6. Mass Assignment (atribuição em massa)
50
+ O corpo da requisição inteiro repassado para o banco (`spread` do objeto, `update`/`create` com o payload completo) deixa o atacante setar qualquer coluna, mesmo as que não estão no formulário. Alvos clássicos: `role`, `is_admin`, `plan`, `balance`, `payment_status`, `user_id`/`owner_id`.
51
+
52
+ ```ts
53
+ // ERRADO: o cliente manda { name: "x", role: "admin" } e vira admin
54
+ await supabase.from("users").update({ ...body }).eq("id", userId)
55
+
56
+ // CERTO: allowlist explícita, só os campos do formulário
57
+ const { name, bio } = updateSchema.parse(body) // Zod com só os campos permitidos
58
+ await supabase.from("users").update({ name, bio }).eq("id", userId)
59
+ ```
60
+
61
+ Regra: **nunca** `...body` / `...req.body` num write. Sempre desestruture (ou valide com um schema que só contém os campos editáveis). Campos de papel, permissão, plano, saldo, status de pagamento e id de dono nunca vêm do cliente.
62
+
31
63
  ## 2. Cryptographic Failures
32
64
  - HTTPS em tudo, sem exceção
33
65
  - HSTS header com includeSubDomains
@@ -15,6 +15,7 @@ Use esta tabela **só fora de projeto Wizz** (sem `_wizz/`), quando o router map
15
15
  | Auth, secrets, tokens, OAuth, JWT, Clerk, permissões | `auth-and-secrets` + `web-security` | 1 |
16
16
  | Dependências, packages, vulnerabilidades, npm audit | `database-and-deps` | 2 |
17
17
  | Segurança, XSS, CSRF, SQLi, IDOR, OWASP, rate limit, CORS, clickjacking, PII na resposta, enumeração de usuário, headers | `web-security` + `auth-and-secrets` | 1 |
18
+ | Auditoria/pentest de segurança do app inteiro, "auditar segurança", "pentest", "encontrar vulnerabilidades", varredura antes de release | `security-audit-pentest` | 1 |
18
19
  | Desktop, Electron, contextIsolation, code signing | `desktop-security` | 2 |
19
20
 
20
21
  ## Área Técnica — Código e Qualidade
@@ -29,17 +29,36 @@
29
29
  // placeholder stays in `.mcp.json`, and the summary explains how to
30
30
  // configure it later.
31
31
  //
32
+ // GLOBAL KEY STORE (`~/.claude/wizz-env.json`): a key the user already typed
33
+ // in ANY project is reused silently in every new install — the store is read
34
+ // by the installer only (C7 still holds: the runtime never reads it), and a
35
+ // hit is copied into the new project's `settings.local.json`. A key typed at
36
+ // the prompt is saved to the store too, so it is only ever asked once.
37
+ //
32
38
  // API (decomposed per E3 so each piece is unit-testable without a TTY):
33
39
  // extractEnvPlaceholders(mcps) — pure
34
40
  // resolveEnvVars(vars, opts) — I/O read (providers + prompt)
35
41
  // persistEnvValues(toPersist, opts) — I/O write (settings.local.json)
36
42
  // persistProjectEnv(projectDir, envRecord) — the actual writer, reusable
43
+ // persistGlobalEnv(storePath, envRecord) — writer do store global
37
44
  // promptMissingEnvVars(mcps, opts) — thin orchestrator of the above
38
45
 
39
46
  const path = require('node:path');
47
+ const os = require('node:os');
40
48
  const fs = require('../fs-native');
41
49
  const prompts = require('../prompts');
42
50
 
51
+ // Global key store, read by the INSTALLER only (never by the Claude Code
52
+ // runtime — C7 still holds). A value found here is copied into the project's
53
+ // `.claude/settings.local.json` at install time, which IS what reaches the
54
+ // MCP subprocess. This is what makes a key typed once in project A resolve
55
+ // silently in projects B, C, D... without ever living in the global
56
+ // settings.json `env` (which would expose it to every session of every
57
+ // project — the exact pattern the 360° audit flagged as a security critical).
58
+ function defaultGlobalEnvPath() {
59
+ return path.join(os.homedir(), '.claude', 'wizz-env.json');
60
+ }
61
+
43
62
  // Deliberately POSIX-strict (uppercase + underscore only): this both matches
44
63
  // standard env var naming and doubles as a defensive filter against false
45
64
  // positives like `{bin}` (no `$` prefix at all, so it never matches) or a
@@ -168,12 +187,51 @@ function createDotenvFileProvider(dotenvPath) {
168
187
  }
169
188
 
170
189
  /**
171
- * Try each provider in order, returning the first non-empty value found.
190
+ * The global key store (`~/.claude/wizz-env.json`, flat `{ "VAR": "value" }`
191
+ * map, chmod 600) as a provider. Read-only here — `persistGlobalEnv` is the
192
+ * writer. Marked `persistToProject: true`: unlike `process.env`, a value from
193
+ * this store is NOT in the runtime's environment, so the resolver must copy
194
+ * it into the project's `settings.local.json` for it to actually reach the
195
+ * MCP subprocess (C7).
196
+ * @param {string} storePath - Absolute path to the global store file
197
+ * @returns {{name: string, persistToProject: boolean, available: () => Promise<boolean>, get: (name: string) => Promise<string|undefined>}}
198
+ */
199
+ function createGlobalStoreProvider(storePath) {
200
+ let cache = null;
201
+
202
+ async function load() {
203
+ if (cache) return cache;
204
+ cache = {};
205
+ if (!storePath || !(await fs.pathExists(storePath))) return cache;
206
+ try {
207
+ const parsed = JSON.parse(await fs.readFile(storePath, 'utf8'));
208
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) cache = parsed;
209
+ } catch {
210
+ // Malformed/unreadable store — treat as empty, never throw.
211
+ cache = {};
212
+ }
213
+ return cache;
214
+ }
215
+
216
+ return {
217
+ name: 'global-store',
218
+ persistToProject: true,
219
+ available: async () => !!storePath && (await fs.pathExists(storePath)),
220
+ get: async (name) => {
221
+ const value = (await load())[name];
222
+ return typeof value === 'string' && value !== '' ? value : undefined;
223
+ },
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Try each provider in order, returning the first non-empty value found and
229
+ * the provider that had it (so the caller can honor `persistToProject`).
172
230
  * A provider throwing or being unavailable is skipped, never fatal — one
173
231
  * broken provider must not block the chain (e.g. an unreadable `.env`).
174
232
  * @param {string} name - Var name to look up
175
233
  * @param {Array<Object>} providers
176
- * @returns {Promise<string|undefined>}
234
+ * @returns {Promise<{value: string, provider: Object}|undefined>}
177
235
  */
178
236
  async function findInProviders(name, providers) {
179
237
  for (const provider of providers || []) {
@@ -182,7 +240,7 @@ async function findInProviders(name, providers) {
182
240
  const isAvailable = typeof provider.available === 'function' ? await provider.available() : true;
183
241
  if (!isAvailable) continue;
184
242
  const value = await provider.get(name);
185
- if (value !== undefined && value !== null && value !== '') return value;
243
+ if (value !== undefined && value !== null && value !== '') return { value, provider };
186
244
  } catch {
187
245
  continue;
188
246
  }
@@ -221,7 +279,14 @@ function createDefaultPrompter() {
221
279
  * @param {boolean} [opts.interactive=false] - Whether to prompt for missing vars
222
280
  * @param {Array<Object>} [opts.providers] - Provider chain, tried in order
223
281
  * @param {(entry) => Promise<string|undefined>} [opts.prompter] - Injectable prompt fn
224
- * @returns {Promise<{filled: Array, skipped: Array, existing: Array, toPersist: Record<string,string>}>}
282
+ * @returns {Promise<{filled: Array, skipped: Array, existing: Array, imported: Array,
283
+ * toPersist: Record<string,string>, toPersistGlobal: Record<string,string>}>}
284
+ * `existing` = found in a runtime-visible source (process.env/.env), nothing
285
+ * to write. `imported` = found in a `persistToProject` provider (the global
286
+ * store): resolved without prompting, but must be written to the project's
287
+ * settings.local.json (included in `toPersist`). `toPersistGlobal` = the
288
+ * subset of typed answers that should ALSO be saved to the global store so
289
+ * the next project never asks.
225
290
  */
226
291
  async function resolveEnvVars(vars, opts = {}) {
227
292
  const { interactive = false, providers = [createProcessEnvProvider()], prompter = null } = opts;
@@ -229,7 +294,9 @@ async function resolveEnvVars(vars, opts = {}) {
229
294
  const filled = [];
230
295
  const skipped = [];
231
296
  const existing = [];
297
+ const imported = [];
232
298
  const toPersist = {};
299
+ const toPersistGlobal = {};
233
300
 
234
301
  for (const entry of vars || []) {
235
302
  // A default is resolved by the Claude Code runtime itself; asking would
@@ -242,7 +309,12 @@ async function resolveEnvVars(vars, opts = {}) {
242
309
 
243
310
  const found = await findInProviders(entry.name, providers);
244
311
  if (found !== undefined) {
245
- existing.push(entry);
312
+ if (found.provider && found.provider.persistToProject) {
313
+ imported.push(entry);
314
+ toPersist[entry.name] = found.value;
315
+ } else {
316
+ existing.push(entry);
317
+ }
246
318
  continue;
247
319
  }
248
320
 
@@ -259,9 +331,10 @@ async function resolveEnvVars(vars, opts = {}) {
259
331
 
260
332
  filled.push(entry);
261
333
  toPersist[entry.name] = answer;
334
+ toPersistGlobal[entry.name] = answer;
262
335
  }
263
336
 
264
- return { filled, skipped, existing, toPersist };
337
+ return { filled, skipped, existing, imported, toPersist, toPersistGlobal };
265
338
  }
266
339
 
267
340
  /**
@@ -352,23 +425,80 @@ async function persistEnvValues(toPersist, opts = {}) {
352
425
  return persistProjectEnv(projectDir, toPersist);
353
426
  }
354
427
 
428
+ /**
429
+ * Merge `envRecord` into the flat global store (`~/.claude/wizz-env.json`).
430
+ * Additive only — a key already present is never overwritten (same rule as
431
+ * `persistProjectEnv`; a prompt only ever fires for a var no provider had,
432
+ * so an overwrite here would always mean clobbering something newer). File
433
+ * is chmod 600 after any write that changed it, same best-effort semantics
434
+ * as the project writer.
435
+ *
436
+ * @param {string} storePath - Absolute path to the global store file
437
+ * @param {Record<string,string>} envRecord - Vars to merge
438
+ * @returns {Promise<string|null>} Path written/merged, or null when empty
439
+ */
440
+ async function persistGlobalEnv(storePath, envRecord) {
441
+ if (!storePath || !envRecord || Object.keys(envRecord).length === 0) return null;
442
+
443
+ await fs.ensureDir(path.dirname(storePath));
444
+
445
+ let store = {};
446
+ if (await fs.pathExists(storePath)) {
447
+ try {
448
+ const parsed = JSON.parse(await fs.readFile(storePath, 'utf8'));
449
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) store = parsed;
450
+ } catch {
451
+ // A corrupt store must never eat keys the user just typed: keep the
452
+ // broken file aside and start a fresh store with the new values.
453
+ try {
454
+ await fs.rename(storePath, `${storePath}.bak`);
455
+ } catch {
456
+ // Even the rename failing must not block the install.
457
+ }
458
+ }
459
+ }
460
+
461
+ let changed = false;
462
+ for (const [key, value] of Object.entries(envRecord)) {
463
+ if (Object.prototype.hasOwnProperty.call(store, key)) continue;
464
+ store[key] = value;
465
+ changed = true;
466
+ }
467
+
468
+ if (changed) {
469
+ await fs.writeJson(storePath, store, { spaces: 2 });
470
+ if (process.platform !== 'win32') {
471
+ try {
472
+ await fs.chmod(storePath, 0o600);
473
+ } catch {
474
+ // Best-effort, same as persistProjectEnv.
475
+ }
476
+ }
477
+ }
478
+
479
+ return storePath;
480
+ }
481
+
355
482
  /**
356
483
  * Render the DX summary (E6) — the highest-value part of this feature: turn
357
484
  * a skipped var from a dead end into a 30-second fix. One line per var,
358
485
  * grouped by outcome. Never includes a raw secret value, only names/status.
359
- * @param {{filled: Array, skipped: Array, existing: Array}} resolved
486
+ * @param {{filled: Array, skipped: Array, existing: Array, imported: Array}} resolved
360
487
  * @returns {string|null} Formatted block, or null when there is nothing to say
361
488
  */
362
- function formatSummary({ filled, skipped, existing }) {
363
- const total = (filled?.length || 0) + (skipped?.length || 0) + (existing?.length || 0);
489
+ function formatSummary({ filled, skipped, existing, imported }) {
490
+ const total = (filled?.length || 0) + (skipped?.length || 0) + (existing?.length || 0) + (imported?.length || 0);
364
491
  if (total === 0) return null;
365
492
 
366
493
  const lines = [];
367
494
  for (const entry of existing || []) {
368
495
  lines.push(` ✓ ${entry.name.padEnd(28)} já existia no ambiente`);
369
496
  }
497
+ for (const entry of imported || []) {
498
+ lines.push(` ✓ ${entry.name.padEnd(28)} importada do global (~/.claude/wizz-env.json)`);
499
+ }
370
500
  for (const entry of filled || []) {
371
- lines.push(` ✓ ${entry.name.padEnd(28)} configurada agora (.claude/settings.local.json)`);
501
+ lines.push(` ✓ ${entry.name.padEnd(28)} configurada agora (+ salva no global p/ próximos projetos)`);
372
502
  }
373
503
  for (const entry of skipped || []) {
374
504
  if (entry.hasDefault) {
@@ -403,18 +533,26 @@ function formatSummary({ filled, skipped, existing }) {
403
533
  * @param {string} opts.projectDir - Project root (for persistence + the
404
534
  * default `.env` provider)
405
535
  * @param {boolean} [opts.interactive=false]
406
- * @param {Array<Object>} [opts.providers] - Defaults to `[processEnv, dotenvFile(<projectDir>/.env)]`
536
+ * @param {Array<Object>} [opts.providers] - Defaults to `[processEnv,
537
+ * dotenvFile(<projectDir>/.env), globalStore(~/.claude/wizz-env.json)]`
538
+ * @param {string} [opts.globalEnvPath] - Global store path (default
539
+ * `~/.claude/wizz-env.json`); used for both the default provider chain and
540
+ * the save-on-prompt write. Injectable so tests never touch the real home.
407
541
  * @param {(entry) => Promise<string|undefined>} [opts.prompter] - Defaults to
408
542
  * the masked `password()` prompter
409
- * @returns {Promise<{filled: Array, skipped: Array, existing: Array, envFile: string|null}>}
543
+ * @returns {Promise<{filled: Array, skipped: Array, existing: Array, imported: Array, envFile: string|null}>}
410
544
  */
411
545
  async function promptMissingEnvVars(mcps, opts = {}) {
412
- const { projectDir, interactive = false, providers, prompter } = opts;
546
+ const { projectDir, interactive = false, providers, prompter, globalEnvPath = defaultGlobalEnvPath() } = opts;
413
547
 
414
548
  const vars = extractEnvPlaceholders(mcps);
415
- if (vars.length === 0) return { filled: [], skipped: [], existing: [], envFile: null };
549
+ if (vars.length === 0) return { filled: [], skipped: [], existing: [], imported: [], envFile: null };
416
550
 
417
- const resolvedProviders = providers || [createProcessEnvProvider(), createDotenvFileProvider(path.join(projectDir, '.env'))];
551
+ const resolvedProviders = providers || [
552
+ createProcessEnvProvider(),
553
+ createDotenvFileProvider(path.join(projectDir, '.env')),
554
+ createGlobalStoreProvider(globalEnvPath),
555
+ ];
418
556
  const resolvedPrompter = interactive ? prompter || createDefaultPrompter() : null;
419
557
 
420
558
  const resolved = await resolveEnvVars(vars, {
@@ -428,10 +566,27 @@ async function promptMissingEnvVars(mcps, opts = {}) {
428
566
  envFile = await persistEnvValues(resolved.toPersist, { projectDir, target: 'settings-local' });
429
567
  }
430
568
 
569
+ // Typed answers also go to the global store so the NEXT project resolves
570
+ // them silently. Failure here must never block the install — the project
571
+ // write above already succeeded, which is what this install needs.
572
+ if (Object.keys(resolved.toPersistGlobal).length > 0) {
573
+ try {
574
+ await persistGlobalEnv(globalEnvPath, resolved.toPersistGlobal);
575
+ } catch {
576
+ // Global save is a convenience for future installs, never a blocker.
577
+ }
578
+ }
579
+
431
580
  const summary = formatSummary(resolved);
432
581
  if (summary) await prompts.log.info(summary);
433
582
 
434
- return { filled: resolved.filled, skipped: resolved.skipped, existing: resolved.existing, envFile };
583
+ return {
584
+ filled: resolved.filled,
585
+ skipped: resolved.skipped,
586
+ existing: resolved.existing,
587
+ imported: resolved.imported,
588
+ envFile,
589
+ };
435
590
  }
436
591
 
437
592
  module.exports = {
@@ -439,8 +594,11 @@ module.exports = {
439
594
  resolveEnvVars,
440
595
  persistEnvValues,
441
596
  persistProjectEnv,
597
+ persistGlobalEnv,
442
598
  promptMissingEnvVars,
443
599
  createProcessEnvProvider,
444
600
  createDotenvFileProvider,
601
+ createGlobalStoreProvider,
602
+ defaultGlobalEnvPath,
445
603
  formatSummary,
446
604
  };
@@ -19,6 +19,7 @@
19
19
  // a `claude mcp add` command renderer for the recommend path.
20
20
 
21
21
  const path = require('node:path');
22
+ const os = require('node:os');
22
23
  const crypto = require('node:crypto');
23
24
  const fs = require('../fs-native');
24
25
  const { defaultExec } = require('./cli-config');
@@ -325,6 +326,48 @@ async function partitionAlreadyConfigured({ projectDir, mcps }) {
325
326
  return { toPrepare, alreadyConfigured };
326
327
  }
327
328
 
329
+ /**
330
+ * Split resolved MCP entries into those still worth offering/installing in
331
+ * the project and those the user already configured GLOBALLY (user scope, the
332
+ * `mcpServers` key of `~/.claude.json`). A server configured there is live in
333
+ * every project already — usually with the real key embedded — so writing the
334
+ * registry's `${VAR}`-placeholder copy into the project `.mcp.json` would at
335
+ * best duplicate it and at worst shadow a working global config with a broken
336
+ * placeholder one, then prompt the user for a key they already provided.
337
+ *
338
+ * Read failures (missing/malformed `~/.claude.json`) fall back to "nothing is
339
+ * global", the safe default: worst case the user sees the old behavior.
340
+ *
341
+ * @param {Object} args
342
+ * @param {Array<{id: string}>} args.mcps - Resolved MCP entries to partition
343
+ * @param {string} [args.claudeJsonPath] - Override of `~/.claude.json` (tests)
344
+ * @returns {Promise<{toInstall: Array<Object>, globallyConfigured: string[]}>}
345
+ */
346
+ async function partitionGloballyConfigured({ mcps, claudeJsonPath }) {
347
+ if (!mcps || mcps.length === 0) return { toInstall: [], globallyConfigured: [] };
348
+
349
+ const file = claudeJsonPath || path.join(os.homedir(), '.claude.json');
350
+ let globalIds = new Set();
351
+ if (await fs.pathExists(file)) {
352
+ try {
353
+ const config = JSON.parse(await fs.readFile(file, 'utf8'));
354
+ const servers =
355
+ config && typeof config === 'object' && config.mcpServers && !Array.isArray(config.mcpServers) ? config.mcpServers : null;
356
+ if (servers) globalIds = new Set(Object.keys(servers));
357
+ } catch {
358
+ globalIds = new Set();
359
+ }
360
+ }
361
+
362
+ const toInstall = [];
363
+ const globallyConfigured = [];
364
+ for (const mcp of mcps) {
365
+ if (mcp && mcp.id && globalIds.has(mcp.id)) globallyConfigured.push(mcp.id);
366
+ else toInstall.push(mcp);
367
+ }
368
+ return { toInstall, globallyConfigured };
369
+ }
370
+
328
371
  /**
329
372
  * Merge the chosen MCP entries into `<projectDir>/.mcp.json`, additively.
330
373
  * Reads any existing file (preserving unknown keys and existing servers),
@@ -442,6 +485,7 @@ module.exports = {
442
485
  prepareMcp,
443
486
  prepareMcps,
444
487
  partitionAlreadyConfigured,
488
+ partitionGloballyConfigured,
445
489
  resolveBinPath,
446
490
  shellQuote,
447
491
  substituteBin,
@@ -17,7 +17,7 @@ const {
17
17
  bundledTargetWarnings,
18
18
  } = require('./modules/channel-plan');
19
19
  const channelResolver = require('./modules/channel-resolver');
20
- const { resolveMcps } = require('./modules/mcp-config');
20
+ const { resolveMcps, partitionGloballyConfigured } = require('./modules/mcp-config');
21
21
  const { resolveClis, detectClis } = require('./modules/cli-config');
22
22
  const prompts = require('./prompts');
23
23
  const { parseSetEntries } = require('./set-overrides');
@@ -571,9 +571,21 @@ class UI {
571
571
  if (!selectedModules.includes('bmm')) return { toWrite: [], toRecommend: [] };
572
572
 
573
573
  const registry = this._loadSkillsRegistry();
574
- const resolved = resolveMcps(registry, selectedAreas);
574
+ let resolved = resolveMcps(registry, selectedAreas);
575
575
  if (resolved.length === 0) return { toWrite: [], toRecommend: [] };
576
576
 
577
+ // A server the user already configured globally (user scope, `mcpServers`
578
+ // in ~/.claude.json) is live in every project — offering it again would
579
+ // duplicate config and re-prompt for a key that already works. Filter it
580
+ // out of every path below (multiselect, --mcps, --yes) with an info line
581
+ // so nothing disappears silently.
582
+ const { toInstall, globallyConfigured } = await partitionGloballyConfigured({ mcps: resolved });
583
+ if (globallyConfigured.length > 0) {
584
+ await prompts.log.info(`MCPs já configurados no seu Claude global (~/.claude.json), pulados: ${globallyConfigured.join(', ')}`);
585
+ resolved = toInstall;
586
+ if (resolved.length === 0) return { toWrite: [], toRecommend: [] };
587
+ }
588
+
577
589
  const byId = new Map(resolved.map((m) => [m.id, m]));
578
590
  const split = (writeIds) => {
579
591
  const writeSet = new Set(writeIds);