@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.
- package/CHANGELOG.md +9 -0
- package/harness/packages/core/src/agent/runAgent.js +9 -5
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +15 -0
- package/harness/packages/core/src/net/hosts.js +70 -0
- package/harness/packages/core/src/redact.js +2 -0
- package/harness/packages/core/src/tools/executors.js +19 -24
- package/harness/packages/core/src/tools/index.js +1 -1
- package/harness/packages/core/src/tools/paths.js +35 -5
- package/harness/packages/core/src/tools/permissions.js +41 -2
- package/harness/packages/core/src/tools/registry.js +8 -3
- package/harness/packages/core/src/tunnel.js +15 -5
- package/harness/packages/core/test/tools.test.js +44 -1
- package/harness/packages/server/public/app.js +343 -6754
- package/harness/packages/server/public/chat.js +1893 -0
- package/harness/packages/server/public/chrome.js +812 -0
- package/harness/packages/server/public/commands.js +403 -0
- package/harness/packages/server/public/core.js +209 -0
- package/harness/packages/server/public/explorer.js +625 -0
- package/harness/packages/server/public/index.html +19 -19
- package/harness/packages/server/public/sessions.js +274 -0
- package/harness/packages/server/public/settings.js +1076 -0
- package/harness/packages/server/public/styles.css +25 -2
- package/harness/packages/server/public/system.js +495 -0
- package/harness/packages/server/public/voice.js +1058 -0
- package/harness/packages/server/src/auth.js +12 -10
- package/harness/packages/server/src/lib/agentRun.js +6 -1
- package/harness/packages/server/src/lib/channels/telegram.js +1 -0
- package/harness/packages/server/src/lib/presence.js +12 -1
- package/harness/packages/server/src/lib/scheduler.js +11 -2
- package/harness/packages/server/src/lib/tasks.js +20 -3
- package/harness/packages/server/src/lib/workspace.js +90 -2
- package/harness/packages/server/src/routes/auth.js +8 -4
- package/harness/packages/server/src/routes/chat.js +15 -4
- package/harness/packages/server/src/routes/preview.js +10 -2
- package/harness/packages/server/src/routes/providers.js +12 -9
- package/harness/packages/server/src/server.js +19 -0
- package/harness/packages/server/test/chat.test.js +586 -0
- package/harness/packages/server/test/helpers.js +107 -0
- package/harness/packages/server/test/ops.test.js +511 -0
- package/harness/packages/server/test/runs.test.js +816 -0
- package/harness/packages/server/test/security.test.js +112 -9
- package/harness/packages/server/test/sessions.test.js +437 -0
- package/harness/packages/server/test/settings.test.js +465 -0
- package/harness/tamnora.example.json +1 -1
- package/package.json +1 -1
- 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) {
|
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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' } });
|