feat: rol auditor (solo lectura total) en el MCP + agente soporte
- roleCheck.js: isAuditor()/canEditContent(); canEditCode() tambien excluye auditor. Superficie editor/developer intacta (verificado contra el codigo pre-cambio en el test). - records/tables/media/languages: tools de escritura gateadas con canEditContent(), orden de registro original preservado. - agents/soporte: agente de evaluacion de incidencias en produccion, allowed_tools allowlist namespaceada (23 acai_code + 18 playwright, cero fetch), system.md con formato de informe obligatorio y trato del texto del cliente como dato no confiable. - test/auditor-role.test.js: 22 tests de superficie exacta por rol.
This commit is contained in:
217
mcp-server/test/auditor-role.test.js
Normal file
217
mcp-server/test/auditor-role.test.js
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Smoke test del rol "auditor" (solo lectura total) y no-regresion del rol
|
||||
* "editor".
|
||||
*
|
||||
* El rol efectivo se lee de ACAI_ROLE_OVERRIDE en CADA llamada a
|
||||
* getEffectiveRole(), pero los `tools/<grupo>/index.js` lo consultan en el
|
||||
* momento del REGISTRO. Por eso basta con fijar la env var antes de invocar
|
||||
* las funciones de registro (no hace falta recargar modulos).
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { registerRecordTools } from "../tools/records/index.js";
|
||||
import { registerTableTools } from "../tools/tables/index.js";
|
||||
import { registerMediaTools } from "../tools/media/index.js";
|
||||
import { registerLanguageTools } from "../tools/languages/index.js";
|
||||
import { registerFileTools } from "../tools/files/index.js";
|
||||
import { registerModuleTools } from "../tools/modules/index.js";
|
||||
import { registerLayoutTools } from "../tools/layout/index.js";
|
||||
import { registerHookTools } from "../tools/hooks/index.js";
|
||||
import { registerLibrariesTools } from "../tools/libraries/index.js";
|
||||
import { registerProjectTools } from "../tools/project/index.js";
|
||||
|
||||
const GROUPS = {
|
||||
records: registerRecordTools,
|
||||
tables: registerTableTools,
|
||||
media: registerMediaTools,
|
||||
languages: registerLanguageTools,
|
||||
files: registerFileTools,
|
||||
modules: registerModuleTools,
|
||||
layout: registerLayoutTools,
|
||||
hooks: registerHookTools,
|
||||
libraries: registerLibrariesTools,
|
||||
project: registerProjectTools,
|
||||
};
|
||||
|
||||
/** Server MCP falso: solo acumula los nombres de tool registrados. */
|
||||
function createFakeServer() {
|
||||
const names = [];
|
||||
return {
|
||||
names,
|
||||
tool(name) { names.push(name); },
|
||||
};
|
||||
}
|
||||
|
||||
function registerWithRole(role, groupName) {
|
||||
const previous = process.env.ACAI_ROLE_OVERRIDE;
|
||||
process.env.ACAI_ROLE_OVERRIDE = role;
|
||||
try {
|
||||
const server = createFakeServer();
|
||||
GROUPS[groupName](server);
|
||||
return server.names;
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.ACAI_ROLE_OVERRIDE;
|
||||
else process.env.ACAI_ROLE_OVERRIDE = previous;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rol "auditor": SOLO lectura. Listas exactas (nombre y orden de registro).
|
||||
// ---------------------------------------------------------------------------
|
||||
const AUDITOR_EXPECTED = {
|
||||
records: [
|
||||
"list_table_records",
|
||||
"get_record",
|
||||
"list_page_modules",
|
||||
"get_module_config_vars",
|
||||
],
|
||||
tables: [
|
||||
"list_tables",
|
||||
"get_table_schema",
|
||||
],
|
||||
media: [
|
||||
"analyze_image",
|
||||
],
|
||||
languages: [
|
||||
"list_web_languages",
|
||||
"get_record_translations",
|
||||
],
|
||||
files: [
|
||||
"acai-view",
|
||||
"acai-glob",
|
||||
"acai-grep",
|
||||
],
|
||||
modules: [
|
||||
"check_module",
|
||||
"check_module_usage",
|
||||
],
|
||||
layout: [
|
||||
"get_layout_field",
|
||||
],
|
||||
hooks: [
|
||||
"get_hook_middleware",
|
||||
"get_hook_entryparams",
|
||||
],
|
||||
libraries: [
|
||||
"list_global_libraries",
|
||||
],
|
||||
project: [
|
||||
"get_web_url",
|
||||
],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rol "editor": exactamente lo mismo que registraba ANTES de introducir el rol
|
||||
// auditor (derivado del codigo previo al cambio). Si este bloque se rompe, la
|
||||
// regresion es real.
|
||||
// ---------------------------------------------------------------------------
|
||||
const EDITOR_EXPECTED = {
|
||||
records: [
|
||||
"list_table_records",
|
||||
"get_record",
|
||||
"create_or_update_record",
|
||||
"delete_table_records",
|
||||
"add_module_to_record",
|
||||
"remove_module_from_record",
|
||||
"list_page_modules",
|
||||
"reorder_module",
|
||||
"toggle_module_visibility",
|
||||
"set_module_config_vars",
|
||||
"get_module_config_vars",
|
||||
],
|
||||
tables: [
|
||||
"list_tables",
|
||||
"get_table_schema",
|
||||
"create_table",
|
||||
"update_table_metadata",
|
||||
"delete_table",
|
||||
"reorder_tables",
|
||||
"create_field",
|
||||
"update_field",
|
||||
"delete_field",
|
||||
"reorder_fields",
|
||||
"regenerate_enlaces",
|
||||
],
|
||||
media: [
|
||||
"upload_record_image",
|
||||
"list_record_uploads",
|
||||
"replace_record_image",
|
||||
"delete_record_upload",
|
||||
"reorder_record_uploads",
|
||||
"upload_image_to_assets",
|
||||
"generate_image",
|
||||
"analyze_image",
|
||||
"set_upload_info",
|
||||
],
|
||||
languages: [
|
||||
"list_web_languages",
|
||||
"get_record_translations",
|
||||
"set_record_translations",
|
||||
],
|
||||
files: [
|
||||
"acai-view",
|
||||
"acai-glob",
|
||||
"acai-grep",
|
||||
],
|
||||
modules: [
|
||||
"check_module",
|
||||
"check_module_usage",
|
||||
],
|
||||
layout: [
|
||||
"get_layout_field",
|
||||
],
|
||||
hooks: [
|
||||
"get_hook_middleware",
|
||||
"get_hook_entryparams",
|
||||
],
|
||||
libraries: [
|
||||
"list_global_libraries",
|
||||
],
|
||||
project: [
|
||||
"get_web_url",
|
||||
],
|
||||
};
|
||||
|
||||
for (const [group, expected] of Object.entries(AUDITOR_EXPECTED)) {
|
||||
test(`auditor: ${group} registra solo tools de lectura`, () => {
|
||||
assert.deepEqual(registerWithRole("auditor", group), expected);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [group, expected] of Object.entries(EDITOR_EXPECTED)) {
|
||||
test(`editor: ${group} sigue registrando lo mismo que antes`, () => {
|
||||
assert.deepEqual(registerWithRole("editor", group), expected);
|
||||
});
|
||||
}
|
||||
|
||||
test("auditor: ninguna tool de escritura conocida queda expuesta", () => {
|
||||
const registered = new Set(
|
||||
Object.keys(GROUPS).flatMap((group) => registerWithRole("auditor", group))
|
||||
);
|
||||
const writeTools = [
|
||||
"create_or_update_record", "delete_table_records", "add_module_to_record",
|
||||
"remove_module_from_record", "reorder_module", "toggle_module_visibility",
|
||||
"set_module_config_vars", "create_table", "update_table_metadata",
|
||||
"delete_table", "reorder_tables", "create_field", "update_field",
|
||||
"delete_field", "reorder_fields", "regenerate_enlaces",
|
||||
"upload_record_image", "replace_record_image", "delete_record_upload",
|
||||
"reorder_record_uploads", "upload_image_to_assets", "generate_image",
|
||||
"set_upload_info", "set_record_translations", "acai-write",
|
||||
"acai-line-replace", "acai-delete", "compile_module", "delete_module",
|
||||
"update_module_metadata", "set_layout_field", "set_hook_middleware",
|
||||
"set_hook_entryparams", "add_global_library", "remove_global_library",
|
||||
"set_global_libraries", "save_project_styles",
|
||||
];
|
||||
for (const name of writeTools) {
|
||||
assert.equal(registered.has(name), false, `tool de escritura expuesta al auditor: ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("developer: conserva las tools de escritura de codigo", () => {
|
||||
const files = registerWithRole("developer", "files");
|
||||
assert.deepEqual(files, [
|
||||
"acai-view", "acai-glob", "acai-grep",
|
||||
"acai-write", "acai-line-replace", "acai-delete",
|
||||
]);
|
||||
});
|
||||
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* Helper central para determinar el rol efectivo del MCP y bloquear tools
|
||||
* peligrosas cuando el user es "editor".
|
||||
* peligrosas cuando el user es "editor" o "auditor".
|
||||
*
|
||||
* El rol se recibe principalmente via env var ACAI_ROLE_OVERRIDE inyectada
|
||||
* por el backend Python (agentic.py y cronjobs.py). Hay autoderivacion
|
||||
* defensiva en caso de que alguien lance el MCP sin el override:
|
||||
* - Si ACAI_MODE(_OVERRIDE) = "production" → rol editor por defecto.
|
||||
* - Si no → rol developer.
|
||||
*
|
||||
* Roles y permisos:
|
||||
* - "developer": todo (codigo + contenido).
|
||||
* - "editor": contenido si, codigo no.
|
||||
* - "auditor": SOLO LECTURA TOTAL — ni codigo, ni contenido, ni estructura
|
||||
* de BD, ni media. Se usa para evaluar incidencias en produccion sin
|
||||
* tocar nada.
|
||||
*/
|
||||
export function getEffectiveRole() {
|
||||
if (process.env.ACAI_ROLE_OVERRIDE) return process.env.ACAI_ROLE_OVERRIDE;
|
||||
@@ -15,10 +22,26 @@ export function getEffectiveRole() {
|
||||
return "developer";
|
||||
}
|
||||
|
||||
/**
|
||||
* True si el rol efectivo es "auditor" (solo lectura total).
|
||||
*/
|
||||
export function isAuditor() {
|
||||
return getEffectiveRole() === "auditor";
|
||||
}
|
||||
|
||||
/**
|
||||
* True si el rol efectivo puede editar archivos de codigo.
|
||||
* Los roles permitidos son todo lo que NO sea "editor".
|
||||
* Los roles permitidos son todo lo que NO sea "editor" ni "auditor".
|
||||
*/
|
||||
export function canEditCode() {
|
||||
return getEffectiveRole() !== "editor";
|
||||
const role = getEffectiveRole();
|
||||
return role !== "editor" && role !== "auditor";
|
||||
}
|
||||
|
||||
/**
|
||||
* True si el rol efectivo puede editar contenido (registros, tablas, media,
|
||||
* traducciones). "editor" y "developer" pueden; "auditor" no.
|
||||
*/
|
||||
export function canEditContent() {
|
||||
return !isAuditor();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { registerListWebLanguagesTool } from './listWebLanguages.js';
|
||||
import { registerGetRecordTranslationsTool } from './getRecordTranslations.js';
|
||||
import { registerSetRecordTranslationsTool } from './setRecordTranslations.js';
|
||||
import { canEditContent } from '../helpers/roleCheck.js';
|
||||
|
||||
export function registerLanguageTools(server) {
|
||||
// Lectura de idiomas/traducciones: siempre (incluido el rol auditor).
|
||||
registerListWebLanguagesTool(server);
|
||||
registerGetRecordTranslationsTool(server);
|
||||
registerSetRecordTranslationsTool(server);
|
||||
// Escritura de traducciones: developer y editor si, auditor no.
|
||||
if (canEditContent()) {
|
||||
registerSetRecordTranslationsTool(server);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,29 @@ import { registerUploadImageToAssetsTool } from './uploadImageToAssets.js';
|
||||
import { registerGenerateImageTool } from './generateImage.js';
|
||||
import { registerAnalyzeImageTool } from './analyze_image.js';
|
||||
import { registerSetUploadInfoTool } from './setUploadInfo.js';
|
||||
import { canEditContent } from '../helpers/roleCheck.js';
|
||||
|
||||
/**
|
||||
* Tools de media.
|
||||
*
|
||||
* Solo `analyze_image` es de lectura, asi que es la unica que ve el rol
|
||||
* "auditor". El resto (subidas, generacion y metadatos de uploads) va tras
|
||||
* canEditContent(). El orden de registro se mantiene igual que antes del gate
|
||||
* para no alterar el listado de tools de los roles existentes.
|
||||
*/
|
||||
export function registerMediaTools(server) {
|
||||
registerUploadRecordImageTool(server);
|
||||
registerUploadImageToAssetsTool(server);
|
||||
registerGenerateImageTool(server);
|
||||
const canWriteContent = canEditContent();
|
||||
|
||||
if (canWriteContent) {
|
||||
registerUploadRecordImageTool(server);
|
||||
registerUploadImageToAssetsTool(server);
|
||||
registerGenerateImageTool(server);
|
||||
}
|
||||
registerAnalyzeImageTool(server);
|
||||
// Metadatos info1..info5 de uploads: son datos de contenido, no codigo,
|
||||
// asi que va sin gate de canEditCode() como el resto de tools de media.
|
||||
registerSetUploadInfoTool(server);
|
||||
if (canWriteContent) {
|
||||
// Metadatos info1..info5 de uploads: son datos de contenido, no codigo,
|
||||
// asi que sigue sin gate de canEditCode() — pero si pasa por
|
||||
// canEditContent(), porque el auditor no escribe nada.
|
||||
registerSetUploadInfoTool(server);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,18 +9,35 @@ import { registerReorderModuleTool } from './reorderModule.js';
|
||||
import { registerToggleModuleVisibilityTool } from './toggleModuleVisibility.js';
|
||||
import { registerSetModuleConfigVarsTool } from './setModuleConfigVars.js';
|
||||
import { registerGetModuleConfigVarsTool } from './getModuleConfigVars.js';
|
||||
import { canEditContent } from '../helpers/roleCheck.js';
|
||||
|
||||
/**
|
||||
* Tools de registros del CMS.
|
||||
*
|
||||
* Las de lectura se registran siempre (tambien para el rol "auditor", que es
|
||||
* solo lectura total). Las de escritura van tras canEditContent(): developer
|
||||
* y editor si, auditor no.
|
||||
*
|
||||
* El orden de registro es el mismo que antes del gate para no alterar el
|
||||
* listado de tools que ven los roles existentes.
|
||||
*/
|
||||
export function registerRecordTools(server) {
|
||||
const canWriteContent = canEditContent();
|
||||
|
||||
registerListTableRecordsTool(server);
|
||||
registerGetRecordTool(server);
|
||||
registerCreateOrUpdateRecordTool(server);
|
||||
registerDeleteTableRecordsTool(server);
|
||||
registerAddModuleToRecordTool(server);
|
||||
registerRemoveModuleFromRecordTool(server);
|
||||
if (canWriteContent) {
|
||||
registerCreateOrUpdateRecordTool(server);
|
||||
registerDeleteTableRecordsTool(server);
|
||||
registerAddModuleToRecordTool(server);
|
||||
registerRemoveModuleFromRecordTool(server);
|
||||
}
|
||||
registerListPageModulesTool(server);
|
||||
registerReorderModuleTool(server);
|
||||
registerToggleModuleVisibilityTool(server);
|
||||
registerSetModuleConfigVarsTool(server);
|
||||
if (canWriteContent) {
|
||||
registerReorderModuleTool(server);
|
||||
registerToggleModuleVisibilityTool(server);
|
||||
registerSetModuleConfigVarsTool(server);
|
||||
}
|
||||
registerGetModuleConfigVarsTool(server);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,17 +9,22 @@ import { registerUpdateFieldTool } from './updateField.js';
|
||||
import { registerDeleteFieldTool } from './deleteField.js';
|
||||
import { registerReorderFieldsTool } from './reorderFields.js';
|
||||
import { registerRegenerateEnlacesTool } from './regenerateEnlaces.js';
|
||||
import { canEditContent } from '../helpers/roleCheck.js';
|
||||
|
||||
export function registerTableTools(server) {
|
||||
// Lectura de estructura: siempre disponible (incluido el rol auditor).
|
||||
registerListTablesTool(server);
|
||||
registerGetTableSchemaTool(server);
|
||||
registerCreateTableTool(server);
|
||||
registerUpdateTableMetadataTool(server);
|
||||
registerDeleteTableTool(server);
|
||||
registerReorderTablesTool(server);
|
||||
registerCreateFieldTool(server);
|
||||
registerUpdateFieldTool(server);
|
||||
registerDeleteFieldTool(server);
|
||||
registerReorderFieldsTool(server);
|
||||
registerRegenerateEnlacesTool(server);
|
||||
// Escritura de estructura: developer y editor si, auditor no.
|
||||
if (canEditContent()) {
|
||||
registerCreateTableTool(server);
|
||||
registerUpdateTableMetadataTool(server);
|
||||
registerDeleteTableTool(server);
|
||||
registerReorderTablesTool(server);
|
||||
registerCreateFieldTool(server);
|
||||
registerUpdateFieldTool(server);
|
||||
registerDeleteFieldTool(server);
|
||||
registerReorderFieldsTool(server);
|
||||
registerRegenerateEnlacesTool(server);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user