@danieltmn/tamnora 0.21.0 → 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,18 @@ 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
+
14
+ ## [0.21.1] - 2026-10-16
15
+
16
+ ### Cambios
17
+
18
+ - feat(config): export/import de la configuracion de la casa
19
+
8
20
  ## [0.21.0] - 2026-10-14
9
21
 
10
22
  ### 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`,
@@ -10,6 +10,7 @@ import { bridgeCommand } from './commands/bridge.js';
10
10
  import { updateCommand } from './commands/update.js';
11
11
  import { autostartCommand } from './commands/autostart.js';
12
12
  import { doctorCommand } from './commands/doctor.js';
13
+ import { configCommand } from './commands/config.js';
13
14
  import { tuiCommand } from './commands/tui.js';
14
15
  import { ensureUtf8Console } from './lib/util.js';
15
16
  import { readPackageVersion } from './lib/core.js';
@@ -29,6 +30,7 @@ const COMMANDS = {
29
30
  update: updateCommand,
30
31
  autostart: autostartCommand,
31
32
  doctor: doctorCommand,
33
+ config: configCommand,
32
34
  };
33
35
 
34
36
  function readVersion() {
@@ -76,6 +78,7 @@ Uso:
76
78
  tamnora autostart install|remove Arranque automatico del server
77
79
  tamnora serve Consola web local (primer plano)
78
80
  tamnora doctor Verificar Node, casa, puerto y base
81
+ tamnora config export|import Exportar/importar la configuracion (proveedores + ajustes)
79
82
  tamnora update Buscar/instalar una version nueva
80
83
  tamnora init Crear tamnora.json
81
84
 
@@ -0,0 +1,141 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ appConfigPath,
5
+ databasePath,
6
+ exportConfig,
7
+ homeDir,
8
+ importConfig,
9
+ Store,
10
+ } from '../../../core/src/index.js';
11
+ import { parse } from '../lib/util.js';
12
+ import { applyHomeOverride, stopRunningServer } from '../lib/daemon.js';
13
+
14
+ const DEFAULT_FILE = 'tamnora-config.json';
15
+
16
+ const OPTIONS = {
17
+ home: { type: 'string' },
18
+ out: { type: 'string', short: 'o' },
19
+ 'with-secrets': { type: 'boolean' },
20
+ stdout: { type: 'boolean' },
21
+ 'dry-run': { type: 'boolean' },
22
+ help: { type: 'boolean', short: 'h' },
23
+ };
24
+
25
+ export async function configCommand(argv) {
26
+ const { values, positionals } = parse(argv, OPTIONS);
27
+ if (values.help) {
28
+ printHelp();
29
+ return 0;
30
+ }
31
+ applyHomeOverride(values);
32
+
33
+ const sub = (positionals[0] || 'path').toLowerCase();
34
+ const target = positionals[1] || values.out;
35
+
36
+ if (sub === 'export') return doExport(values, target);
37
+ if (sub === 'import') return doImport(values, target);
38
+ if (sub === 'path') {
39
+ printPaths();
40
+ return 0;
41
+ }
42
+ printHelp();
43
+ return 1;
44
+ }
45
+
46
+ function withStore(fn) {
47
+ const store = new Store(databasePath());
48
+ try {
49
+ return fn(store);
50
+ } finally {
51
+ store.close();
52
+ }
53
+ }
54
+
55
+ function doExport(values, target) {
56
+ const data = withStore((store) => exportConfig({ store, withSecrets: Boolean(values['with-secrets']) }));
57
+ const json = `${JSON.stringify(data, null, 2)}\n`;
58
+ if (values.stdout || target === '-') {
59
+ process.stdout.write(json);
60
+ return 0;
61
+ }
62
+ const file = path.resolve(target || DEFAULT_FILE);
63
+ writeFileSync(file, json);
64
+ console.log(`Configuracion exportada a ${file}`);
65
+ console.log(` proveedores: ${data.providers.length} · ajustes: ${Object.keys(data.settings).length}`);
66
+ console.log(` secretos: ${data.secrets ? 'incluidos' : 'enmascarados (usa --with-secrets para incluirlos)'}`);
67
+ return 0;
68
+ }
69
+
70
+ function doImport(values, target) {
71
+ if (!target) {
72
+ console.error('Falta el archivo: tamnora config import <archivo.json>');
73
+ return 1;
74
+ }
75
+ const file = path.resolve(target);
76
+ let data;
77
+ try {
78
+ data = JSON.parse(readFileSync(file, 'utf8'));
79
+ } catch (error) {
80
+ console.error(`No pude leer ${file}: ${error.message}`);
81
+ return 1;
82
+ }
83
+
84
+ const dryRun = Boolean(values['dry-run']);
85
+ if (!dryRun && stopRunningServer()) {
86
+ console.log('Habia un server corriendo; lo detuve para aplicar los cambios.');
87
+ }
88
+
89
+ let summary;
90
+ try {
91
+ summary = withStore((store) => importConfig(store, data, { dryRun }));
92
+ } catch (error) {
93
+ console.error(error.message);
94
+ return 1;
95
+ }
96
+
97
+ if (dryRun) {
98
+ console.log('Simulacion (--dry-run): no se cambio nada.');
99
+ printSummary(summary);
100
+ return 0;
101
+ }
102
+ printSummary(summary);
103
+ console.log('Volve a arrancar el server: tamnora server --start');
104
+ return 0;
105
+ }
106
+
107
+ function printSummary(summary) {
108
+ console.log(`Importado: ${summary.providers} proveedor(es), ${summary.settings} ajuste(s), app.json actualizado.`);
109
+ if (summary.missingKeys.length) {
110
+ console.log(` Ojo: ${summary.missingKeys.join(', ')} quedaron sin API key (venian enmascaradas); cargalas en la consola.`);
111
+ }
112
+ }
113
+
114
+ function printPaths() {
115
+ console.log(`Casa de Tamnora : ${homeDir()}`);
116
+ console.log(` config app : ${appConfigPath()}`);
117
+ console.log(` base de datos : ${databasePath()}`);
118
+ console.log(' por proyecto : <cwd>/tamnora.json y <cwd>/.tamnora/');
119
+ }
120
+
121
+ function printHelp() {
122
+ console.log(`Uso: tamnora config <export|import|path> [archivo] [opciones]
123
+
124
+ Exporta o importa la configuracion de la casa (app.json + proveedores + ajustes).
125
+ No incluye sesiones, historial, MCP servers ni tareas programadas.
126
+
127
+ export [archivo] Vuelca la configuracion a JSON (default: ${DEFAULT_FILE})
128
+ import <archivo> Aplica el JSON sobre esta instalacion
129
+ path Muestra donde vive la configuracion
130
+
131
+ Opciones:
132
+ --with-secrets Incluye API keys y secretos reales (default: enmascarados)
133
+ -o, --out <archivo> Archivo de salida para export
134
+ --stdout Imprime el JSON por stdout (export)
135
+ --dry-run Muestra que haria el import sin escribir nada
136
+ --home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)
137
+
138
+ Ejemplos:
139
+ tamnora config export tamnora-config.json --with-secrets
140
+ tamnora config import tamnora-config.json`);
141
+ }
@@ -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');
@@ -0,0 +1,138 @@
1
+ import { readApp, writeApp } from './app.js';
2
+
3
+ /**
4
+ * Export/import portable de la configuracion de la casa (app.json + tablas
5
+ * `providers` y `settings` de la base global). Los secretos (API keys, hash de
6
+ * usuario, token de Telegram, `authSecret`) se enmascaran salvo `withSecrets`;
7
+ * al importar, los valores enmascarados se saltan y se conserva lo existente.
8
+ */
9
+
10
+ export const CONFIG_KIND = 'tamnora-config';
11
+ export const CONFIG_VERSION = 1;
12
+ export const MASKED = '<<tamnora:secreto>>';
13
+
14
+ const LINKAGE_PREFIXES = ['telegram:', 'scheduleSession:'];
15
+
16
+ function isSecretValue(value) {
17
+ return typeof value === 'string' && value !== '' && !value.startsWith('env:');
18
+ }
19
+
20
+ function isLinkageKey(key) {
21
+ return LINKAGE_PREFIXES.some((prefix) => key.startsWith(prefix));
22
+ }
23
+
24
+ function maskApp(app) {
25
+ const out = structuredClone(app);
26
+ if (isSecretValue(out.authSecret)) out.authSecret = MASKED;
27
+ if (out.user) out.user = MASKED;
28
+ if (out.telegram && isSecretValue(out.telegram.token)) out.telegram.token = MASKED;
29
+ if (out.tunnel && isSecretValue(out.tunnel.token)) out.tunnel.token = MASKED;
30
+ return out;
31
+ }
32
+
33
+ function providerToPortable(provider, withSecrets) {
34
+ const apiKey = withSecrets || !isSecretValue(provider.apiKey) ? provider.apiKey ?? '' : MASKED;
35
+ return {
36
+ id: provider.id,
37
+ label: provider.label,
38
+ baseUrl: provider.baseUrl ?? '',
39
+ apiKey,
40
+ models: provider.models ?? [],
41
+ requiresKey: provider.requiresKey,
42
+ custom: Boolean(provider.custom),
43
+ enabled: provider.enabled !== false,
44
+ };
45
+ }
46
+
47
+ function exportableSettings(all) {
48
+ const out = {};
49
+ for (const [key, value] of Object.entries(all)) {
50
+ if (isLinkageKey(key)) continue;
51
+ out[key] = value;
52
+ }
53
+ return out;
54
+ }
55
+
56
+ function normalizePayload(data) {
57
+ if (!data || typeof data !== 'object') {
58
+ throw new Error('El archivo no es un objeto JSON valido.');
59
+ }
60
+ if (data.kind !== CONFIG_KIND) {
61
+ throw new Error(`No parece un export de Tamnora (kind: ${data.kind ?? 'desconocido'}).`);
62
+ }
63
+ if (Number(data.version) > CONFIG_VERSION) {
64
+ throw new Error(`Version de export no soportada: ${data.version} (max ${CONFIG_VERSION}).`);
65
+ }
66
+ return data;
67
+ }
68
+
69
+ function mergeMaskAware(target, source) {
70
+ for (const [key, value] of Object.entries(source)) {
71
+ if (value === MASKED) continue;
72
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
73
+ const base = target[key] && typeof target[key] === 'object' && !Array.isArray(target[key]) ? target[key] : {};
74
+ target[key] = mergeMaskAware({ ...base }, value);
75
+ } else {
76
+ target[key] = value;
77
+ }
78
+ }
79
+ return target;
80
+ }
81
+
82
+ /** Arma el payload portable a partir del store y `app.json`. */
83
+ export function exportConfig({ store, withSecrets = false, now } = {}) {
84
+ const app = readApp();
85
+ return {
86
+ kind: CONFIG_KIND,
87
+ version: CONFIG_VERSION,
88
+ exportedAt: new Date(now ?? Date.now()).toISOString(),
89
+ secrets: withSecrets,
90
+ app: withSecrets ? app : maskApp(app),
91
+ providers: store.listProviders().map((provider) => providerToPortable(provider, withSecrets)),
92
+ settings: exportableSettings(store.allSettings()),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Aplica un payload portable sobre el store y `app.json`. Con `dryRun` no
98
+ * escribe nada. Devuelve un resumen (proveedores, ajustes y claves faltantes).
99
+ */
100
+ export function importConfig(store, data, { dryRun = false } = {}) {
101
+ const payload = normalizePayload(data);
102
+ const summary = { app: true, providers: 0, settings: 0, missingKeys: [] };
103
+
104
+ const app = readApp();
105
+ mergeMaskAware(app, payload.app ?? {});
106
+
107
+ for (const provider of payload.providers ?? []) {
108
+ if (!provider?.id) continue;
109
+ const existing = store.getProvider(provider.id);
110
+ let apiKey = provider.apiKey ?? '';
111
+ if (apiKey === MASKED) {
112
+ apiKey = existing?.apiKey ?? '';
113
+ if (!apiKey) summary.missingKeys.push(provider.id);
114
+ }
115
+ if (!dryRun) {
116
+ store.saveProvider({
117
+ id: provider.id,
118
+ label: provider.label,
119
+ baseUrl: provider.baseUrl,
120
+ apiKey,
121
+ models: provider.models,
122
+ requiresKey: provider.requiresKey,
123
+ custom: provider.custom,
124
+ enabled: provider.enabled !== false,
125
+ });
126
+ }
127
+ summary.providers += 1;
128
+ }
129
+
130
+ for (const [key, value] of Object.entries(payload.settings ?? {})) {
131
+ if (isLinkageKey(key)) continue;
132
+ if (!dryRun) store.setSetting(key, value);
133
+ summary.settings += 1;
134
+ }
135
+
136
+ if (!dryRun) writeApp(app);
137
+ return summary;
138
+ }
@@ -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 {
@@ -70,6 +70,7 @@ export {
70
70
  migrateLegacyDb,
71
71
  } from './config/home.js';
72
72
  export { DEFAULT_APP, readApp, writeApp, ensureApp, randomToken } from './config/app.js';
73
+ export { exportConfig, importConfig, CONFIG_KIND, CONFIG_VERSION, MASKED } from './config/portable.js';
73
74
  export { hashPassword, verifyPassword, userValidName, makeUser } from './config/identity.js';
74
75
  export { composeProviderSettings } from './config/providers.js';
75
76
  export {
@@ -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 {