Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,14 @@ export const PUT = withRouteHandler(
// master on/off and the per-auth-type allow-list); disabling is always
// allowed so users can still un-share after the policy is turned on.
if (isActive) {
// Validate the auth type that will ACTUALLY be persisted. upsertFileShare
// falls back to the existing share's authType when none is passed, so a bare
// re-enable must be checked against that stored mode — not 'public' — or a
// now-disallowed password/email/sso share could be silently reactivated.
const existingShare = await getShareForResource('file', fileId)
const effectiveAuthType = authType ?? existingShare?.authType ?? 'public'
try {
await validatePublicFileSharing(session.user.id, workspaceId, authType ?? 'public')
await validatePublicFileSharing(session.user.id, workspaceId, effectiveAuthType)
} catch (error) {
if (error instanceof PublicFileSharingNotAllowedError) {
logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`)
Expand Down
57 changes: 57 additions & 0 deletions apps/sim/lib/copilot/generated/tool-catalog-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export interface ToolCatalogEntry {
| 'set_block_enabled'
| 'set_environment_variables'
| 'set_global_workflow_variables'
| 'share_file'
| 'table'
| 'update_deployment_version'
| 'update_scheduled_task_history'
Expand Down Expand Up @@ -191,6 +192,7 @@ export interface ToolCatalogEntry {
| 'set_block_enabled'
| 'set_environment_variables'
| 'set_global_workflow_variables'
| 'share_file'
| 'table'
| 'update_deployment_version'
| 'update_scheduled_task_history'
Expand Down Expand Up @@ -3925,6 +3927,60 @@ export const SetGlobalWorkflowVariables: ToolCatalogEntry = {
requiredPermission: 'write',
}

export const ShareFile: ToolCatalogEntry = {
id: 'share_file',
name: 'share_file',
route: 'sim',
mode: 'async',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
description: 'Whether to create/update the share link or deactivate it.',
enum: ['share', 'unshare'],
default: 'share',
},
allowedEmails: {
type: 'array',
description:
'Allowed emails or "@domain" patterns for authType "email" or "sso". Ignored for other auth types.',
items: { type: 'string' },
},
authType: {
type: 'string',
description: 'How viewers authenticate to open the link. Ignored for unshare.',
enum: ['public', 'password', 'email', 'sso'],
default: 'public',
},
password: {
type: 'string',
description:
'Password for authType "password". Leave empty to keep the file\'s existing password when re-sharing an already password-protected file. Ignored for other auth types.',
},
path: {
type: 'string',
description: 'Canonical workspace file VFS path to share, e.g. "files/Reports/Q4.md".',
},
},
required: ['path'],
},
resultSchema: {
type: 'object',
properties: {
data: {
type: 'object',
description:
'Share state. Contains url (the {baseUrl}/f/{token} link), token, authType, hasPassword, and isActive.',
},
message: { type: 'string', description: 'Human-readable outcome.' },
success: { type: 'boolean', description: 'Whether the share action succeeded.' },
},
required: ['success', 'message'],
},
requiredPermission: 'write',
}

export const Table: ToolCatalogEntry = {
id: 'table',
name: 'table',
Expand Down Expand Up @@ -4879,6 +4935,7 @@ export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {
[SetBlockEnabled.id]: SetBlockEnabled,
[SetEnvironmentVariables.id]: SetEnvironmentVariables,
[SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables,
[ShareFile.id]: ShareFile,
[Table.id]: Table,
[UpdateDeploymentVersion.id]: UpdateDeploymentVersion,
[UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory,
Expand Down
56 changes: 56 additions & 0 deletions apps/sim/lib/copilot/generated/tool-schemas-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3715,6 +3715,62 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
},
resultSchema: undefined,
},
share_file: {
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
description: 'Whether to create/update the share link or deactivate it.',
enum: ['share', 'unshare'],
default: 'share',
},
allowedEmails: {
type: 'array',
description:
'Allowed emails or "@domain" patterns for authType "email" or "sso". Ignored for other auth types.',
items: {
type: 'string',
},
},
authType: {
type: 'string',
description: 'How viewers authenticate to open the link. Ignored for unshare.',
enum: ['public', 'password', 'email', 'sso'],
default: 'public',
},
password: {
type: 'string',
description:
'Password for authType "password". Leave empty to keep the file\'s existing password when re-sharing an already password-protected file. Ignored for other auth types.',
},
path: {
type: 'string',
description: 'Canonical workspace file VFS path to share, e.g. "files/Reports/Q4.md".',
},
},
required: ['path'],
},
resultSchema: {
type: 'object',
properties: {
data: {
type: 'object',
description:
'Share state. Contains url (the {baseUrl}/f/{token} link), token, authType, hasPassword, and isActive.',
},
message: {
type: 'string',
description: 'Human-readable outcome.',
},
success: {
type: 'boolean',
description: 'Whether the share action succeeded.',
},
},
required: ['success', 'message'],
},
},
table: {
parameters: {
properties: {
Expand Down
186 changes: 186 additions & 0 deletions apps/sim/lib/copilot/tools/server/files/share-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
import { ShareFile } from '@/lib/copilot/generated/tool-catalog-v1'
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
import {
assertServerToolNotAborted,
type BaseServerTool,
type ServerToolContext,
} from '@/lib/copilot/tools/server/base-tool'
import {
getShareForResource,
ShareValidationError,
upsertFileShare,
} from '@/lib/public-shares/share-manager'
import {
getWorkspaceFile,
resolveWorkspaceFileReference,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import {
PublicFileSharingNotAllowedError,
validatePublicFileSharing,
} from '@/ee/access-control/utils/permission-check'

const logger = createLogger('ShareFileServerTool')

interface ShareFileArgs {
path?: string
fileId?: string
action?: 'share' | 'unshare'
authType?: ShareAuthType
password?: string
allowedEmails?: string[]
args?: Record<string, unknown>
}

interface ShareFileResult {
success: boolean
message: string
data?: {
url: string
token: string
authType: ShareAuthType
hasPassword: boolean
isActive: boolean
}
}

export const shareFileServerTool: BaseServerTool<ShareFileArgs, ShareFileResult> = {
name: ShareFile.id,
async execute(params: ShareFileArgs, context?: ServerToolContext): Promise<ShareFileResult> {
if (!context?.userId) {
throw new Error('Authentication required')
}
const workspaceId = context.workspaceId
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')

const nested = params.args
const path = params.path || (nested?.path as string) || ''
const legacyFileId = params.fileId || (nested?.fileId as string) || ''
const action = (params.action || (nested?.action as string) || 'share') as 'share' | 'unshare'
const authType = (params.authType || (nested?.authType as ShareAuthType | undefined)) as
| ShareAuthType
| undefined
const password = params.password || (nested?.password as string) || undefined
const allowedEmails =
params.allowedEmails || (nested?.allowedEmails as string[] | undefined) || undefined

const targetRef = path || legacyFileId
if (!targetRef) return { success: false, message: 'path is required' }

const existingFile = path
? await resolveWorkspaceFileReference(workspaceId, path)
: await getWorkspaceFile(workspaceId, legacyFileId)
if (!existingFile) {
return { success: false, message: `File not found: ${targetRef}` }
}
const fileId = existingFile.id
const isActive = action !== 'unshare'
const existingShare = await getShareForResource('file', fileId)

// Unsharing a file that was never shared (or is already disabled) is a no-op:
// never insert an inactive row, emit a FILE_SHARE_DISABLED audit, or return a
// link claiming a share was revoked when none existed.
if (!isActive && !existingShare?.isActive) {
return {
success: true,
message: `"${existingFile.name}" isn't shared — nothing to unshare.`,
}
}

// Enabling a share is gated by the org's access-control policy (both the
// master on/off and the per-auth-type allow-list); disabling is always
// allowed so users can still un-share after the policy is turned on.
if (isActive) {
// Validate the auth type that will ACTUALLY be persisted. upsertFileShare
// falls back to the existing share's authType when none is passed, so a bare
// re-enable must be checked against that stored mode — not 'public' — or a
// now-disallowed password/email/sso share could be silently reactivated.
const effectiveAuthType = authType ?? existingShare?.authType ?? 'public'
try {
await validatePublicFileSharing(context.userId, workspaceId, effectiveAuthType)
} catch (error) {
if (error instanceof PublicFileSharingNotAllowedError) {
return { success: false, message: error.message }
}
throw error
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

assertServerToolNotAborted(context)

let share
try {
share = await upsertFileShare({
workspaceId,
fileId,
userId: context.userId,
isActive,
authType,
password,
allowedEmails,
})
Comment thread
cursor[bot] marked this conversation as resolved.
} catch (error) {
if (error instanceof ShareValidationError) {
return { success: false, message: error.message }
}
throw error
}

logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file via share_file`, {
fileId,
workspaceId,
authType: share.authType,
userId: context.userId,
})

recordAudit({
workspaceId,
actorId: context.userId,
action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED,
resourceType: AuditResourceType.FILE,
resourceId: fileId,
resourceName: existingFile.name,
description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${existingFile.name}"`,
})

if (!isActive) {
return {
success: true,
message: `Stopped sharing "${existingFile.name}". The previous link no longer works.`,
data: {
url: share.url,
token: share.token,
authType: share.authType,
hasPassword: share.hasPassword,
isActive: share.isActive,
},
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

const authNote =
share.authType === 'password'
? ' (password-protected — share the password separately)'
: share.authType === 'email'
? ' (restricted to allowed emails via one-time code)'
: share.authType === 'sso'
? ' (restricted to allowed emails via SSO)'
: ''

return {
success: true,
message: `Shared "${existingFile.name}"${authNote}: ${share.url}`,
data: {
url: share.url,
token: share.token,
authType: share.authType,
hasPassword: share.hasPassword,
isActive: share.isActive,
},
}
},
}
3 changes: 3 additions & 0 deletions apps/sim/lib/copilot/tools/server/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
renameFileFolderServerTool,
} from '@/lib/copilot/tools/server/files/file-folders'
import { renameFileServerTool } from '@/lib/copilot/tools/server/files/rename-file'
import { shareFileServerTool } from '@/lib/copilot/tools/server/files/share-file'
import { workspaceFileServerTool } from '@/lib/copilot/tools/server/files/workspace-file'
import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generated-schema'
import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image'
Expand Down Expand Up @@ -129,6 +130,7 @@ const WRITE_ACTIONS: Record<string, string[]> = {
[CreateFile.id]: ['*'],
rename_file: ['*'],
[DeleteFile.id]: ['*'],
[shareFileServerTool.name]: ['*'],
move_file: ['*'],
create_file_folder: ['*'],
rename_file_folder: ['*'],
Expand Down Expand Up @@ -176,6 +178,7 @@ const baseServerToolRegistry: Record<string, BaseServerTool> = {
[createFileServerTool.name]: createFileServerTool,
[renameFileServerTool.name]: renameFileServerTool,
[deleteFileServerTool.name]: deleteFileServerTool,
[shareFileServerTool.name]: shareFileServerTool,
[moveFileServerTool.name]: moveFileServerTool,
[listFileFoldersServerTool.name]: listFileFoldersServerTool,
[createFileFolderServerTool.name]: createFileFolderServerTool,
Expand Down
Loading
Loading