@danieltmn/tamnora 0.21.1 → 0.22.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +10 -6
  3. package/SECURITY.md +13 -0
  4. package/harness/packages/core/src/agent/runAgent.js +9 -5
  5. package/harness/packages/core/src/browser/index.js +56 -2
  6. package/harness/packages/core/src/hooks/index.js +9 -5
  7. package/harness/packages/core/src/index.js +1 -0
  8. package/harness/packages/core/src/mcp/stdio.js +15 -0
  9. package/harness/packages/core/src/net/hosts.js +181 -0
  10. package/harness/packages/core/src/plugins/index.js +6 -4
  11. package/harness/packages/core/src/redact.js +2 -0
  12. package/harness/packages/core/src/tools/executors.js +54 -53
  13. package/harness/packages/core/src/tools/index.js +1 -1
  14. package/harness/packages/core/src/tools/paths.js +35 -5
  15. package/harness/packages/core/src/tools/permissions.js +41 -2
  16. package/harness/packages/core/src/tools/registry.js +8 -3
  17. package/harness/packages/core/src/tunnel.js +15 -5
  18. package/harness/packages/core/test/hooks.test.js +6 -3
  19. package/harness/packages/core/test/hosts.test.js +50 -0
  20. package/harness/packages/core/test/plugins.test.js +5 -3
  21. package/harness/packages/core/test/tools.test.js +98 -2
  22. package/harness/packages/server/public/app.js +343 -6729
  23. package/harness/packages/server/public/chat.js +1893 -0
  24. package/harness/packages/server/public/chrome.js +812 -0
  25. package/harness/packages/server/public/commands.js +403 -0
  26. package/harness/packages/server/public/core.js +209 -0
  27. package/harness/packages/server/public/explorer.js +625 -0
  28. package/harness/packages/server/public/index.html +19 -19
  29. package/harness/packages/server/public/sessions.js +274 -0
  30. package/harness/packages/server/public/settings.js +1076 -0
  31. package/harness/packages/server/public/styles.css +29 -2
  32. package/harness/packages/server/public/system.js +495 -0
  33. package/harness/packages/server/public/voice.js +1058 -0
  34. package/harness/packages/server/src/auth.js +12 -10
  35. package/harness/packages/server/src/lib/agentRun.js +6 -1
  36. package/harness/packages/server/src/lib/channels/telegram.js +1 -0
  37. package/harness/packages/server/src/lib/presence.js +12 -1
  38. package/harness/packages/server/src/lib/procs.js +30 -3
  39. package/harness/packages/server/src/lib/scheduler.js +11 -2
  40. package/harness/packages/server/src/lib/tasks.js +20 -3
  41. package/harness/packages/server/src/lib/workspace.js +90 -2
  42. package/harness/packages/server/src/routes/auth.js +8 -4
  43. package/harness/packages/server/src/routes/chat.js +15 -4
  44. package/harness/packages/server/src/routes/preview.js +10 -2
  45. package/harness/packages/server/src/routes/procs.js +2 -6
  46. package/harness/packages/server/src/routes/providers.js +12 -9
  47. package/harness/packages/server/src/server.js +28 -2
  48. package/harness/packages/server/test/chat.test.js +586 -0
  49. package/harness/packages/server/test/helpers.js +107 -0
  50. package/harness/packages/server/test/ops.test.js +511 -0
  51. package/harness/packages/server/test/procs-manager.test.js +43 -0
  52. package/harness/packages/server/test/runs.test.js +816 -0
  53. package/harness/packages/server/test/security.test.js +112 -9
  54. package/harness/packages/server/test/sessions.test.js +437 -0
  55. package/harness/packages/server/test/settings.test.js +465 -0
  56. package/harness/tamnora.example.json +1 -1
  57. package/package.json +1 -1
  58. package/harness/packages/server/test/server.test.js +0 -2878
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.22.0] - 2026-10-22
9
+
10
+ ### Cambios
11
+
12
+ - docs: AGENTS.md al dia con la auditoria, los modulos de la consola y el preview opt-in
13
+ - refactor(test): parte server.test.js en archivos por dominio
14
+ - refactor(consola): parte app.js en modulos y pule la UX
15
+ - fix: cierra los hallazgos de seguridad de la auditoria y agrega regresiones
16
+
17
+ ## [0.21.2] - 2026-10-20
18
+
19
+ ### Cambios
20
+
21
+ - fix: cierra los P0 de la auditoria y pule la compactacion
22
+
8
23
  ## [0.21.1] - 2026-10-16
9
24
 
10
25
  ### Cambios
package/README.md CHANGED
@@ -29,7 +29,8 @@ con TUI, consola web, tools locales, MCP y evaluacion de modelos.
29
29
  - **UI interactiva**: el agente pide un **formulario** (`ask_user`), ofrece
