@danieltmn/tamnora 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +10 -6
  3. package/SECURITY.md +13 -0
  4. package/harness/packages/core/src/agent/runAgent.js +9 -5
  5. package/harness/packages/core/src/browser/index.js +56 -2
  6. package/harness/packages/core/src/hooks/index.js +9 -5
  7. package/harness/packages/core/src/index.js +1 -0
  8. package/harness/packages/core/src/mcp/stdio.js +15 -0
  9. package/harness/packages/core/src/net/hosts.js +181 -0
  10. package/harness/packages/core/src/plugins/index.js +6 -4
  11. package/harness/packages/core/src/redact.js +2 -0
  12. package/harness/packages/core/src/tools/executors.js +54 -53
  13. package/harness/packages/core/src/tools/index.js +1 -1
  14. package/harness/packages/core/src/tools/paths.js +35 -5
  15. package/harness/packages/core/src/tools/permissions.js +41 -2
  16. package/harness/packages/core/src/tools/registry.js +8 -3
  17. package/harness/packages/core/src/tunnel.js +15 -5
  18. package/harness/packages/core/test/hooks.test.js +6 -3
  19. package/harness/packages/core/test/hosts.test.js +50 -0
  20. package/harness/packages/core/test/plugins.test.js +5 -3
  21. package/harness/packages/core/test/tools.test.js +98 -2
  22. package/harness/packages/server/public/app.js +343 -6729
  23. package/harness/packages/server/public/chat.js +1893 -0
  24. package/harness/packages/server/public/chrome.js +812 -0
  25. package/harness/packages/server/public/commands.js +403 -0
  26. package/harness/packages/server/public/core.js +209 -0
  27. package/harness/packages/server/public/explorer.js +625 -0
  28. package/harness/packages/server/public/index.html +19 -19
  29. package/harness/packages/server/public/sessions.js +274 -0
  30. package/harness/packages/server/public/settings.js +1076 -0
  31. package/harness/packages/server/public/styles.css +29 -2
  32. package/harness/packages/server/public/system.js +495 -0
  33. package/harness/packages/server/public/voice.js +1058 -0
  34. package/harness/packages/server/src/auth.js +12 -10
  35. package/harness/packages/server/src/lib/agentRun.js +6 -1
  36. package/harness/packages/server/src/lib/channels/telegram.js +1 -0
  37. package/harness/packages/server/src/lib/presence.js +12 -1
  38. package/harness/packages/server/src/lib/procs.js +30 -3
  39. package/harness/packages/server/src/lib/scheduler.js +11 -2
  40. package/harness/packages/server/src/lib/tasks.js +20 -3
  41. package/harness/packages/server/src/lib/workspace.js +90 -2
  42. package/harness/packages/server/src/routes/auth.js +8 -4
  43. package/harness/packages/server/src/routes/chat.js +15 -4
  44. package/harness/packages/server/src/routes/preview.js +10 -2
  45. package/harness/packages/server/src/routes/procs.js +2 -6
  46. package/harness/packages/server/src/routes/providers.js +12 -9
  47. package/harness/packages/server/src/server.js +28 -2
  48. package/harness/packages/server/test/chat.test.js +586 -0
  49. package/harness/packages/server/test/helpers.js +107 -0
  50. package/harness/packages/server/test/ops.test.js +511 -0
  51. package/harness/packages/server/test/procs-manager.test.js +43 -0
  52. package/harness/packages/server/test/runs.test.js +816 -0
  53. package/harness/packages/server/test/security.test.js +112 -9
  54. package/harness/packages/server/test/sessions.test.js +437 -0
  55. package/harness/packages/server/test/settings.test.js +465 -0
  56. package/harness/tamnora.example.json +1 -1
  57. package/package.json +1 -1
  58. package/harness/packages/server/test/server.test.js +0 -2878
@@ -4,7 +4,7 @@ import { MCP_ROUTER_NAME, planMcpTools } from './mcp.js';
4
4
  export { createToolRegistry } from './registry.js';
5
5
  export { TOOL_DEFINITIONS, TOOL_NAMES } from './schema.js';
6
6
  export { DEFAULT_PERMISSIONS, evaluatePermission, bashGuard } from './permissions.js';
7
- export { resolveInRoot, toRel } from './paths.js';
7
+ export { resolveInRoot, toRel, realpathDeep } from './paths.js';
8
8
  export { globToRegExp, walkFiles, DEFAULT_IGNORES } from './glob.js';
9
9
  export { MCP_ROUTER_NAME, MCP_AUTO_THRESHOLD, useLazyMcp, buildMcpIndex, mcpRouterDefinition, planMcpTools } from './mcp.js';
10
10
  export * as executors from './executors.js';
@@ -1,9 +1,36 @@
1
+ import fs from 'node:fs';
1
2
  import path from 'node:path';
2
3
 
4
+ /**
5
+ * realpath del segmento existente más profundo, más el resto del camino (que
6
+ * todavía no existe): `a/b/c` con `a/b` real devuelve `<real(a/b)>/c`.
7
+ */
8
+ export function realpathDeep(target) {
9
+ let current = target;
10
+ const rest = [];
11
+ for (;;) {
12
+ try {
13
+ const real = fs.realpathSync(current);
14
+ return rest.length ? path.join(real, ...rest.reverse()) : real;
15
+ } catch {
16
+ const parent = path.dirname(current);
17
+ if (parent === current) return target;
18
+ rest.push(path.basename(current));
19
+ current = parent;
20
+ }
21
+ }
22
+ }
23
+
24
+ function escapes(base, target) {
25
+ const rel = path.relative(base, target);
26
+ return rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
27
+ }
28
+
3
29
  /**
4
30
  * Resolve a user-supplied path against the workspace root, refusing to escape
5
- * it. This is a lexical guard: `..`, absolute paths outside the root and
6
- * drive changes are rejected.
31
+ * it. Two guards: a lexical one (`..`, absolute paths outside the root, drive
32
+ * changes) and a real one over `fs.realpath`, because a symlink inside the
33
+ * workspace can point anywhere and the lexical check cannot see it.
7
34
  *
8
35
  * @param {string} root - absolute workspace root
9
36
  * @param {string} input - relative (or absolute-inside-root) path
@@ -14,11 +41,14 @@ export function resolveInRoot(root, input) {
14
41
  const raw = String(input ?? '.').trim() || '.';
15
42
  const target = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(base, raw);
16
43
 
17
- const rel = path.relative(base, target);
18
- const escapes = rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
19
- if (escapes) {
44
+ if (escapes(base, target)) {
20
45
  throw new Error(`Ruta fuera del workspace: "${input}"`);
21
46
  }
47
+ const realBase = realpathDeep(base);
48
+ const realTarget = realpathDeep(target);
49
+ if (escapes(realBase, realTarget)) {
50
+ throw new Error(`Ruta fuera del workspace (symlink): "${input}"`);
51
+ }
22
52
  return target;
23
53
  }
24
54
 
@@ -58,7 +58,6 @@ function subjectFor(name, args) {
58
58
  // `ask` = nunca se autoaprueba (siempre consulta, aunque bash sea `allow`).
59
59
  const BASH_DENY = [
60
60
  /:\s*\(\s*\)\s*\{[\s\S]*\}\s*;?\s*:/, // fork bomb :(){ :|:& };:
61
- /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)(\s+--?[\w-]+)*\s+(\/|~|\$HOME|\/\*)(\s|$)/i,
62
61
  /\bmkfs(\.|\s|$)/i,
63
62
  /\bdd\b[^\n]*\bof=\/dev\//i,
64
63
  />\s*\/dev\/(sd|nvme|disk)/i,
@@ -67,18 +66,58 @@ const BASH_DENY = [
67
66
  /\b(?:remove-item|del|rd|rmdir|format)\b[^\n]*(?:-recurse|-r\b)[^\n]*\b[a-z]:\\?\s*$/i,
68
67
  ];
69
68
  const BASH_ASK = [
70
- /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)(\s+--?[\w-]+)*\s+(\*|\.|\.\/)(\s|$)/i, // rm -rf * | . (workspace)
71
69
  /\bgit\s+push\b[^\n]*(\s-f\b|\s--force\b)/i,
72
70
  /\bgit\s+reset\s+--hard\b/i,
73
71
  /\bgit\s+clean\s+-[a-z]*f/i,
74
72
  ];
75
73
 
74
+ // `rm` con flags peligrosos: se parsean los tokens en vez de mirar la grafia,
75
+ // porque `rm --recursive --force /`, `rm -r -f /` y `rm -rf /` son lo mismo y
76
+ // las regex de un solo cluster de flags solo cazaban la ultima.
77
+ const RM_DEADLY_TARGETS = /^(\/|\/\*|\/\.\.|~|\$HOME|\$\{HOME\}|[a-z]:\\?(\*)?)$/i;
78
+ const RM_WORKSPACE_TARGETS = /^(\*|\.{1,2}|\.\/?)$/i;
79
+
80
+ /** Invocaciones de `rm` del comando: flags normalizados + primer argumento. */
81
+ function rmInvocations(command) {
82
+ const out = [];
83
+ for (const segment of String(command).split(/[;&|\n]/)) {
84
+ const m = /(?:^|\s)(?:sudo\s+)?rm\s+(.*)$/i.exec(segment);
85
+ if (!m) continue;
86
+ let recursive = false;
87
+ let force = false;
88
+ let target = '';
89
+ for (const token of m[1].trim().split(/\s+/).filter(Boolean)) {
90
+ if (token === '--') continue;
91
+ if (token.startsWith('--')) {
92
+ if (/^--(recursive|no-preserve-root)$/i.test(token)) recursive = true;
93
+ if (/^--force$/i.test(token)) force = true;
94
+ continue;
95
+ }
96
+ if (token.startsWith('-')) {
97
+ const letters = token.slice(1);
98
+ if (/[rR]/.test(letters)) recursive = true;
99
+ if (/f/.test(letters)) force = true;
100
+ continue;
101
+ }
102
+ target = token.replace(/^["']|["']$/g, '');
103
+ break;
104
+ }
105
+ out.push({ recursive, force, target });
106
+ }
107
+ return out;
108
+ }
109
+
76
110
  /** Devuelve `'deny'`/`'ask'` si el comando es peligroso, o `null`. */
77
111
  export function bashGuard(command) {
78
112
  const text = String(command ?? '');
79
113
  if (!text.trim()) return null;
80
114
  for (const re of BASH_DENY) if (re.test(text)) return 'deny';
81
115
  for (const re of BASH_ASK) if (re.test(text)) return 'ask';
116
+ for (const rm of rmInvocations(text)) {
117
+ if (!rm.recursive) continue;
118
+ if (RM_DEADLY_TARGETS.test(rm.target)) return 'deny';
119
+ if (rm.force && RM_WORKSPACE_TARGETS.test(rm.target)) return 'ask';
120
+ }
82
121
  return null;
83
122
  }
84
123
 
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { TOOL_DEFINITIONS, TOOL_NAMES } from './schema.js';
3
3
  import { EXECUTORS } from './executors.js';
4
- import { evaluatePermission } from './permissions.js';
4
+ import { evaluatePermission, bashGuard } from './permissions.js';
5
5
 
6
6
  const WRITE_TOOLS = new Set(['write', 'edit']);
7
7
 
@@ -24,7 +24,9 @@ function extensionOf(p) {
24
24
  * @param {AbortSignal} [opts.signal] - cancela las tools de larga duracion (bash, git)
25
25
  */
26
26
  export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQuestion, yes = false, signal, writeExtensions } = {}) {
27
- const names = new Set(enabled?.length ? enabled : TOOL_NAMES);
27
+ // `enabled` undefined = todas; una lista se respeta tal cual (vacía = ninguna,
28
+ // no "todas": un modo restringido no puede quedar sin tools por un filtro).
29
+ const names = new Set(Array.isArray(enabled) ? enabled : TOOL_NAMES);
28
30
  const definitions = TOOL_DEFINITIONS.filter((d) => names.has(d.function.name));
29
31
  const allowedWrite = Array.isArray(writeExtensions) && writeExtensions.length
30
32
  ? new Set(writeExtensions.map((ext) => String(ext).toLowerCase()))
@@ -38,8 +40,11 @@ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQ
38
40
  if (!writeAllowed(name, args)) return 'deny';
39
41
  let decision = evaluatePermission(name, args, permissions);
40
42
  if (decision === 'ask') {
43
+ // Un `ask` del piso de seguridad (bashGuard) nunca se autoaprueba con
44
+ // `yes`: sin UI que pregunte (onAsk) queda denegado.
45
+ const guarded = name === 'bash' && bashGuard(args?.command) === 'ask';
41
46
  if (onAsk) decision = (await onAsk(name, args)) ? 'allow' : 'deny';
42
- else decision = yes ? 'allow' : 'deny';
47
+ else decision = !guarded && yes ? 'allow' : 'deny';
43
48
  }
44
49
  return decision;
45
50
  }
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, statSync } from 'node:fs';
3
3
  import { bridgeStatePath } from './config/home.js';
4
4
  import { pidAlive, killPidTree } from './pid.js';
5
5
 
@@ -18,9 +18,10 @@ function killTree(child) {
18
18
  * Estado del bridge (escrito por `tamnora bridge`), si el supervisor sigue
19
19
  * vivo. Lo consulta el server para saber que hay un tunel publico activo y
20
20
  * ajustar el modelo de confianza (guards locales, cookie Secure).
21
- * Cache de 1 s: se lee en cada request sensible y el pid no cambia tan rapido.
21
+ * Cache de 1 s + invalidacion por mtime del archivo: se lee en cada request
22
+ * sensible, pero un bridge.json nuevo (o borrado) se ve al instante.
22
23
  */
23
- let tunnelCache = { key: null, at: 0, state: null };
24
+ let tunnelCache = { key: null, mtime: null, at: 0, state: null };
24
25
  export function tunnelState() {
25
26
  let statePath;
26
27
  try {
@@ -29,7 +30,16 @@ export function tunnelState() {
29
30
  return null;
30
31
  }
31
32
  const now = Date.now();
32
- if (tunnelCache.key === statePath && now - tunnelCache.at < 1000) return tunnelCache.state;
33
+ let mtime = null;
34
+ try {
35
+ mtime = statSync(statePath).mtimeMs;
36
+ } catch {
37
+ tunnelCache = { key: statePath, mtime: null, at: now, state: null };
38
+ return null;
39
+ }
40
+ if (tunnelCache.key === statePath && tunnelCache.mtime === mtime && now - tunnelCache.at < 1000) {
41
+ return tunnelCache.state;
42
+ }
33
43
  let state = null;
34
44
  try {
35
45
  const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
@@ -39,7 +49,7 @@ export function tunnelState() {
39
49
  } catch {
40
50
  /* sin bridge */
41
51
  }
42
- tunnelCache = { key: statePath, at: now, state };
52
+ tunnelCache = { key: statePath, mtime, at: now, state };
43
53
  return state;
44
54
  }
45
55
 
@@ -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
  });
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtemp, readFile, writeFile, mkdir } from 'node:fs/promises';
3
+ import { mkdtemp, readFile, writeFile, mkdir, symlink } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { resolveInRoot, toRel } from '../src/tools/paths.js';
@@ -8,6 +8,7 @@ import { evaluatePermission, bashGuard } from '../src/tools/permissions.js';
8
8
  import { createTools, createToolRegistry } from '../src/tools/index.js';
9
9
  import {
10
10
  fetchUrlTool,
11
+ gitDiffTool,
11
12
  previewUrlTool,
12
13
  screenshotTool,
13
14
  consoleTool,
@@ -61,6 +62,48 @@ test('bashGuard marca comandos peligrosos (piso de seguridad)', () => {
61
62
  assert.equal(evaluatePermission('bash', { command: 'rm -rf ./dist' }, { bash: 'allow' }), 'allow');
62
63
  });
63
64
 
65
+ test('bashGuard tolera grafías alternativas de flags en rm', () => {
66
+ assert.equal(bashGuard('rm --recursive --force /'), 'deny');
67
+ assert.equal(bashGuard('rm -r -f /'), 'deny');
68
+ assert.equal(bashGuard('rm -fr /'), 'deny');
69
+ assert.equal(bashGuard('rm -r -f ~'), 'deny');
70
+ assert.equal(bashGuard('rm --recursive --force *'), 'ask');
71
+ assert.equal(bashGuard('rm -r -f .'), 'ask');
72
+ assert.equal(bashGuard('rm -rf ./build'), null);
73
+ assert.equal(bashGuard('rm archivo.txt'), null);
74
+ });
75
+
76
+ test('el tier ask del piso de seguridad no se autoaprueba con yes', async () => {
77
+ const root = await makeWorkspace();
78
+ const auto = createToolRegistry({ root, permissions: { bash: 'allow' }, yes: true });
79
+ assert.equal(await auto.authorize('bash', { command: 'rm -rf *' }), 'deny', 'yes no saltea la guarda');
80
+
81
+ const interactive = createToolRegistry({ root, permissions: { bash: 'allow' }, onAsk: async () => true });
82
+ assert.equal(await interactive.authorize('bash', { command: 'rm -rf *' }), 'allow', 'onAsk si puede aprobar');
83
+
84
+ // Con `yes`, un ask normal (sin guarda) sigue autoaprobando.
85
+ assert.equal(await auto.authorize('write', { path: 'x.txt' }), 'allow');
86
+ });
87
+
88
+ test('git_diff rechaza refs que parecen opciones', async () => {
89
+ const root = await makeWorkspace();
90
+ const result = await gitDiffTool(root, { ref: '--output=/tmp/inyectado' });
91
+ assert.match(result, /ref.*inválido/i);
92
+ });
93
+
94
+ test('resolveInRoot no deja escapar por symlink', async () => {
95
+ const root = await makeWorkspace();
96
+ const outside = await mkdtemp(path.join(tmpdir(), 'tamnora-fuera-'));
97
+ await writeFile(path.join(outside, 'secreto.txt'), 'no tocar');
98
+ try {
99
+ await symlink(outside, path.join(root, 'link'), 'junction');
100
+ } catch {
101
+ return; // el fs no permite crear symlinks: nada que probar
102
+ }
103
+ assert.throws(() => resolveInRoot(root, 'link/secreto.txt'), /fuera del workspace/);
104
+ assert.equal(toRel(root, resolveInRoot(root, 'src/a.js')), 'src/a.js');
105
+ });
106
+
64
107
  test('local tools read, write, list, glob, grep and edit', async () => {
65
108
  const root = await makeWorkspace();
66
109
  const reg = createToolRegistry({ root, permissions: { write: 'allow', edit: 'allow' } });
@@ -133,7 +176,7 @@ test('permission "ask" is denied by default and allowed with yes', async () => {
133
176
  test('fetchUrlTool rejects invalid URLs and private hosts', async () => {
134
177
  const root = await makeWorkspace();
135
178
  assert.match(await fetchUrlTool(root, { url: 'not-a-url' }), /URL inválida/);
136
- assert.match(await fetchUrlTool(root, { url: 'ftp://example.com' }), /http\/https/);
179
+ assert.match(await fetchUrlTool(root, { url: 'ftp://example.com' }), /http\(s\)/);
137
180
  assert.match(await fetchUrlTool(root, { url: 'http://127.0.0.1:9/' }), /host bloqueado/);
138
181
  assert.match(await fetchUrlTool(root, { url: 'http://192.168.1.10/' }), /host bloqueado/);
139
182
  assert.match(await fetchUrlTool(root, { url: 'http://169.254.169.254/' }), /host bloqueado/);
@@ -192,6 +235,55 @@ test('fetchUrlTool raw returns the original body', async () => {
192
235
  assert.match(out, /<p>hola<\/p>/);
193
236
  });
194
237
 
238
+ test('fetchUrlTool revalida el host en cada redirect', async () => {
239
+ let calls = 0;
240
+ const out = await withFetch(
241
+ async () => {
242
+ calls++;
243
+ return new Response(null, { status: 302, headers: { location: 'http://127.0.0.1:9/admin' } });
244
+ },
245
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
246
+ );
247
+ assert.match(out, /host bloqueado/);
248
+ assert.equal(calls, 1, 'no debe pedir el destino bloqueado');
249
+ });
250
+
251
+ test('fetchUrlTool bloquea redirects a IPv6 internos y protocolos no http', async () => {
252
+ const mapped = await withFetch(
253
+ async () => new Response(null, { status: 301, headers: { location: 'http://[::ffff:127.0.0.1]/' } }),
254
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
255
+ );
256
+ assert.match(mapped, /host bloqueado/);
257
+
258
+ const file = await withFetch(
259
+ async () => new Response(null, { status: 302, headers: { location: 'file:///etc/passwd' } }),
260
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
261
+ );
262
+ assert.match(file, /protocolo no permitido/);
263
+ });
264
+
265
+ test('fetchUrlTool sigue redirects a hosts publicos', async () => {
266
+ const out = await withFetch(
267
+ async (url) => {
268
+ if (String(url) === 'https://example.com/') {
269
+ return new Response(null, { status: 301, headers: { location: 'https://example.org/final' } });
270
+ }
271
+ return new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } });
272
+ },
273
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
274
+ );
275
+ assert.match(out, /200 .*example\.org\/final/);
276
+ assert.match(out, /ok/);
277
+ });
278
+
279
+ test('fetchUrlTool corta cadenas de redirects demasiado largas', async () => {
280
+ const out = await withFetch(
281
+ async () => new Response(null, { status: 302, headers: { location: 'https://example.com/loop' } }),
282
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
283
+ );
284
+ assert.match(out, /demasiadas redirecciones/);
285
+ });
286
+
195
287
  test('createTools combines local and MCP tool routing', async () => {
196
288
  const root = await makeWorkspace();
197
289
  const mcp = {
@@ -265,10 +357,14 @@ test('la tool speak valida el texto y la recorta', async () => {
265
357
 
266
358
  test('screenshot, console_errors y preview_url validan la URL sin lanzar el navegador', async () => {
267
359
  assert.match(previewUrlTool(null, { url: 'https://example.com/x' }), /example\.com/);
360
+ assert.match(previewUrlTool(null, { url: 'http://127.0.0.1:5173/' }), /127\.0\.0\.1/, 'preview permite loopback');
268
361
  assert.match(previewUrlTool(null, {}), /falta "url"/);
269
362
  assert.match(await screenshotTool(null, { url: 'ftp://x' }), /Solo se admite http/);
270
363
  assert.match(await screenshotTool(null, {}), /falta "url"/);
364
+ assert.match(await screenshotTool(null, { url: 'http://127.0.0.1:4517/' }), /host bloqueado/);
365
+ assert.match(await screenshotTool(null, { url: 'http://[fd00::1]/' }), /host bloqueado/);
271
366
  assert.match(await consoleTool(null, { url: 'no-url' }), /URL inválida/);
367
+ assert.match(await consoleTool(null, { url: 'http://localhost:4517/' }), /host bloqueado/);
272
368
  assert.equal(evaluatePermission('screenshot', {}, {}), 'allow');
273
369
  assert.equal(evaluatePermission('console_errors', {}, {}), 'allow');
274
370
  assert.equal(evaluatePermission('preview_url', {}, {}), 'allow');