primocode 8.1.0 → 8.1.1

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v7.10.1
1
+ # PrimoCode v8.1.1
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v7.10.1
12
+ ▐▛███▜▌ Primo Code v8.1.1
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -263,7 +263,7 @@ primocode -f app.js "onde está o bug?" # com arquivo anexado
263
263
  | `/model top\|main\|fast` | **Todos grátis.** top = o mais capaz (550B, 1M de contexto), main = agente de código (padrão), fast = o mais rápido. Aceita qualquer ID do OpenRouter |
264
264
  | `/attach <arquivo>` | Anexa arquivo ao próximo pedido |
265
265
  | `/detach` · `/files` | Remove / lista anexos |
266
- | `/tudo` | Lista as 30 ferramentas que ele sabe usar, por grupo |
266
+ | `/tudo` | Lista as 44 ferramentas que ele sabe usar, por grupo |
267
267
  | `/tree [n]` | Árvore do projeto atual |
268
268
  | `/read <arquivo>` | Mostra um arquivo do projeto |
269
269
  | `/run <comando>` | Executa no shell, dentro da pasta do projeto |
package/lib/browser.js CHANGED
@@ -88,8 +88,9 @@ const APPS_MAC = [
88
88
  *
89
89
  * Um app rodando de dentro do `.dmg` funciona, mas é frágil: o disco é
90
90
  * somente-leitura, o macOS o executa por AppTranslocation (caminho temporário
91
- * aleatório) e ele desaparece na hora que o disco for ejetado. Achamos e usamos —
92
- * e `avisoDeLocal()` diz ao usuário para arrastar o app para Aplicativos.
91
+ * aleatório) e ele desaparece na hora que o disco for ejetado. Achamos e
92
+ * usamos mesmo assim — sem aviso adicional, o disco ejetado já falha na hora
93
+ * de abrir o navegador, o que já é sinal suficiente do que aconteceu.
93
94
  */
