@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.
- package/CHANGELOG.md +15 -0
- package/README.md +10 -6
- package/SECURITY.md +13 -0
- package/harness/packages/core/src/agent/runAgent.js +9 -5
- package/harness/packages/core/src/browser/index.js +56 -2
- package/harness/packages/core/src/hooks/index.js +9 -5
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +15 -0
- package/harness/packages/core/src/net/hosts.js +181 -0
- package/harness/packages/core/src/plugins/index.js +6 -4
- package/harness/packages/core/src/redact.js +2 -0
- package/harness/packages/core/src/tools/executors.js +54 -53
- 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/hooks.test.js +6 -3
- package/harness/packages/core/test/hosts.test.js +50 -0
- package/harness/packages/core/test/plugins.test.js +5 -3
- package/harness/packages/core/test/tools.test.js +98 -2
- package/harness/packages/server/public/app.js +343 -6729
- 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 +29 -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/procs.js +30 -3
- 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/procs.js +2 -6
- package/harness/packages/server/src/routes/providers.js +12 -9
- package/harness/packages/server/src/server.js +28 -2
- 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/procs-manager.test.js +43 -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 -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.
|
|
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
|
|
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { normalizeHookModule, loadHooks } from '../src/hooks/index.js';
|
|
6
|
+
import { normalizeHookModule, loadHooks, DEFAULT_HOOK_PATTERNS } from '../src/hooks/index.js';
|
|
7
7
|
|
|
8
8
|
test('normalizeHookModule acepta objeto events, funcion y onEvent', () => {
|
|
9
9
|
const byEvents = normalizeHookModule({ default: { name: 'a', events: { 'run.end': () => {} } } }, 'x');
|
|
@@ -14,7 +14,7 @@ test('normalizeHookModule acepta objeto events, funcion y onEvent', () => {
|
|
|
14
14
|
assert.equal(normalizeHookModule({ default: {} }, 'n'), null);
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
test('loadHooks
|
|
17
|
+
test('loadHooks no ejecuta nada por presencia: exige un patron explicito', async () => {
|
|
18
18
|
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-hooks-'));
|
|
19
19
|
await mkdir(path.join(cwd, '.tamnora', 'hooks'), { recursive: true });
|
|
20
20
|
await writeFile(
|
|
@@ -22,7 +22,10 @@ test('loadHooks carga .tamnora/hooks/*.mjs e ignora lo roto', async () => {
|
|
|
22
22
|
"export default { name: 'demo', events: { 'run.end': () => {} } };\n"
|
|
23
23
|
);
|
|
24
24
|
await writeFile(path.join(cwd, '.tamnora', 'hooks', 'roto.mjs'), 'this is not valid javascript {{{\n');
|
|
25
|
-
|
|
25
|
+
|
|
26
|
+
assert.deepEqual(await loadHooks(cwd, []), [], 'sin patrones no se importa nada');
|
|
27
|
+
|
|
28
|
+
const hooks = await loadHooks(cwd, DEFAULT_HOOK_PATTERNS);
|
|
26
29
|
assert.equal(hooks.length, 1);
|
|
27
30
|
assert.equal(hooks[0].id, 'demo');
|
|
28
31
|
assert.deepEqual(Object.keys(hooks[0].events), ['run.end']);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { checkHttpUrl, isBlockedAddress, isBlockedHost } from '../src/net/hosts.js';
|
|
4
|
+
|
|
5
|
+
test('isBlockedHost bloquea loopback, redes privadas y sufijos internos', () => {
|
|
6
|
+
for (const host of [
|
|
7
|
+
'localhost',
|
|
8
|
+
'foo.localhost',
|
|
9
|
+
'app.local',
|
|
10
|
+
'intranet',
|
|
11
|
+
'metadata.internal',
|
|
12
|
+
'127.0.0.1',
|
|
13
|
+
'10.1.2.3',
|
|
14
|
+
'172.16.0.1',
|
|
15
|
+
'192.168.1.10',
|
|
16
|
+
'169.254.169.254',
|
|
17
|
+
'100.64.0.1',
|
|
18
|
+
'0.0.0.0',
|
|
19
|
+
'224.0.0.1',
|
|
20
|
+
]) {
|
|
21
|
+
assert.equal(isBlockedHost(host), true, `deberia bloquear ${host}`);
|
|
22
|
+
}
|
|
23
|
+
for (const host of ['example.com', 'api.openai.com', '8.8.8.8', '2606:4700:4700::1111']) {
|
|
24
|
+
assert.equal(isBlockedHost(host), false, `no deberia bloquear ${host}`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('isBlockedHost cubre IPv6-mapped y rangos IPv6 internos', () => {
|
|
29
|
+
for (const host of ['[::1]', '::1', '[::ffff:127.0.0.1]', '[::ffff:7f00:1]', '[fd00::1]', '[fe80::1]', '[::]']) {
|
|
30
|
+
assert.equal(isBlockedHost(host), true, `deberia bloquear ${host}`);
|
|
31
|
+
}
|
|
32
|
+
assert.equal(isBlockedHost('[2001:db8::1]'), false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('isBlockedAddress valida el rango de una IP', () => {
|
|
36
|
+
assert.equal(isBlockedAddress('127.0.0.1'), true);
|
|
37
|
+
assert.equal(isBlockedAddress('::ffff:10.0.0.1'), true);
|
|
38
|
+
assert.equal(isBlockedAddress('100.64.1.1'), true);
|
|
39
|
+
assert.equal(isBlockedAddress('8.8.4.4'), false);
|
|
40
|
+
assert.equal(isBlockedAddress('nope'), true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('checkHttpUrl valida protocolo y host', () => {
|
|
44
|
+
assert.match(checkHttpUrl('').error, /falta "url"/);
|
|
45
|
+
assert.match(checkHttpUrl('nope').error, /URL inválida/);
|
|
46
|
+
assert.match(checkHttpUrl('ftp://example.com').error, /http\(s\)/);
|
|
47
|
+
assert.match(checkHttpUrl('http://127.0.0.1:4517/').error, /host bloqueado/);
|
|
48
|
+
assert.match(checkHttpUrl('http://[::ffff:127.0.0.1]/').error, /host bloqueado/);
|
|
49
|
+
assert.equal(checkHttpUrl('https://example.com/x').url, 'https://example.com/x');
|
|
50
|
+
});
|
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { normalizePlugin, loadPlugins } from '../src/plugins/index.js';
|
|
6
|
+
import { normalizePlugin, loadPlugins, DEFAULT_PLUGIN_PATTERNS } from '../src/plugins/index.js';
|
|
7
7
|
|
|
8
8
|
test('normalizePlugin filtra tools invalidas', () => {
|
|
9
9
|
const plugin = normalizePlugin({ default: { name: 'demo', tools: [{ name: 'x', handler: () => {} }, { name: 'bad' }] } }, 'z');
|
|
@@ -12,14 +12,16 @@ test('normalizePlugin filtra tools invalidas', () => {
|
|
|
12
12
|
assert.equal(normalizePlugin({ default: {} }, 'n'), null);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
test('loadPlugins
|
|
15
|
+
test('loadPlugins exige un patron explicito (no ejecuta por presencia)', async () => {
|
|
16
16
|
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-plugins-'));
|
|
17
17
|
await mkdir(path.join(cwd, '.tamnora', 'plugins'), { recursive: true });
|
|
18
18
|
await writeFile(
|
|
19
19
|
path.join(cwd, '.tamnora', 'plugins', 'demo.mjs'),
|
|
20
20
|
"export default { name: 'demo', tools: [{ name: 'saludar', handler: () => 'hi' }] };\n"
|
|
21
21
|
);
|
|
22
|
-
|
|
22
|
+
assert.deepEqual(await loadPlugins(cwd, []), []);
|
|
23
|
+
|
|
24
|
+
const plugins = await loadPlugins(cwd, DEFAULT_PLUGIN_PATTERNS);
|
|
23
25
|
assert.equal(plugins.length, 1);
|
|
24
26
|
assert.equal(plugins[0].tools[0].name, 'saludar');
|
|
25
27
|
});
|
|
@@ -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
|
|
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');
|