terminal-smart-cli 0.98.0 → 0.99.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/lib/tools.js CHANGED
@@ -37,7 +37,8 @@ function _snapshot(absPath) {
37
37
 
38
38
  // Gate de destrutivo: fonte ÚNICA no núcleo canônico (lib/core.js). Reexportado aqui
39
39
  // pra compatibilidade (tools.isDestructive segue funcionando pra quem já importava).
40
- const { isDestructive } = require('./core');
40
+ const core = require('./core');
41
+ const { isDestructive } = core;
41
42
 
42
43
  // `findstr`, `rg` e `grep` usam o código 1 para informar que a busca foi
43
44
  // executada corretamente, mas não encontrou ocorrências. Para o harness isso
@@ -397,18 +398,592 @@ function _abs(p, base) {
397
398
  // (skill_gerenciar/lembrar passam por aprovação; hooks/mcp são só do humano) —
398
399
  // prompt-injection persistente. Recusa com o caminho certo indicado.
399
400
  const _TS_HOME = path.join(os.homedir(), '.ts');
401
+ // O caminho é ~/.ts ou fica dentro dela? Mesma régua da leitura: caminho CANÔNICO sem caixa
402
+ // (Windows/macOS) e, para os outros nomes da mesma pasta (\\localhost\C$\…, nome 8.3, junção,
403
+ // unidade mapeada, caixa trocada), a IDENTIDADE (volume + índice) do alvo e de cada pasta acima
404
+ // dele. Antes era texto puro com startsWith: `<home>\.TS\providers.json` passava. Quem chama
405
+ // vai abrir o caminho de qualquer forma, então aqui o stat vale também para UNC.
406
+ function _statId(p) {
407
+ try { const s = fs.statSync(p, { bigint: true }); return s.ino ? s.dev + ':' + s.ino : null; } catch (_) { return null; }
408
+ }
409
+ function _insideTsHome(p) {
410
+ const abs = path.resolve(p);
411
+ const home = _normCase(_canonPath(_TS_HOME));
412
+ const c = _normCase(_canonPath(abs));
413
+ if (c === home || c.startsWith(home + path.sep)) return true;
414
+ const homeId = _statId(_TS_HOME);
415
+ if (!homeId) return false;
416
+ for (let cur = abs; ; ) {
417
+ if (_statId(cur) === homeId) return true;
418
+ const up = path.dirname(cur);
419
+ if (up === cur) break;
420
+ cur = up;
421
+ }
422
+ return false;
423
+ }
424
+ const _MASK_WRITE_REFUSAL = 'RECUSADO: o texto traz a máscara "' + core.SECRET_MASK + '", que esconde um segredo na leitura — gravar isso trocaria o valor real pela máscara. Edite com editar_arquivo só o trecho que precisa mudar, sem a linha mascarada; se for preciso trocar o segredo, peça ao usuário.';
400
425
  function _guardTsHome(p) {
401
426
  const abs = path.resolve(p);
402
- if (abs === _TS_HOME || abs.startsWith(_TS_HOME + path.sep)) {
427
+ // _tsSecretMatch: o mesmo ARQUIVO de uma credencial do ts com outro nome fora de ~/.ts
428
+ // (hardlink) — gravar nele reescreveria a credencial
429
+ if (_insideTsHome(abs) || _tsSecretMatch(abs)) {
403
430
  return 'RECUSADO: "' + abs + '" está na área de configuração do ts (~/.ts) — ferramentas de arquivo NÃO escrevem aqui. Pra skill use skill_gerenciar; pra memória use lembrar; hooks/mcp/config só o USUÁRIO altera manualmente. Não tente por shell — será recusado também.';
404
431
  }
405
432
  return null;
406
433
  }
434
+ // LEITURA: o que ler_arquivo devolve vira contexto do modelo — e vai pro provedor de IA.
435
+ // Em ~/.ts ficam o token de sessão e o perfil SSH (config.json), as chaves BYOK
436
+ // (providers.json), a auth dos servidores MCP (mcp*.json) e o histórico salvo das sessões
437
+ // (sessions; no CLI, as sessões do `ts agente --continuar` ficam em agente/).
438
+ // Skills e memória continuam legíveis. Compara pelo caminho CANÔNICO, sem caixa no Windows
439
+ // e no macOS (sistemas de arquivos sem distinção de caixa por padrão), e pela identidade do
440
+ // arquivo (outros caminhos para o mesmo conteúdo).
441
+ const _TS_SECRET_READ = (rel) => /^(?:config\.json|providers\.json|mcp[^\\\/]*|sessions|agente)(?:[\\\/]|$)/i.test(rel);
442
+ // Caminho canônico: realpath NATIVO (no Windows expande o nome curto 8.3 — PROVID~1.JSO —,
443
+ // tira o "::$DATA" e devolve a caixa gravada no disco; resolve junções e links), com a versão
444
+ // JS de reserva. Arquivo que não existe: tira o que o Windows ignoraria ao abrir (":stream",
445
+ // pontos e espaços no fim de cada parte).
446
+ function _canonPath(p) {
447
+ const abs = path.resolve(p);
448
+ if (!_remoteUnc(abs)) {
449
+ try { return fs.realpathSync.native(abs); } catch (_) {}
450
+ try { return fs.realpathSync(abs); } catch (_) {}
451
+ }
452
+ if (process.platform !== 'win32') return abs;
453
+ const root = path.parse(abs).root;
454
+ return root + abs.slice(root.length).split(/[\\\/]+/).map(seg => seg.replace(/:.*$/, '').replace(/[. ]+$/, '')).join(path.sep);
455
+ }
456
+ const _normCase = (s) => (process.platform === 'win32' || process.platform === 'darwin') ? s.toLowerCase() : s;
457
+ // O caminho é (ou fica dentro de) um item protegido de ~/.ts? Devolve o caminho canônico ou
458
+ // null. `ids` (opcional) evita reler a pasta quando o chamador testa vários caminhos.
459
+ // fns (opcional): { canon, fid } com memória — a avaliação de um comando de shell testa dezenas
460
+ // de palavras que dividem as mesmas pastas acima (_tsShellCtx).
461
+ function _tsSecretMatch(raw, ids, fns) {
462
+ const canon = (fns && fns.canon) || _canonPath, fid = (fns && fns.fid) || _fileId;
463
+ const abs = canon(raw);
464
+ const rel = path.relative(_normCase(canon(_TS_HOME)), _normCase(abs));
465
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel) && _TS_SECRET_READ(rel)) return abs;
466
+ // Mesmo arquivo com outro caminho: o compartilhamento administrativo (\\localhost\C$\...,
467
+ // \\127.0.0.1\c$, \\?\UNC\...), unidade mapeada ou subst e hardlink não passam pela
468
+ // comparação de texto — o realpath mantém o UNC. Compara a IDENTIDADE (volume + índice do
469
+ // arquivo) do alvo e de cada pasta acima dele com a dos itens protegidos de ~/.ts.
470
+ const set = ids || _tsSecretIds();
471
+ if (!set.size) return null;
472
+ for (let cur = abs; ; ) {
473
+ const id = fid(cur);
474
+ if (id && set.has(id)) return abs;
475
+ const up = path.dirname(cur);
476
+ if (up === cur) break;
477
+ cur = up;
478
+ }
479
+ return null;
480
+ }
481
+ function _guardTsSecretRead(p) {
482
+ const raw = path.resolve(p);
483
+ // stream alternativo do NTFS (arquivo:stream, arquivo::$DATA): é outro nome para o conteúdo
484
+ // do arquivo e driblava a comparação de caminho. ler_arquivo não precisa disso em lugar nenhum.
485
+ if (process.platform === 'win32' && raw.slice(path.parse(raw).root.length).includes(':')) {
486
+ return 'RECUSADO: "' + raw + '" usa um stream alternativo do NTFS (nome:stream) — ler_arquivo só lê arquivos pelo nome normal.';
487
+ }
488
+ const abs = _tsSecretMatch(raw);
489
+ if (!abs) return null;
490
+ return 'RECUSADO: "' + abs + '" guarda credenciais do ts (token de sessão, senha SSH, chaves de IA, auth de MCP ou histórico de sessões) — ferramentas de leitura não abrem esse arquivo, porque o conteúdo iria para o modelo. Se o usuário precisar de algo daí, peça que ele consulte manualmente.';
491
+ }
492
+ // Identidade "volume:índice" seguindo links. bigint porque no Windows o índice do NTFS pode
493
+ // passar de 2^53; índice 0 (sistema de arquivos sem índice estável) não identifica nada.
494
+ function _fileId(p) {
495
+ if (_remoteUnc(path.resolve(p))) return null;
496
+ try { const s = fs.statSync(p, { bigint: true }); return s.ino ? s.dev + ':' + s.ino : null; } catch (_) { return null; }
497
+ }
498
+ // Caminho UNC de OUTRA máquina (\\host\share, //host/x, \\?\UNC\host\…): realpath e stat abririam
499
+ // uma conexão SMB/WebDAV com um host tirado do texto do comando — o Windows autentica sozinho
500
+ // (NTLM) nesse host e o processo trava por segundos esperando a rede. As checagens de ~/.ts não
501
+ // tocam nesses caminhos: fica só a comparação de texto. Contam como locais localhost, 127.0.0.1,
502
+ // ::1, o nome desta máquina e o host do próprio ~/.ts (perfil em rede); \\?\C:\ e \\.\C:\ são a
503
+ // unidade local. Outros dispositivos (\\.\pipe\…, \\?\Volume{…}) também ficam sem disco.
504
+ const _LOCAL_UNC_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0--1.ipv6-literal.net']);
505
+ // host do UNC; null = \\?\C:\… ou \\.\C:\… (unidade local); '' = dispositivo sem host
506
+ function _uncHost(abs) {
507
+ const parts = String(abs).slice(2).split(/[\\\/]+/);
508
+ if (parts[0] !== '?' && parts[0] !== '.') return parts[0] || '';
509
+ if (/^[A-Za-z]:$/.test(parts[1] || '')) return null;
510
+ return /^unc$/i.test(parts[1] || '') ? (parts[2] || '') : '';
511
+ }
512
+ for (const h of [os.hostname(), /^[\\\/]{2}/.test(_TS_HOME) ? _uncHost(_TS_HOME) : '']) if (h) _LOCAL_UNC_HOSTS.add(String(h).toLowerCase());
513
+ function _remoteUnc(abs) {
514
+ if (process.platform !== 'win32' || !/^[\\\/]{2}/.test(String(abs))) return false;
515
+ const host = _uncHost(abs);
516
+ return host !== null && !_LOCAL_UNC_HOSTS.has(host.toLowerCase());
517
+ }
518
+ // Leitura do ler_arquivo pelo DESCRITOR aberto. Confere a identidade (volume:índice) do arquivo
519
+ // aberto e de cada pasta acima dele contra os itens protegidos de ~/.ts ANTES de ler: pega
520
+ // qualquer apelido do mesmo arquivo que o _guardTsSecretRead não reconhece pelo caminho — UNC
521
+ // com o IP de rede desta máquina, 127.x, nome completo (FQDN), unidade mapeada — e fecha a janela
522
+ // entre checar e ler. Aqui não há rede a evitar: o arquivo vai ser aberto de qualquer forma.
523
+ // Devolve o texto ou { erro }.
524
+ function _readTextNotTsSecret(p) {
525
+ const ids = _tsSecretIds();
526
+ const recusa = () => ({ erro: 'RECUSADO: "' + path.resolve(p) + '" é o mesmo arquivo de um item de ~/.ts que guarda credenciais do ts — ferramentas de leitura não abrem esse arquivo, porque o conteúdo iria para o modelo. Se o usuário precisar de algo daí, peça que ele consulte manualmente.' });
527
+ const fd = fs.openSync(p, 'r');
528
+ try {
529
+ if (ids.size) {
530
+ const s = fs.fstatSync(fd, { bigint: true });
531
+ if (s.ino && ids.has(s.dev + ':' + s.ino)) return recusa();
532
+ for (let cur = path.dirname(path.resolve(p)); ; ) {
533
+ try { const d = fs.statSync(cur, { bigint: true }); if (d.ino && ids.has(d.dev + ':' + d.ino)) return recusa(); } catch (_) {}
534
+ const up = path.dirname(cur);
535
+ if (up === cur) break;
536
+ cur = up;
537
+ }
538
+ }
539
+ return fs.readFileSync(fd, 'utf8');
540
+ } finally {
541
+ try { fs.closeSync(fd); } catch (_) {}
542
+ }
543
+ }
544
+ function _tsSecretIds() {
545
+ const ids = new Set();
546
+ let nomes = [];
547
+ try { nomes = fs.readdirSync(_TS_HOME); } catch (_) { return ids; }
548
+ for (const n of nomes) {
549
+ if (!_TS_SECRET_READ(n)) continue;
550
+ const id = _fileId(path.join(_TS_HOME, n));
551
+ if (id) ids.add(id);
552
+ }
553
+ return ids;
554
+ }
555
+
556
+ // ── SEGREDOS CONHECIDOS: rede de segurança por VALOR ────────────────────────
557
+ // As recusas de leitura acima fecham os caminhos conhecidos; sempre vai existir outro (grep
558
+ // recursivo no home, tar | base64, cópia antiga, um link). O ts conhece os próprios segredos:
559
+ // o token de sessão e a senha SSH (config.json), as chaves BYOK (providers.json) e a auth dos
560
+ // servidores MCP (mcp*.json). Carrega os VALORES (8+ caracteres) e toda saída de ferramenta
561
+ // passa pelo mascarador do núcleo (core.buildSecretMasker: o valor e as variações óbvias, como
562
+ // base64 e hexadecimal) antes de virar contexto do modelo. Os valores ficam só na memória do
563
+ // processo: nunca são impressos nem registrados, e nada aqui os manda para fora.
564
+ const _SECRET_FIELD_RE = /(?:^|_)(?:pass|passwd|password|pwd|senha|secret|token|key|apikey|auth|authorization|credential|credentials)$/;
565
+ const _secretFieldName = (k) => _SECRET_FIELD_RE.test(String(k).replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/-/g, '_').toLowerCase());
566
+ function _collectSecretValues(node, out, field, depth) {
567
+ if (depth > 8 || out.length > 500) return;
568
+ if (typeof node === 'string') {
569
+ if (field != null && _secretFieldName(field)) {
570
+ out.push(node);
571
+ // auth de MCP: "Header-Name: valor" ou "Bearer x" — o valor sozinho também é segredo
572
+ const header = node.match(/^[A-Za-z0-9-]+\s*:\s*(\S.*)$/);
573
+ if (header) out.push(header[1]);
574
+ const scheme = node.match(/^(?:[A-Za-z0-9-]+\s*:\s*)?(?:Bearer|Basic|Token|Bot)\s+(\S.*)$/i);
575
+ if (scheme) out.push(scheme[1]);
576
+ }
577
+ // URL com credencial (endpoint de MCP, baseUrl): senha do usuário:senha@ e parâmetros
578
+ // com nome de segredo (?token=…, ?api_key=…)
579
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(node)) {
580
+ try {
581
+ const u = new URL(node);
582
+ if (u.password) out.push(decodeURIComponent(u.password));
583
+ for (const [k, v] of u.searchParams) if (_secretFieldName(k)) out.push(v);
584
+ } catch (_) {}
585
+ }
586
+ return;
587
+ }
588
+ if (Array.isArray(node)) { for (const x of node) _collectSecretValues(x, out, field, depth + 1); return; }
589
+ if (node && typeof node === 'object') for (const k of Object.keys(node)) _collectSecretValues(node[k], out, k, depth + 1);
590
+ }
591
+ const _KNOWN_SECRET_FILE = (n) => /^(?:config|providers)\.json$/i.test(n) || /^mcp[^\\\/]*\.json$/i.test(n);
592
+ let _knownCache = { sig: null, values: [] };
593
+ // Valores dos segredos do ts. Releitura só quando algum arquivo muda (nome:mtime:tamanho).
594
+ function knownSecretValues() {
595
+ let nomes = [];
596
+ try { nomes = fs.readdirSync(_TS_HOME).filter(_KNOWN_SECRET_FILE).sort(); } catch (_) { nomes = []; }
597
+ const sig = nomes.map(n => {
598
+ try { const s = fs.statSync(path.join(_TS_HOME, n)); return s.isFile() && s.size <= 4 * 1024 * 1024 ? `${n}:${s.mtimeMs}:${s.size}` : ''; } catch (_) { return ''; }
599
+ }).join('|');
600
+ if (sig === _knownCache.sig) return _knownCache.values;
601
+ const out = [];
602
+ for (const n of nomes) {
603
+ try {
604
+ const p = path.join(_TS_HOME, n);
605
+ if (fs.statSync(p).size > 4 * 1024 * 1024) continue;
606
+ _collectSecretValues(JSON.parse(fs.readFileSync(p, 'utf8')), out, null, 0);
607
+ } catch (_) { /* arquivo ausente ou inválido: nada a carregar dele */ }
608
+ }
609
+ _knownCache = { sig, values: [...new Set(out.filter(v => typeof v === 'string' && v.trim().length >= core.SECRET_MIN_LEN))] };
610
+ return _knownCache.values;
611
+ }
612
+ // Valores dos segredos do ts + extra: segredos da execução que não estão em arquivo (o token
613
+ // passado ao executor, a chave de IA em uso). Aceita string ou o objeto de credencial ({ key }).
614
+ // É o que vai em core.redactForApproval({ known }) no comando mostrado para aprovação.
615
+ function knownSecretsFor(extra) {
616
+ const ext = (Array.isArray(extra) ? extra : [extra])
617
+ .map(v => (v && typeof v === 'object' ? v.key : v))
618
+ .filter(v => typeof v === 'string' && v.trim().length >= core.SECRET_MIN_LEN);
619
+ return knownSecretValues().concat(ext);
620
+ }
621
+ let _maskerCache = { key: null, masker: null };
622
+ // Mascara (folha a folha, sem alterar a entrada) toda ocorrência de um segredo conhecido.
623
+ function maskKnownSecrets(value, extra) {
624
+ const vals = knownSecretsFor(extra);
625
+ const key = vals.join('\u0000');
626
+ if (_maskerCache.key !== key) _maskerCache = { key, masker: core.buildSecretMasker(vals) };
627
+ return _maskerCache.masker.size ? _maskerCache.masker.deep(value) : value;
628
+ }
629
+
630
+ // ── LEITURA das credenciais do ts POR SHELL ──────────────────────────────────
631
+ // O que ler_arquivo recusa também não sai por executar_comando. Um trecho do comando que CITA
632
+ // ou recebe como argumento um item protegido de ~/.ts (config.json, providers.json, mcp*,
633
+ // sessions, agente) — ou a pasta ~/.ts inteira — é RECUSADO, seja qual for o programa: cat, type,
634
+ // Get-Content, jq, node -p "require(…)", tar | base64, zip, Compress-Archive… Antes só uma lista
635
+ // de leitores conhecidos era barrada e `jq . ~/.ts/providers.json` passava sem aprovação,
636
+ // inclusive em --yes. Fica livre só o que não lê o conteúdo (_tsSegmentNonRead): listar ou ver
637
+ // metadados (ls, dir, Get-ChildItem, stat, find sem leitor no -exec), criar e apagar (o apagar
638
+ // recursivo segue no gate de destrutivo), mudar permissão, ecoar o caminho, `sed -i` (escrita no
639
+ // lugar) e a cópia/movimento cuja ORIGEM não é protegida — copiar PARA dentro de ~/.ts é escrita
640
+ // e cai no gate de ~/.ts (touchesTsConfig), como `> ~/.ts/mcp.json`.
641
+ // Duas checagens em cada trecho: o TEXTO (".ts/" seguido de item protegido, de curinga ou do fim
642
+ // do caminho, depois de um prefixo que é a pasta pessoal — ~, $HOME, %USERPROFILE%,
643
+ // $env:USERPROFILE, /home/<usuário>, C:\Users\<usuário>; ver _tsTextCitesSecret) e cada argumento
644
+ // EXPANDIDO e resolvido (um `cd` anterior na mesma linha conta) pela régua do ler_arquivo:
645
+ // caminho canônico e identidade (nome 8.3, UNC local, link). A busca RECURSIVA que entraria em
646
+ // ~/.ts (grep -r, rg --hidden, findstr /s, Get-ChildItem -Recurse | Select-String, find -exec)
647
+ // também é recusada, com a dica de excluir a pasta. Nada disso cobre todo jeito de ler um arquivo
648
+ // por shell: a saída de qualquer comando ainda passa por redactSecrets e pela máscara dos
649
+ // segredos conhecidos (maskKnownSecrets).
650
+ const _TS_READERS = new Set(['cat', 'tac', 'nl', 'type', 'more', 'less', 'head', 'tail', 'get-content', 'gc', 'findstr', 'select-string', 'sls', 'grep', 'egrep', 'fgrep', 'rg', 'awk', 'sed', 'strings', 'xxd', 'od', 'hexdump', 'base64']);
651
+ // cópia e movimento: só a ORIGEM conta
652
+ const _TS_COPIERS = new Set(['copy', 'xcopy', 'robocopy', 'cp', 'copy-item', 'cpi', 'mv', 'move', 'move-item', 'mi', 'ren', 'rename', 'rename-item', 'rni']);
653
+ const _TS_CD = new Set(['cd', 'chdir', 'pushd', 'set-location', 'sl', 'push-location']);
654
+ // no PowerShell o pipe passa o ARQUIVO, não o texto: `Get-ChildItem ~/.ts/sessions | Get-Content`
655
+ const _TS_PIPE_READERS = new Set(['get-content', 'gc', 'select-string', 'sls', 'copy-item', 'cpi']);
656
+ // programas que leem, transformam ou transmitem conteúdo: no mesmo trecho de um comando que
657
+ // "não lê" (find … -exec cat, ls $(cat …), xargs cat), tiram a isenção
658
+ const _TS_CONTENT_CMDS = new Set([..._TS_READERS, 'node', 'nodejs', 'deno', 'bun', 'python', 'python3', 'py', 'perl', 'ruby', 'php', 'lua',
659
+ 'powershell', 'pwsh', 'cmd', 'bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'wsl', 'jq', 'yq', 'tar', 'zip', '7z', '7za', 'gzip', 'bzip2', 'xz', 'zstd',
660
+ 'compress-archive', 'certutil', 'openssl', 'gpg', 'curl', 'wget', 'iwr', 'irm', 'invoke-webrequest', 'invoke-restmethod', 'scp', 'rsync',
661
+ 'diff', 'cmp', 'sort', 'uniq', 'cut', 'tr', 'iconv', 'vim', 'vi', 'nano', 'code', 'notepad', 'get-filehash', 'md5sum', 'sha256sum', 'import-csv']);
662
+ // o que pode citar um item protegido de ~/.ts sem ler o conteúdo dele
663
+ const _TS_NONREAD = new Set(['ls', 'dir', 'gci', 'get-childitem', 'tree', 'stat', 'du', 'file', 'find', 'where', 'which', 'test-path', 'get-item', 'gi',
664
+ 'get-acl', 'resolve-path', 'rvpa', 'realpath', 'readlink', 'basename', 'dirname', 'echo', 'printf', 'write-host', 'mkdir', 'md', 'new-item', 'ni',
665
+ 'touch', 'rm', 'del', 'erase', 'rd', 'rmdir', 'remove-item', 'ri', 'chmod', 'chown', 'chgrp', 'chattr', 'icacls', 'attrib', 'takeown']);
666
+ const _TS_CODE_READ_RE = /\b(?:readFileSync|readFile|ReadAllText|ReadAllBytes|ReadAllLines|OpenText|OpenRead|ReadToEnd|open|require|import)\s*\(/i;
667
+ // prefixos que só executam o comando seguinte
668
+ const _TS_WRAPPERS = new Set(['sudo', 'nohup', 'time', 'command', 'builtin', 'exec']);
669
+ // ".ts" CITADO no texto como a pasta do ts: seguido de separador e item protegido (grupo 1), de
670
+ // um nome com curinga (grupo 2: *.json, {config,providers}.json) ou do fim do caminho (a pasta
671
+ // inteira: grep -r, findstr /s, xcopy). Quem decide se é caminho é o que vem ANTES, em
672
+ // _tsTextCitesSecret: ".ts" também é a extensão do TypeScript e "\" também é o escape de regex.
673
+ const _TS_TEXT_RE = /\.ts(?:[\\\/]+(?:(config\.json|providers\.json|mcp|sessions|agente)|[^\s\\\/"';|&]*([*?[{]))|[\\\/]*(?=$|[\s"';|&)]))/gi;
674
+ const _TS_TEXT_DELIM = /[\s"'`=(<>,;|&]/;
675
+ // raiz de pasta: ~, %USERPROFILE% (%HOMEDRIVE%%HOMEPATH%), $HOME, ${HOME}, $env:USERPROFILE, C:
676
+ const _TS_ROOT_VAR = String.raw`(?:~|%[A-Za-z_][\w()]*%|\$\{?(?:env:)?[A-Za-z_]\w*\}?|[A-Za-z]:)`;
677
+ const _TS_ROOT_VAR_ONLY_RE = new RegExp(String.raw`^${_TS_ROOT_VAR}+$`);
678
+ // nome de pasta: sem metacaractere de regex (o prefixo já para nos delimitadores acima)
679
+ const _TS_DIR_NAME = String.raw`[^\\\/*?^()[\]{}+]+`;
680
+ // antes de "/.ts": raiz (acima, . ou ..) ou caminho absoluto do Unix, com qualquer separador
681
+ const _TS_DIR_ANY_RE = new RegExp(String.raw`^(?:(?:${_TS_ROOT_VAR}+|\.{1,2})(?=[\\\/]|$)|(?=[\\\/]))(?:[\\\/]+${_TS_DIR_NAME})*$`);
682
+ // antes de "\.ts": caminho Windows com raiz de verdade (acima ou \\host) e só "\" como separador
683
+ const _TS_DIR_WIN_RE = new RegExp(String.raw`^(?:${_TS_ROOT_VAR}+|\\\\[^\\\/\s]+)(?:\\+${_TS_DIR_NAME})*$`);
684
+ // pasta pessoal de QUALQUER usuário (outro perfil também guarda credenciais do ts)
685
+ const _TS_HOME_LIKE_RE = /(?:^|[\\\/])(?:Users|home|Documents and Settings)[\\\/]+[^\\\/]+[\\\/]*$|^[\\\/]+(?:root|var[\\\/]+root)[\\\/]*$/i;
686
+ // O prefixo antes de "/.ts" é uma pasta pessoal? Variável de raiz sozinha (~, $HOME,
687
+ // %USERPROFILE%… — uma variável desconhecida também conta, por cautela), ~usuario, /home/<x>,
688
+ // C:\Users\<x>, /root, ou um caminho que expande para a pasta pessoal deste usuário. Pasta
689
+ // ".ts" de PROJETO (docs/.ts, C:/Users/x/proj/.ts) não é: quem pega o caso real é o argumento
690
+ // expandido (_tsShellWordIsSecret).
691
+ function _tsHomeLike(prefix, cwd) {
692
+ const p = String(prefix || '');
693
+ if (_TS_ROOT_VAR_ONLY_RE.test(p) && !/^[A-Za-z]:$/.test(p)) return true;
694
+ if (/^~[\w.-]+$/.test(p) || _TS_HOME_LIKE_RE.test(p)) return true;
695
+ const abs = _expandShellPath(p, cwd || process.cwd());
696
+ if (!abs || _remoteUnc(abs)) return false;
697
+ return _normCase(_canonPath(abs)) === _normCase(_canonPath(os.homedir()));
698
+ }
699
+ // O texto cita ~/.ts/{item protegido}, um curinga lá dentro ou a pasta inteira?
700
+ // - nada antes (início, espaço, aspas, =, (, <): ".ts/providers.json" relativo — item ou curinga,
701
+ // quando a pasta de trabalho é a pessoal (senão é uma pasta .ts do projeto);
702
+ // - "/" antes: depois de uma pasta pessoal (~/.ts, $HOME/.ts, /home/x/.ts, C:/Users/x/.ts); sem
703
+ // prefixo, o item protegido ou o curinga ('/.ts/providers.json' concatenado num código). Assim
704
+ // sed 's/\.js$/.ts/' e docs/.ts/config.json não são a pasta do ts;
705
+ // - "\" antes: depois de um caminho Windows com raiz que é pasta pessoal (%USERPROFILE%\.ts,
706
+ // $env:USERPROFILE\.ts, C:\Users\x\.ts, \\host\c$\Users\x\.ts); sem prefixo, só o item protegido
707
+ // com "\" também DEPOIS ('\.ts\providers.json' concatenado). "\.ts/mcp", grep "utils\.ts",
708
+ // rg "\w\.ts", "/utils\.ts", awk '/\.ts/' e "\.ts/[a-z]*" são regex. A recusa é definitiva,
709
+ // então na dúvida o texto fica livre: o argumento expandido e resolvido (_tsShellWordIsSecret)
710
+ // continua pegando o caminho real em qualquer forma.
711
+ function _tsTextCitesSecret(text, cwd) {
712
+ const s = String(text || '');
713
+ for (const m of s.matchAll(_TS_TEXT_RE)) {
714
+ const named = m[1] != null, glob = m[2] != null;
715
+ let j = m.index;
716
+ while (j > 0 && (s[j - 1] === '\\' || s[j - 1] === '/')) j--;
717
+ const sep = s.slice(j, m.index);
718
+ let k = j;
719
+ while (k > 0 && !_TS_TEXT_DELIM.test(s[k - 1])) k--;
720
+ const prefix = s.slice(k, j).replace(/^(?:-{1,2}[A-Za-z][\w-]*:|@)/, ''); // -Path:C:\…, curl @arquivo
721
+ const after = s[m.index + 3] || ''; // separador logo depois de ".ts"
722
+ let ok;
723
+ if (!sep) ok = !prefix && (named || glob) && (cwd == null || _tsHomeLike('.', cwd));
724
+ else if (sep.includes('\\')) ok = prefix ? (_TS_DIR_WIN_RE.test(prefix) && _tsHomeLike(prefix, cwd)) : (named && after === '\\');
725
+ else ok = prefix ? (_TS_DIR_ANY_RE.test(prefix) && _tsHomeLike(prefix, cwd)) : (named || glob);
726
+ if (ok) return true;
727
+ }
728
+ return false;
729
+ }
730
+ const _TS_SHELL_READ_REFUSAL = 'RECUSADO: o comando lê credenciais do ts em ~/.ts (config.json, providers.json, mcp*, sessions ou agente: token de sessão, senha SSH, chaves de IA, auth de MCP, histórico de sessões). A saída iria para o modelo — nem ler_arquivo nem comandos (leitura, cópia, compactação, interpretadores) abrem esses arquivos. Se o usuário precisar de algo daí, peça que ele consulte manualmente; para ver só os nomes, use listar_diretorio.';
731
+ const _tsRecursiveRefusal = (dir) => 'RECUSADO: a busca recursiva a partir de "' + dir + '" entraria em ~/.ts, onde ficam as credenciais do ts (token de sessão, chaves de IA, auth de MCP, histórico de sessões) — a saída iria para o modelo. Busque numa pasta abaixo (a do projeto) ou exclua a pasta do ts: grep -r --exclude-dir=.ts …, rg -g "!.ts" …, find … -path "*/.ts" -prune -o ….';
732
+
733
+ // Trechos do comando, divididos em ; & | e quebra de linha FORA de aspas. pipe=true quando o
734
+ // trecho veio depois de um `|` simples.
735
+ function _shellSegments(cmd) {
736
+ const out = []; const s = String(cmd);
737
+ let cur = '', q = null, pipe = false;
738
+ for (let i = 0; i < s.length; i++) {
739
+ const ch = s[i];
740
+ if (q) { cur += ch; if (ch === q) q = null; continue; }
741
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
742
+ if (ch === ';' || ch === '&' || ch === '|' || ch === '\n' || ch === '\r') {
743
+ out.push({ text: cur, pipe });
744
+ cur = '';
745
+ const dbl = (ch === '&' || ch === '|') && s[i + 1] === ch;
746
+ pipe = ch === '|' && !dbl;
747
+ if (dbl) i++;
748
+ continue;
749
+ }
750
+ cur += ch;
751
+ }
752
+ out.push({ text: cur, pipe });
753
+ return out;
754
+ }
755
+ // Palavras do trecho. top: o conteúdo entre aspas vale inteiro (posição dos argumentos);
756
+ // flat: o conteúdo entre aspas também é quebrado em palavras (`powershell -Command "gc …"`).
757
+ // Abertura de subcomando ($(, `, () fica fora da palavra: `ls $(cat x)` tem a palavra "cat".
758
+ function _shellWords(seg) {
759
+ const top = [], flat = [];
760
+ const re = /"([^"]*)"|'([^']*)'|([^\s"']+)/g;
761
+ const clean = (w) => w.replace(/^(?:[@(`]|\$\()+|[;,)`]+$/g, '');
762
+ let m;
763
+ while ((m = re.exec(seg))) {
764
+ const q = m[1] != null ? m[1] : m[2];
765
+ if (q != null) {
766
+ if (q) top.push(q);
767
+ for (const w of q.split(/\s+/)) if (clean(w)) flat.push(clean(w));
768
+ } else if (clean(m[3])) { top.push(clean(m[3])); flat.push(clean(m[3])); }
769
+ }
770
+ return { top, flat };
771
+ }
772
+ const _shellCmdName = (w) => String(w).split(/[\\\/]/).pop().toLowerCase().replace(/\.(?:exe|com)$/, '');
773
+ // Argumento → caminho absoluto, expandindo %VAR%, $env:VAR, $HOME, $VAR e ~ (como o shell faria).
774
+ function _expandShellPath(w, cwd) {
775
+ let t = String(w);
776
+ const named = t.match(/^-{1,2}[A-Za-z][\w-]*[=:](.+)$/); // --arquivo=~/.ts/x, -Path:C:\x
777
+ if (named) t = named[1];
778
+ const env = (n) => (process.env[n] != null ? process.env[n] : null);
779
+ t = t.replace(/%([A-Za-z_][\w()]*)%/g, (m, n) => env(n) ?? m)
780
+ .replace(/\$\{?env:([A-Za-z_]\w*)\}?/gi, (m, n) => env(n) ?? m)
781
+ .replace(/\$\{?HOME\}?(?!\w)/gi, () => os.homedir())
782
+ .replace(/\$\{?([A-Za-z_]\w*)\}?/g, (m, n) => env(n) ?? m);
783
+ if (/^~(?=$|[\\\/])/.test(t)) t = os.homedir() + t.slice(1);
784
+ try { return path.resolve(cwd, t); } catch (_) { return null; }
785
+ }
786
+ function _tsShellCtx() {
787
+ // realpath e stat com memória nesta avaliação: as palavras de um comando dividem as pastas acima
788
+ const memo = (fn) => { const m = new Map(); return (p) => { if (!m.has(p)) m.set(p, fn(p)); return m.get(p); }; };
789
+ const canon = memo(_canonPath), fid = memo(_fileId);
790
+ const ctx = { ids: _tsSecretIds(), home: _normCase(canon(_TS_HOME)), homeId: fid(_TS_HOME), _up: null, canon, fid };
791
+ // identidade de ~/.ts e de cada pasta acima dela (raiz de busca recursiva com outro nome)
792
+ ctx.upIds = () => {
793
+ if (ctx._up) return ctx._up;
794
+ ctx._up = new Set();
795
+ for (let cur = _TS_HOME; ; ) {
796
+ const id = ctx.fid(cur);
797
+ if (id) ctx._up.add(id);
798
+ const up = path.dirname(cur);
799
+ if (up === cur) break;
800
+ cur = up;
801
+ }
802
+ return ctx._up;
803
+ };
804
+ return ctx;
805
+ }
806
+ const _isTsHomeDir = (abs, ctx) => _normCase(ctx.canon(abs)) === ctx.home || (!!ctx.homeId && ctx.fid(abs) === ctx.homeId);
807
+ // Base de um caminho com curinga (*.json, {a,b}): a pasta onde ele começa. O "?" de \\?\ e o "."
808
+ // de \\.\ (prefixos de caminho do Windows) não são curinga.
809
+ function _globBase(abs) {
810
+ const off = /^\\\\[?.]\\/.test(abs) ? 4 : 0;
811
+ const g = abs.slice(off).search(/[*?[{]/);
812
+ return g >= 0 ? abs.slice(0, off + g).replace(/[^\\\/]*$/, '') : abs;
813
+ }
814
+ function _tsShellWordIsSecret(w, cwd, ctx) {
815
+ if (_tsTextCitesSecret(w, cwd)) return true;
816
+ const abs = _expandShellPath(w, cwd);
817
+ // UNC de outra máquina: só o texto vale (nenhum realpath/stat abre conexão com esse host)
818
+ if (!abs || _remoteUnc(abs)) return false;
819
+ const base = _globBase(abs);
820
+ if (base !== abs) return !!base && (_isTsHomeDir(base, ctx) || !!_tsSecretMatch(base, ctx.ids, ctx));
821
+ return _isTsHomeDir(abs, ctx) || !!_tsSecretMatch(abs, ctx.ids, ctx);
822
+ }
823
+ // A pasta é ~/.ts ou fica ACIMA dela (uma busca recursiva a partir daí entra em ~/.ts)?
824
+ // posixGlob: o curinga é expandido no estilo do shell POSIX (grep, rg, find), em que "*" não pega
825
+ // nome começado por ponto — `grep -r x *` na pasta pessoal não entra em .ts; só entra se a base
826
+ // do curinga estiver ACIMA da pasta pessoal (C:/Users/* → C:/Users/<você>/.ts).
827
+ function _tsRootCoversTsHome(w, cwd, ctx, posixGlob) {
828
+ const abs = _expandShellPath(w, cwd);
829
+ if (!abs || _remoteUnc(abs)) return null;
830
+ const dir = _globBase(abs);
831
+ if (!dir) return null;
832
+ const c = _normCase(ctx.canon(dir));
833
+ const withSep = c.endsWith(path.sep) ? c : c + path.sep;
834
+ if (posixGlob && dir !== abs && /^[*?[]/.test(abs.slice(dir.length))) {
835
+ const parent = path.dirname(ctx.home);
836
+ return parent !== c && parent.startsWith(withSep) ? dir : null;
837
+ }
838
+ if (c === ctx.home || ctx.home.startsWith(withSep)) return dir;
839
+ const id = ctx.fid(dir);
840
+ return id && ctx.upIds().has(id) ? dir : null;
841
+ }
842
+ // exclusão explícita da pasta do ts na própria busca
843
+ const _TS_EXCLUDE_RE = /--exclude-dir(?:=|\s+)["']?(?:\*\*\/)?\.ts\b|(?:-g|--glob|--iglob)(?:=|\s*)["']?!(?:\*\*\/)?\.ts\b|-Exclude\s+["']?\.ts\b|-prune\b/i;
844
+ const _TS_GREPS = new Set(['grep', 'egrep', 'fgrep', 'zgrep']);
845
+ const _TS_LISTERS = new Set(['get-childitem', 'gci', 'dir', 'ls', 'find']);
846
+ // Busca recursiva que entra em ~/.ts: { read: true, dir } (lê conteúdo: grep -r, rg --hidden,
847
+ // findstr /s, lista recursiva + leitor no mesmo trecho) ou { read: false, dir } (só lista — vale se
848
+ // o trecho seguinte do pipe ler os arquivos), ou null. Raiz omitida = pasta de trabalho.
849
+ function _tsRecursiveReach(flat, names, seg, cwd, ctx) {
850
+ if (_TS_EXCLUDE_RE.test(seg)) return null;
851
+ const covers = (words, posixGlob = true) => { for (const w of words) { const d = _tsRootCoversTsHome(w, cwd, ctx, posixGlob); if (d) return d; } return null; };
852
+ for (let i = 0; i < names.length; i++) {
853
+ const n = names[i];
854
+ let args = flat.slice(i + 1);
855
+ const stop = args.findIndex(w => w === '|' || w === ';' || w === '&&' || w === '||');
856
+ if (stop >= 0) args = args.slice(0, stop);
857
+ const nonflag = args.filter(w => !/^-/.test(w));
858
+ const roots = () => (nonflag.length <= 1 ? ['.'] : nonflag.slice(1));
859
+ let dir = null;
860
+ if (_TS_GREPS.has(n)) {
861
+ const rec = args.some(w => (/^-[A-Za-z0-9]+$/.test(w) && /[rR]/.test(w)) || /^--(?:recursive|dereference-recursive|directories=recurse)$/.test(w))
862
+ || /(?:^|\s)-d\s+recurse\b/.test(args.join(' '));
863
+ if (rec) dir = covers(roots());
864
+ } else if (n === 'rg' || n === 'ag') {
865
+ // rg e ag pulam pastas ocultas; só entram em .ts com --hidden, -., -uu (rg) ou -u (ag)
866
+ const hidden = args.some(w => w === '--hidden' || w === '--unrestricted'
867
+ || (/^-[A-Za-z.]+$/.test(w) && (w.includes('.') || (w.match(/u/g) || []).length >= (n === 'ag' ? 1 : 2))));
868
+ if (hidden) dir = covers(roots());
869
+ } else if (n === 'findstr') {
870
+ // chaves do findstr: /s, /si, -s… (com ":" são /c:texto, /g:arquivo — não contam)
871
+ const sw = args.filter(w => /^[\/-]/.test(w));
872
+ if (sw.some(w => /^[\/-][a-z]+$/i.test(w) && /s/i.test(w))) {
873
+ const plain = args.filter(w => !/^[\/-]/.test(w));
874
+ const specs = sw.some(w => /^[\/-][cg]:/i.test(w)) ? plain : plain.slice(1);
875
+ // findstr é do Windows: o curinga pega nomes com ponto (sem a regra do shell POSIX)
876
+ dir = covers(specs.map(s => path.dirname(_expandShellPath(s, cwd) || s)), false);
877
+ }
878
+ }
879
+ if (dir) return { read: true, dir };
880
+ if (_TS_LISTERS.has(n)) {
881
+ const rec = n === 'find' || args.some(w => /^-rec/i.test(w) || /^-r$/i.test(w) || /^\/s$/i.test(w));
882
+ if (!rec) continue;
883
+ let rs;
884
+ if (n === 'find') {
885
+ const lead = [];
886
+ for (const w of args) { if (/^[-(!]/.test(w)) break; lead.push(w); }
887
+ rs = lead.length ? lead : ['.'];
888
+ } else {
889
+ rs = [];
890
+ for (let a = 0; a < args.length; a++) {
891
+ const w = args[a];
892
+ if (/^-(?:Path|LiteralPath)$/i.test(w)) { if (args[a + 1]) rs.push(args[++a]); continue; }
893
+ if (/^-(?:Filter|Include|Exclude|Depth|Attributes)$/i.test(w)) { a++; continue; }
894
+ if (!/^[-\/]/.test(w)) rs.push(w);
895
+ }
896
+ if (!rs.length) rs = ['.'];
897
+ }
898
+ const d = covers(rs, n === 'find' || n === 'ls');
899
+ if (!d) continue;
900
+ // lista recursiva com leitor no MESMO trecho (find -exec cat, PowerShell entre aspas com
901
+ // "| Select-String", for /f … type): lê
902
+ if (names.slice(i + 1).some(x => _TS_CONTENT_CMDS.has(x))) return { read: true, dir: d };
903
+ return { read: false, dir: d };
904
+ }
905
+ }
906
+ return null;
907
+ }
908
+ // Trecho que cita item protegido sem ler o conteúdo (ver o comentário da seção)?
909
+ function _tsSegmentNonRead(head, names, flat, raw) {
910
+ if (/\$\(|`|<\(/.test(raw) || raw.includes('<') || _TS_CODE_READ_RE.test(raw)) return false;
911
+ if (names.slice(1).some(n => _TS_CONTENT_CMDS.has(n))) return false;
912
+ // copiador: a origem já foi conferida (_tsCopyOrigins); o que sobra citado é o destino
913
+ if (_TS_NONREAD.has(head) || _TS_COPIERS.has(head)) return true;
914
+ return head === 'sed' && flat.some(w => /^(?:-i\S*|--in-place\S*)$/.test(w));
915
+ }
916
+ // Cópia/movimento no trecho: as ORIGENS (o destino fica de fora), ou null sem copiador.
917
+ function _tsCopyOrigins(top, flat) {
918
+ for (const words of [top, flat]) {
919
+ const ci = words.findIndex(w => _TS_COPIERS.has(_shellCmdName(w)));
920
+ if (ci < 0) continue;
921
+ const kind = _shellCmdName(words[ci]);
922
+ const winSwitch = kind === 'copy' || kind === 'xcopy' || kind === 'robocopy' || kind === 'move';
923
+ const args = []; let named = false;
924
+ for (let i = ci + 1; i < words.length; i++) {
925
+ const w = words[i];
926
+ if (/^(?:copy-item|cpi|move-item|mi|rename-item|rni)$/.test(kind) && /^-(?:d(?:estination|est)?|newname)$/i.test(w)) { i++; named = true; continue; }
927
+ if (/^-/.test(w) || (winSwitch && /^\/[A-Za-z?][\w:+-]*$/.test(w))) continue;
928
+ args.push(w);
929
+ }
930
+ return { kind, origins: kind === 'robocopy' ? args.slice(0, 1) : (named || args.length < 2 ? args : args.slice(0, -1)) };
931
+ }
932
+ return null;
933
+ }
934
+ function tsSecretReadReason(cmd, opts = {}) {
935
+ const text = String(cmd || '');
936
+ if (!text.trim()) return null;
937
+ let cwd = path.resolve(String(opts.cwd || process.cwd()));
938
+ let ctx = null;
939
+ const getCtx = () => ctx || (ctx = _tsShellCtx());
940
+ const secret = (w, dir) => _tsShellWordIsSecret(w, dir, getCtx());
941
+ const hit = (seg, dir) => {
942
+ if (_tsTextCitesSecret(seg, dir)) return true;
943
+ const { top, flat } = _shellWords(seg);
944
+ // teto de palavras: cada uma custa algumas chamadas ao disco
945
+ return [...new Set([...top, ...flat])].slice(0, 64).some(w => secret(w, dir));
946
+ };
947
+ let pipeline = [];
948
+ for (const part of _shellSegments(text)) {
949
+ if (!part.pipe) pipeline = [];
950
+ // destino de redirecionamento (> x, >> x, 2> x) é escrita, não leitura
951
+ const seg = part.text.replace(/(?:\d|&)?>>?\|?\s*(?:"[^"]*"|'[^']*'|[^\s"';|&<>]+)/g, ' ');
952
+ const { top, flat } = _shellWords(seg);
953
+ const head = top.length ? _shellCmdName(top[0]) : '';
954
+ if (_TS_CD.has(head)) {
955
+ const alvo = top.slice(1).find(w => !/^(?:\/d|-(?:Path|LiteralPath))$/i.test(w));
956
+ const d = alvo ? _expandShellPath(alvo, cwd) : null;
957
+ if (d) cwd = d;
958
+ continue;
959
+ }
960
+ const names = flat.map(_shellCmdName);
961
+ // cópia/movimento: a ORIGEM protegida recusa; o destino (copiar PARA dentro) é escrita
962
+ const copy = _tsCopyOrigins(top, flat);
963
+ if (copy && copy.origins.some(w => secret(w, cwd))) return _TS_SHELL_READ_REFUSAL;
964
+ const segHit = hit(seg, cwd);
965
+ // o programa de fato, sem prefixo (sudo ls ~/.ts, nohup, VAR=x comando)
966
+ let hi = 0;
967
+ while (hi < top.length - 1 && (_TS_WRAPPERS.has(_shellCmdName(top[hi])) || /^[A-Za-z_]\w*=/.test(top[hi]))) hi++;
968
+ // `<`, subcomando e leitura por código olham o trecho ORIGINAL (a limpeza dos
969
+ // redirecionamentos acima pode levar junto um pedaço de `=>` de arrow function)
970
+ if (segHit && !_tsSegmentNonRead(top.length ? _shellCmdName(top[hi]) : '', names, flat, part.text)) return _TS_SHELL_READ_REFUSAL;
971
+ const rec = _tsRecursiveReach(flat, names, part.text, cwd, getCtx());
972
+ if (rec && rec.read) return _tsRecursiveRefusal(rec.dir);
973
+ if (part.pipe) {
974
+ const reads = _TS_PIPE_READERS.has(head) || (head === 'xargs' && names.slice(1).some(n => _TS_CONTENT_CMDS.has(n)));
975
+ const from = reads && pipeline.find(p => p.hit || p.rec);
976
+ if (from) return from.hit ? _TS_SHELL_READ_REFUSAL : _tsRecursiveRefusal(from.rec);
977
+ }
978
+ pipeline.push({ hit: segHit, rec: rec ? rec.dir : null });
979
+ }
980
+ return null;
981
+ }
407
982
 
408
983
  // ── RECUSA INSTANTÂNEA de comandos AUTO-DESTRUTIVOS ──────────────────────────
409
984
  // Diferente do gate destrutivo comum (que PEDE aprovação humana): estes comandos matam a
410
985
  // PRÓPRIA missão — o processo do agente, o dev server de que a missão depende — ou a
411
- // máquina inteira. Esperar aprovação (local, ou remota via Telegram ~150s POR tentativa)
986
+ // máquina inteira. Esperar aprovação (que já chegou a custar ~150s POR tentativa)
412
987
  // por algo que JAMAIS deve rodar é desperdício puro: recusamos NA HORA, apontando a
413
988
  // alternativa cirúrgica. Retorna a mensagem (motivo pro modelo) quando é auto-destrutivo,
414
989
  // ou null quando é seguro (aí segue o fluxo normal, inclusive o gate de aprovação comum).
@@ -662,7 +1237,15 @@ function _vizinhos(absPath) {
662
1237
  } catch (_) { return []; }
663
1238
  }
664
1239
 
1240
+ // Executor público: roda a ferramenta e passa TODA a saída pela máscara dos segredos conhecidos
1241
+ // do ts (token de sessão, chaves BYOK, senha SSH, auth de MCP e o token recebido em opts) antes de
1242
+ // ela virar contexto do modelo, log ou evidência. Se a máscara falhar, o resultado é descartado.
665
1243
  async function execute(name, input, opts = {}) {
1244
+ const raw = await _executeTool(name, input, opts);
1245
+ try { return maskKnownSecrets(raw, [opts.token]); }
1246
+ catch (_) { return { erro: 'Resultado descartado: a verificação de segredos na saída falhou.' }; }
1247
+ }
1248
+ async function _executeTool(name, input, opts = {}) {
666
1249
  // BASE de trabalho: cwd da sessão do agente (opts.baseDir) → pasta confinada da
667
1250
  // missão (opts.confineDir) → process.cwd(). É a raiz de todo caminho relativo.
668
1251
  const baseDir = opts.baseDir || opts.confineDir || process.cwd();
@@ -692,6 +1275,9 @@ async function execute(name, input, opts = {}) {
692
1275
  const comando = String(input.comando || '');
693
1276
  { const scope = _commandScopeViolation(comando, opts.confineDir); if (scope) return { erro: scope }; }
694
1277
  { const exfil = secretExfiltrationReason(comando); if (exfil) return { erro: exfil }; }
1278
+ // credenciais do ts por shell: mesma recusa do ler_arquivo (defesa em profundidade — o
1279
+ // gate do agent.js já pega antes)
1280
+ { const tr = tsSecretReadReason(comando, { cwd: baseDir }); if (tr) return { erro: tr }; }
695
1281
  // RECUSA INSTANTÂNEA (defesa em profundidade — o gate do agent.js já pega antes):
696
1282
  // comando auto-destrutivo NUNCA roda e NUNCA espera aprovação.
697
1283
  { const sd = selfDestructiveReason(comando, { cwd: baseDir }); if (sd) return { erro: sd }; }
@@ -765,7 +1351,16 @@ async function execute(name, input, opts = {}) {
765
1351
  }
766
1352
  case 'ler_arquivo': {
767
1353
  const p = _abs(input.caminho, baseDir);
1354
+ // Recusa os arquivos de credenciais do ts. O conteúdo devolvido NÃO passa por
1355
+ // redactSecrets, de propósito: editar_arquivo precisa do texto exato (o "buscar" sai do
1356
+ // que foi lido) e, com máscara, a edição falharia ou gravaria ••••REDACTED no arquivo.
1357
+ // Segredo de projeto (.env, config com senha) chega inteiro ao modelo — decisão, não esquecimento.
1358
+ // A exceção são os segredos do PRÓPRIO ts (maskKnownSecrets, no execute): se o .env tiver a
1359
+ // mesma chave do providers.json, ela chega mascarada — e escrever_arquivo/editar_arquivo
1360
+ // recusam gravar a máscara por cima do valor real.
1361
+ // O escopo vem antes: ele recusa pelo texto, sem tocar no disco, o que está fora da missão.
768
1362
  if (_outsideScope(p)) return _scopeError(p);
1363
+ { const g = _guardTsSecretRead(p); if (g) return { erro: g }; }
769
1364
  if (BIN_RE.test(p)) return { erro: 'Arquivo binário — ler_arquivo só lê texto. Use executar_comando com uma ferramenta adequada.' };
770
1365
  // Não achou? Sugere nomes PARECIDOS na mesma pasta (o modelo chuta o nome do recurso).
771
1366
  if (!fs.existsSync(p)) {
@@ -773,7 +1368,8 @@ async function execute(name, input, opts = {}) {
773
1368
  return { erro: 'Arquivo não encontrado: ' + p + (v.length ? '. Nomes parecidos na mesma pasta: ' + v.join(', ') + ' — confira o nome EXATO (maiúsculas/plural contam).' : '. Confira o caminho (use listar_diretorio/buscar_arquivos).') };
774
1369
  }
775
1370
  try { if (fs.statSync(p).isDirectory()) return { erro: p + ' é uma PASTA, não um arquivo. Use listar_diretorio pra ver o conteúdo.' }; } catch (_) {}
776
- const txt = fs.readFileSync(p, 'utf8');
1371
+ const txt = _readTextNotTsSecret(p);
1372
+ if (typeof txt !== 'string') return txt;
777
1373
  const linhas = txt.split('\n'); const total = linhas.length; const MAXL = 400;
778
1374
  const ini = parseInt(input.inicio) || 0;
779
1375
  if (ini > 0) {
@@ -828,6 +1424,15 @@ async function execute(name, input, opts = {}) {
828
1424
  }
829
1425
  }
830
1426
  if (_outsideScope(p)) return _scopeError(p);
1427
+ // o rebase pode ter trocado o alvo: a pasta do ts é conferida de novo no caminho final
1428
+ if (_rebased) { const g = _guardTsHome(p); if (g) return { erro: g }; }
1429
+ // A leitura mascara os segredos conhecidos do ts: regravar o arquivo inteiro com o texto
1430
+ // lido trocaria o valor real pela máscara (ex.: .env com a mesma chave do providers.json).
1431
+ if (String(input.conteudo ?? '').includes(core.SECRET_MASK)) {
1432
+ let atual = '';
1433
+ try { if (fs.statSync(p).isFile()) atual = fs.readFileSync(p, 'utf8'); } catch (_) {}
1434
+ if (atual && !atual.includes(core.SECRET_MASK)) return { erro: _MASK_WRITE_REFUSAL };
1435
+ }
831
1436
  const held = _acquireFileMutation(p); if (held.erro) return { erro: held.erro };
832
1437
  try {
833
1438
  fs.mkdirSync(path.dirname(p), { recursive: true });
@@ -860,14 +1465,24 @@ async function execute(name, input, opts = {}) {
860
1465
  }
861
1466
  }
862
1467
  if (_outsideScope(p)) return _scopeError(p);
1468
+ { const g = _guardTsHome(p); if (g) return { erro: g }; } // de novo, no alvo final do rebase
1469
+ // Sem "substituir" a edição APAGAVA o trecho buscado ('' por padrão). Apagar é pedido
1470
+ // explícito: substituir vazio ("").
1471
+ if (input.substituir === undefined || input.substituir === null) {
1472
+ return { erro: 'Informe substituir (o texto novo). Para apagar o trecho buscado, passe substituir vazio ("").' };
1473
+ }
863
1474
  const held = _acquireFileMutation(p); if (held.erro) return { erro: held.erro };
864
1475
  try {
865
1476
  if (!fs.existsSync(p)) {
866
1477
  const v = _vizinhos(p);
867
1478
  return { erro: 'Arquivo não existe: ' + p + (v.length ? '. Nomes parecidos na mesma pasta: ' + v.join(', ') + ' — confira se não errou o nome.' : '') + ' Pra criar arquivo NOVO use escrever_arquivo.' };
868
1479
  }
869
- const orig = fs.readFileSync(p, 'utf8');
1480
+ // Lê pelo descritor com a mesma checagem de identidade do ler_arquivo: um apelido de
1481
+ // credencial do ts (hardlink, UNC por IP) não chega à mensagem de "Linha parecida" abaixo.
1482
+ const orig = _readTextNotTsSecret(p);
1483
+ if (typeof orig !== 'string') return orig;
870
1484
  let buscar = String(input.buscar ?? ''), substituir = String(input.substituir ?? '');
1485
+ if (substituir.includes(core.SECRET_MASK) && !orig.includes(core.SECRET_MASK)) return { erro: _MASK_WRITE_REFUSAL };
871
1486
  if (substituir.includes('\uFFFD') && !orig.includes('\uFFFD')) return { erro: 'CODIFICAÇÃO BLOQUEADA: a edição introduziria o caractere de substituição �. Releia a fonte em UTF-8 e envie o texto correto.' };
872
1487
  if (!buscar) return { erro: 'buscar vazio.' };
873
1488
  // tolerância CRLF: o modelo manda \n mas arquivo Windows tem \r\n — normaliza o par
@@ -881,7 +1496,7 @@ async function execute(name, input, opts = {}) {
881
1496
  const lines = orig.split(/\r?\n/);
882
1497
  const idx = alvo ? lines.findIndex(l => l.includes(alvo.slice(0, 25))) : -1;
883
1498
  return { erro: 'Trecho de "buscar" NÃO encontrado — tem que ser EXATO (mesmos espaços/indentação). '
884
- + (idx >= 0 ? `Linha parecida (linha ${idx + 1}): ${lines[idx].slice(0, 160)} — releia com ler_arquivo e copie exato.` : 'Releia o arquivo com ler_arquivo e copie o trecho exato.') };
1499
+ + (idx >= 0 ? `Linha parecida (linha ${idx + 1}): ${core.redactSecrets(lines[idx]).slice(0, 160)} — releia com ler_arquivo e copie exato.` : 'Releia o arquivo com ler_arquivo e copie o trecho exato.') };
885
1500
  }
886
1501
  if (count > 1 && !input.todas) return { erro: `Trecho AMBÍGUO: ${count} ocorrências. Inclua mais linhas de contexto em "buscar" pra ficar único, ou passe todas:true.` };
887
1502
  const novo = input.todas ? orig.split(buscar).join(substituir) : orig.replace(buscar, substituir);
@@ -893,6 +1508,7 @@ async function execute(name, input, opts = {}) {
893
1508
  case 'restaurar_arquivo': {
894
1509
  const p = _abs(input.caminho, baseDir);
895
1510
  if (_outsideScope(p)) return _scopeError(p);
1511
+ { const g = _guardTsHome(p); if (g) return { erro: g }; }
896
1512
  const held = _acquireFileMutation(p); if (held.erro) return { erro: held.erro };
897
1513
  try {
898
1514
  const dir = path.join(BK_DIR, _bkKey(p));
@@ -920,6 +1536,10 @@ async function execute(name, input, opts = {}) {
920
1536
  // próximos caminhos relativos (e comandos) a partir daqui.
921
1537
  const alvo = _abs(input.caminho || input.diretorio || '.', baseDir);
922
1538
  if (_outsideScope(alvo)) return _scopeError(alvo);
1539
+ // ~/.ts não vira pasta de trabalho: buscar_codigo indexaria as credenciais (o índice pula
1540
+ // ".ts" só como SUBpasta) e caminhos relativos cairiam nelas. Skills e memória continuam
1541
+ // legíveis pelo caminho completo.
1542
+ if (_insideTsHome(alvo)) return { erro: 'RECUSADO: "' + alvo + '" é a área de configuração do ts (~/.ts) — ela não vira pasta de trabalho, porque guarda credenciais (token de sessão, chaves de IA, auth de MCP). Para ler uma skill ou a memória, use ler_arquivo com o caminho completo; para ver os nomes, listar_diretorio.' };
923
1543
  let st; try { st = fs.statSync(alvo); } catch (_) { const v = _vizinhos(alvo); return { erro: 'Pasta não existe: ' + alvo + (v.length ? '. Nomes parecidos na pasta acima: ' + v.join(', ') : '') + '. Confira o caminho (use listar_diretorio/buscar_arquivos).' }; }
924
1544
  if (!st.isDirectory()) return { erro: 'Não é uma pasta: ' + alvo };
925
1545
  return { ok: true, cwd: alvo, _setCwd: alvo };
@@ -947,6 +1567,8 @@ async function execute(name, input, opts = {}) {
947
1567
  case 'buscar_codigo': {
948
1568
  const consulta = String(input.consulta || '').trim();
949
1569
  if (!consulta) return { erro: 'consulta vazia' };
1570
+ // dentro de ~/.ts o índice leria (e gravaria em .ts-indice.json) as credenciais do ts
1571
+ if (_insideTsHome(baseDir)) return { erro: 'RECUSADO: a pasta de trabalho está dentro de ~/.ts (configuração e credenciais do ts) — buscar_codigo não indexa essa área. Mude para a pasta do projeto com mudar_diretorio.' };
950
1572
  const ix = require('./indice');
951
1573
  let idx = ix.load(baseDir);
952
1574
  let construiu = false;
@@ -1063,6 +1685,7 @@ async function execute(name, input, opts = {}) {
1063
1685
  case 'ler_documento': {
1064
1686
  const target = _abs(input.caminho, baseDir);
1065
1687
  if (_outsideScope(target)) return _scopeError(target);
1688
+ { const g = _guardTsSecretRead(target); if (g) return { erro: g }; }
1066
1689
  return await require('./office-readers').lerDocumento({ ...input, caminho: target });
1067
1690
  }
1068
1691
  case 'editar_documento': {
@@ -1070,6 +1693,7 @@ async function execute(name, input, opts = {}) {
1070
1693
  if (_outsideScope(target)) return _scopeError(target);
1071
1694
  const copy = input.caminho_copia ? _abs(input.caminho_copia, baseDir) : undefined;
1072
1695
  if (copy && _outsideScope(copy)) return _scopeError(copy);
1696
+ for (const alvo of [target, copy]) { const g = alvo && _guardTsHome(alvo); if (g) return { erro: g }; }
1073
1697
  const held = _acquireFileMutation(target); if (held.erro) return { erro: held.erro };
1074
1698
  try {
1075
1699
  const result = await require('./office-editors').editarDocumento({ ...input, caminho: target, caminho_copia: copy });
@@ -1082,6 +1706,7 @@ async function execute(name, input, opts = {}) {
1082
1706
  if (_outsideScope(target)) return _scopeError(target);
1083
1707
  const copy = input.caminho_copia ? _abs(input.caminho_copia, baseDir) : undefined;
1084
1708
  if (copy && _outsideScope(copy)) return _scopeError(copy);
1709
+ for (const alvo of [target, copy]) { const g = alvo && _guardTsHome(alvo); if (g) return { erro: g }; }
1085
1710
  const held = _acquireFileMutation(target); if (held.erro) return { erro: held.erro };
1086
1711
  try {
1087
1712
  const result = await require('./office-editors').editarPlanilha({ ...input, caminho: target, caminho_copia: copy });
@@ -1092,6 +1717,7 @@ async function execute(name, input, opts = {}) {
1092
1717
  case 'ler_planilha': {
1093
1718
  const target = _abs(input.caminho, baseDir);
1094
1719
  if (_outsideScope(target)) return _scopeError(target);
1720
+ { const g = _guardTsSecretRead(target); if (g) return { erro: g }; }
1095
1721
  return await require('./office-readers').lerPlanilha({ ...input, caminho: target });
1096
1722
  }
1097
1723
  case 'status_microsoft365':
@@ -1182,6 +1808,7 @@ async function execute(name, input, opts = {}) {
1182
1808
  case 'ler_apresentacao': {
1183
1809
  const target = _abs(input.caminho, baseDir);
1184
1810
  if (_outsideScope(target)) return _scopeError(target);
1811
+ { const g = _guardTsSecretRead(target); if (g) return { erro: g }; }
1185
1812
  return await require('./office-readers').lerApresentacao({ ...input, caminho: target });
1186
1813
  }
1187
1814
  case 'android_parear':
@@ -1197,6 +1824,7 @@ async function execute(name, input, opts = {}) {
1197
1824
  case 'android_capturar_tela': {
1198
1825
  const target = _abs(input.caminho, baseDir);
1199
1826
  if (_outsideScope(target)) return _scopeError(target);
1827
+ { const g = _guardTsHome(target); if (g) return { erro: g }; }
1200
1828
  return await require('./device-tools').capturarTela({ ...input, caminho: target });
1201
1829
  }
1202
1830
  default: {
@@ -1212,5 +1840,7 @@ async function execute(name, input, opts = {}) {
1212
1840
  } catch (e) { return { erro: String((e && e.message) || e).slice(0, 400) }; }
1213
1841
  }
1214
1842
 
1215
- module.exports = { DEFS, execute, isDestructive, selfDestructiveReason, secretExfiltrationReason, buildProjectMap,
1216
- _test: { shellGotcha: _shellGotcha, normalizeWindowsReadOnlyCommand: _normalizeWindowsReadOnlyCommand, commandScopeViolation: _commandScopeViolation, isReadOnlySearchNoMatch: _isReadOnlySearchNoMatch } };
1843
+ module.exports = { DEFS, execute, isDestructive, selfDestructiveReason, secretExfiltrationReason, tsSecretReadReason, buildProjectMap,
1844
+ knownSecretValues, knownSecretsFor, maskKnownSecrets,
1845
+ _test: { shellGotcha: _shellGotcha, normalizeWindowsReadOnlyCommand: _normalizeWindowsReadOnlyCommand, commandScopeViolation: _commandScopeViolation, isReadOnlySearchNoMatch: _isReadOnlySearchNoMatch, guardTsSecretRead: _guardTsSecretRead, remoteUnc: _remoteUnc,
1846
+ guardTsHome: _guardTsHome, tsTextCitesSecret: _tsTextCitesSecret } };