@open-mercato/ai-assistant 0.10.1-develop.7776.1.3032ea0741 → 0.10.1-develop.7781.1.079f42c863
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/dist/modules/ai_assistant/api/tools/execute/route.js +15 -1
- package/dist/modules/ai_assistant/api/tools/execute/route.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +1 -0
- package/dist/modules/ai_assistant/i18n/en.json +1 -0
- package/dist/modules/ai_assistant/i18n/es.json +1 -0
- package/dist/modules/ai_assistant/i18n/ko.json +1 -0
- package/dist/modules/ai_assistant/i18n/pl.json +1 -0
- package/package.json +6 -6
- package/src/modules/ai_assistant/api/tools/execute/__tests__/route.test.ts +169 -0
- package/src/modules/ai_assistant/api/tools/execute/route.ts +17 -2
- package/src/modules/ai_assistant/i18n/de.json +1 -0
- package/src/modules/ai_assistant/i18n/en.json +1 -0
- package/src/modules/ai_assistant/i18n/es.json +1 -0
- package/src/modules/ai_assistant/i18n/ko.json +1 -0
- package/src/modules/ai_assistant/i18n/pl.json +1 -0
- package/src/modules/ai_assistant/lib/__tests__/direct-execution-approval-paths.test.ts +124 -0
|
@@ -2,14 +2,20 @@ import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
|
2
2
|
import { NextResponse } from "next/server";
|
|
3
3
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
5
6
|
import { executeTool } from "../../../lib/tool-executor.js";
|
|
6
7
|
import { loadAllModuleTools } from "../../../lib/tool-loader.js";
|
|
8
|
+
import { getToolRegistry } from "../../../lib/tool-registry.js";
|
|
7
9
|
const logger = createLogger("ai_assistant");
|
|
8
10
|
const openApi = {
|
|
9
11
|
tag: "AI Assistant",
|
|
10
12
|
summary: "Execute AI tool",
|
|
11
13
|
methods: {
|
|
12
|
-
POST: {
|
|
14
|
+
POST: {
|
|
15
|
+
summary: "Execute a specific read-only MCP tool by name",
|
|
16
|
+
description: "Mutation tools must use the agent chat or run-object dispatcher and its mutation approval policy.",
|
|
17
|
+
errors: [{ status: 403, description: "Mutation tools cannot execute directly, or caller lacks tool permissions." }]
|
|
18
|
+
}
|
|
13
19
|
}
|
|
14
20
|
};
|
|
15
21
|
const metadata = {
|
|
@@ -33,6 +39,14 @@ async function POST(req) {
|
|
|
33
39
|
organizationId: auth.orgId
|
|
34
40
|
});
|
|
35
41
|
await loadAllModuleTools();
|
|
42
|
+
const tool = getToolRegistry().getTool(toolName);
|
|
43
|
+
if (tool?.isMutation === true) {
|
|
44
|
+
const { t } = await resolveTranslations();
|
|
45
|
+
return NextResponse.json(
|
|
46
|
+
{ success: false, error: t("ai_assistant.errors.directMutationDenied") },
|
|
47
|
+
{ status: 403 }
|
|
48
|
+
);
|
|
49
|
+
}
|
|
36
50
|
const toolContext = {
|
|
37
51
|
tenantId: auth.tenantId,
|
|
38
52
|
organizationId: auth.orgId,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/ai_assistant/api/tools/execute/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { executeTool } from '../../../lib/tool-executor'\nimport { loadAllModuleTools } from '../../../lib/tool-loader'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport type { McpToolContext } from '../../../lib/types'\n\nconst logger = createLogger('ai_assistant')\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'Execute AI tool',\n methods: {\n POST: {
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,oBAAsC;AAE/C,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,mBAAmB;AAC5B,SAAS,0BAA0B;
|
|
4
|
+
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { executeTool } from '../../../lib/tool-executor'\nimport { loadAllModuleTools } from '../../../lib/tool-loader'\nimport { getToolRegistry } from '../../../lib/tool-registry'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport type { AiToolDefinition, McpToolContext } from '../../../lib/types'\n\nconst logger = createLogger('ai_assistant')\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'Execute AI tool',\n methods: {\n POST: {\n summary: 'Execute a specific read-only MCP tool by name',\n description: 'Mutation tools must use the agent chat or run-object dispatcher and its mutation approval policy.',\n errors: [{ status: 403, description: 'Mutation tools cannot execute directly, or caller lacks tool permissions.' }],\n },\n },\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },\n}\n\nexport async function POST(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n try {\n const body = await req.json()\n const { toolName, args = {} } = body\n\n if (!toolName || typeof toolName !== 'string') {\n return NextResponse.json({ error: 'toolName is required' }, { status: 400 })\n }\n\n const container = await createRequestContainer()\n const rbacService = container.resolve<RbacService>('rbacService')\n\n // Load ACL for user\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n // Ensure tools are loaded\n await loadAllModuleTools()\n\n const tool = getToolRegistry().getTool(toolName) as AiToolDefinition | undefined\n if (tool?.isMutation === true) {\n const { t } = await resolveTranslations()\n return NextResponse.json(\n { success: false, error: t('ai_assistant.errors.directMutationDenied') },\n { status: 403 }\n )\n }\n\n // Build tool context\n const toolContext: McpToolContext = {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n userId: auth.sub,\n container,\n userFeatures: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n }\n\n // Execute the tool\n const result = await executeTool(toolName, args, toolContext)\n\n if (!result.success) {\n return NextResponse.json(\n { success: false, error: result.error },\n { status: result.errorCode === 'UNAUTHORIZED' ? 403 : 400 }\n )\n }\n\n return NextResponse.json({\n success: true,\n result: result.result,\n })\n } catch (error) {\n logger.error('AI Tools \u2014 Error executing tool', { err: error })\n return NextResponse.json(\n { success: false, error: 'Tool execution failed' },\n { status: 500 }\n )\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,oBAAsC;AAE/C,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,2BAA2B;AACpC,SAAS,mBAAmB;AAC5B,SAAS,0BAA0B;AACnC,SAAS,uBAAuB;AAIhC,MAAM,SAAS,aAAa,cAAc;AAEnC,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aAAa;AAAA,MACb,QAAQ,CAAC,EAAE,QAAQ,KAAK,aAAa,4EAA4E,CAAC;AAAA,IACpH;AAAA,EACF;AACF;AAEO,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,mBAAmB,EAAE;AACpE;AAEA,eAAsB,KAAK,KAAkB;AAC3C,QAAM,OAAO,MAAM,mBAAmB,GAAG;AAEzC,MAAI,CAAC,MAAM;AACT,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,MAAI;AACF,UAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,UAAM,EAAE,UAAU,OAAO,CAAC,EAAE,IAAI;AAEhC,QAAI,CAAC,YAAY,OAAO,aAAa,UAAU;AAC7C,aAAO,aAAa,KAAK,EAAE,OAAO,uBAAuB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC7E;AAEA,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,cAAc,UAAU,QAAqB,aAAa;AAGhE,UAAM,MAAM,MAAM,YAAY,QAAQ,KAAK,KAAK;AAAA,MAC9C,UAAU,KAAK;AAAA,MACf,gBAAgB,KAAK;AAAA,IACvB,CAAC;AAGD,UAAM,mBAAmB;AAEzB,UAAM,OAAO,gBAAgB,EAAE,QAAQ,QAAQ;AAC/C,QAAI,MAAM,eAAe,MAAM;AAC7B,YAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,aAAO,aAAa;AAAA,QAClB,EAAE,SAAS,OAAO,OAAO,EAAE,0CAA0C,EAAE;AAAA,QACvE,EAAE,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AAGA,UAAM,cAA8B;AAAA,MAClC,UAAU,KAAK;AAAA,MACf,gBAAgB,KAAK;AAAA,MACrB,QAAQ,KAAK;AAAA,MACb;AAAA,MACA,cAAc,IAAI;AAAA,MAClB,cAAc,IAAI;AAAA,IACpB;AAGA,UAAM,SAAS,MAAM,YAAY,UAAU,MAAM,WAAW;AAE5D,QAAI,CAAC,OAAO,SAAS;AACnB,aAAO,aAAa;AAAA,QAClB,EAAE,SAAS,OAAO,OAAO,OAAO,MAAM;AAAA,QACtC,EAAE,QAAQ,OAAO,cAAc,iBAAiB,MAAM,IAAI;AAAA,MAC5D;AAAA,IACF;AAEA,WAAO,aAAa,KAAK;AAAA,MACvB,SAAS;AAAA,MACT,QAAQ,OAAO;AAAA,IACjB,CAAC;AAAA,EACH,SAAS,OAAO;AACd,WAAO,MAAM,wCAAmC,EAAE,KAAK,MAAM,CAAC;AAC9D,WAAO,aAAa;AAAA,MAClB,EAAE,SAAS,OAAO,OAAO,wBAAwB;AAAA,MACjD,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -316,6 +316,7 @@
|
|
|
316
316
|
"ai_assistant.dock.left": "Links andocken",
|
|
317
317
|
"ai_assistant.dock.minimize": "Minimieren",
|
|
318
318
|
"ai_assistant.dock.right": "Rechts andocken",
|
|
319
|
+
"ai_assistant.errors.directMutationDenied": "Änderungswerkzeuge können nicht direkt ausgeführt werden. Verwenden Sie einen Agenten-Chat oder eine run-object-Anfrage mit der Richtlinie zur Genehmigung von Änderungen.",
|
|
319
320
|
"ai_assistant.errors.moderationBlocked": "Ihre Nachricht wurde vom Inhaltssicherheitsfilter blockiert. Bitte formulieren Sie sie um und versuchen Sie es erneut.",
|
|
320
321
|
"ai_assistant.errors.moderationUnavailable": "Die Inhaltssicherheitsprüfung ist vorübergehend nicht verfügbar. Bitte versuchen Sie es in einem Moment erneut.",
|
|
321
322
|
"ai_assistant.errors.pendingAllRecordsStale": "Alle ausstehenden Datensätze sind veraltet; der Assistent muss die Änderung neu vorschlagen.",
|
|
@@ -316,6 +316,7 @@
|
|
|
316
316
|
"ai_assistant.dock.left": "Dock Left",
|
|
317
317
|
"ai_assistant.dock.minimize": "Minimize",
|
|
318
318
|
"ai_assistant.dock.right": "Dock Right",
|
|
319
|
+
"ai_assistant.errors.directMutationDenied": "Mutation tools cannot execute directly. Use an agent chat or run-object request with the mutation approval policy.",
|
|
319
320
|
"ai_assistant.errors.moderationBlocked": "Your message was blocked by the content safety filter. Please rephrase and try again.",
|
|
320
321
|
"ai_assistant.errors.moderationUnavailable": "The content safety check is temporarily unavailable. Please try again in a moment.",
|
|
321
322
|
"ai_assistant.errors.pendingAllRecordsStale": "All pending records are stale; the model must re-propose the batch.",
|
|
@@ -316,6 +316,7 @@
|
|
|
316
316
|
"ai_assistant.dock.left": "Anclar a la izquierda",
|
|
317
317
|
"ai_assistant.dock.minimize": "Minimizar",
|
|
318
318
|
"ai_assistant.dock.right": "Anclar a la derecha",
|
|
319
|
+
"ai_assistant.errors.directMutationDenied": "Las herramientas de modificación no pueden ejecutarse directamente. Use un chat de agente o una solicitud run-object con la política de aprobación de modificaciones.",
|
|
319
320
|
"ai_assistant.errors.moderationBlocked": "Tu mensaje fue bloqueado por el filtro de seguridad de contenido. Reformúlalo e inténtalo de nuevo.",
|
|
320
321
|
"ai_assistant.errors.moderationUnavailable": "La verificación de seguridad de contenido no está disponible temporalmente. Inténtalo de nuevo en un momento.",
|
|
321
322
|
"ai_assistant.errors.pendingAllRecordsStale": "Todos los registros pendientes están desactualizados; el asistente debe volver a proponer el cambio.",
|
|
@@ -316,6 +316,7 @@
|
|
|
316
316
|
"ai_assistant.dock.left": "왼쪽 독",
|
|
317
317
|
"ai_assistant.dock.minimize": "최소화",
|
|
318
318
|
"ai_assistant.dock.right": "오른쪽 독",
|
|
319
|
+
"ai_assistant.errors.directMutationDenied": "변경 도구는 직접 실행할 수 없습니다. 변경 승인 정책이 적용되는 에이전트 채팅 또는 run-object 요청을 사용하세요.",
|
|
319
320
|
"ai_assistant.errors.moderationBlocked": "귀하의 메시지가 콘텐츠 안전 필터에 의해 차단되었습니다. 문구를 바꾸고 다시 시도해 주세요.",
|
|
320
321
|
"ai_assistant.errors.moderationUnavailable": "콘텐츠 안전 확인을 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.",
|
|
321
322
|
"ai_assistant.errors.pendingAllRecordsStale": "대기 중인 모든 레코드가 오래되었습니다. 어시스턴트가 변경을 다시 제안해야 합니다.",
|
|
@@ -316,6 +316,7 @@
|
|
|
316
316
|
"ai_assistant.dock.left": "Dokuj po lewej",
|
|
317
317
|
"ai_assistant.dock.minimize": "Minimalizuj",
|
|
318
318
|
"ai_assistant.dock.right": "Dokuj po prawej",
|
|
319
|
+
"ai_assistant.errors.directMutationDenied": "Narzędzia modyfikujące dane nie mogą być uruchamiane bezpośrednio. Użyj czatu agenta lub żądania run-object z zasadami zatwierdzania zmian.",
|
|
319
320
|
"ai_assistant.errors.moderationBlocked": "Twoja wiadomość została zablokowana przez filtr bezpieczeństwa treści. Przeformułuj ją i spróbuj ponownie.",
|
|
320
321
|
"ai_assistant.errors.moderationUnavailable": "Sprawdzanie bezpieczeństwa treści jest chwilowo niedostępne. Spróbuj ponownie za chwilę.",
|
|
321
322
|
"ai_assistant.errors.pendingAllRecordsStale": "Wszystkie rekordy oczekującej akcji są nieaktualne; asystent musi ponownie zaproponować zmianę.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ai-assistant",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7781.1.079f42c863",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -100,16 +100,16 @@
|
|
|
100
100
|
"zod-to-json-schema": "^3.25.2"
|
|
101
101
|
},
|
|
102
102
|
"peerDependencies": {
|
|
103
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
104
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
103
|
+
"@open-mercato/shared": "0.10.1-develop.7781.1.079f42c863",
|
|
104
|
+
"@open-mercato/ui": "0.10.1-develop.7781.1.079f42c863",
|
|
105
105
|
"react": "^19.0.0",
|
|
106
106
|
"react-dom": "^19.0.0",
|
|
107
107
|
"zod": ">=3.23.0"
|
|
108
108
|
},
|
|
109
109
|
"devDependencies": {
|
|
110
|
-
"@open-mercato/cli": "0.10.1-develop.
|
|
111
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
112
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
110
|
+
"@open-mercato/cli": "0.10.1-develop.7781.1.079f42c863",
|
|
111
|
+
"@open-mercato/shared": "0.10.1-develop.7781.1.079f42c863",
|
|
112
|
+
"@open-mercato/ui": "0.10.1-develop.7781.1.079f42c863",
|
|
113
113
|
"@types/react": "^19.2.18",
|
|
114
114
|
"@types/react-dom": "^19.2.5",
|
|
115
115
|
"react": "19.2.8",
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { registerMcpTool, toolRegistry } from '../../../../lib/tool-registry'
|
|
4
|
+
import type { AiToolDefinition } from '../../../../lib/types'
|
|
5
|
+
import enTranslations from '../../../../i18n/en.json'
|
|
6
|
+
import { POST, metadata } from '../route'
|
|
7
|
+
|
|
8
|
+
const authMock = jest.fn()
|
|
9
|
+
const loadAclMock = jest.fn()
|
|
10
|
+
const resolveMock = jest.fn()
|
|
11
|
+
const loadAllModuleToolsMock = jest.fn()
|
|
12
|
+
|
|
13
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
14
|
+
getAuthFromRequest: (...args: unknown[]) => authMock(...args),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
18
|
+
createRequestContainer: async () => ({ resolve: resolveMock }),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
22
|
+
resolveTranslations: async () => ({
|
|
23
|
+
t: (key: string) => (enTranslations as Record<string, string>)[key] ?? key,
|
|
24
|
+
}),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
jest.mock('../../../../lib/tool-loader', () => ({
|
|
28
|
+
loadAllModuleTools: (...args: unknown[]) => loadAllModuleToolsMock(...args),
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
function request(body: Record<string, unknown>): NextRequest {
|
|
32
|
+
return new NextRequest('http://localhost/api/ai_assistant/tools/execute', {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'content-type': 'application/json' },
|
|
35
|
+
body: JSON.stringify(body),
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function registerTool(isMutation?: boolean) {
|
|
40
|
+
const handler = jest.fn(async () => ({ recordId: 'record-1' }))
|
|
41
|
+
const tool: AiToolDefinition = {
|
|
42
|
+
name: 'test.direct_execution',
|
|
43
|
+
description: 'Direct execution regression fixture',
|
|
44
|
+
inputSchema: z.object({ title: z.string() }),
|
|
45
|
+
requiredFeatures: ['test.records.manage'],
|
|
46
|
+
...(isMutation === undefined ? {} : { isMutation }),
|
|
47
|
+
handler,
|
|
48
|
+
}
|
|
49
|
+
registerMcpTool(tool, { moduleId: 'test' })
|
|
50
|
+
return handler
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('POST /api/ai_assistant/tools/execute approval boundary', () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
jest.clearAllMocks()
|
|
56
|
+
toolRegistry.clear()
|
|
57
|
+
authMock.mockResolvedValue({ sub: 'user-1', tenantId: 'tenant-1', orgId: 'org-1' })
|
|
58
|
+
loadAclMock.mockResolvedValue({ features: ['test.records.manage'], isSuperAdmin: false })
|
|
59
|
+
resolveMock.mockReturnValue({ loadAcl: loadAclMock })
|
|
60
|
+
loadAllModuleToolsMock.mockResolvedValue(undefined)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
afterEach(() => toolRegistry.clear())
|
|
64
|
+
|
|
65
|
+
it.each([false, true])('refuses mutations before their handler runs (superadmin=%s)', async (isSuperAdmin) => {
|
|
66
|
+
const handler = registerTool(true)
|
|
67
|
+
loadAclMock.mockResolvedValue({ features: ['test.records.manage'], isSuperAdmin })
|
|
68
|
+
|
|
69
|
+
const response = await POST(request({ toolName: 'test.direct_execution', args: { title: 'Changed' } }))
|
|
70
|
+
|
|
71
|
+
expect(handler).not.toHaveBeenCalled()
|
|
72
|
+
expect(response.status).toBe(403)
|
|
73
|
+
expect(await response.json()).toEqual({
|
|
74
|
+
success: false,
|
|
75
|
+
error: enTranslations['ai_assistant.errors.directMutationDenied'],
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('ignores client-supplied approval and agent policy claims', async () => {
|
|
80
|
+
const handler = registerTool(true)
|
|
81
|
+
|
|
82
|
+
const response = await POST(request({
|
|
83
|
+
toolName: 'test.direct_execution',
|
|
84
|
+
args: { title: 'Changed', approved: true },
|
|
85
|
+
approved: true,
|
|
86
|
+
pendingActionId: 'forged-action',
|
|
87
|
+
agent: 'test.assistant',
|
|
88
|
+
mutationPolicy: 'destructive-confirm-required',
|
|
89
|
+
}))
|
|
90
|
+
|
|
91
|
+
expect(handler).not.toHaveBeenCalled()
|
|
92
|
+
expect(response.status).toBe(403)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('checks the registry populated by tool loading', async () => {
|
|
96
|
+
const handler = jest.fn(async () => ({ written: true }))
|
|
97
|
+
loadAllModuleToolsMock.mockImplementationOnce(async () => {
|
|
98
|
+
const tool: AiToolDefinition = {
|
|
99
|
+
name: 'test.loaded_mutation',
|
|
100
|
+
description: 'Loaded mutation fixture',
|
|
101
|
+
inputSchema: z.object({}),
|
|
102
|
+
isMutation: true,
|
|
103
|
+
handler,
|
|
104
|
+
}
|
|
105
|
+
registerMcpTool(tool, { moduleId: 'test' })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const response = await POST(request({ toolName: 'test.loaded_mutation' }))
|
|
109
|
+
|
|
110
|
+
expect(handler).not.toHaveBeenCalled()
|
|
111
|
+
expect(response.status).toBe(403)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it.each([false, undefined])('executes read tools and preserves the scoped context (isMutation=%s)', async (isMutation) => {
|
|
115
|
+
const handler = registerTool(isMutation)
|
|
116
|
+
|
|
117
|
+
const response = await POST(request({ toolName: 'test.direct_execution', args: { title: 'Read' } }))
|
|
118
|
+
|
|
119
|
+
expect(response.status).toBe(200)
|
|
120
|
+
expect(await response.json()).toEqual({ success: true, result: { recordId: 'record-1' } })
|
|
121
|
+
expect(handler).toHaveBeenCalledWith({ title: 'Read' }, expect.objectContaining({
|
|
122
|
+
tenantId: 'tenant-1',
|
|
123
|
+
organizationId: 'org-1',
|
|
124
|
+
userId: 'user-1',
|
|
125
|
+
userFeatures: ['test.records.manage'],
|
|
126
|
+
isSuperAdmin: false,
|
|
127
|
+
}))
|
|
128
|
+
expect(loadAclMock).toHaveBeenCalledWith('user-1', { tenantId: 'tenant-1', organizationId: 'org-1' })
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('keeps tool ACL checks on read execution', async () => {
|
|
132
|
+
const handler = registerTool(false)
|
|
133
|
+
loadAclMock.mockResolvedValue({ features: [], isSuperAdmin: false })
|
|
134
|
+
|
|
135
|
+
const response = await POST(request({ toolName: 'test.direct_execution', args: { title: 'Read' } }))
|
|
136
|
+
|
|
137
|
+
expect(handler).not.toHaveBeenCalled()
|
|
138
|
+
expect(response.status).toBe(403)
|
|
139
|
+
expect(await response.json()).toMatchObject({ success: false, error: expect.stringContaining('Insufficient permissions') })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('keeps tool schema validation on read execution', async () => {
|
|
143
|
+
const handler = registerTool(false)
|
|
144
|
+
|
|
145
|
+
const response = await POST(request({ toolName: 'test.direct_execution', args: { title: 123 } }))
|
|
146
|
+
|
|
147
|
+
expect(handler).not.toHaveBeenCalled()
|
|
148
|
+
expect(response.status).toBe(400)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('keeps unknown-tool errors', async () => {
|
|
152
|
+
const response = await POST(request({ toolName: 'test.unknown' }))
|
|
153
|
+
|
|
154
|
+
expect(response.status).toBe(400)
|
|
155
|
+
expect(await response.json()).toMatchObject({ success: false, error: expect.stringContaining('not found') })
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('keeps endpoint authentication and declarative feature requirements', async () => {
|
|
159
|
+
const handler = registerTool(true)
|
|
160
|
+
authMock.mockResolvedValue(null)
|
|
161
|
+
|
|
162
|
+
const response = await POST(request({ toolName: 'test.direct_execution', args: { title: 'Changed' } }))
|
|
163
|
+
|
|
164
|
+
expect(handler).not.toHaveBeenCalled()
|
|
165
|
+
expect(loadAllModuleToolsMock).not.toHaveBeenCalled()
|
|
166
|
+
expect(response.status).toBe(401)
|
|
167
|
+
expect(metadata.POST).toEqual({ requireAuth: true, requireFeatures: ['ai_assistant.view'] })
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -3,10 +3,12 @@ import { NextResponse, type NextRequest } from 'next/server'
|
|
|
3
3
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
6
7
|
import { executeTool } from '../../../lib/tool-executor'
|
|
7
8
|
import { loadAllModuleTools } from '../../../lib/tool-loader'
|
|
9
|
+
import { getToolRegistry } from '../../../lib/tool-registry'
|
|
8
10
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
9
|
-
import type { McpToolContext } from '../../../lib/types'
|
|
11
|
+
import type { AiToolDefinition, McpToolContext } from '../../../lib/types'
|
|
10
12
|
|
|
11
13
|
const logger = createLogger('ai_assistant')
|
|
12
14
|
|
|
@@ -14,7 +16,11 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
14
16
|
tag: 'AI Assistant',
|
|
15
17
|
summary: 'Execute AI tool',
|
|
16
18
|
methods: {
|
|
17
|
-
POST: {
|
|
19
|
+
POST: {
|
|
20
|
+
summary: 'Execute a specific read-only MCP tool by name',
|
|
21
|
+
description: 'Mutation tools must use the agent chat or run-object dispatcher and its mutation approval policy.',
|
|
22
|
+
errors: [{ status: 403, description: 'Mutation tools cannot execute directly, or caller lacks tool permissions.' }],
|
|
23
|
+
},
|
|
18
24
|
},
|
|
19
25
|
}
|
|
20
26
|
|
|
@@ -49,6 +55,15 @@ export async function POST(req: NextRequest) {
|
|
|
49
55
|
// Ensure tools are loaded
|
|
50
56
|
await loadAllModuleTools()
|
|
51
57
|
|
|
58
|
+
const tool = getToolRegistry().getTool(toolName) as AiToolDefinition | undefined
|
|
59
|
+
if (tool?.isMutation === true) {
|
|
60
|
+
const { t } = await resolveTranslations()
|
|
61
|
+
return NextResponse.json(
|
|
62
|
+
{ success: false, error: t('ai_assistant.errors.directMutationDenied') },
|
|
63
|
+
{ status: 403 }
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
// Build tool context
|
|
53
68
|
const toolContext: McpToolContext = {
|
|
54
69
|
tenantId: auth.tenantId,
|
|
@@ -316,6 +316,7 @@
|
|
|
316
316
|
"ai_assistant.dock.left": "Links andocken",
|
|
317
317
|
"ai_assistant.dock.minimize": "Minimieren",
|
|
318
318
|
"ai_assistant.dock.right": "Rechts andocken",
|
|
319
|
+
"ai_assistant.errors.directMutationDenied": "Änderungswerkzeuge können nicht direkt ausgeführt werden. Verwenden Sie einen Agenten-Chat oder eine run-object-Anfrage mit der Richtlinie zur Genehmigung von Änderungen.",
|
|
319
320
|
"ai_assistant.errors.moderationBlocked": "Ihre Nachricht wurde vom Inhaltssicherheitsfilter blockiert. Bitte formulieren Sie sie um und versuchen Sie es erneut.",
|
|
320
321
|
"ai_assistant.errors.moderationUnavailable": "Die Inhaltssicherheitsprüfung ist vorübergehend nicht verfügbar. Bitte versuchen Sie es in einem Moment erneut.",
|
|
321
322
|
"ai_assistant.errors.pendingAllRecordsStale": "Alle ausstehenden Datensätze sind veraltet; der Assistent muss die Änderung neu vorschlagen.",
|
|
@@ -316,6 +316,7 @@
|
|
|
316
316
|
"ai_assistant.dock.left": "Dock Left",
|
|
317
317
|
"ai_assistant.dock.minimize": "Minimize",
|
|
318
318
|
"ai_assistant.dock.right": "Dock Right",
|
|
319
|
+
"ai_assistant.errors.directMutationDenied": "Mutation tools cannot execute directly. Use an agent chat or run-object request with the mutation approval policy.",
|
|
319
320
|
"ai_assistant.errors.moderationBlocked": "Your message was blocked by the content safety filter. Please rephrase and try again.",
|
|
320
321
|
"ai_assistant.errors.moderationUnavailable": "The content safety check is temporarily unavailable. Please try again in a moment.",
|
|
321
322
|
"ai_assistant.errors.pendingAllRecordsStale": "All pending records are stale; the model must re-propose the batch.",
|
|
@@ -316,6 +316,7 @@
|
|
|
316
316
|
"ai_assistant.dock.left": "Anclar a la izquierda",
|
|
317
317
|
"ai_assistant.dock.minimize": "Minimizar",
|
|
318
318
|
"ai_assistant.dock.right": "Anclar a la derecha",
|
|
319
|
+
"ai_assistant.errors.directMutationDenied": "Las herramientas de modificación no pueden ejecutarse directamente. Use un chat de agente o una solicitud run-object con la política de aprobación de modificaciones.",
|
|
319
320
|
"ai_assistant.errors.moderationBlocked": "Tu mensaje fue bloqueado por el filtro de seguridad de contenido. Reformúlalo e inténtalo de nuevo.",
|
|
320
321
|
"ai_assistant.errors.moderationUnavailable": "La verificación de seguridad de contenido no está disponible temporalmente. Inténtalo de nuevo en un momento.",
|
|
321
322
|
"ai_assistant.errors.pendingAllRecordsStale": "Todos los registros pendientes están desactualizados; el asistente debe volver a proponer el cambio.",
|
|
@@ -316,6 +316,7 @@
|
|
|
316
316
|
"ai_assistant.dock.left": "왼쪽 독",
|
|
317
317
|
"ai_assistant.dock.minimize": "최소화",
|
|
318
318
|
"ai_assistant.dock.right": "오른쪽 독",
|
|
319
|
+
"ai_assistant.errors.directMutationDenied": "변경 도구는 직접 실행할 수 없습니다. 변경 승인 정책이 적용되는 에이전트 채팅 또는 run-object 요청을 사용하세요.",
|
|
319
320
|
"ai_assistant.errors.moderationBlocked": "귀하의 메시지가 콘텐츠 안전 필터에 의해 차단되었습니다. 문구를 바꾸고 다시 시도해 주세요.",
|
|
320
321
|
"ai_assistant.errors.moderationUnavailable": "콘텐츠 안전 확인을 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.",
|
|
321
322
|
"ai_assistant.errors.pendingAllRecordsStale": "대기 중인 모든 레코드가 오래되었습니다. 어시스턴트가 변경을 다시 제안해야 합니다.",
|
|
@@ -316,6 +316,7 @@
|
|
|
316
316
|
"ai_assistant.dock.left": "Dokuj po lewej",
|
|
317
317
|
"ai_assistant.dock.minimize": "Minimalizuj",
|
|
318
318
|
"ai_assistant.dock.right": "Dokuj po prawej",
|
|
319
|
+
"ai_assistant.errors.directMutationDenied": "Narzędzia modyfikujące dane nie mogą być uruchamiane bezpośrednio. Użyj czatu agenta lub żądania run-object z zasadami zatwierdzania zmian.",
|
|
319
320
|
"ai_assistant.errors.moderationBlocked": "Twoja wiadomość została zablokowana przez filtr bezpieczeństwa treści. Przeformułuj ją i spróbuj ponownie.",
|
|
320
321
|
"ai_assistant.errors.moderationUnavailable": "Sprawdzanie bezpieczeństwa treści jest chwilowo niedostępne. Spróbuj ponownie za chwilę.",
|
|
321
322
|
"ai_assistant.errors.pendingAllRecordsStale": "Wszystkie rekordy oczekującej akcji są nieaktualne; asystent musi ponownie zaproponować zmianę.",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { asValue, createContainer } from 'awilix'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { ToolExecutionOptions } from 'ai'
|
|
4
|
+
import { resolveAiAgentTools } from '../agent-tools'
|
|
5
|
+
import { resetAgentRegistryForTests, seedAgentRegistryForTests } from '../agent-registry'
|
|
6
|
+
import { registerMcpTool, toolRegistry } from '../tool-registry'
|
|
7
|
+
import type { AiToolDefinition } from '../types'
|
|
8
|
+
import type { AiAgentDefinition } from '../ai-agent-definition'
|
|
9
|
+
|
|
10
|
+
const prepareMutationMock = jest.fn()
|
|
11
|
+
const container = createContainer()
|
|
12
|
+
container.register({ rbacService: asValue({}) })
|
|
13
|
+
|
|
14
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
15
|
+
createRequestContainer: async () => container,
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
jest.mock('../tool-loader', () => ({
|
|
19
|
+
ensureModuleToolsLoaded: async () => undefined,
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
jest.mock('../prepare-mutation', () => ({
|
|
23
|
+
prepareMutation: (...args: unknown[]) => prepareMutationMock(...args),
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
const authContext = {
|
|
27
|
+
tenantId: 'tenant-1',
|
|
28
|
+
organizationId: 'org-1',
|
|
29
|
+
userId: 'user-1',
|
|
30
|
+
features: ['test.records.manage'],
|
|
31
|
+
isSuperAdmin: false,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const executionOptions: ToolExecutionOptions<unknown> = { toolCallId: 'call-1', messages: [], context: undefined }
|
|
35
|
+
|
|
36
|
+
function seedAgent(overrides: Partial<AiAgentDefinition> = {}) {
|
|
37
|
+
const agent: AiAgentDefinition = {
|
|
38
|
+
id: 'test.assistant',
|
|
39
|
+
moduleId: 'test',
|
|
40
|
+
label: 'Test assistant',
|
|
41
|
+
description: 'Approval path fixture',
|
|
42
|
+
systemPrompt: 'Test',
|
|
43
|
+
allowedTools: ['test.mutation'],
|
|
44
|
+
readOnly: false,
|
|
45
|
+
mutationPolicy: 'confirm-required',
|
|
46
|
+
...overrides,
|
|
47
|
+
}
|
|
48
|
+
seedAgentRegistryForTests([agent])
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('agent approval paths alongside the direct HTTP refusal', () => {
|
|
52
|
+
let handler: jest.Mock
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
jest.clearAllMocks()
|
|
56
|
+
resetAgentRegistryForTests()
|
|
57
|
+
toolRegistry.clear()
|
|
58
|
+
handler = jest.fn(async () => ({ written: true }))
|
|
59
|
+
const tool: AiToolDefinition = {
|
|
60
|
+
name: 'test.mutation',
|
|
61
|
+
description: 'Mutation fixture',
|
|
62
|
+
inputSchema: z.object({ title: z.string() }),
|
|
63
|
+
isMutation: true,
|
|
64
|
+
isDestructive: false,
|
|
65
|
+
requiredFeatures: ['test.records.manage'],
|
|
66
|
+
handler,
|
|
67
|
+
}
|
|
68
|
+
registerMcpTool(tool, { moduleId: 'test' })
|
|
69
|
+
prepareMutationMock.mockResolvedValue({
|
|
70
|
+
uiPart: { type: 'mutation-preview-card', data: { pendingActionId: 'action-1' } },
|
|
71
|
+
pendingAction: { id: 'action-1', expiresAt: new Date('2099-01-01'), status: 'pending' },
|
|
72
|
+
reused: false,
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
resetAgentRegistryForTests()
|
|
78
|
+
toolRegistry.clear()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it.each(['chat', 'object'] as const)('still stages confirm-required mutations for %s agents', async (requestedExecutionMode) => {
|
|
82
|
+
seedAgent({ executionMode: requestedExecutionMode })
|
|
83
|
+
const resolved = await resolveAiAgentTools({
|
|
84
|
+
agentId: 'test.assistant', authContext, container, requestedExecutionMode,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const result = await resolved.tools.test__mutation.execute?.({ title: 'Changed' }, executionOptions)
|
|
88
|
+
|
|
89
|
+
expect(handler).not.toHaveBeenCalled()
|
|
90
|
+
expect(prepareMutationMock).toHaveBeenCalledWith(
|
|
91
|
+
expect.objectContaining({ toolCallArgs: { title: 'Changed' }, agent: expect.objectContaining({ id: 'test.assistant' }) }),
|
|
92
|
+
expect.objectContaining({ tenantId: 'tenant-1', organizationId: 'org-1', userId: 'user-1', container }),
|
|
93
|
+
)
|
|
94
|
+
expect(result).toEqual(expect.stringContaining('pending-confirmation'))
|
|
95
|
+
expect(resolved.uiPartQueue.drain()).toEqual([
|
|
96
|
+
{ type: 'mutation-preview-card', data: { pendingActionId: 'action-1' } },
|
|
97
|
+
])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('preserves policy-permitted non-destructive writes through the shared executor', async () => {
|
|
101
|
+
seedAgent({ mutationPolicy: 'destructive-confirm-required' })
|
|
102
|
+
const resolved = await resolveAiAgentTools({ agentId: 'test.assistant', authContext, container })
|
|
103
|
+
|
|
104
|
+
const result = await resolved.tools.test__mutation.execute?.({ title: 'Changed' }, executionOptions)
|
|
105
|
+
|
|
106
|
+
expect(prepareMutationMock).not.toHaveBeenCalled()
|
|
107
|
+
expect(handler).toHaveBeenCalledWith({ title: 'Changed' }, expect.objectContaining({
|
|
108
|
+
tenantId: 'tenant-1', organizationId: 'org-1', userId: 'user-1',
|
|
109
|
+
}))
|
|
110
|
+
expect(result).toEqual(expect.stringContaining('"written": true'))
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it.each([
|
|
114
|
+
{ mutationPolicyOverride: 'read-only' as const },
|
|
115
|
+
{ authContext: { ...authContext, features: [] } },
|
|
116
|
+
])('keeps tenant policy downgrades and tool ACL filtering', async (overrides) => {
|
|
117
|
+
seedAgent()
|
|
118
|
+
const resolved = await resolveAiAgentTools({ agentId: 'test.assistant', authContext, container, ...overrides })
|
|
119
|
+
|
|
120
|
+
expect(resolved.tools).toEqual({})
|
|
121
|
+
expect(handler).not.toHaveBeenCalled()
|
|
122
|
+
expect(prepareMutationMock).not.toHaveBeenCalled()
|
|
123
|
+
})
|
|
124
|
+
})
|