94
95
  function procurarEmVolumes() {
95
96
  const bases = [];
@@ -107,24 +108,6 @@ function procurarEmVolumes() {
107
108
  return null;
108
109
  }
109
110
 
110
- /**
111
- * O navegador achado está num lugar problemático? Devolve o aviso, ou null.
112
- *
113
- * Não bloqueia nada: o controle funciona de dentro do disco montado. Só avisa,
114
- * porque quem ejetar o disco vai ver a ferramenta parar de funcionar sem entender
115
- * o motivo.
116
- */
117
- function avisoDeLocal(caminho) {
118
- if (!caminho) return null;
119
- if (caminho.startsWith('/Volumes/')) {
120
- const app = (caminho.match(/\/Volumes\/[^/]+\/([^/]+\.app)/) || [, 'o navegador'])[1];
121
- return `${app} está rodando do disco de instalação, não instalado. `
122
- + 'Funciona, mas para de funcionar quando você ejetar esse disco — '
123
- + 'arraste o app para a pasta Aplicativos.';
124
- }
125
- return null;
126
- }
127
-
128
111
  function httpGetJson(url, timeoutMs = 3000) {
129
112
  return new Promise((resolve, reject) => {
130
113
  const req = http.get(url, (res) => {
@@ -450,8 +433,6 @@ async function open(url) {
450
433
  return { ok: true, url };
451
434
  }
452
435
 
453
- async function navigate(url) { return open(url); }
454
-
455
436
  async function evaluate(js) {
456
437
  await ensure();
457
438
  const r = await currentSession.send('Runtime.evaluate', {
@@ -639,11 +620,6 @@ async function screenshot(saveTo) {
639
620
  return { ok: true, path: filename, bytes: Buffer.byteLength(r.data, 'base64') };
640
621
  }
641
622
 
642
- async function currentUrl() {
643
- await ensure();
644
- return evaluate('location.href');
645
- }
646
-
647
623
  async function waitFor(selector, timeoutMs = 15000) {
648
624
  await ensure();
649
625
  const deadline = Date.now() + timeoutMs;
@@ -669,8 +645,6 @@ function close() {
669
645
  }
670
646
 
671
647
  module.exports = {
672
- open, navigate, click, clickText, clickSelector, type, typeInSelector,
673
- key, screenshot, evaluate, currentUrl, waitFor, wait, scroll, close,
674
- avisoDeLocal,
675
- _internal: { findChrome, procurarEmVolumes, PROFILE_DIR, DEBUG_PORT },
648
+ open, click, clickText, clickSelector, type, typeInSelector,
649
+ key, screenshot, evaluate, waitFor, wait, scroll, close,
676
650
  };
package/lib/config.js CHANGED
@@ -73,10 +73,15 @@ function ensureStructure() {
73
73
  return { rootDir: ROOT_DIR };
74
74
  }
75
75
 
76
+ // Tira acento (NFD + remove os diacríticos combinantes). Compartilhado com
77
+ // lib/desktop.js — as duas versões faziam a mesma conta separadas.
78
+ function stripAccents(s) {
79
+ return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '');
80
+ }
81
+
76
82
  // "Jogo de Carro!" -> "jogo-de-carro"
77
83
  function slugify(name) {
78
- return String(name)
79
- .normalize('NFD').replace(/[̀-ͯ]/g, '')
84
+ return stripAccents(name)
80
85
  .toLowerCase()
81
86
  .replace(/[^a-z0-9]+/g, '-')
82
87
  .replace(/^-+|-+$/g, '')
@@ -208,7 +213,7 @@ function appendHistory(entry) {
208
213
  module.exports = {
209
214
  loadConfig, saveConfig, appendHistory,
210
215
  CONFIG_DIR, CONFIG_FILE, HISTORY_FILE, ROOT_DIR, GENERAL_MEMORY,
211
- ensureStructure, slugify,
216
+ ensureStructure, slugify, stripAccents,
212
217
  createProjectDir, getProjectDir, listProjects, findSimilarProject,
213
218
  readGeneralMemory, readProjectMemory, appendProjectMemory, appendGeneralMemory,
214
219
  };
package/lib/desktop.js CHANGED
@@ -15,6 +15,7 @@
15
15
 
16
16
  const { execSync } = require('child_process');
17
17
  const os = require('os');
18
+ const { stripAccents } = require('./config');
18
19
 
19
20
  const PLATFORM = os.platform();
20
21
 
@@ -344,7 +345,7 @@ const APP_ALIASES = {
344
345
  };
345
346
 
346
347
  function normaliza(s) {
347
- return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
348
+ return stripAccents(s).toLowerCase().trim();
348
349
  }
349
350
 
350
351
  function aliasDoApp(nome, platform = PLATFORM) {
@@ -371,7 +372,17 @@ function openApp(nome) {
371
372
  } else {
372
373
  const bin = app.split(/\s+/)[0];
373
374
  if (hasCmd('gtk-launch') && run(`gtk-launch ${sh1(bin)}`).ok) r = { ok: true };
374
- else if (hasCmd(bin)) r = run(`bash -c ${sh1(`setsid ${app} >/dev/null 2>&1 < /dev/null &`)}`);
375
+ else if (hasCmd(bin)) {
376
+ // sh1() aqui só protegia o script INTEIRO como um argumento pro
377
+ // `bash -c` — dentro do script, bash reparsa o texto, e "app" cru
378
+ // continuava vulnerável a injeção (achado de segurança: um app tipo
379
+ // "a;touch /tmp/pwned;" executava o comando extra). Cada palavra de
380
+ // "app" agora entra entre aspas simples individualmente — preserva
381
+ // "programa --flag" como dois argumentos, mas nenhuma delas deixa
382
+ // metacaractere de shell passar como sintaxe.
383
+ const appEscapado = app.split(/\s+/).map(sh1).join(' ');
384
+ r = run(`bash -c ${sh1(`setsid ${appEscapado} >/dev/null 2>&1 < /dev/null &`)}`);
385
+ }
375
386
  else r = { ok: false, error: `Não achei o programa "${pedido}" no PATH.` };
376
387
  }
377
388
 
package/lib/modo.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Escolha automática de modo.
3
3
  *
4
- * O modo primocode manda o schema das 30 ferramentas em todo pedido — ~4.700
4
+ * O modo primocode manda o schema das 44 ferramentas em todo pedido — ~4.700
5
5
  * tokens antes de você digitar a primeira letra. Um "oi" gasta o mesmo que uma
6
6
  * refatoração, e num plano com teto diário isso é dinheiro jogado fora (foi
7
7
  * assim que a cota de 100 mil tokens/dia acabou às 15h).
package/lib/referencia.js CHANGED
@@ -29,6 +29,33 @@ const net = require('net');
29
29
  const tls = require('tls');
30
30
  const { URL } = require('url');
31
31
 
32
+ // Achado de segurança: redirect sem checar destino é SSRF — uma fonte de
33
+ // busca (conteúdo de terceiro, não confiável) pode responder com um Location
34
+ // pra localhost ou pro metadado de nuvem (169.254.169.254), e pegar() seguia
35
+ // sem checar host nenhum. Bloqueia loopback/link-local/rede privada antes de
36
+ // abrir a conexão, tanto na URL original quanto em cada redirect seguido.
37
+ function hostBloqueado(hostname) {
38
+ const h = String(hostname || '').toLowerCase();
39
+ if (h === 'localhost' || h.endsWith('.localhost')) return true;
40
+ const versao = net.isIP(h);
41
+ if (versao === 4) {
42
+ const partes = h.split('.').map(Number);
43
+ const [a, b] = partes;
44
+ if (a === 127 || a === 10 || a === 0) return true; // loopback, privada, "esta rede"
45
+ if (a === 169 && b === 254) return true; // link-local — inclui metadado de nuvem
46
+ if (a === 172 && b >= 16 && b <= 31) return true; // privada
47
+ if (a === 192 && b === 168) return true; // privada
48
+ return false;
49
+ }
50
+ if (versao === 6) {
51
+ if (h === '::1' || h === '::') return true; // loopback / unspecified
52
+ if (h.startsWith('fc') || h.startsWith('fd')) return true; // unique local (fc00::/7)
53
+ if (h.startsWith('fe80:') || h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return true; // link-local
54
+ return false;
55
+ }
56
+ return false; // hostname comum (DNS) — não dá pra saber o IP sem resolver
57
+ }
58
+
32
59
  const TIMEOUT_MS = 12000;
33
60
  const MAX_POR_FONTE = 4;
34
61
  const MAX_TEXTO = 4500;
@@ -72,6 +99,7 @@ function pegar(url, { timeout = TIMEOUT_MS, saltos = 0 } = {}) {
72
99
  // 5 saltos cobre qualquer doc real; sem teto, um redirect em loop
73
100
  // (A→B→A→...) trava essa fonte — e a pesquisa inteira — para sempre.
74
101
  if (saltos > 5) return resolve(null);
102
+ if (hostBloqueado(alvo.hostname)) return resolve(null);
75
103
  const cliente = alvo.protocol === 'http:' ? http : https;
76
104
 
77
105
  const opcoes = {
package/lib/tools.js CHANGED
@@ -10,7 +10,7 @@
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
  const os = require('os');
13
- const { exec } = require('child_process');
13
+ const { exec, execFile } = require('child_process');
14
14
  const browser = require('./browser');
15
15
  const desktop = require('./desktop');
16
16
  const referencia = require('./referencia');
@@ -165,19 +165,41 @@ async function runShell(command, timeout = 60000, cwd) {
165
165
  });
166
166
  }
167
167
 
168
- async function openInBrowser(target) {
168
+ // `target` vem do modelo (args.target) — que por sua vez pode ter lido de uma
169
+ // página não confiável (prompt injection via pesquisar_referencia/browser_read).
170
+ // execFile com array de argumentos nunca passa por shell nenhum: "target" vira
171
+ // UM argumento, não texto que um shell reinterpreta — sem isso, um valor tipo
172
+ // `https://x/"; touch /tmp/pwned; echo "` injetava comando (achado de segurança).
173
+ function openInBrowser(target) {
169
174
  const p = os.platform();
170
- const cmd = p === 'darwin' ? `open "${target}"` : p === 'win32' ? `start "" "${target}"` : `xdg-open "${target}"`;
171
- const r = await runShell(cmd);
172
- return r.ok ? { ok: true, opener: cmd.split(' ')[0] } : { ok: false, error: r.stderr || 'falhou ao abrir' };
175
+ const [cmd, args, opener] = p === 'darwin' ? ['open', [target], 'open']
176
+ : p === 'win32' ? ['cmd.exe', ['/c', 'start', '', target], 'start']
177
+ : ['xdg-open', [target], 'xdg-open'];
178
+ return new Promise((resolve) => {
179
+ execFile(cmd, args, { timeout: 10000 }, (error, stdout, stderr) => {
180
+ resolve(error ? { ok: false, error: (stderr || error.message || '').toString() } : { ok: true, opener });
181
+ });
182
+ });
173
183
  }
174
184
 
175
185
  async function runInNewTerminal(command, cwd) {
176
186
  const p = os.platform();
187
+ const pastaAlvo = cwd || '.';
188
+ // pastaAlvo normalmente é a pasta do projeto, mas pode vir de input do
189
+ // modelo — sem checar, um valor malicioso quebra pra fora do "cd" embutido
190
+ // no comando que abre o terminal (AppleScript/cmd/x-terminal-emulator),
191
+ // que reinterpreta a string inteira como shell (achado de segurança).
192
+ // Recusa em vez de tentar escapar certo em três dialetos de shell ao
193
+ // mesmo tempo (osascript, cmd.exe, sh) — "command" continua livre de
194
+ // propósito, é o próprio contrato desta tool (abrir terminal com um
195
+ // comando de shell de verdade).
196
+ if (/["'`;&|$\\]/.test(pastaAlvo)) {
197
+ return { ok: false, error: `Pasta com caractere não permitido: ${pastaAlvo}` };
198
+ }
177
199
  let cmd;
178
- if (p === 'darwin') cmd = `osascript -e 'tell application "Terminal" to do script "cd ${cwd || '.'} && ${command}"'`;
179
- else if (p === 'win32') cmd = `start cmd /k "cd /d ${cwd || '.'} && ${command}"`;
180
- else cmd = `x-terminal-emulator -e "bash -c 'cd ${cwd || '.'} && ${command}; exec bash'"`;
200
+ if (p === 'darwin') cmd = `osascript -e 'tell application "Terminal" to do script "cd ${pastaAlvo} && ${command}"'`;
201
+ else if (p === 'win32') cmd = `start cmd /k "cd /d ${pastaAlvo} && ${command}"`;
202
+ else cmd = `x-terminal-emulator -e "bash -c 'cd ${pastaAlvo} && ${command}; exec bash'"`;
181
203
  const r = await runShell(cmd);
182
204
  return r.ok ? { ok: true, opener: cmd.split(' ')[0] } : { ok: false, error: r.stderr || 'falhou' };
183
205
  }
package/lib/ui.js CHANGED
@@ -4,7 +4,6 @@
4
4
 
5
5
  'use strict';
6
6
 
7
- const readline = require('readline');
8
7
 
9
8
  // ── Paleta ───────────────────────────────────────────────────────────────
10
9
  // Vem do design system do produto (product-plan/design-system): primária cyan,
@@ -264,39 +263,6 @@ class Spinner {
264
263
  }
265
264
  }
266
265
 
267
- // Barra de progresso estilo Claude Code — silenciosa, elegante
268
- class ProgressBar {
269
- constructor(label = 'Instalando') {
270
- this.label = label;
271
- this.width = 30;
272
- this.progress = 0;
273
- this.timer = null;
274
- this.done = false;
275
- }
276
- start() {
277
- process.stdout.write(` ${c.muted(this.label)} [`);
278
- this.timer = setInterval(() => {
279
- if (this.done) return;
280
- this.progress = Math.min(this.progress + 1, this.width);
281
- const filled = '█'.repeat(this.progress);
282
- const empty = '░'.repeat(this.width - this.progress);
283
- process.stdout.write(`\r ${c.muted(this.label)} [${c.brand(filled)}${c.dim(empty)}] ${c.muted(Math.round((this.progress/this.width)*100) + '%')}`);
284
- }, 200);
285
- return this;
286
- }
287
- tick() {
288
- this.progress = Math.min(this.progress + 2, this.width);
289
- const filled = '█'.repeat(this.progress);
290
- const empty = '░'.repeat(this.width - this.progress);
291
- process.stdout.write(`\r ${c.muted(this.label)} [${c.brand(filled)}${c.dim(empty)}] ${c.muted(Math.round((this.progress/this.width)*100) + '%')}`);
292
- }
293
- stop() {
294
- this.done = true;
295
- if (this.timer) { clearInterval(this.timer); this.timer = null; }
296
- process.stdout.write(`\r ${c.muted(this.label)} [${c.ok('██████████ DONE ██████████')}] ${c.ok('100%')}\n`);
297
- }
298
- }
299
-
300
266
  function actionHeader(name, args) {
301
267
  const nice = {
302
268
  read_file:'Read', write_file:'Write', edit_file:'Edit', multi_edit:'MultiEdit',
@@ -422,38 +388,5 @@ function friendlyError(raw) {
422
388
  return clean.length > 200 ? clean.slice(0, 200) + '…' : clean || 'Algo deu errado.';
423
389
  }
424
390
 
425
- function toolLine(text) { return ` ${c.dim('⎿')} ${c.muted(text)}`; }
426
-
427
- function toolResult(result) {
428
- if (!result) return '';
429
- if (result.ok === false) return c.err(` ✗ ${result.error || 'failed'}`);
430
- if (result.created !== undefined) return c.ok(` ${result.created?'created':'updated'} · ${fmtBytes(result.bytes||0)}`);
431
- return '';
432
- }
433
-
434
- function fmtBytes(n) {
435
- if (n < 1024) return `${n} B`;
436
- if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`;
437
- return `${(n/(1024*1024)).toFixed(1)} MB`;
438
- }
439
-
440
- function renderMarkdown(text) {
441
- return text.replace(/\*\*([^*]+)\*\*/g,'$1').replace(/__([^_]+)__/g,'$1').replace(/`([^`]+)`/g,'$1').replace(/^#{1,6}\s+/gm,'');
442
- }
443
-
444
- // Pergunta Yes/No — aguarda resposta real do usuário
445
- function askYesNo(question) {
446
- return new Promise((resolve) => {
447
- const rl = readline.createInterface({
448
- input: process.stdin,
449
- output: process.stdout,
450
- });
451
- rl.question(`${c.brand('?')} ${question} ${c.muted('(Y/n)')} `, (answer) => {
452
- rl.close();
453
- const a = (answer || '').trim().toLowerCase();
454
- resolve(a === 'y' || a === 'yes' || a === '');
455
- });
456
- });
457
- }
458
391
 
459
- module.exports = { c, mascot, welcomeBox, Spinner, ProgressBar, actionHeader, toolLine, toolResult, fmtBytes, renderMarkdown, askYesNo, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
392
+ module.exports = { c, mascot, welcomeBox, Spinner, actionHeader, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.1.0",
3
+ "version": "8.1.1",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
package/studio/server.py CHANGED
@@ -395,6 +395,24 @@ class Handler(SimpleHTTPRequestHandler):
395
395
  except Exception:
396
396
  return {}
397
397
 
398
+ # Achado de segurança: POST/PUT/DELETE sem checagem de Origin é CSRF — uma
399
+ # página de terceiro (aberta no MESMO navegador, sem relação nenhuma com o
400
+ # Studio) consegue disparar um fetch() com Content-Type: text/plain (que
401
+ # não exige preflight de CORS) contra http://127.0.0.1:7777 e o pedido
402
+ # chega igual a um legítimo. Bloqueia quando o Origin existe e não é o
403
+ # próprio servidor — sem Origin nenhum (cliente não-navegador, como o CLI
404
+ # do PrimoCode via http nativo do Node) continua liberado, já que isso
405
+ # nunca é o vetor de um navegador sendo usado contra o usuário.
406
+ def origem_ok(self):
407
+ origem = self.headers.get("Origin")
408
+ if not origem:
409
+ return True
410
+ esperados = {
411
+ "http://127.0.0.1:%d" % PORTA,
412
+ "http://localhost:%d" % PORTA,
413
+ }
414
+ return origem in esperados
415
+
398
416
  # -------------------------------------------------------------- rotas --
399
417
 
400
418
  @seguro
@@ -502,6 +520,8 @@ class Handler(SimpleHTTPRequestHandler):
502
520
 
503
521
  @seguro
504
522
  def do_POST(self):
523
+ if not self.origem_ok():
524
+ return self.js({"erro": "origem não permitida"}, 403)
505
525
  rota = urlparse(self.path).path
506
526
  if rota == "/api/midia":
507
527
  corpo, status = guardar_midia(self.corpo())
@@ -527,6 +547,8 @@ class Handler(SimpleHTTPRequestHandler):
527
547
 
528
548
  @seguro
529
549
  def do_PUT(self):
550
+ if not self.origem_ok():
551
+ return self.js({"erro": "origem não permitida"}, 403)
530
552
  m = re.fullmatch(r"/api/arquivos/([^/]+)", urlparse(self.path).path)
531
553
  if not m:
532
554
  return self.js({"erro": "não encontrado"}, 404)
@@ -547,6 +569,8 @@ class Handler(SimpleHTTPRequestHandler):
547
569
 
548
570
  @seguro
549
571
  def do_DELETE(self):
572
+ if not self.origem_ok():
573
+ return self.js({"erro": "origem não permitida"}, 403)
550
574
  rota = urlparse(self.path).path
551
575
  m = re.fullmatch(r"/api/midia/([^/]+)", rota)
552
576
  if m: