@danieltmn/tamnora 0.21.1 → 0.21.2

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/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ 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.21.2] - 2026-10-20
9
+
10
+ ### Cambios
11
+
12
+ - fix: cierra los P0 de la auditoria y pule la compactacion
13
+
8
14
  ## [0.21.1] - 2026-10-16
9
15
 
10
16
  ### 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`,
@@ -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 {
@@ -0,0 +1,111 @@
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
+
7
+ const BLOCKED_SUFFIXES = ['.localhost', '.local', '.internal', '.intranet', '.lan', '.home.arpa'];
8
+
9
+ export function normalizeHost(hostname) {
10
+ return String(hostname ?? '')
11
+ .toLowerCase()
12
+ .replace(/^\[|\]$/g, '')
13
+ .replace(/\.$/, '');
14
+ }
15
+
16
+ function isBlockedV4(octets) {
17
+ const [a, b] = octets;
18
+ if (!Number.isInteger(a) || !Number.isInteger(b)) return true;
19
+ if (a === 0 || a === 10 || a === 127) return true;
20
+ if (a === 169 && b === 254) return true;
21
+ if (a === 172 && b >= 16 && b <= 31) return true;
22
+ if (a === 192 && b === 168) return true;
23
+ if (a === 100 && b >= 64 && b <= 127) return true;
24
+ if (a >= 224) return true;
25
+ return false;
26
+ }
27
+
28
+ function parseV4(value) {
29
+ const parts = String(value).split('.').map(Number);
30
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
31
+ return parts;
32
+ }
33
+
34
+ function parseV6(value) {
35
+ let text = String(value).toLowerCase().split('%')[0];
36
+ const embedded = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(text);
37
+ if (embedded) {
38
+ const v4 = parseV4(embedded[1]);
39
+ if (!v4) return null;
40
+ const hex = `${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`;
41
+ text = text.slice(0, text.length - embedded[1].length) + hex;
42
+ }
43
+ const halves = text.split('::');
44
+ if (halves.length > 2) return null;
45
+ const left = halves[0] ? halves[0].split(':') : [];
46
+ const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
47
+ const missing = 8 - left.length - right.length;
48
+ if (halves.length === 1 ? missing !== 0 : missing < 0) return null;
49
+ const groups = [...left, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...right];
50
+ if (groups.length !== 8) return null;
51
+ const parsed = groups.map((group) => (/^[0-9a-f]{1,4}$/.test(group) ? parseInt(group, 16) : NaN));
52
+ return parsed.some(Number.isNaN) ? null : parsed;
53
+ }
54
+
55
+ function isBlockedV6(groups) {
56
+ if (!groups) return true;
57
+ const [g0, g1, g2, g3, g4, g5, g6, g7] = groups;
58
+ if (groups.every((g) => g === 0)) return true; // ::
59
+ if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0 && g6 === 0 && g7 === 1) {
60
+ return true; // ::1
61
+ }
62
+ if ((g0 & 0xfe00) === 0xfc00) return true; // fc00::/7
63
+ if ((g0 & 0xffc0) === 0xfe80) return true; // fe80::/10
64
+ if ((g0 & 0xff00) === 0xff00) return true; // multicast
65
+ if (g0 === 0x64 && g1 === 0xff9b && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) {
66
+ return isBlockedV4([g6 >> 8, g6 & 0xff, g7 >> 8, g7 & 0xff]); // 64:ff9b::/96
67
+ }
68
+ if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && (g5 === 0 || g5 === 0xffff)) {
69
+ return isBlockedV4([g6 >> 8, g6 & 0xff, g7 >> 8, g7 & 0xff]); // IPv4-compatible / mapped
70
+ }
71
+ return false;
72
+ }
73
+
74
+ /** `true` si la IP (v4 o v6) es loopback, privada, link-local o reservada. */
75
+ export function isBlockedAddress(address) {
76
+ const value = normalizeHost(address);
77
+ const family = net.isIP(value);
78
+ if (family === 4) return isBlockedV4(parseV4(value) ?? []);
79
+ if (family === 6) return isBlockedV6(parseV6(value));
80
+ return true;
81
+ }
82
+
83
+ /** `true` si el hostname (o literal IP) no debe usarse para salir del harness. */
84
+ export function isBlockedHost(hostname) {
85
+ const host = normalizeHost(hostname);
86
+ if (!host) return true;
87
+ if (net.isIP(host)) return isBlockedAddress(host);
88
+ if (host === 'localhost' || host === 'localhost.localdomain') return true;
89
+ if (BLOCKED_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
90
+ if (!host.includes('.')) return true; // una sola etiqueta suele ser un host interno
91
+ return false;
92
+ }
93
+
94
+ /** Valida una URL http(s) para una salida del harness. */
95
+ export function checkHttpUrl(value) {
96
+ const raw = String(value ?? '').trim();
97
+ if (!raw) return { error: 'falta "url".' };
98
+ let parsed;
99
+ try {
100
+ parsed = new URL(raw);
101
+ } catch {
102
+ return { error: `URL inválida: "${raw}".` };
103
+ }
104
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
105
+ return { error: 'Solo se admite http(s).' };
106
+ }
107
+ if (isBlockedHost(parsed.hostname)) {
108
+ return { error: `host bloqueado (${normalizeHost(parsed.hostname)}).` };
109
+ }
110
+ return { url: parsed.toString(), parsed };
111
+ }
@@ -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 {
@@ -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 { checkHttpUrl, isBlockedHost, normalizeHost } 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,47 @@ 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 = checkHttpUrl(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
+ if (isBlockedHost(next.hostname)) {
133
+ return `Error: host bloqueado (${normalizeHost(next.hostname)}).`;
134
+ }
135
+ try {
136
+ await res.body?.cancel();
137
+ } catch {
138
+ /* el cuerpo del redirect no importa */
139
+ }
140
+ target = next;
141
+ }
136
142
  const type = res.headers.get('content-type') || '';
137
143
  const limit = Math.min(Math.max(Number(maxLength) || 20000, 500), MAX_FETCH_CHARS);
138
144
  const full = await res.text();
@@ -552,7 +558,7 @@ export function showPlanTool(_root, { title, steps } = {}) {
552
558
  * muestre y Telegram la mande como foto.
553
559
  */
554
560
  export async function screenshotTool(_root, { url, fullPage, width, height } = {}) {
555
- const parsed = normalizeHttpUrl(url);
561
+ const parsed = checkHttpUrl(url);
556
562
  if (parsed.error) return `Error: ${parsed.error}`;
557
563
  const size = {
558
564
  width: clampInt(width, 1280, 320, 3840),
@@ -589,7 +595,7 @@ export async function screenshotTool(_root, { url, fullPage, width, height } = {
589
595
 
590
596
  /** Errores y mensajes de consola de una pagina (ejecutando su JS). */
591
597
  export async function consoleTool(_root, { url, waitMs } = {}) {
592
- const parsed = normalizeHttpUrl(url);
598
+ const parsed = checkHttpUrl(url);
593
599
  if (parsed.error) return `Error: ${parsed.error}`;
594
600
 
595
601
  let result;
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
- import { normalizeHookModule, loadHooks } from '../src/hooks/index.js';
6
+ import { normalizeHookModule, loadHooks, DEFAULT_HOOK_PATTERNS } from '../src/hooks/index.js';
7
7
 
8
8
  test('normalizeHookModule acepta objeto events, funcion y onEvent', () => {
9
9
  const byEvents = normalizeHookModule({ default: { name: 'a', events: { 'run.end': () => {} } } }, 'x');
@@ -14,7 +14,7 @@ test('normalizeHookModule acepta objeto events, funcion y onEvent', () => {
14
14
  assert.equal(normalizeHookModule({ default: {} }, 'n'), null);
15
15
  });
16
16
 
17
- test('loadHooks carga .tamnora/hooks/*.mjs e ignora lo roto', async () => {
17
+ test('loadHooks no ejecuta nada por presencia: exige un patron explicito', async () => {
18
18
  const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-hooks-'));
19
19
  await mkdir(path.join(cwd, '.tamnora', 'hooks'), { recursive: true });
20
20
  await writeFile(
@@ -22,7 +22,10 @@ test('loadHooks carga .tamnora/hooks/*.mjs e ignora lo roto', async () => {
22
22
  "export default { name: 'demo', events: { 'run.end': () => {} } };\n"
23
23
  );
24
24
  await writeFile(path.join(cwd, '.tamnora', 'hooks', 'roto.mjs'), 'this is not valid javascript {{{\n');
25
- const hooks = await loadHooks(cwd, []);
25
+
26
+ assert.deepEqual(await loadHooks(cwd, []), [], 'sin patrones no se importa nada');
27
+
28
+ const hooks = await loadHooks(cwd, DEFAULT_HOOK_PATTERNS);
26
29
  assert.equal(hooks.length, 1);
27
30
  assert.equal(hooks[0].id, 'demo');
28
31
  assert.deepEqual(Object.keys(hooks[0].events), ['run.end']);
@@ -0,0 +1,50 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { checkHttpUrl, isBlockedAddress, isBlockedHost } from '../src/net/hosts.js';
4
+
5
+ test('isBlockedHost bloquea loopback, redes privadas y sufijos internos', () => {
6
+ for (const host of [
7
+ 'localhost',
8
+ 'foo.localhost',
9
+ 'app.local',
10
+ 'intranet',
11
+ 'metadata.internal',
12
+ '127.0.0.1',
13
+ '10.1.2.3',
14
+ '172.16.0.1',
15
+ '192.168.1.10',
16
+ '169.254.169.254',
17
+ '100.64.0.1',
18
+ '0.0.0.0',
19
+ '224.0.0.1',
20
+ ]) {
21
+ assert.equal(isBlockedHost(host), true, `deberia bloquear ${host}`);
22
+ }
23
+ for (const host of ['example.com', 'api.openai.com', '8.8.8.8', '2606:4700:4700::1111']) {
24
+ assert.equal(isBlockedHost(host), false, `no deberia bloquear ${host}`);
25
+ }
26
+ });
27
+
28
+ test('isBlockedHost cubre IPv6-mapped y rangos IPv6 internos', () => {
29
+ for (const host of ['[::1]', '::1', '[::ffff:127.0.0.1]', '[::ffff:7f00:1]', '[fd00::1]', '[fe80::1]', '[::]']) {
30
+ assert.equal(isBlockedHost(host), true, `deberia bloquear ${host}`);
31
+ }
32
+ assert.equal(isBlockedHost('[2001:db8::1]'), false);
33
+ });
34
+
35
+ test('isBlockedAddress valida el rango de una IP', () => {
36
+ assert.equal(isBlockedAddress('127.0.0.1'), true);
37
+ assert.equal(isBlockedAddress('::ffff:10.0.0.1'), true);
38
+ assert.equal(isBlockedAddress('100.64.1.1'), true);
39
+ assert.equal(isBlockedAddress('8.8.4.4'), false);
40
+ assert.equal(isBlockedAddress('nope'), true);
41
+ });
42
+
43
+ test('checkHttpUrl valida protocolo y host', () => {
44
+ assert.match(checkHttpUrl('').error, /falta "url"/);
45
+ assert.match(checkHttpUrl('nope').error, /URL inválida/);
46
+ assert.match(checkHttpUrl('ftp://example.com').error, /http\(s\)/);
47
+ assert.match(checkHttpUrl('http://127.0.0.1:4517/').error, /host bloqueado/);
48
+ assert.match(checkHttpUrl('http://[::ffff:127.0.0.1]/').error, /host bloqueado/);
49
+ assert.equal(checkHttpUrl('https://example.com/x').url, 'https://example.com/x');
50
+ });
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
- import { normalizePlugin, loadPlugins } from '../src/plugins/index.js';
6
+ import { normalizePlugin, loadPlugins, DEFAULT_PLUGIN_PATTERNS } from '../src/plugins/index.js';
7
7
 
8
8
  test('normalizePlugin filtra tools invalidas', () => {
9
9
  const plugin = normalizePlugin({ default: { name: 'demo', tools: [{ name: 'x', handler: () => {} }, { name: 'bad' }] } }, 'z');
@@ -12,14 +12,16 @@ test('normalizePlugin filtra tools invalidas', () => {
12
12
  assert.equal(normalizePlugin({ default: {} }, 'n'), null);
13
13
  });
14
14
 
15
- test('loadPlugins carga .tamnora/plugins/*.mjs', async () => {
15
+ test('loadPlugins exige un patron explicito (no ejecuta por presencia)', async () => {
16
16
  const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-plugins-'));
17
17
  await mkdir(path.join(cwd, '.tamnora', 'plugins'), { recursive: true });
18
18
  await writeFile(
19
19
  path.join(cwd, '.tamnora', 'plugins', 'demo.mjs'),
20
20
  "export default { name: 'demo', tools: [{ name: 'saludar', handler: () => 'hi' }] };\n"
21
21
  );
22
- const plugins = await loadPlugins(cwd, []);
22
+ assert.deepEqual(await loadPlugins(cwd, []), []);
23
+
24
+ const plugins = await loadPlugins(cwd, DEFAULT_PLUGIN_PATTERNS);
23
25
  assert.equal(plugins.length, 1);
24
26
  assert.equal(plugins[0].tools[0].name, 'saludar');
25
27
  });
@@ -133,7 +133,7 @@ test('permission "ask" is denied by default and allowed with yes', async () => {
133
133
  test('fetchUrlTool rejects invalid URLs and private hosts', async () => {
134
134
  const root = await makeWorkspace();
135
135
  assert.match(await fetchUrlTool(root, { url: 'not-a-url' }), /URL inválida/);
136
- assert.match(await fetchUrlTool(root, { url: 'ftp://example.com' }), /http\/https/);
136
+ assert.match(await fetchUrlTool(root, { url: 'ftp://example.com' }), /http\(s\)/);
137
137
  assert.match(await fetchUrlTool(root, { url: 'http://127.0.0.1:9/' }), /host bloqueado/);
138
138
  assert.match(await fetchUrlTool(root, { url: 'http://192.168.1.10/' }), /host bloqueado/);
139
139
  assert.match(await fetchUrlTool(root, { url: 'http://169.254.169.254/' }), /host bloqueado/);
@@ -192,6 +192,55 @@ test('fetchUrlTool raw returns the original body', async () => {
192
192
  assert.match(out, /<p>hola<\/p>/);
193
193
  });
194
194
 
195
+ test('fetchUrlTool revalida el host en cada redirect', async () => {
196
+ let calls = 0;
197
+ const out = await withFetch(
198
+ async () => {
199
+ calls++;
200
+ return new Response(null, { status: 302, headers: { location: 'http://127.0.0.1:9/admin' } });
201
+ },
202
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
203
+ );
204
+ assert.match(out, /host bloqueado/);
205
+ assert.equal(calls, 1, 'no debe pedir el destino bloqueado');
206
+ });
207
+
208
+ test('fetchUrlTool bloquea redirects a IPv6 internos y protocolos no http', async () => {
209
+ const mapped = await withFetch(
210
+ async () => new Response(null, { status: 301, headers: { location: 'http://[::ffff:127.0.0.1]/' } }),
211
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
212
+ );
213
+ assert.match(mapped, /host bloqueado/);
214
+
215
+ const file = await withFetch(
216
+ async () => new Response(null, { status: 302, headers: { location: 'file:///etc/passwd' } }),
217
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
218
+ );
219
+ assert.match(file, /protocolo no permitido/);
220
+ });
221
+
222
+ test('fetchUrlTool sigue redirects a hosts publicos', async () => {
223
+ const out = await withFetch(
224
+ async (url) => {
225
+ if (String(url) === 'https://example.com/') {
226
+ return new Response(null, { status: 301, headers: { location: 'https://example.org/final' } });
227
+ }
228
+ return new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } });
229
+ },
230
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
231
+ );
232
+ assert.match(out, /200 .*example\.org\/final/);
233
+ assert.match(out, /ok/);
234
+ });
235
+
236
+ test('fetchUrlTool corta cadenas de redirects demasiado largas', async () => {
237
+ const out = await withFetch(
238
+ async () => new Response(null, { status: 302, headers: { location: 'https://example.com/loop' } }),
239
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
240
+ );
241
+ assert.match(out, /demasiadas redirecciones/);
242
+ });
243
+
195
244
  test('createTools combines local and MCP tool routing', async () => {
196
245
  const root = await makeWorkspace();
197
246
  const mcp = {
@@ -265,10 +314,14 @@ test('la tool speak valida el texto y la recorta', async () => {
265
314
 
266
315
  test('screenshot, console_errors y preview_url validan la URL sin lanzar el navegador', async () => {
267
316
  assert.match(previewUrlTool(null, { url: 'https://example.com/x' }), /example\.com/);
317
+ assert.match(previewUrlTool(null, { url: 'http://127.0.0.1:5173/' }), /127\.0\.0\.1/, 'preview permite loopback');
268
318
  assert.match(previewUrlTool(null, {}), /falta "url"/);
269
319
  assert.match(await screenshotTool(null, { url: 'ftp://x' }), /Solo se admite http/);
270
320
  assert.match(await screenshotTool(null, {}), /falta "url"/);
321
+ assert.match(await screenshotTool(null, { url: 'http://127.0.0.1:4517/' }), /host bloqueado/);
322
+ assert.match(await screenshotTool(null, { url: 'http://[fd00::1]/' }), /host bloqueado/);
271
323
  assert.match(await consoleTool(null, { url: 'no-url' }), /URL inválida/);
324
+ assert.match(await consoleTool(null, { url: 'http://localhost:4517/' }), /host bloqueado/);
272
325
  assert.equal(evaluatePermission('screenshot', {}, {}), 'allow');
273
326
  assert.equal(evaluatePermission('console_errors', {}, {}), 'allow');
274
327
  assert.equal(evaluatePermission('preview_url', {}, {}), 'allow');
@@ -1892,18 +1892,39 @@ function addAdvisorCard(container, { text, kind } = {}) {
1892
1892
  * el resumen que pasa a ser el contexto del modelo. Lo de arriba sigue visible
1893
1893
  * como histórico; al modelo solo le llega lo de abajo (más los mensajes nuevos).
1894
1894
  */
1895
- function addCompactCard(container, { text, label = 'contexto compactado' } = {}) {
1895
+ function addCompactCard(container, { text, label, running = false } = {}) {
1896
1896
  if (!container) return null;
1897
- const card = el('div', 'compact-card');
1898
- card.append(el('div', 'compact-rule', label));
1897
+ const card = el('div', `compact-card${running ? ' running' : ''}`);
1898
+ const rule = el('div', 'compact-rule', label ?? (running ? 'compactando…' : 'contexto compactado'));
1899
+ card.append(rule);
1900
+ if (running) {
1901
+ // La compactación puede tardar (el modelo resume el historial): mostramos el
1902
+ // corte con un loader animado hasta que el resumen llegue.
1903
+ rule.append(el('span', 'loader loader-thinking'));
1904
+ } else {
1905
+ const clean = String(text ?? '').trim();
1906
+ if (clean) {
1907
+ const body = el('div', 'compact-text text');
1908
+ body.innerHTML = renderMarkdown(clean);
1909
+ card.append(body);
1910
+ }
1911
+ }
1912
+ container.appendChild(card);
1913
+ return card;
1914
+ }
1915
+
1916
+ /** Completa la tarjeta de compactación en curso con el resumen del modelo. */
1917
+ function finishCompactCard(card, text) {
1918
+ if (!card) return;
1919
+ card.classList.remove('running');
1920
+ const rule = card.querySelector('.compact-rule');
1921
+ if (rule) rule.textContent = 'contexto compactado';
1899
1922
  const clean = String(text ?? '').trim();
1900
- if (clean) {
1923
+ if (clean && !card.querySelector('.compact-text')) {
1901
1924
  const body = el('div', 'compact-text text');
1902
1925
  body.innerHTML = renderMarkdown(clean);
1903
1926
  card.append(body);
1904
1927
  }
1905
- container.appendChild(card);
1906
- return card;
1907
1928
  }
1908
1929
 
1909
1930
  function renderStored(message, pane) {
@@ -3853,17 +3874,21 @@ async function runCommand(name, arg) {
3853
3874
  }
3854
3875
  case 'compact': {
3855
3876
  if (!state.sessionId) return needSession();
3856
- toast('Compactando la conversación…');
3877
+ // Animación en el pane: el corte queda a la vista con un loader mientras
3878
+ // el modelo resume (puede tardar), y se completa con el resumen.
3879
+ const card = addCompactCard(activePane, { running: true });
3880
+ scrollMessages();
3857
3881
  const out = await api(`/api/sessions/${state.sessionId}/compact`, { method: 'POST' }).catch((error) => ({
3858
3882
  error: error.message,
3859
3883
  }));
3860
3884
  if (out.error) {
3885
+ card?.remove();
3861
3886
  notify(`No se pudo compactar: ${out.error}`, 'notice err');
3862
3887
  return { handled: true };
3863
3888
  }
3864
3889
  // No borramos el pane: el historial queda a la vista y marcamos el corte
3865
3890
  // con la línea + el resumen que ahora ve el modelo.
3866
- addCompactCard(activePane, { text: out.summary });
3891
+ finishCompactCard(card, out.summary);
3867
3892
  scrollMessages();
3868
3893
  refreshSessions();
3869
3894
  return { handled: true };
@@ -75,7 +75,7 @@
75
75
  </head>
76
76
  <body>
77
77
  <header class="topbar">
78
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.21.1</span></div>
78
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.21.2</span></div>
79
79
  <nav class="tabs">
80
80
  <button class="tab active" data-tab="chat">chat</button>
81
81
  <button class="tab" data-tab="providers">proveedores</button>
@@ -2505,6 +2505,10 @@ dialog.modal::backdrop { background: rgba(24, 22, 16, 0.55); backdrop-filter: bl
2505
2505
  }
2506
2506
  .compact-rule::before,
2507
2507
  .compact-rule::after { content: ''; flex: 1; height: 1px; background: var(--border); }
2508
+ .compact-card.running .compact-rule { color: var(--accent); }
2509
+ .compact-card.running .compact-rule::before,
2510
+ .compact-card.running .compact-rule::after { background: var(--accent-soft); }
2511
+ .compact-rule .loader-thinking { width: 22px; height: 6px; }
2508
2512
  .compact-text {
2509
2513
  margin-top: 10px;
2510
2514
  padding-left: 12px;
@@ -4,6 +4,25 @@ import path from 'node:path';
4
4
  import { DEFAULT_PROCESS_ALLOW, killPidTree } from './core.js';
5
5
 
6
6
  const MAX_LOG_LINES = 400;
7
+ const MAX_ARGS = 32;
8
+ const MAX_ARG_LENGTH = 1000;
9
+ // Con `shell: true` (Windows y comando sin ruta) Node pega los args sin
10
+ // escapar: cualquier metacaracter del shell permitiria inyectar comandos.
11
+ const SAFE_SHELL_ARG = /^[^&|<>^%!"\r\n]+$/;
12
+
13
+ function normalizeArgs(args) {
14
+ if (args === undefined || args === null) return [];
15
+ if (!Array.isArray(args)) throw new Error('"args" debe ser una lista de strings.');
16
+ if (args.length > MAX_ARGS) throw new Error(`Demasiados argumentos (máximo ${MAX_ARGS}).`);
17
+ return args.map((arg) => {
18
+ const value = String(arg);
19
+ if (value.length > MAX_ARG_LENGTH) {
20
+ throw new Error(`Un argumento supera los ${MAX_ARG_LENGTH} caracteres.`);
21
+ }
22
+ if (/[\0\r\n]/.test(value)) throw new Error('Argumento inválido.');
23
+ return value;
24
+ });
25
+ }
7
26
 
8
27
  function killTree(child) {
9
28
  if (!child || child.killed) return;
@@ -49,18 +68,26 @@ export function createProcManager({ allow } = {}) {
49
68
  if (!allows(bin)) {
50
69
  throw new Error(`Comando no permitido: ${bin}. Agregalo a processes.allow en tamnora.json.`);
51
70
  }
52
- const child = spawn(bin, args.map(String), {
71
+ const list = normalizeArgs(args);
72
+ const shell = process.platform === 'win32' && !path.isAbsolute(bin);
73
+ if (shell) {
74
+ const bad = list.find((arg) => !SAFE_SHELL_ARG.test(arg));
75
+ if (bad !== undefined) {
76
+ throw new Error(`Argumento no permitido con shell en Windows: "${bad}".`);
77
+ }
78
+ }
79
+ const child = spawn(bin, list, {
53
80
  cwd,
54
81
  stdio: ['ignore', 'pipe', 'pipe'],
55
82
  windowsHide: true,
56
- shell: process.platform === 'win32' && !path.isAbsolute(bin),
83
+ shell,
57
84
  detached: process.platform !== 'win32',
58
85
  });
59
86
  const id = randomUUID().slice(0, 8);
60
87
  const record = {
61
88
  id,
62
89
  command: bin,
63
- args: args.map(String),
90
+ args: list,
64
91
  cwd,
65
92
  port: port ? Number(port) : null,
66
93
  pid: child.pid,
@@ -16,9 +16,7 @@ export function registerProcs(route, ctx) {
16
16
  });
17
17
 
18
18
  route('POST', '/api/procs/start', async ({ req, res }) => {
19
- if (!ctx.authEnabled && !requireLocal(req, res, 'Iniciar procesos solo está disponible en conexiones locales.')) {
20
- return;
21
- }
19
+ if (!requireLocal(req, res, 'Iniciar procesos solo está disponible en conexiones locales.')) return;
22
20
  if (!ctx.procsEnabled) return sendError(res, 403, 'Los procesos están deshabilitados (processes.enabled = false).');
23
21
  const body = await readJson(req);
24
22
  const dir = resolveDirOr400(res, body.cwd, ctx.cwd);
@@ -38,9 +36,7 @@ export function registerProcs(route, ctx) {
38
36
  });
39
37
 
40
38
  route('POST', '/api/procs/:id/stop', ({ req, res, params }) => {
41
- if (!ctx.authEnabled && !requireLocal(req, res, 'Detener procesos solo está disponible en conexiones locales.')) {
42
- return;
43
- }
39
+ if (!requireLocal(req, res, 'Detener procesos solo está disponible en conexiones locales.')) return;
44
40
  if (!ctx.procs.stop(params.id)) return sendError(res, 404, 'Proceso no encontrado.');
45
41
  sendJson(res, 200, { ok: true });
46
42
  });
@@ -254,11 +254,18 @@ export async function createTamnoraServer({
254
254
  ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
255
255
  ctx.agentTools = createAgentTools(ctx);
256
256
 
257
- // Hooks locales (opt-in: solo si hay archivos). Observan el ciclo de vida.
257
+ // Hooks locales (opt-in explicito: solo los patrones de `config.hooks[]`).
258
+ // Observan el ciclo de vida.
258
259
  ctx.hooks = await createHookRunner(ctx, config.hooks ?? []);
260
+ if (ctx.hooks.count) {
261
+ process.stderr.write(`tamnora: ${ctx.hooks.count} hook(s) cargados desde config.hooks\n`);
262
+ }
259
263
 
260
- // Plugins locales: tools propias del usuario (`.tamnora/plugins/*.mjs`).
264
+ // Plugins locales: tools propias del usuario (solo `config.plugins[]`).
261
265
  ctx.plugins = await createPlugins(ctx, config.plugins ?? []);
266
+ if (ctx.plugins.count) {
267
+ process.stderr.write(`tamnora: ${ctx.plugins.count} tool(s) de plugin cargadas desde config.plugins\n`);
268
+ }
262
269
 
263
270
  // Canales de mensajeria (Telegram via long polling) si hay token.
264
271
  const channels = createChannelsManager(ctx, {
@@ -0,0 +1,43 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { createProcManager } from '../src/lib/procs.js';
7
+
8
+ async function workspace() {
9
+ return mkdtemp(path.join(tmpdir(), 'tamnora-procs-'));
10
+ }
11
+
12
+ test('procs rechaza binarios fuera de la allowlist y args invalidos', async () => {
13
+ const cwd = await workspace();
14
+ const procs = createProcManager({ allow: ['node'] });
15
+ assert.throws(() => procs.start({ cwd, command: 'calc' }), /no permitido/);
16
+ assert.throws(() => procs.start({ cwd, command: 'node', args: 'x' }), /lista de strings/);
17
+ assert.throws(() => procs.start({ cwd, command: 'node', args: ['a\0b'] }), /Argumento inválido/);
18
+ assert.throws(
19
+ () => procs.start({ cwd, command: 'node', args: Array.from({ length: 33 }, () => 'x') }),
20
+ /Demasiados argumentos/
21
+ );
22
+ });
23
+
24
+ test('en Windows los args no admiten metacaracteres del shell', { skip: process.platform !== 'win32' }, async () => {
25
+ const cwd = await workspace();
26
+ const procs = createProcManager({ allow: ['npm', 'node'] });
27
+ for (const arg of ['x & calc', 'x | calc', 'x > out.txt', 'x %PATH%', 'x^y', 'x"y']) {
28
+ assert.throws(() => procs.start({ cwd, command: 'npm', args: ['run', arg] }), /Argumento no permitido/);
29
+ }
30
+ });
31
+
32
+ test('procs arranca y detiene un proceso permitido', async () => {
33
+ const cwd = await workspace();
34
+ const procs = createProcManager({ allow: ['node'] });
35
+ const proc = procs.start({ cwd, command: process.execPath, args: ['-e', 'setTimeout(() => {}, 5000)'] });
36
+ try {
37
+ assert.ok(proc.id);
38
+ assert.equal(proc.running, true);
39
+ assert.equal(procs.list().length, 1);
40
+ } finally {
41
+ assert.equal(procs.stop(proc.id), true);
42
+ }
43
+ });
@@ -1578,7 +1578,14 @@ test('hooks observe tool events and plugins add custom tools', async () => {
1578
1578
  path.join(cwd, '.tamnora', 'plugins', 'demo.mjs'),
1579
1579
  "export default { name: 'demo', tools: [{ name: 'saludar', description: 'saluda', parameters: { quien: { type: 'string' } }, handler: (args) => `Hola ${args.quien}` }] };\n"
1580
1580
  );
1581
- await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify({ permissions: { plugin__demo__saludar: 'allow' } }));
1581
+ await writeFile(
1582
+ path.join(cwd, 'tamnora.json'),
1583
+ JSON.stringify({
1584
+ hooks: ['.tamnora/hooks/*.mjs'],
1585
+ plugins: ['.tamnora/plugins/*.mjs'],
1586
+ permissions: { plugin__demo__saludar: 'allow' },
1587
+ })
1588
+ );
1582
1589
 
1583
1590
  let captured = null;
1584
1591
  const provider = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",