30
30
  **botones** (`offer_choices`) o muestra un **checklist** (`show_plan`); la
31
31
  consola los renderiza.
32
- - **Plataforma**: hooks y plugins locales (`.tamnora/hooks`, `.tamnora/plugins`).
32
+ - **Plataforma**: hooks y plugins locales declarados en `tamnora.json`
33
+ (`.tamnora/hooks`, `.tamnora/plugins`; nunca se cargan por presencia).
33
34
 
34
35
  ## Instalacion
35
36
 
@@ -186,11 +187,14 @@ Los tres se renderizan en vivo y se reconstruyen al recargar la consola.
186
187
 
187
188
  ### Hooks y plugins
188
189
 
189
- - **Hooks** (`.tamnora/hooks/*.mjs` o `hooks[]`): observan el ciclo de vida
190
- (`run.start/end`, `tool.before/after`, `session.compacting/compacted`, `advice`).
191
- Corren aislados y con los datos redactados; son opt-in.
192
- - **Plugins** (`.tamnora/plugins/*.mjs` o `plugins[]`): agregan **tools propias**
193
- al agente (expuestas como `plugin__<plugin>__<tool>`, con permisos).
190
+ - **Hooks** (`hooks[]` en `tamnora.json`, p. ej. `[".tamnora/hooks/*.mjs"]`):
191
+ observan el ciclo de vida (`run.start/end`, `tool.before/after`,
192
+ `session.compacting/compacted`, `advice`). Corren aislados y con los datos
193
+ redactados. Es **opt-in explícito**: la presencia de archivos en el proyecto no
194
+ ejecuta nada.
195
+ - **Plugins** (`plugins[]`, p. ej. `[".tamnora/plugins/*.mjs"]`): agregan **tools
196
+ propias** al agente (expuestas como `plugin__<plugin>__<tool>`, con permisos).
197
+ Igual que los hooks, solo se cargan desde la config.
194
198
 
195
199
  ### Preview
196
200
 
package/SECURITY.md CHANGED
@@ -35,6 +35,19 @@ asi que se lo debe tratar como codigo con acceso a tu usuario.
35
35
  servidores de confianza. Un tool tipo `get-env` no puede leer tus secretos del
36
36
  shell, y los resultados MCP se **redactan** (`redactSecrets`) por si devuelven un
37
37
  token. No pases credenciales en los argumentos de las tools: van por `env`.
38
+ - **Hooks y plugins**: son **opt-in explicito**. Solo se ejecutan los modulos
39
+ `.mjs` cuyos patrones declares en `hooks[]`/`plugins[]` de `tamnora.json`; la
40
+ presencia de `.tamnora/hooks` o `.tamnora/plugins` en un proyecto **no** ejecuta
41
+ nada. Aun asi, un hook/plugin corre con tus privilegios: revisalo antes de
42
+ declararlo.
43
+ - **Salidas HTTP**: `fetch_url`, `screenshot` y `console_errors` bloquean loopback,
44
+ redes privadas, link-local, CGNAT y rangos IPv6 internos (incluido
45
+ IPv4-mapped), y **revalidan el host en cada redirect**. `preview_url` es la
46
+ excepcion a proposito: abre URLs locales en el panel de vista previa de la
47
+ consola.
48
+ - **Procesos de dev**: `POST /api/procs/start` solo acepta conexiones locales,
49
+ pasa por la allowlist `processes.allow` y valida los argumentos; en Windows no
50
+ se permiten metacaracteres del shell.
38
51
  - **Tools locales y `bash`**: `bash` corre en tu maquina como tu usuario, asi que
39
52
  hay un piso de seguridad que **no se puede saltear** ni con `bash: "allow"` ni
40
53
  con `--yes`: los comandos catastroficos (`rm -rf /`/`~`, fork bomb, `mkfs`,
@@ -1,4 +1,5 @@
1
1
  import { withRetry, isRetryableError } from '../providers/resilience.js';
2
+ import { redactSecrets, redactDeep } from '../redact.js';
2
3
  import { trimContext, estimateTokens } from './context.js';
3
4
 
4
5
  // 0 = sin limite de rondas: el loop sigue hasta que el modelo deja de pedir
@@ -66,6 +67,7 @@ export async function runAgent({
66
67
  retryDelayMs = 500,
67
68
  contextTokens = 0,
68
69
  onLifecycle,
70
+ redactExtra = [],
69
71
  }) {
70
72
  const working = [...messages];
71
73
  // Mensajes efimeros (p. ej. reglas por path inyectadas en runtime): entran al
@@ -234,7 +236,7 @@ export async function runAgent({
234
236
  }
235
237
  }
236
238
 
237
- const normalized = normalizeToolOutput(output);
239
+ const normalized = normalizeToolOutput(output, { extra: redactExtra });
238
240
  onEvent?.({
239
241
  type: 'tool_result',
240
242
  id: call.id,
@@ -357,14 +359,16 @@ function createStallGuard(onEvent, stallWarnMs) {
357
359
  * Normaliza el resultado de un tool a `{ content, meta }`. Un executor puede
358
360
  * devolver un string (lo normal) o `{ content, meta }` cuando necesita mandar
359
361
  * metadata al cliente sin ensuciar lo que ve el modelo (p. ej. una captura).
362
+ * El contenido va redactado (`redactSecrets`): un tool result que pegue una
363
+ * API key no puede llegar al historial, la UI ni los canales.
360
364
  */
361
- export function normalizeToolOutput(output) {
362
- if (typeof output === 'string') return { content: output };
365
+ export function normalizeToolOutput(output, opts = {}) {
366
+ if (typeof output === 'string') return { content: redactSecrets(output, opts) };
363
367
  if (output && typeof output === 'object' && typeof output.content === 'string') {
364
- return { content: output.content, meta: output.meta };
368
+ return { content: redactSecrets(output.content, opts), meta: redactDeep(output.meta, opts) };
365
369
  }
366
370
  if (output == null) return { content: '' };
367
- return { content: JSON.stringify(output) };
371
+ return { content: redactSecrets(JSON.stringify(output), opts) };
368
372
  }
369
373
 
370
374
  async function defaultExecute(name) {
@@ -8,6 +8,7 @@ import { spawn } from 'node:child_process';
8
8
  import { existsSync, mkdtempSync, rmSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import path from 'node:path';
11
+ import { isBlockedHost, normalizeHost } from '../net/hosts.js';
11
12
 
12
13
  const LAUNCH_TIMEOUT_MS = 15000;
13
14
 
@@ -52,6 +53,31 @@ function delay(ms) {
52
53
  return new Promise((resolve) => setTimeout(resolve, ms));
53
54
  }
54
55
 
56
+ function assertAllowedUrl(raw) {
57
+ let parsed;
58
+ try {
59
+ parsed = new URL(String(raw));
60
+ } catch {
61
+ throw new Error(`URL inválida: ${raw}`);
62
+ }
63
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
64
+ throw new Error(`Solo se admite http(s): ${raw}`);
65
+ }
66
+ if (isBlockedHost(parsed.hostname)) {
67
+ throw new Error(`host bloqueado (${normalizeHost(parsed.hostname)}).`);
68
+ }
69
+ }
70
+
71
+ function isBlockedNavigation(raw) {
72
+ try {
73
+ const parsed = new URL(String(raw));
74
+ if (!parsed.hostname) return parsed.protocol === 'file:';
75
+ return isBlockedHost(parsed.hostname);
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
55
81
  function launchBrowser(bin) {
56
82
  return new Promise((resolve, reject) => {
57
83
  const profile = mkdtempSync(path.join(tmpdir(), 'tamnora-browser-'));
@@ -209,8 +235,35 @@ async function openPage(wsUrl, url, { timeoutMs = 20000, collectMessages = true
209
235
  }
210
236
  });
211
237
  });
212
- await cdp.send('Page.navigate', { url }, sessionId);
213
- await Promise.race([loaded, delay(timeoutMs)]);
238
+ let blockedUrl = '';
239
+ const offNav = cdp.on((message) => {
240
+ if (message.method !== 'Page.frameNavigated' || message.sessionId !== sessionId) return;
241
+ const frame = message.params?.frame;
242
+ if (!frame || frame.parentId || !frame.url || frame.url === 'about:blank') return;
243
+ if (isBlockedNavigation(frame.url)) blockedUrl = frame.url;
244
+ });
245
+ try {
246
+ await cdp.send('Page.navigate', { url }, sessionId);
247
+ await Promise.race([loaded, delay(timeoutMs)]);
248
+ } finally {
249
+ offNav();
250
+ }
251
+ if (!blockedUrl) {
252
+ try {
253
+ const { result } = await cdp.send(
254
+ 'Runtime.evaluate',
255
+ { expression: 'location.href', returnByValue: true },
256
+ sessionId
257
+ );
258
+ const finalUrl = String(result?.value ?? '');
259
+ if (finalUrl && isBlockedNavigation(finalUrl)) blockedUrl = finalUrl;
260
+ } catch {
261
+ /* sin runtime disponible: nos quedamos con los eventos de navegacion */
262
+ }
263
+ }
264
+ if (blockedUrl) {
265
+ throw new Error(`host bloqueado (${normalizeHost(new URL(blockedUrl).hostname)}).`);
266
+ }
214
267
 
215
268
  return { cdp, sessionId, targetId, messages };
216
269
  }
@@ -234,6 +287,7 @@ function killBrowser(child) {
234
287
  }
235
288
 
236
289
  async function withBrowser(url, options, run) {
290
+ assertAllowedUrl(url);
237
291
  const bin = findBrowserPath();
238
292
  if (!bin) {
239
293
  throw new Error('no encontré Chrome/Edge/Chromium; definí TAMNORA_BROWSER con la ruta al ejecutable');
@@ -1,7 +1,9 @@
1
1
  // Hooks locales: modulos `.mjs` que observan el ciclo de vida del agente
2
- // (cargados con `import()` dinamico, opt-in por presencia y aislados de errores).
3
- // Un hook exporta por default un objeto `{ name?, events: { '<tipo>': fn } }` o
4
- // una funcion (se llama para todos los eventos).
2
+ // (cargados con `import()` dinamico). Es **opt-in explicito**: solo se cargan
3
+ // los patrones declarados en `config.hooks[]`; la mera presencia de archivos en
4
+ // el cwd no ejecuta nada. Un hook exporta por default un objeto
5
+ // `{ name?, events: { '<tipo>': fn } }` o una funcion (se llama para todos los
6
+ // eventos).
5
7
 
6
8
  import path from 'node:path';
7
9
  import { pathToFileURL } from 'node:url';
@@ -23,11 +25,13 @@ export function normalizeHookModule(mod, id = '') {
23
25
  }
24
26
 
25
27
  /**
26
- * Carga los hooks de `patterns` + `.tamnora/hooks/*.mjs` (relativos a `cwd`).
28
+ * Carga los hooks de los `patterns` explicitos (relativos a `cwd`). Sin
29
+ * patrones no se importa nada: abrir un proyecto no ejecuta codigo por si solo.
27
30
  * Un modulo roto se ignora.
28
31
  */
29
32
  export async function loadHooks(cwd, patterns = []) {
30
- const list = [...new Set([...(patterns ?? []), ...DEFAULT_HOOK_PATTERNS])];
33
+ const list = [...new Set(patterns ?? [])];
34
+ if (!list.length) return [];
31
35
  const hooks = [];
32
36
  for (const rel of await globFiles(cwd, list)) {
33
37
  try {
@@ -83,6 +83,7 @@ export {
83
83
  bashGuard,
84
84
  resolveInRoot,
85
85
  toRel,
86
+ realpathDeep,
86
87
  MCP_ROUTER_NAME,
87
88
  MCP_AUTO_THRESHOLD,
88
89
  useLazyMcp,
@@ -26,6 +26,20 @@ function shouldUseShell(server, command) {
26
26
  return true;
27
27
  }
28
28
 
29
+ // Con `shell: true` Node concatena los args sin escapar: un arg con
30
+ // metacaracteres (`&`, `|`, `<`, `>`, `^`, `%`, `!`) inyectaria comandos. Misma
31
+ // regla que `lib/procs.js` del server.
32
+ const SAFE_SHELL_ARG = /^[^&|<>^%!"\r\n]+$/;
33
+
34
+ function assertSafeShellArgs(server, command, args = []) {
35
+ if (!shouldUseShell(server, command)) return;
36
+ for (const arg of args) {
37
+ if (!SAFE_SHELL_ARG.test(String(arg))) {
38
+ throw new Error(`MCP stdio: argumento no permitido con shell en Windows: "${arg}".`);
39
+ }
40
+ }
41
+ }
42
+
29
43
  // Variables minimas para que un proceso hijo comun (node, npx, uvx) arranque.
30
44
  const BASE_ENV_KEYS = [
31
45
  'PATH',
@@ -73,6 +87,7 @@ export function childEnv(server = {}) {
73
87
  export async function connectMcpStdioServer(server, { signal, timeout = 30000 } = {}) {
74
88
  const command = server.command;
75
89
  if (!command) throw new Error(`MCP stdio: falta "command" en el servidor ${server.id ?? ''}`);
90
+ assertSafeShellArgs(server, command, server.args ?? []);
76
91
 
77
92
  const child = spawn(command, server.args ?? [], {
78
93
  cwd: server.cwd,
@@ -0,0 +1,181 @@
1
+ // Guarda de hosts para salidas del harness: bloquea loopback, redes privadas,
2
+ // link-local, CGNAT y rangos IPv6 internos (incluido IPv4-mapped). La usan
3
+ // `fetch_url` (tambien en cada redirect) y el navegador headless.
4
+
5
+ import net from 'node:net';
6
+ import { lookup } from 'node:dns/promises';
7
+
8
+ const BLOCKED_SUFFIXES = ['.localhost', '.local', '.internal', '.intranet', '.lan', '.home.arpa'];
9
+
10
+ export function normalizeHost(hostname) {
11
+ return String(hostname ?? '')
12
+ .toLowerCase()
13
+ .replace(/^\[|\]$/g, '')
14
+ .replace(/\.$/, '');
15
+ }
16
+
17
+ function isBlockedV4(octets) {
18
+ const [a, b] = octets;
19
+ if (!Number.isInteger(a) || !Number.isInteger(b)) return true;
20
+ if (a === 0 || a === 10 || a === 127) return true;
21
+ if (a === 169 && b === 254) return true;
22
+ if (a === 172 && b >= 16 && b <= 31) return true;
23
+ if (a === 192 && b === 168) return true;
24
+ if (a === 100 && b >= 64 && b <= 127) return true;
25
+ if (a >= 224) return true;
26
+ return false;
27
+ }
28
+
29
+ function parseV4(value) {
30
+ const parts = String(value).split('.').map(Number);
31
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
32
+ return parts;
33
+ }
34
+
35
+ function parseV6(value) {
36
+ let text = String(value).toLowerCase().split('%')[0];
37
+ const embedded = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(text);
38
+ if (embedded) {
39
+ const v4 = parseV4(embedded[1]);
40
+ if (!v4) return null;
41
+ const hex = `${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`;
42
+ text = text.slice(0, text.length - embedded[1].length) + hex;
43
+ }
44
+ const halves = text.split('::');
45
+ if (halves.length > 2) return null;
46
+ const left = halves[0] ? halves[0].split(':') : [];
47
+ const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
48
+ const missing = 8 - left.length - right.length;
49
+ if (halves.length === 1 ? missing !== 0 : missing < 0) return null;
50
+ const groups = [...left, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...right];
51
+ if (groups.length !== 8) return null;
52
+ const parsed = groups.map((group) => (/^[0-9a-f]{1,4}$/.test(group) ? parseInt(group, 16) : NaN));
53
+ return parsed.some(Number.isNaN) ? null : parsed;
54
+ }
55
+
56
+ function isBlockedV6(groups) {
57
+ if (!groups) return true;
58
+ const [g0, g1, g2, g3, g4, g5, g6, g7] = groups;
59
+ if (groups.every((g) => g === 0)) return true; // ::
60
+ if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0 && g6 === 0 && g7 === 1) {
61
+ return true; // ::1
62
+ }
63
+ if ((g0 & 0xfe00) === 0xfc00) return true; // fc00::/7
64
+ if ((g0 & 0xffc0) === 0xfe80) return true; // fe80::/10
65
+ if ((g0 & 0xff00) === 0xff00) return true; // multicast
66
+ if (g0 === 0x64 && g1 === 0xff9b && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) {
67
+ return isBlockedV4([g6 >> 8, g6 & 0xff, g7 >> 8, g7 & 0xff]); // 64:ff9b::/96
68
+ }
69
+ if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && (g5 === 0 || g5 === 0xffff)) {
70
+ return isBlockedV4([g6 >> 8, g6 & 0xff, g7 >> 8, g7 & 0xff]); // IPv4-compatible / mapped
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /** `true` si la IP (v4 o v6) es loopback, privada, link-local o reservada. */
76
+ export function isBlockedAddress(address) {
77
+ const value = normalizeHost(address);
78
+ const family = net.isIP(value);
79
+ if (family === 4) return isBlockedV4(parseV4(value) ?? []);
80
+ if (family === 6) return isBlockedV6(parseV6(value));
81
+ return true;
82
+ }
83
+
84
+ /** `true` si el hostname (o literal IP) no debe usarse para salir del harness. */
85
+ export function isBlockedHost(hostname) {
86
+ const host = normalizeHost(hostname);
87
+ if (!host) return true;
88
+ if (net.isIP(host)) return isBlockedAddress(host);
89
+ if (host === 'localhost' || host === 'localhost.localdomain') return true;
90
+ if (BLOCKED_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
91
+ if (!host.includes('.')) return true; // una sola etiqueta suele ser un host interno
92
+ return false;
93
+ }
94
+
95
+ /** Valida una URL http(s) para una salida del harness. */
96
+ export function checkHttpUrl(value) {
97
+ const raw = String(value ?? '').trim();
98
+ if (!raw) return { error: 'falta "url".' };
99
+ let parsed;
100
+ try {
101
+ parsed = new URL(raw);
102
+ } catch {
103
+ return { error: `URL inválida: "${raw}".` };
104
+ }
105
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
106
+ return { error: 'Solo se admite http(s).' };
107
+ }
108
+ if (isBlockedHost(parsed.hostname)) {
109
+ return { error: `host bloqueado (${normalizeHost(parsed.hostname)}).` };
110
+ }
111
+ return { url: parsed.toString(), parsed };
112
+ }
113
+
114
+ /**
115
+ * Validacion para `preview_url`: la vista previa embebe un dev server, que casi
116
+ * siempre es local (127.0.0.1, LAN), asi que NO aplica la guarda de hosts
117
+ * privados de `checkHttpUrl`. Se bloquean igual los hosts de metadatos de la
118
+ * nube y las direcciones link-local/multicast.
119
+ */
120
+ export function checkPreviewUrl(value) {
121
+ const raw = String(value ?? '').trim();
122
+ if (!raw) return { error: 'falta "url".' };
123
+ let parsed;
124
+ try {
125
+ parsed = new URL(raw);
126
+ } catch {
127
+ return { error: `URL inválida: "${raw}".` };
128
+ }
129
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
130
+ return { error: 'Solo se admite http(s).' };
131
+ }
132
+ const host = normalizeHost(parsed.hostname);
133
+ if (!host) return { error: 'URL inválida (sin host).' };
134
+ if (host === 'metadata' || host.endsWith('.internal')) {
135
+ return { error: `host bloqueado (${host}).` };
136
+ }
137
+ if (net.isIP(host) && isLinkLocalOrReserved(host)) {
138
+ return { error: `host bloqueado (${host}).` };
139
+ }
140
+ return { url: parsed.toString(), parsed };
141
+ }
142
+
143
+ function isLinkLocalOrReserved(address) {
144
+ const value = normalizeHost(address);
145
+ const family = net.isIP(value);
146
+ if (family === 4) {
147
+ const [a, b] = parseV4(value) ?? [];
148
+ return a === 0 || (a === 169 && b === 254) || a >= 224;
149
+ }
150
+ if (family === 6) {
151
+ const groups = parseV6(value) ?? [];
152
+ const [g0] = groups;
153
+ return groups.every((g) => g === 0) || (g0 & 0xffc0) === 0xfe80 || (g0 & 0xff00) === 0xff00;
154
+ }
155
+ return true;
156
+ }
157
+
158
+ /**
159
+ * Como `checkHttpUrl`, pero ademas resuelve el hostname y valida las IPs: un
160
+ * dominio publico que resuelva a una IP interna (DNS rebinding) no pasa. Vale
161
+ * para la URL inicial y para cada redirect.
162
+ */
163
+ export async function checkHttpUrlResolved(value) {
164
+ const checked = checkHttpUrl(value);
165
+ if (checked.error) return checked;
166
+ const host = normalizeHost(checked.parsed.hostname);
167
+ if (net.isIP(host)) return checked;
168
+ let addresses;
169
+ try {
170
+ addresses = await lookup(host, { all: true });
171
+ } catch {
172
+ return { error: `No pude resolver el host "${host}".` };
173
+ }
174
+ if (!addresses.length) return { error: `No pude resolver el host "${host}".` };
175
+ for (const { address } of addresses) {
176
+ if (isBlockedAddress(address)) {
177
+ return { error: `host bloqueado (${host} resuelve a ${normalizeHost(address)}).` };
178
+ }
179
+ }
180
+ return checked;
181
+ }
@@ -1,7 +1,8 @@
1
1
  // Plugins locales: modulos `.mjs` que exportan `{ name?, tools: [...] }` para
2
2
  // agregar herramientas propias al agente. Se cargan con `import()` dinamico,
3
- // opt-in por presencia. Cada tool: `{ name, description, parameters?, required?,
4
- // handler(args, ctx) }`.
3
+ // **opt-in explicito**: solo los patrones declarados en `config.plugins[]`; la
4
+ // presencia de archivos en el cwd no ejecuta nada. Cada tool:
5
+ // `{ name, description, parameters?, required?, handler(args, ctx) }`.
5
6
 
6
7
  import path from 'node:path';
7
8
  import { pathToFileURL } from 'node:url';
@@ -19,9 +20,10 @@ export function normalizePlugin(mod, id = '') {
19
20
  return { id: value.name || id, tools: valid };
20
21
  }
21
22
 
22
- /** Carga los plugins de `patterns` + `.tamnora/plugins/*.mjs`. */
23
+ /** Carga los plugins de los `patterns` explicitos (relativos a `cwd`). */
23
24
  export async function loadPlugins(cwd, patterns = []) {
24
- const list = [...new Set([...(patterns ?? []), ...DEFAULT_PLUGIN_PATTERNS])];
25
+ const list = [...new Set(patterns ?? [])];
26
+ if (!list.length) return [];
25
27
  const plugins = [];
26
28
  for (const rel of await globFiles(cwd, list)) {
27
29
  try {
@@ -17,6 +17,8 @@ const SECRET_PATTERNS = [
17
17
  /AIza[0-9A-Za-z_-]{30,}/g,
18
18
  /xox[baprs]-[A-Za-z0-9-]{10,}/g,
19
19
  /ya29\.[A-Za-z0-9_-]{20,}/g,
20
+ /gsk_[A-Za-z0-9]{20,}/g, // Groq
21
+ /\b\d{8,10}:[A-Za-z0-9_-]{30,}\b/g, // token de bot de Telegram
20
22
  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
21
23
  ];
22
24
 
@@ -7,6 +7,7 @@ import { diffLines } from './diff.js';
7
7
  import { captureScreenshot, readConsole } from '../browser/index.js';
8
8
  import { screenshotsDir } from '../config/home.js';
9
9
  import { scrubEnv } from '../redact.js';
10
+ import { checkHttpUrlResolved, checkPreviewUrl } from '../net/hosts.js';
10
11
 
11
12
  const MAX_READ_LINES = 2000;
12
13
  const MAX_LIST = 500;
@@ -16,6 +17,8 @@ const MAX_GREP_FILE_BYTES = 2_000_000;
16
17
  const MAX_BASH_OUTPUT = 100_000;
17
18
  const FETCH_TIMEOUT_MS = 20000;
18
19
  const MAX_FETCH_CHARS = 200_000;
20
+ const MAX_REDIRECTS = 5;
21
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
19
22
 
20
23
  const NAMED_ENTITIES = {
21
24
  amp: '&',
@@ -35,23 +38,6 @@ const NAMED_ENTITIES = {
35
38
  trade: '\u2122',
36
39
  };
37
40
 
38
- function isBlockedHost(hostname) {
39
- const h = String(hostname ?? '')
40
- .toLowerCase()
41
- .replace(/^\[|\]$/g, '');
42
- if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
43
- if (h === '::1' || h === '0.0.0.0') return true;
44
- const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
45
- if (!m) return false;
46
- const a = Number(m[1]);
47
- const b = Number(m[2]);
48
- if (a === 127 || a === 10 || a === 0) return true;
49
- if (a === 169 && b === 254) return true;
50
- if (a === 192 && b === 168) return true;
51
- if (a === 172 && b >= 16 && b <= 31) return true;
52
- return false;
53
- }
54
-
55
41
  function isTextualType(type) {
56
42
  const t = String(type || '').toLowerCase();
57
43
  if (!t) return true;
@@ -112,27 +98,46 @@ function formatBody(head, text, limit) {
112
98
  }
113
99
 
114
100
  export async function fetchUrlTool(_root, { url, maxLength, raw } = {}) {
115
- let target;
116
- try {
117
- target = new URL(String(url ?? ''));
118
- } catch {
119
- return 'Error: URL inválida.';
120
- }
121
- if (!/^https?:$/.test(target.protocol)) return 'Error: solo se permite http/https.';
122
- if (isBlockedHost(target.hostname)) return `Error: host bloqueado (${target.hostname}).`;
101
+ const checked = await checkHttpUrlResolved(url);
102
+ if (checked.error) return `Error: ${checked.error}`;
103
+ let target = checked.parsed;
123
104
 
124
105
  const controller = new AbortController();
125
106
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
126
107
  try {
127
- const res = await fetch(target.toString(), {
128
- redirect: 'follow',
129
- signal: controller.signal,
130
- headers: {
131
- 'User-Agent': 'Mozilla/5.0 (compatible; tamnora-harness; +https://github.com/tamnora/tamnora-harness)',
132
- Accept: 'text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.5',
133
- 'Accept-Language': 'es,en;q=0.8',
134
- },
135
- });
108
+ let res;
109
+ for (let hop = 0; ; hop++) {
110
+ res = await fetch(target.toString(), {
111
+ redirect: 'manual',
112
+ signal: controller.signal,
113
+ headers: {
114
+ 'User-Agent': 'Mozilla/5.0 (compatible; tamnora-harness; +https://github.com/tamnora/tamnora-harness)',
115
+ Accept: 'text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.5',
116
+ 'Accept-Language': 'es,en;q=0.8',
117
+ },
118
+ });
119
+ if (!REDIRECT_STATUS.has(res.status)) break;
120
+ const location = res.headers.get('location');
121
+ if (!location) break;
122
+ if (hop >= MAX_REDIRECTS) return `Error: demasiadas redirecciones (${MAX_REDIRECTS}).`;
123
+ let next;
124
+ try {
125
+ next = new URL(location, target);
126
+ } catch {
127
+ return `Error: redirección inválida (${location}).`;
128
+ }
129
+ if (next.protocol !== 'http:' && next.protocol !== 'https:') {
130
+ return `Error: redirección a un protocolo no permitido (${next.protocol}).`;
131
+ }
132
+ const recheck = await checkHttpUrlResolved(next.toString());
133
+ if (recheck.error) return `Error: ${recheck.error}`;
134
+ try {
135
+ await res.body?.cancel();
136
+ } catch {
137
+ /* el cuerpo del redirect no importa */
138
+ }
139
+ target = next;
140
+ }
136
141
  const type = res.headers.get('content-type') || '';
137
142
  const limit = Math.min(Math.max(Number(maxLength) || 20000, 500), MAX_FETCH_CHARS);
138
143
  const full = await res.text();
@@ -325,7 +330,8 @@ export function bashTool(root, { command, timeout } = {}, { signal } = {}) {
325
330
  let settled = false;
326
331
  let timer = null;
327
332
  const append = (chunk) => {
328
- if (output.length < MAX_BASH_OUTPUT) output += chunk.toString();
333
+ const room = MAX_BASH_OUTPUT - output.length;
334
+ if (room > 0) output += chunk.toString().slice(0, room);
329
335
  };
330
336
 
331
337
  const finish = (text) => {
@@ -368,7 +374,8 @@ function runGit(root, args) {
368
374
  let output = '';
369
375
  let settled = false;
370
376
  const append = (chunk) => {
371
- if (output.length < MAX_BASH_OUTPUT) output += chunk.toString();
377
+ const room = MAX_BASH_OUTPUT - output.length;
378
+ if (room > 0) output += chunk.toString().slice(0, room);
372
379
  };
373
380
  child.stdout?.on('data', append);
374
381
  child.stderr?.on('data', append);
@@ -393,7 +400,14 @@ export function gitStatusTool(root) {
393
400
  export function gitDiffTool(root, { path: p, staged, ref } = {}) {
394
401
  const args = ['diff', '--no-color'];
395
402
  if (staged) args.push('--staged');
396
- if (ref) args.push(String(ref));
403
+ if (ref) {
404
+ // Un "ref" que empieza con '-' no es un ref: git lo tomaria como opcion
405
+ // (`--output=`, `--ext-diff`) y escribiria/ejecutaria cosas, siendo
406
+ // `git_diff` una tool de solo lectura con permiso `allow`.
407
+ const value = String(ref);
408
+ if (value.startsWith('-')) return 'Error: "ref" inválido (no puede empezar con "-").';
409
+ args.push(value);
410
+ }
397
411
  if (p) args.push('--', String(p));
398
412
  return runGit(root, args);
399
413
  }
@@ -430,19 +444,6 @@ export function speakTool(_root, { texto, voice, rate, pitch } = {}) {
430
444
  return `Audio pedido${opciones ? ` (${opciones})` : ''}: "${recortado}". El cliente lo reproduce o lo envía como nota de voz.`;
431
445
  }
432
446
 
433
- function normalizeHttpUrl(value) {
434
- const raw = String(value ?? '').trim();
435
- if (!raw) return { error: 'falta "url".' };
436
- let parsed;
437
- try {
438
- parsed = new URL(raw);
439
- } catch {
440
- return { error: `URL inválida: "${raw}".` };
441
- }
442
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return { error: 'Solo se admite http(s).' };
443
- return { url: parsed.toString() };
444
- }
445
-
446
447
  function clampInt(value, fallback, min, max) {
447
448
  const n = Math.round(Number(value));
448
449
  if (!Number.isFinite(n)) return fallback;
@@ -454,7 +455,7 @@ function clampInt(value, fallback, min, max) {
454
455
  * del cliente (igual que `speak`): aca solo se valida la URL.
455
456
  */
456
457
  export function previewUrlTool(_root, { url } = {}) {
457
- const parsed = normalizeHttpUrl(url);
458
+ const parsed = checkPreviewUrl(url);
458
459
  if (parsed.error) return `Error: ${parsed.error}`;
459
460
  return `Vista previa pedida: ${parsed.url}. La consola la abre en el panel de vista previa.`;
460
461
  }
@@ -552,7 +553,7 @@ export function showPlanTool(_root, { title, steps } = {}) {
552
553
  * muestre y Telegram la mande como foto.
553
554
  */
554
555
  export async function screenshotTool(_root, { url, fullPage, width, height } = {}) {
555
- const parsed = normalizeHttpUrl(url);
556
+ const parsed = await checkHttpUrlResolved(url);
556
557
  if (parsed.error) return `Error: ${parsed.error}`;
557
558
  const size = {
558
559
  width: clampInt(width, 1280, 320, 3840),
@@ -589,7 +590,7 @@ export async function screenshotTool(_root, { url, fullPage, width, height } = {
589
590
 
590
591
  /** Errores y mensajes de consola de una pagina (ejecutando su JS). */
591
592
  export async function consoleTool(_root, { url, waitMs } = {}) {
592
- const parsed = normalizeHttpUrl(url);
593
+ const parsed = await checkHttpUrlResolved(url);
593
594
  if (parsed.error) return `Error: ${parsed.error}`;
594
595
 
595
596
  let result;