@danieltmn/tamnora 0.21.2 → 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 (47) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/harness/packages/core/src/agent/runAgent.js +9 -5
  3. package/harness/packages/core/src/index.js +1 -0
  4. package/harness/packages/core/src/mcp/stdio.js +15 -0
  5. package/harness/packages/core/src/net/hosts.js +70 -0
  6. package/harness/packages/core/src/redact.js +2 -0
  7. package/harness/packages/core/src/tools/executors.js +19 -24
  8. package/harness/packages/core/src/tools/index.js +1 -1
  9. package/harness/packages/core/src/tools/paths.js +35 -5
  10. package/harness/packages/core/src/tools/permissions.js +41 -2
  11. package/harness/packages/core/src/tools/registry.js +8 -3
  12. package/harness/packages/core/src/tunnel.js +15 -5
  13. package/harness/packages/core/test/tools.test.js +44 -1
  14. package/harness/packages/server/public/app.js +343 -6754
  15. package/harness/packages/server/public/chat.js +1893 -0
  16. package/harness/packages/server/public/chrome.js +812 -0
  17. package/harness/packages/server/public/commands.js +403 -0
  18. package/harness/packages/server/public/core.js +209 -0
  19. package/harness/packages/server/public/explorer.js +625 -0
  20. package/harness/packages/server/public/index.html +19 -19
  21. package/harness/packages/server/public/sessions.js +274 -0
  22. package/harness/packages/server/public/settings.js +1076 -0
  23. package/harness/packages/server/public/styles.css +25 -2
  24. package/harness/packages/server/public/system.js +495 -0
  25. package/harness/packages/server/public/voice.js +1058 -0
  26. package/harness/packages/server/src/auth.js +12 -10
  27. package/harness/packages/server/src/lib/agentRun.js +6 -1
  28. package/harness/packages/server/src/lib/channels/telegram.js +1 -0
  29. package/harness/packages/server/src/lib/presence.js +12 -1
  30. package/harness/packages/server/src/lib/scheduler.js +11 -2
  31. package/harness/packages/server/src/lib/tasks.js +20 -3
  32. package/harness/packages/server/src/lib/workspace.js +90 -2
  33. package/harness/packages/server/src/routes/auth.js +8 -4
  34. package/harness/packages/server/src/routes/chat.js +15 -4
  35. package/harness/packages/server/src/routes/preview.js +10 -2
  36. package/harness/packages/server/src/routes/providers.js +12 -9
  37. package/harness/packages/server/src/server.js +19 -0
  38. package/harness/packages/server/test/chat.test.js +586 -0
  39. package/harness/packages/server/test/helpers.js +107 -0
  40. package/harness/packages/server/test/ops.test.js +511 -0
  41. package/harness/packages/server/test/runs.test.js +816 -0
  42. package/harness/packages/server/test/security.test.js +112 -9
  43. package/harness/packages/server/test/sessions.test.js +437 -0
  44. package/harness/packages/server/test/settings.test.js +465 -0
  45. package/harness/tamnora.example.json +1 -1
  46. package/package.json +1 -1
  47. package/harness/packages/server/test/server.test.js +0 -2885
package/CHANGELOG.md CHANGED
@@ -5,6 +5,15 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.22.0] - 2026-10-22
9
+
10
+ ### Cambios
11
+
12
+ - docs: AGENTS.md al dia con la auditoria, los modulos de la consola y el preview opt-in
13
+ - refactor(test): parte server.test.js en archivos por dominio
14
+ - refactor(consola): parte app.js en modulos y pule la UX
15
+ - fix: cierra los hallazgos de seguridad de la auditoria y agrega regresiones
16
+
8
17
  ## [0.21.2] - 2026-10-20
9
18
 
10
19
  ### Cambios
@@ -1,4 +1,5 @@
1
1
  import { withRetry, isRetryableError } from '../providers/resilience.js';
2
+ import { redactSecrets, redactDeep } from '../redact.js';
2
3
  import { trimContext, estimateTokens } from './context.js';
3
4
 
4
5
  // 0 = sin limite de rondas: el loop sigue hasta que el modelo deja de pedir
@@ -66,6 +67,7 @@ export async function runAgent({
66
67
  retryDelayMs = 500,
67
68
  contextTokens = 0,
68
69
  onLifecycle,
70
+ redactExtra = [],
69
71
  }) {
70
72
  const working = [...messages];
71
73
  // Mensajes efimeros (p. ej. reglas por path inyectadas en runtime): entran al
@@ -234,7 +236,7 @@ export async function runAgent({
234
236
  }
235
237
  }
236
238
 
237
- const normalized = normalizeToolOutput(output);
239
+ const normalized = normalizeToolOutput(output, { extra: redactExtra });
238
240
  onEvent?.({
239
241
  type: 'tool_result',
240
242
  id: call.id,
@@ -357,14 +359,16 @@ function createStallGuard(onEvent, stallWarnMs) {
357
359
  * Normaliza el resultado de un tool a `{ content, meta }`. Un executor puede
358
360
  * devolver un string (lo normal) o `{ content, meta }` cuando necesita mandar
359
361
  * metadata al cliente sin ensuciar lo que ve el modelo (p. ej. una captura).
362
+ * El contenido va redactado (`redactSecrets`): un tool result que pegue una
363
+ * API key no puede llegar al historial, la UI ni los canales.
360
364
  */
361
- export function normalizeToolOutput(output) {
362
- if (typeof output === 'string') return { content: output };
365
+ export function normalizeToolOutput(output, opts = {}) {
366
+ if (typeof output === 'string') return { content: redactSecrets(output, opts) };
363
367
  if (output && typeof output === 'object' && typeof output.content === 'string') {
364
- return { content: output.content, meta: output.meta };
368
+ return { content: redactSecrets(output.content, opts), meta: redactDeep(output.meta, opts) };
365
369
  }
366
370
  if (output == null) return { content: '' };
367
- return { content: JSON.stringify(output) };
371
+ return { content: redactSecrets(JSON.stringify(output), opts) };
368
372
  }
369
373
 
370
374
  async function defaultExecute(name) {
@@ -83,6 +83,7 @@ export {
83
83
  bashGuard,
84
84
  resolveInRoot,
85
85
  toRel,
86
+ realpathDeep,
86
87
  MCP_ROUTER_NAME,
87
88
  MCP_AUTO_THRESHOLD,
88
89
  useLazyMcp,
@@ -26,6 +26,20 @@ function shouldUseShell(server, command) {
26
26
  return true;
27
27
  }
28
28
 
29
+ // Con `shell: true` Node concatena los args sin escapar: un arg con
30
+ // metacaracteres (`&`, `|`, `<`, `>`, `^`, `%`, `!`) inyectaria comandos. Misma
31
+ // regla que `lib/procs.js` del server.
32
+ const SAFE_SHELL_ARG = /^[^&|<>^%!"\r\n]+$/;
33
+
34
+ function assertSafeShellArgs(server, command, args = []) {
35
+ if (!shouldUseShell(server, command)) return;
36
+ for (const arg of args) {
37
+ if (!SAFE_SHELL_ARG.test(String(arg))) {
38
+ throw new Error(`MCP stdio: argumento no permitido con shell en Windows: "${arg}".`);
39
+ }
40
+ }
41
+ }
42
+
29
43
  // Variables minimas para que un proceso hijo comun (node, npx, uvx) arranque.
30
44
  const BASE_ENV_KEYS = [
31
45
  'PATH',
@@ -73,6 +87,7 @@ export function childEnv(server = {}) {
73
87
  export async function connectMcpStdioServer(server, { signal, timeout = 30000 } = {}) {
74
88
  const command = server.command;
75
89
  if (!command) throw new Error(`MCP stdio: falta "command" en el servidor ${server.id ?? ''}`);
90
+ assertSafeShellArgs(server, command, server.args ?? []);
76
91
 
77
92
  const child = spawn(command, server.args ?? [], {
78
93
  cwd: server.cwd,
@@ -3,6 +3,7 @@
3
3
  // `fetch_url` (tambien en cada redirect) y el navegador headless.
4
4
 
5
5
  import net from 'node:net';
6
+ import { lookup } from 'node:dns/promises';
6
7
 
7
8
  const BLOCKED_SUFFIXES = ['.localhost', '.local', '.internal', '.intranet', '.lan', '.home.arpa'];
8
9
 
@@ -109,3 +110,72 @@ export function checkHttpUrl(value) {
109
110
  }
110
111
  return { url: parsed.toString(), parsed };
111
112
  }
113
+
114
+ /**
115
+ * Validacion para `preview_url`: la vista previa embebe un dev server, que casi
116
+ * siempre es local (127.0.0.1, LAN), asi que NO aplica la guarda de hosts
117
+ * privados de `checkHttpUrl`. Se bloquean igual los hosts de metadatos de la
118
+ * nube y las direcciones link-local/multicast.
119
+ */
120
+ export function checkPreviewUrl(value) {
121
+ const raw = String(value ?? '').trim();
122
+ if (!raw) return { error: 'falta "url".' };
123
+ let parsed;
124
+ try {
125
+ parsed = new URL(raw);
126
+ } catch {
127
+ return { error: `URL inválida: "${raw}".` };
128
+ }
129
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
130
+ return { error: 'Solo se admite http(s).' };
131
+ }
132
+ const host = normalizeHost(parsed.hostname);
133
+ if (!host) return { error: 'URL inválida (sin host).' };
134
+ if (host === 'metadata' || host.endsWith('.internal')) {
135
+ return { error: `host bloqueado (${host}).` };
136
+ }
137
+ if (net.isIP(host) && isLinkLocalOrReserved(host)) {
138
+ return { error: `host bloqueado (${host}).` };
139
+ }
140
+ return { url: parsed.toString(), parsed };
141
+ }
142
+
143
+ function isLinkLocalOrReserved(address) {
144
+ const value = normalizeHost(address);
145
+ const family = net.isIP(value);
146
+ if (family === 4) {
147
+ const [a, b] = parseV4(value) ?? [];
148
+ return a === 0 || (a === 169 && b === 254) || a >= 224;
149
+ }
150
+ if (family === 6) {
151
+ const groups = parseV6(value) ?? [];
152
+ const [g0] = groups;
153
+ return groups.every((g) => g === 0) || (g0 & 0xffc0) === 0xfe80 || (g0 & 0xff00) === 0xff00;
154
+ }
155
+ return true;
156
+ }
157
+
158
+ /**
159
+ * Como `checkHttpUrl`, pero ademas resuelve el hostname y valida las IPs: un
160
+ * dominio publico que resuelva a una IP interna (DNS rebinding) no pasa. Vale
161
+ * para la URL inicial y para cada redirect.
162
+ */
163
+ export async function checkHttpUrlResolved(value) {
164
+ const checked = checkHttpUrl(value);
165
+ if (checked.error) return checked;
166
+ const host = normalizeHost(checked.parsed.hostname);
167
+ if (net.isIP(host)) return checked;
168
+ let addresses;
169
+ try {
170
+ addresses = await lookup(host, { all: true });
171
+ } catch {
172
+ return { error: `No pude resolver el host "${host}".` };
173
+ }
174
+ if (!addresses.length) return { error: `No pude resolver el host "${host}".` };
175
+ for (const { address } of addresses) {
176
+ if (isBlockedAddress(address)) {
177
+ return { error: `host bloqueado (${host} resuelve a ${normalizeHost(address)}).` };
178
+ }
179
+ }
180
+ return checked;
181
+ }
@@ -17,6 +17,8 @@ const SECRET_PATTERNS = [
17
17
  /AIza[0-9A-Za-z_-]{30,}/g,
18
18
  /xox[baprs]-[A-Za-z0-9-]{10,}/g,
19
19
  /ya29\.[A-Za-z0-9_-]{20,}/g,
20
+ /gsk_[A-Za-z0-9]{20,}/g, // Groq
21
+ /\b\d{8,10}:[A-Za-z0-9_-]{30,}\b/g, // token de bot de Telegram
20
22
  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
21
23
  ];
22
24
 
@@ -7,7 +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
+ import { checkHttpUrlResolved, checkPreviewUrl } from '../net/hosts.js';
11
11
 
12
12
  const MAX_READ_LINES = 2000;
13
13
  const MAX_LIST = 500;
@@ -98,7 +98,7 @@ function formatBody(head, text, limit) {
98
98
  }
99
99
 
100
100
  export async function fetchUrlTool(_root, { url, maxLength, raw } = {}) {
101
- const checked = checkHttpUrl(url);
101
+ const checked = await checkHttpUrlResolved(url);
102
102
  if (checked.error) return `Error: ${checked.error}`;
103
103
  let target = checked.parsed;
104
104
 
@@ -129,9 +129,8 @@ export async function fetchUrlTool(_root, { url, maxLength, raw } = {}) {
129
129
  if (next.protocol !== 'http:' && next.protocol !== 'https:') {
130
130
  return `Error: redirección a un protocolo no permitido (${next.protocol}).`;
131
131
  }
132
- if (isBlockedHost(next.hostname)) {
133
- return `Error: host bloqueado (${normalizeHost(next.hostname)}).`;
134
- }
132
+ const recheck = await checkHttpUrlResolved(next.toString());
133
+ if (recheck.error) return `Error: ${recheck.error}`;
135
134
  try {
136
135
  await res.body?.cancel();
137
136
  } catch {
@@ -331,7 +330,8 @@ export function bashTool(root, { command, timeout } = {}, { signal } = {}) {
331
330
  let settled = false;
332
331
  let timer = null;
333
332
  const append = (chunk) => {
334
- if (output.length < MAX_BASH_OUTPUT) output += chunk.toString();
333
+ const room = MAX_BASH_OUTPUT - output.length;
334
+ if (room > 0) output += chunk.toString().slice(0, room);
335
335
  };
336
336
 
337
337
  const finish = (text) => {
@@ -374,7 +374,8 @@ function runGit(root, args) {
374
374
  let output = '';
375
375
  let settled = false;
376
376
  const append = (chunk) => {
377
- if (output.length < MAX_BASH_OUTPUT) output += chunk.toString();
377
+ const room = MAX_BASH_OUTPUT - output.length;
378
+ if (room > 0) output += chunk.toString().slice(0, room);
378
379
  };
379
380
  child.stdout?.on('data', append);
380
381
  child.stderr?.on('data', append);
@@ -399,7 +400,14 @@ export function gitStatusTool(root) {
399
400
  export function gitDiffTool(root, { path: p, staged, ref } = {}) {
400
401
  const args = ['diff', '--no-color'];
401
402
  if (staged) args.push('--staged');
402
- if (ref) args.push(String(ref));
403
+ if (ref) {
404
+ // Un "ref" que empieza con '-' no es un ref: git lo tomaria como opcion
405
+ // (`--output=`, `--ext-diff`) y escribiria/ejecutaria cosas, siendo
406
+ // `git_diff` una tool de solo lectura con permiso `allow`.
407
+ const value = String(ref);
408
+ if (value.startsWith('-')) return 'Error: "ref" inválido (no puede empezar con "-").';
409
+ args.push(value);
410
+ }
403
411
  if (p) args.push('--', String(p));
404
412
  return runGit(root, args);
405
413
  }
@@ -436,19 +444,6 @@ export function speakTool(_root, { texto, voice, rate, pitch } = {}) {
436
444
  return `Audio pedido${opciones ? ` (${opciones})` : ''}: "${recortado}". El cliente lo reproduce o lo envía como nota de voz.`;
437
445
  }
438
446
 
439
- function normalizeHttpUrl(value) {
440
- const raw = String(value ?? '').trim();
441
- if (!raw) return { error: 'falta "url".' };
442
- let parsed;
443
- try {
444
- parsed = new URL(raw);
445
- } catch {
446
- return { error: `URL inválida: "${raw}".` };
447
- }
448
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return { error: 'Solo se admite http(s).' };
449
- return { url: parsed.toString() };
450
- }
451
-
452
447
  function clampInt(value, fallback, min, max) {
453
448
  const n = Math.round(Number(value));
454
449
  if (!Number.isFinite(n)) return fallback;
@@ -460,7 +455,7 @@ function clampInt(value, fallback, min, max) {
460
455
  * del cliente (igual que `speak`): aca solo se valida la URL.
461
456
  */
462
457
  export function previewUrlTool(_root, { url } = {}) {
463
- const parsed = normalizeHttpUrl(url);
458
+ const parsed = checkPreviewUrl(url);
464
459
  if (parsed.error) return `Error: ${parsed.error}`;
465
460
  return `Vista previa pedida: ${parsed.url}. La consola la abre en el panel de vista previa.`;
466
461
  }
@@ -558,7 +553,7 @@ export function showPlanTool(_root, { title, steps } = {}) {
558
553
  * muestre y Telegram la mande como foto.
559
554
  */
560
555
  export async function screenshotTool(_root, { url, fullPage, width, height } = {}) {
561
- const parsed = checkHttpUrl(url);
556
+ const parsed = await checkHttpUrlResolved(url);
562
557
  if (parsed.error) return `Error: ${parsed.error}`;
563
558
  const size = {
564
559
  width: clampInt(width, 1280, 320, 3840),
@@ -595,7 +590,7 @@ export async function screenshotTool(_root, { url, fullPage, width, height } = {
595
590
 
596
591
  /** Errores y mensajes de consola de una pagina (ejecutando su JS). */
597
592
  export async function consoleTool(_root, { url, waitMs } = {}) {
598
- const parsed = checkHttpUrl(url);
593
+ const parsed = await checkHttpUrlResolved(url);
599
594
  if (parsed.error) return `Error: ${parsed.error}`;
600
595
 
601
596
  let result;
@@ -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
 
@@ -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' } });