fix: exclude system objects and workflow/dashboard from AI/MCP write tool descriptors (#20973)

## Summary

fix: exclude system join objects from AI/MCP create/update/delete tool
descriptors

Closes #20403

---
AI was used for assistance.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Matt Van Horn
2026-05-27 11:01:11 -07:00
committed by GitHub
parent c0cbe67bcd
commit de7daaa81a
18 changed files with 283 additions and 115 deletions

View File

@@ -15,7 +15,7 @@ import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components
import { t } from '@lingui/core/macro';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { HorizontalSeparator } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { type JsonValue } from 'type-fest';
@@ -74,9 +74,8 @@ export const WorkflowEditActionCreateRecord = ({
const availableMetadata: Array<SelectOption<string>> =
activeNonSystemObjectMetadataItems
.filter((objectMetadataItem) =>
canObjectBeManagedByWorkflow({
canObjectBeManagedByAutomation({
nameSingular: objectMetadataItem.nameSingular,
isSystem: objectMetadataItem.isSystem,
}),
)
.map((item) => ({

View File

@@ -11,7 +11,7 @@ import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowS
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { HorizontalSeparator } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { type JsonValue } from 'type-fest';
@@ -46,9 +46,8 @@ export const WorkflowEditActionDeleteRecord = ({
const availableMetadata: Array<SelectOption<string>> =
activeNonSystemObjectMetadataItems
.filter((objectMetadataItem) =>
canObjectBeManagedByWorkflow({
canObjectBeManagedByAutomation({
nameSingular: objectMetadataItem.nameSingular,
isSystem: objectMetadataItem.isSystem,
}),
)
.map((item) => ({

View File

@@ -16,7 +16,7 @@ import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components
import { t } from '@lingui/core/macro';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { HorizontalSeparator } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { type JsonValue } from 'type-fest';
@@ -47,9 +47,8 @@ export const WorkflowEditActionUpdateRecord = ({
const availableMetadata: Array<SelectOption<string>> =
activeNonSystemObjectMetadataItems
.filter((objectMetadataItem) =>
canObjectBeManagedByWorkflow({
canObjectBeManagedByAutomation({
nameSingular: objectMetadataItem.nameSingular,
isSystem: objectMetadataItem.isSystem,
}),
)
.map((item) => ({

View File

@@ -17,7 +17,7 @@ import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components
import { t } from '@lingui/core/macro';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { HorizontalSeparator } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { type JsonValue } from 'type-fest';
@@ -84,9 +84,8 @@ export const WorkflowEditActionUpsertRecord = ({
const availableMetadata: Array<SelectOption<string>> =
activeNonSystemObjectMetadataItems
.filter((objectMetadataItem) =>
canObjectBeManagedByWorkflow({
canObjectBeManagedByAutomation({
nameSingular: objectMetadataItem.nameSingular,
isSystem: objectMetadataItem.isSystem,
}),
)
.map((item) => ({

View File

@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { FieldActorSource } from 'twenty-shared/types';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
import {
@@ -38,13 +38,12 @@ export class CreateManyRecordsService {
});
if (
!canObjectBeManagedByWorkflow({
!canObjectBeManagedByAutomation({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to create: Object cannot be created by workflow',
'Failed to create: Object cannot be created by automation',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}

View File

@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { FieldActorSource } from 'twenty-shared/types';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
import {
@@ -38,13 +38,12 @@ export class CreateRecordService {
});
if (
!canObjectBeManagedByWorkflow({
!canObjectBeManagedByAutomation({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to create: Object cannot be created by workflow',
'Failed to create: Object cannot be created by automation',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}

View File

@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { CommonDeleteOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-delete-one-query-runner.service';
import { CommonDestroyOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-destroy-one-query-runner.service';
@@ -42,13 +42,12 @@ export class DeleteRecordService {
});
if (
!canObjectBeManagedByWorkflow({
!canObjectBeManagedByAutomation({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to delete: Object cannot be deleted by workflow',
'Failed to delete: Object cannot be deleted by automation',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { CommonUpdateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-many-query-runner.service';
import {
@@ -37,13 +37,12 @@ export class UpdateManyRecordsService {
});
if (
!canObjectBeManagedByWorkflow({
!canObjectBeManagedByAutomation({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to update: Object cannot be updated by workflow',
'Failed to update: Object cannot be updated by automation',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}

View File

@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { CommonUpdateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-one-query-runner.service';
import {
@@ -52,13 +52,12 @@ export class UpdateRecordService {
});
if (
!canObjectBeManagedByWorkflow({
!canObjectBeManagedByAutomation({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to update: Object cannot be updated by workflow',
'Failed to update: Object cannot be updated by automation',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
import {
@@ -32,13 +32,12 @@ export class UpsertRecordService {
});
if (
!canObjectBeManagedByWorkflow({
!canObjectBeManagedByAutomation({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to update: Object cannot be updated by workflow',
'Failed to upsert: Object cannot be upserted by automation',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}

View File

@@ -0,0 +1,174 @@
import { type ObjectPermissions } from 'twenty-shared/types';
import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
const roleId = 'role-id';
const workspaceId = 'workspace-id';
const allObjectPermissions: ObjectPermissions = {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
};
const createFlatObject = (
overrides: Partial<FlatObjectMetadata> &
Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
) =>
getFlatObjectMetadataMock({
universalIdentifier: overrides.nameSingular,
labelSingular: overrides.nameSingular,
labelPlural: overrides.namePlural,
...overrides,
});
describe('DatabaseToolProvider', () => {
const generateDescriptorNames = async (objects: FlatObjectMetadata[]) => {
const flatObjectMetadataMaps =
createEmptyFlatEntityMaps() as FlatEntityMaps<FlatObjectMetadata>;
for (const object of objects) {
flatObjectMetadataMaps.byUniversalIdentifier[object.universalIdentifier] =
object;
flatObjectMetadataMaps.universalIdentifierById[object.id] =
object.universalIdentifier;
}
const workspaceCacheService = {
getOrRecompute: jest.fn().mockResolvedValue({
rolesPermissions: {
[roleId]: Object.fromEntries(
objects.map((object) => [object.id, allObjectPermissions]),
),
},
}),
} as unknown as WorkspaceCacheService;
const flatEntityMapsCacheService = {
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({
flatObjectMetadataMaps,
flatFieldMetadataMaps: createEmptyFlatEntityMaps(),
}),
} as unknown as WorkspaceManyOrAllFlatEntityMapsCacheService;
const provider = new DatabaseToolProvider(
workspaceCacheService,
flatEntityMapsCacheService,
);
const descriptors = (await provider.generateDescriptors(
{
workspaceId,
roleId,
rolePermissionConfig: { unionOf: [roleId] },
},
{ includeSchemas: false },
)) as ToolDescriptor[];
return descriptors.map((descriptor) => descriptor.name);
};
it('advertises write tools for join/system objects allowed by automation', async () => {
const descriptorNames = await generateDescriptorNames([
createFlatObject({
nameSingular: 'noteTarget',
namePlural: 'noteTargets',
isSystem: true,
}),
createFlatObject({
nameSingular: 'taskTarget',
namePlural: 'taskTargets',
isSystem: true,
}),
createFlatObject({
nameSingular: 'attachment',
namePlural: 'attachments',
isSystem: true,
}),
createFlatObject({
nameSingular: 'timelineActivity',
namePlural: 'timelineActivities',
isSystem: true,
}),
createFlatObject({
nameSingular: 'person',
namePlural: 'people',
}),
]);
expect(descriptorNames).toEqual(
expect.arrayContaining([
'create_note_target',
'create_many_note_targets',
'update_note_target',
'update_many_note_targets',
'delete_note_target',
'create_task_target',
'create_attachment',
'create_timeline_activity',
'create_person',
]),
);
});
it('does not advertise write tools for objects blocked from automation', async () => {
const descriptorNames = await generateDescriptorNames([
createFlatObject({
nameSingular: 'workspaceMember',
namePlural: 'workspaceMembers',
isSystem: true,
}),
createFlatObject({
nameSingular: 'message',
namePlural: 'messages',
isSystem: true,
}),
createFlatObject({
nameSingular: 'calendarEvent',
namePlural: 'calendarEvents',
isSystem: true,
}),
createFlatObject({
nameSingular: 'dashboard',
namePlural: 'dashboards',
}),
]);
expect(descriptorNames).toEqual(
expect.arrayContaining([
'find_workspace_members',
'find_messages',
'find_calendar_events',
'find_dashboards',
]),
);
expect(descriptorNames).toEqual(
expect.not.arrayContaining([
'create_workspace_member',
'update_workspace_member',
'delete_workspace_member',
'create_message',
'update_message',
'delete_message',
'create_calendar_event',
'update_calendar_event',
'delete_calendar_event',
'create_dashboard',
'update_dashboard',
'delete_dashboard',
]),
);
});
});

View File

@@ -5,6 +5,7 @@ import {
type ObjectsPermissionsByRoleId,
} from 'twenty-shared/types';
import { camelToSnakeCase, isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { z } from 'zod';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
@@ -116,6 +117,9 @@ export class DatabaseToolProvider implements ToolProvider {
const restrictedFields = permission.restrictedFields;
const snakePlural = camelToSnakeCase(objectMetadata.namePlural);
const snakeSingular = camelToSnakeCase(objectMetadata.nameSingular);
const canBeManagedByAutomation = canObjectBeManagedByAutomation({
nameSingular: objectMetadata.nameSingular,
});
if (permission.canReadObjectRecords) {
descriptors.push({
@@ -182,7 +186,7 @@ export class DatabaseToolProvider implements ToolProvider {
}
}
if (permission.canUpdateObjectRecords) {
if (permission.canUpdateObjectRecords && canBeManagedByAutomation) {
descriptors.push({
name: `create_${snakeSingular}`,
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
@@ -266,7 +270,7 @@ export class DatabaseToolProvider implements ToolProvider {
});
}
if (permission.canSoftDeleteObjectRecords) {
if (permission.canSoftDeleteObjectRecords && canBeManagedByAutomation) {
descriptors.push({
name: `delete_${snakeSingular}`,
description: `Delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record is hidden from normal queries. This is reversible. Use this to remove records.`,

View File

@@ -0,0 +1,19 @@
// Objects whose records must not be created, updated, or deleted by
// automation callers (workflows and AI tools). Either they back the
// automation runtime itself (recursion risk), gate access/permissions,
// or are owned by background sync (writing to them corrupts state).
export const OBJECTS_BLOCKED_FROM_AUTOMATION = [
'workflow',
'workflowVersion',
'workflowRun',
'workflowAutomatedTrigger',
'workspaceMember',
'dashboard',
'message',
'messageThread',
'messageChannelMessageAssociation',
'messageParticipant',
'calendarEvent',
'calendarEventParticipant',
'calendarChannelEventAssociation',
] as const;

View File

@@ -10,6 +10,7 @@
export { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from './constants/CaptureAllVariableTagInnerRegex';
export { CONTENT_TYPE_VALUES_HTTP_REQUEST } from './constants/ContentTypeValuesHttpRequest';
export { IF_ELSE_BRANCH_POSITION_OFFSETS } from './constants/IfElseBranchPositionOffsets';
export { OBJECTS_BLOCKED_FROM_AUTOMATION } from './constants/ObjectsBlockedFromAutomation';
export { TRIGGER_STEP_ID } from './constants/TriggerStepId';
export { workflowAiAgentActionSchema } from './schemas/ai-agent-action-schema';
export { workflowAiAgentActionSettingsSchema } from './schemas/ai-agent-action-settings-schema';
@@ -81,7 +82,7 @@ export type {
WorkflowRunStepInfos,
} from './types/WorkflowRunStateStepInfos';
export { StepStatus } from './types/WorkflowRunStateStepInfos';
export { canObjectBeManagedByWorkflow } from './utils/canObjectBeManagedByWorkflow';
export { canObjectBeManagedByAutomation } from './utils/canObjectBeManagedByAutomation';
export { extractRawVariableNamePart } from './utils/extractRawVariableNameParts';
export { getFunctionInputFromInputSchema } from './utils/getFunctionInputFromInputSchema';
export { getWorkflowRunContext } from './utils/getWorkflowRunContext';

View File

@@ -0,0 +1,45 @@
import { canObjectBeManagedByAutomation } from '@/workflow/utils/canObjectBeManagedByAutomation';
describe('canObjectBeManagedByAutomation', () => {
it('should return true for a standard non-blocked object', () => {
expect(canObjectBeManagedByAutomation({ nameSingular: 'company' })).toBe(
true,
);
});
it('should return true for noteTarget and taskTarget', () => {
expect(canObjectBeManagedByAutomation({ nameSingular: 'noteTarget' })).toBe(
true,
);
expect(canObjectBeManagedByAutomation({ nameSingular: 'taskTarget' })).toBe(
true,
);
});
it('should return true for attachment and timelineActivity', () => {
expect(canObjectBeManagedByAutomation({ nameSingular: 'attachment' })).toBe(
true,
);
expect(
canObjectBeManagedByAutomation({ nameSingular: 'timelineActivity' }),
).toBe(true);
});
it.each([
'workflow',
'workflowVersion',
'workflowRun',
'workflowAutomatedTrigger',
'workspaceMember',
'dashboard',
'message',
'messageThread',
'messageChannelMessageAssociation',
'messageParticipant',
'calendarEvent',
'calendarEventParticipant',
'calendarChannelEventAssociation',
])('should return false for %s', (nameSingular) => {
expect(canObjectBeManagedByAutomation({ nameSingular })).toBe(false);
});
});

View File

@@ -1,57 +0,0 @@
import { canObjectBeManagedByWorkflow } from '@/workflow/utils/canObjectBeManagedByWorkflow';
describe('canObjectBeManagedByWorkflow', () => {
it('should return true for non-system, non-excluded objects', () => {
expect(
canObjectBeManagedByWorkflow({
nameSingular: 'company',
isSystem: false,
}),
).toBe(true);
});
it('should return false for system objects', () => {
expect(
canObjectBeManagedByWorkflow({
nameSingular: 'company',
isSystem: true,
}),
).toBe(false);
});
it('should return false for workflow object', () => {
expect(
canObjectBeManagedByWorkflow({
nameSingular: 'workflow',
isSystem: false,
}),
).toBe(false);
});
it('should return false for workflowVersion object', () => {
expect(
canObjectBeManagedByWorkflow({
nameSingular: 'workflowVersion',
isSystem: false,
}),
).toBe(false);
});
it('should return false for workflowRun object', () => {
expect(
canObjectBeManagedByWorkflow({
nameSingular: 'workflowRun',
isSystem: false,
}),
).toBe(false);
});
it('should return false for dashboard object', () => {
expect(
canObjectBeManagedByWorkflow({
nameSingular: 'dashboard',
isSystem: false,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,11 @@
import { OBJECTS_BLOCKED_FROM_AUTOMATION } from '../constants/ObjectsBlockedFromAutomation';
export const canObjectBeManagedByAutomation = ({
nameSingular,
}: {
nameSingular: string;
}): boolean => {
return !OBJECTS_BLOCKED_FROM_AUTOMATION.includes(
nameSingular as (typeof OBJECTS_BLOCKED_FROM_AUTOMATION)[number],
);
};

View File

@@ -1,19 +0,0 @@
export const canObjectBeManagedByWorkflow = ({
nameSingular,
isSystem,
}: {
nameSingular: string;
isSystem: boolean;
}) => {
const excludedNonSystemObjectMetadataItemNames = [
'workflow',
'workflowVersion',
'workflowRun',
'dashboard',
];
return (
!excludedNonSystemObjectMetadataItemNames.includes(nameSingular) &&
!isSystem
);
};