@danieltmn/tamnora 0.6.1 → 0.7.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 +29 -0
- package/SECURITY.md +6 -0
- package/harness/packages/cli/src/commands/repl.js +2 -0
- package/harness/packages/cli/src/commands/run.js +2 -0
- package/harness/packages/cli/src/commands/tui.js +2 -0
- package/harness/packages/cli/src/lib/runtime.js +4 -0
- package/harness/packages/cli/src/tui/app.js +4 -0
- package/harness/packages/core/src/agent/runAgent.js +131 -47
- package/harness/packages/core/src/config/index.js +3 -1
- package/harness/packages/core/src/config/secrets.js +36 -0
- package/harness/packages/core/src/eval/runTask.js +2 -11
- package/harness/packages/core/src/index.js +13 -1
- package/harness/packages/core/src/mcp/common.js +15 -11
- package/harness/packages/core/src/mcp/index.js +1 -1
- package/harness/packages/core/src/mcp/registry.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +41 -2
- package/harness/packages/core/src/redact.js +104 -0
- package/harness/packages/core/test/agent.test.js +93 -0
- package/harness/packages/core/test/mcp.test.js +67 -0
- package/harness/packages/core/test/redact.test.js +45 -0
- package/harness/packages/server/public/app.js +222 -18
- package/harness/packages/server/public/index.html +93 -26
- package/harness/packages/server/public/styles.css +61 -1
- package/harness/packages/server/src/lib/agentTools.js +9 -3
- package/harness/packages/server/src/lib/channels/index.js +11 -4
- package/harness/packages/server/src/lib/channels/telegram.js +28 -10
- package/harness/packages/server/src/routes/channels.js +1 -0
- package/harness/packages/server/src/routes/chat.js +8 -1
- package/harness/packages/server/src/routes/mcp.js +29 -5
- package/harness/packages/server/src/routes/providers.js +16 -15
- package/harness/packages/server/src/server.js +13 -0
- package/harness/packages/server/test/server.test.js +78 -2
- package/harness/tamnora.example.json +17 -1
- package/package.json +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Redaccion de secretos para no filtrarlos a la conversacion (y de ahi al
|
|
2
|
+
// historial, a la UI o a un canal de mensajeria). Es defensa en profundidad:
|
|
3
|
+
// la barrera principal es no inyectar secretos en el entorno de los MCP.
|
|
4
|
+
|
|
5
|
+
import { getEnv } from './env.js';
|
|
6
|
+
|
|
7
|
+
/** Un nombre de variable "sensible" (token, password, key, credencial...). */
|
|
8
|
+
const SECRET_NAME = /(TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL|AUTH|COOKIE|SESSION)/i;
|
|
9
|
+
|
|
10
|
+
/** Patrones de tokens conocidos, utiles aunque no vengan de una variable. */
|
|
11
|
+
const SECRET_PATTERNS = [
|
|
12
|
+
/npm_[A-Za-z0-9]{16,}/g,
|
|
13
|
+
/sk-[A-Za-z0-9_-]{16,}/g,
|
|
14
|
+
/ghp_[A-Za-z0-9]{20,}/g,
|
|
15
|
+
/github_pat_[A-Za-z0-9_]{20,}/g,
|
|
16
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
17
|
+
/AIza[0-9A-Za-z_-]{30,}/g,
|
|
18
|
+
/xox[baprs]-[A-Za-z0-9-]{10,}/g,
|
|
19
|
+
/ya29\.[A-Za-z0-9_-]{20,}/g,
|
|
20
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export const REDACTED = '[REDACTADO]';
|
|
24
|
+
|
|
25
|
+
/** ¿El nombre de la variable parece contener un secreto? */
|
|
26
|
+
export function isSecretName(name) {
|
|
27
|
+
return SECRET_NAME.test(String(name ?? ''));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Valores de entorno que hay que redactar: los de variables con nombre
|
|
32
|
+
* sensible y al menos 8 caracteres (evita tapar valores triviales).
|
|
33
|
+
* @param {Record<string,string>} [env]
|
|
34
|
+
* @returns {string[]} ordenados de mayor a menor (los mas largos primero)
|
|
35
|
+
*/
|
|
36
|
+
export function secretValues(env = getEnv()) {
|
|
37
|
+
const values = new Set();
|
|
38
|
+
for (const [name, value] of Object.entries(env ?? {})) {
|
|
39
|
+
if (!isSecretName(name)) continue;
|
|
40
|
+
const text = String(value ?? '');
|
|
41
|
+
if (text.length >= 8) values.add(text);
|
|
42
|
+
}
|
|
43
|
+
return [...values].sort((a, b) => b.length - a.length);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Redacta secretos conocidos (valores de env sensibles + patrones de tokens).
|
|
48
|
+
* @param {string} text
|
|
49
|
+
* @param {{env?:Record<string,string>, extra?:string[]}} [opts]
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function redactSecrets(text, { env, extra = [] } = {}) {
|
|
53
|
+
let output = String(text ?? '');
|
|
54
|
+
if (!output) return output;
|
|
55
|
+
|
|
56
|
+
const values = [...secretValues(env), ...extra].filter((v) => v && v.length >= 8);
|
|
57
|
+
for (const value of values) {
|
|
58
|
+
if (output.includes(value)) output = output.split(value).join(REDACTED);
|
|
59
|
+
}
|
|
60
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
61
|
+
output = output.replace(pattern, REDACTED);
|
|
62
|
+
}
|
|
63
|
+
return output;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Redacta recursivamente strings y valores bajo claves sensibles en un objeto.
|
|
68
|
+
* @param {unknown} value
|
|
69
|
+
* @param {{env?:Record<string,string>, extra?:string[]}} [opts]
|
|
70
|
+
*/
|
|
71
|
+
export function redactDeep(value, opts = {}) {
|
|
72
|
+
if (typeof value === 'string') return redactSecrets(value, opts);
|
|
73
|
+
if (Array.isArray(value)) return value.map((item) => redactDeep(item, opts));
|
|
74
|
+
if (value && typeof value === 'object') {
|
|
75
|
+
const result = {};
|
|
76
|
+
for (const [key, item] of Object.entries(value)) {
|
|
77
|
+
result[key] = isSecretName(key) ? REDACTED : redactDeep(item, opts);
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Enmascara un valor para mostrarlo al cliente (nunca el secreto completo).
|
|
86
|
+
* `env:VAR` se conserva para que se vea la indireccion.
|
|
87
|
+
*/
|
|
88
|
+
export function maskSecret(value) {
|
|
89
|
+
const text = String(value ?? '');
|
|
90
|
+
if (!text) return '';
|
|
91
|
+
if (text.startsWith('env:')) return text;
|
|
92
|
+
if (text.length <= 4) return '••••';
|
|
93
|
+
return `••••${text.slice(-4)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Enmascara un mapa de env para devolverlo por la API. */
|
|
97
|
+
export function maskEnv(env) {
|
|
98
|
+
if (!env || typeof env !== 'object') return env ?? null;
|
|
99
|
+
const result = {};
|
|
100
|
+
for (const [key, value] of Object.entries(env)) {
|
|
101
|
+
result[key] = isSecretName(key) ? maskSecret(value) : String(value ?? '');
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
@@ -235,3 +235,96 @@ test('agent aggregates usage and stamps tokens on assistant messages', async ()
|
|
|
235
235
|
assert.equal(assistants[1].tokens_in, 20);
|
|
236
236
|
assert.equal(assistants[1].tokens_out, 5);
|
|
237
237
|
});
|
|
238
|
+
|
|
239
|
+
test('agent sigue solo hasta end_turn sin tope de rondas', async () => {
|
|
240
|
+
let round = 0;
|
|
241
|
+
const provider = {
|
|
242
|
+
async streamChat(_config, { tools }) {
|
|
243
|
+
if (!tools || tools.length === 0) return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
244
|
+
round += 1;
|
|
245
|
+
if (round <= 10) {
|
|
246
|
+
return {
|
|
247
|
+
content: '',
|
|
248
|
+
reasoning: '',
|
|
249
|
+
toolCalls: [{ id: `c${round}`, function: { name: 'step', arguments: JSON.stringify({ n: round }) } }],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { content: 'terminado', reasoning: '', toolCalls: [] };
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const result = await runAgent({
|
|
257
|
+
provider,
|
|
258
|
+
config: {},
|
|
259
|
+
model: 'm',
|
|
260
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
261
|
+
tools: [{ type: 'function', function: { name: 'step', parameters: {} } }],
|
|
262
|
+
execute: async () => 'ok',
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
assert.equal(result.stopReason, 'end_turn');
|
|
266
|
+
assert.equal(result.rounds, 11);
|
|
267
|
+
assert.equal(result.content, 'terminado');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('agent corta por no progreso y cierra con resumen sin tools', async () => {
|
|
271
|
+
const provider = {
|
|
272
|
+
async streamChat(_config, { tools }) {
|
|
273
|
+
if (!tools || tools.length === 0) return { content: 'resumen final', reasoning: '', toolCalls: [] };
|
|
274
|
+
return {
|
|
275
|
+
content: '',
|
|
276
|
+
reasoning: '',
|
|
277
|
+
toolCalls: [{ id: 'x', function: { name: 'noop', arguments: '{}' } }],
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
let executions = 0;
|
|
282
|
+
const result = await runAgent({
|
|
283
|
+
provider,
|
|
284
|
+
config: {},
|
|
285
|
+
model: 'm',
|
|
286
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
287
|
+
tools: [{ type: 'function', function: { name: 'noop', parameters: {} } }],
|
|
288
|
+
execute: async () => {
|
|
289
|
+
executions += 1;
|
|
290
|
+
return 'ok';
|
|
291
|
+
},
|
|
292
|
+
repeatLimit: 2,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
assert.equal(result.stopReason, 'no_progress');
|
|
296
|
+
assert.equal(result.wrappedUp, true);
|
|
297
|
+
assert.equal(result.content, 'resumen final');
|
|
298
|
+
assert.equal(executions, 2);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('agent respeta el presupuesto de tokens y cierra con resumen', async () => {
|
|
302
|
+
let round = 0;
|
|
303
|
+
const provider = {
|
|
304
|
+
async streamChat(_config, { tools }) {
|
|
305
|
+
if (!tools || tools.length === 0) {
|
|
306
|
+
return { content: 'cierre', reasoning: '', toolCalls: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } };
|
|
307
|
+
}
|
|
308
|
+
round += 1;
|
|
309
|
+
return {
|
|
310
|
+
content: '',
|
|
311
|
+
reasoning: '',
|
|
312
|
+
toolCalls: [{ id: `c${round}`, function: { name: 'step', arguments: JSON.stringify({ n: round }) } }],
|
|
313
|
+
usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 },
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const result = await runAgent({
|
|
319
|
+
provider,
|
|
320
|
+
config: {},
|
|
321
|
+
model: 'm',
|
|
322
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
323
|
+
tools: [{ type: 'function', function: { name: 'step', parameters: {} } }],
|
|
324
|
+
execute: async () => 'ok',
|
|
325
|
+
maxTokens: 50,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
assert.equal(result.stopReason, 'budget');
|
|
329
|
+
assert.equal(result.content, 'cierre');
|
|
330
|
+
});
|
|
@@ -6,7 +6,9 @@ import { tmpdir } from 'node:os';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import {
|
|
8
8
|
buildMcpRegistry,
|
|
9
|
+
childEnv,
|
|
9
10
|
connectMcpStdioServer,
|
|
11
|
+
mcpResultToText,
|
|
10
12
|
mcpTransport,
|
|
11
13
|
} from '../src/index.js';
|
|
12
14
|
|
|
@@ -156,3 +158,68 @@ test('buildMcpRegistry connects over HTTP (JSON responses)', async () => {
|
|
|
156
158
|
server.close();
|
|
157
159
|
}
|
|
158
160
|
});
|
|
161
|
+
|
|
162
|
+
const ENV_SERVER = `
|
|
163
|
+
import readline from 'node:readline';
|
|
164
|
+
readline.createInterface({ input: process.stdin }).on('line', (line) => {
|
|
165
|
+
if (!line.trim()) return;
|
|
166
|
+
const m = JSON.parse(line);
|
|
167
|
+
let r = null;
|
|
168
|
+
if (m.method === 'initialize') r = { jsonrpc: '2.0', id: m.id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {} } } };
|
|
169
|
+
else if (m.method === 'tools/list') r = { jsonrpc: '2.0', id: m.id, result: { tools: [{ name: 'env', inputSchema: { type: 'object' } }] } };
|
|
170
|
+
else if (m.method === 'tools/call') r = { jsonrpc: '2.0', id: m.id, result: { content: [{ type: 'text', text: String(process.env.TAMNORA_TEST_VALUE ?? '') }] } };
|
|
171
|
+
if (r) process.stdout.write(JSON.stringify(r) + '\\n');
|
|
172
|
+
});
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
async function makeEnvServer() {
|
|
176
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-mcp-env-'));
|
|
177
|
+
const file = path.join(dir, 'env-server.mjs');
|
|
178
|
+
await writeFile(file, ENV_SERVER);
|
|
179
|
+
return { id: 'env', command: process.execPath, args: [file] };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
test('childEnv no hereda process.env por defecto y resuelve env:VAR', () => {
|
|
183
|
+
process.env.TAMNORA_TEST_VALUE = 'visible123';
|
|
184
|
+
try {
|
|
185
|
+
assert.equal(childEnv({}).TAMNORA_TEST_VALUE, undefined);
|
|
186
|
+
const declared = childEnv({ env: { VAL: 'env:TAMNORA_TEST_VALUE' } });
|
|
187
|
+
assert.equal(declared.VAL, 'visible123');
|
|
188
|
+
assert.equal(childEnv({ inheritEnv: true }).TAMNORA_TEST_VALUE, 'visible123');
|
|
189
|
+
} finally {
|
|
190
|
+
delete process.env.TAMNORA_TEST_VALUE;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('un MCP stdio no ve el entorno del padre salvo inheritEnv o env declarado', async () => {
|
|
195
|
+
process.env.TAMNORA_TEST_VALUE = 'visible123';
|
|
196
|
+
try {
|
|
197
|
+
const hidden = await connectMcpStdioServer(await makeEnvServer());
|
|
198
|
+
assert.equal(await hidden.callTool('env', {}), '');
|
|
199
|
+
hidden.close();
|
|
200
|
+
|
|
201
|
+
const declared = await connectMcpStdioServer({
|
|
202
|
+
...(await makeEnvServer()),
|
|
203
|
+
env: { TAMNORA_TEST_VALUE: 'env:TAMNORA_TEST_VALUE' },
|
|
204
|
+
});
|
|
205
|
+
assert.equal(await declared.callTool('env', {}), 'visible123');
|
|
206
|
+
declared.close();
|
|
207
|
+
|
|
208
|
+
const inherited = await connectMcpStdioServer({ ...(await makeEnvServer()), inheritEnv: true });
|
|
209
|
+
assert.equal(await inherited.callTool('env', {}), 'visible123');
|
|
210
|
+
inherited.close();
|
|
211
|
+
} finally {
|
|
212
|
+
delete process.env.TAMNORA_TEST_VALUE;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('mcpResultToText redacta secretos del entorno', () => {
|
|
217
|
+
process.env.TAMNORA_TEST_SECRET = 'ultrasecreto123';
|
|
218
|
+
try {
|
|
219
|
+
const text = mcpResultToText({ content: [{ type: 'text', text: 'valor=ultrasecreto123 fin' }] });
|
|
220
|
+
assert.match(text, /REDACTADO/);
|
|
221
|
+
assert.doesNotMatch(text, /ultrasecreto123/);
|
|
222
|
+
} finally {
|
|
223
|
+
delete process.env.TAMNORA_TEST_SECRET;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
REDACTED,
|
|
5
|
+
isSecretName,
|
|
6
|
+
maskEnv,
|
|
7
|
+
maskSecret,
|
|
8
|
+
redactDeep,
|
|
9
|
+
redactSecrets,
|
|
10
|
+
secretValues,
|
|
11
|
+
} from '../src/redact.js';
|
|
12
|
+
|
|
13
|
+
test('isSecretName detecta nombres sensibles', () => {
|
|
14
|
+
assert.equal(isSecretName('NPM_TOKEN'), true);
|
|
15
|
+
assert.equal(isSecretName('MYSQL_PASSWORD'), true);
|
|
16
|
+
assert.equal(isSecretName('OPENAI_API_KEY'), true);
|
|
17
|
+
assert.equal(isSecretName('PATH'), false);
|
|
18
|
+
assert.equal(isSecretName('USERNAME'), false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('secretValues toma valores de nombres sensibles (>= 8 chars)', () => {
|
|
22
|
+
const values = secretValues({ NPM_TOKEN: 'npm_abcdefgh', PATH: '/usr/bin', SHORT: 'x' });
|
|
23
|
+
assert.deepEqual(values, ['npm_abcdefgh']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('redactSecrets tapa valores de env y patrones conocidos', () => {
|
|
27
|
+
const env = { NPM_TOKEN: 'npm_secretvalue123456' };
|
|
28
|
+
assert.equal(redactSecrets('token= npm_secretvalue123456', { env }), `token= ${REDACTED}`);
|
|
29
|
+
assert.equal(redactSecrets('ghp_abcdefghijklmnopqrst'), REDACTED);
|
|
30
|
+
assert.equal(redactSecrets('hola mundo'), 'hola mundo');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('redactDeep tapa claves sensibles dentro de objetos', () => {
|
|
34
|
+
const result = redactDeep({ user: 'ana', password: 'hunter2', nested: { apiKey: 'abc123' } });
|
|
35
|
+
assert.deepEqual(result, { user: 'ana', password: REDACTED, nested: { apiKey: REDACTED } });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('maskSecret y maskEnv enmascaran sin exponer el valor', () => {
|
|
39
|
+
assert.equal(maskSecret('supersecret'), '••••cret');
|
|
40
|
+
assert.equal(maskSecret('env:NPM_TOKEN'), 'env:NPM_TOKEN');
|
|
41
|
+
assert.deepEqual(maskEnv({ MYSQL_HOST: 'db.local', MYSQL_PASSWORD: 'supersecret' }), {
|
|
42
|
+
MYSQL_HOST: 'db.local',
|
|
43
|
+
MYSQL_PASSWORD: '••••cret',
|
|
44
|
+
});
|
|
45
|
+
});
|