@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 +6 -0
- package/README.md +10 -6
- package/SECURITY.md +13 -0
- package/harness/packages/core/src/browser/index.js +56 -2
- package/harness/packages/core/src/hooks/index.js +9 -5
- package/harness/packages/core/src/net/hosts.js +111 -0
- package/harness/packages/core/src/plugins/index.js +6 -4
- package/harness/packages/core/src/tools/executors.js +42 -36
- package/harness/packages/core/test/hooks.test.js +6 -3
- package/harness/packages/core/test/hosts.test.js +50 -0
- package/harness/packages/core/test/plugins.test.js +5 -3
- package/harness/packages/core/test/tools.test.js +54 -1
- package/harness/packages/server/public/app.js +33 -8
- package/harness/packages/server/public/index.html +1 -1
- package/harness/packages/server/public/styles.css +4 -0
- package/harness/packages/server/src/lib/procs.js +30 -3
- package/harness/packages/server/src/routes/procs.js +2 -6
- package/harness/packages/server/src/server.js +9 -2
- package/harness/packages/server/test/procs-manager.test.js +43 -0
- package/harness/packages/server/test/server.test.js +8 -1
- package/package.json +1 -1
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
|
|
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** (
|
|
190
|
-
(`run.start/end`, `tool.before/after`,
|
|
191
|
-
Corren aislados y con los datos
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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`
|
|
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(
|
|
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
|
|
4
|
-
//
|
|
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`
|
|
23
|
+
/** Carga los plugins de los `patterns` explicitos (relativos a `cwd`). */
|
|
23
24
|
export async function loadPlugins(cwd, patterns = []) {
|
|
24
|
-
const list = [...new Set(
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
1895
|
+
function addCompactCard(container, { text, label, running = false } = {}) {
|
|
1896
1896
|
if (!container) return null;
|
|
1897
|
-
const card = el('div',
|
|
1898
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
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 (!
|
|
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 (!
|
|
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
|
|
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 (
|
|
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(
|
|
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