Chore/read write tools update (#5275)

* add tools warning

* Enhance file handling tools with security features

- Introduced new input parameters: workspacePath, enforceWorkspaceBoundaries, maxFileSize, and allowedExtensions for better control over file operations.
- Added validation for file paths and sizes to prevent unsafe operations.
- Implemented workspace boundary checks to restrict file access based on user-defined settings.
This commit is contained in:
Henry Heng 2025-10-08 10:56:01 +01:00 committed by GitHub
parent a0dca552a2
commit 1fb12cd931
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 392 additions and 22 deletions

View File

@ -1,9 +1,10 @@
import { z } from 'zod' import { z } from 'zod'
import path from 'path'
import { StructuredTool, ToolParams } from '@langchain/core/tools' import { StructuredTool, ToolParams } from '@langchain/core/tools'
import { Serializable } from '@langchain/core/load/serializable' import { Serializable } from '@langchain/core/load/serializable'
import { NodeFileStore } from 'langchain/stores/file/node'
import { INode, INodeData, INodeParams } from '../../../src/Interface' import { INode, INodeData, INodeParams } from '../../../src/Interface'
import { getBaseClasses } from '../../../src/utils' import { getBaseClasses, getUserHome } from '../../../src/utils'
import { SecureFileStore, FileSecurityConfig } from '../../../src/SecureFileStore'
abstract class BaseFileStore extends Serializable { abstract class BaseFileStore extends Serializable {
abstract readFile(path: string): Promise<string> abstract readFile(path: string): Promise<string>
@ -20,30 +21,91 @@ class ReadFile_Tools implements INode {
category: string category: string
baseClasses: string[] baseClasses: string[]
inputs: INodeParams[] inputs: INodeParams[]
warning: string
constructor() { constructor() {
this.label = 'Read File' this.label = 'Read File'
this.name = 'readFile' this.name = 'readFile'
this.version = 1.0 this.version = 2.0
this.type = 'ReadFile' this.type = 'ReadFile'
this.icon = 'readfile.svg' this.icon = 'readfile.svg'
this.category = 'Tools' this.category = 'Tools'
this.warning = 'This tool can be used to read files from the disk. It is recommended to use this tool with caution.'
this.description = 'Read file from disk' this.description = 'Read file from disk'
this.baseClasses = [this.type, 'Tool', ...getBaseClasses(ReadFileTool)] this.baseClasses = [this.type, 'Tool', ...getBaseClasses(ReadFileTool)]
this.inputs = [ this.inputs = [
{ {
label: 'Base Path', label: 'Workspace Path',
name: 'basePath', name: 'workspacePath',
placeholder: `C:\\Users\\User\\Desktop`, placeholder: `C:\\Users\\User\\MyProject`,
type: 'string', type: 'string',
description: 'Base workspace directory for file operations. All file paths will be relative to this directory.',
optional: true
},
{
label: 'Enforce Workspace Boundaries',
name: 'enforceWorkspaceBoundaries',
type: 'boolean',
description: 'When enabled, restricts file access to the workspace directory for security. Recommended: true',
default: true,
optional: true
},
{
label: 'Max File Size (MB)',
name: 'maxFileSize',
type: 'number',
description: 'Maximum file size in megabytes that can be read',
default: 10,
optional: true
},
{
label: 'Allowed Extensions',
name: 'allowedExtensions',
type: 'string',
description: 'Comma-separated list of allowed file extensions (e.g., .txt,.json,.md). Leave empty to allow all.',
placeholder: '.txt,.json,.md,.py,.js',
optional: true optional: true
} }
] ]
} }
async init(nodeData: INodeData): Promise<any> { async init(nodeData: INodeData): Promise<any> {
const basePath = nodeData.inputs?.basePath as string const workspacePath = nodeData.inputs?.workspacePath as string
const store = basePath ? new NodeFileStore(basePath) : new NodeFileStore() const enforceWorkspaceBoundaries = nodeData.inputs?.enforceWorkspaceBoundaries !== false // Default to true
const maxFileSize = nodeData.inputs?.maxFileSize as number
const allowedExtensions = nodeData.inputs?.allowedExtensions as string
// Parse allowed extensions
const allowedExtensionsList = allowedExtensions ? allowedExtensions.split(',').map((ext) => ext.trim().toLowerCase()) : []
let store: BaseFileStore
if (workspacePath) {
// Create secure file store with workspace boundaries
const config: FileSecurityConfig = {
workspacePath,
enforceWorkspaceBoundaries,
maxFileSize: maxFileSize ? maxFileSize * 1024 * 1024 : undefined, // Convert MB to bytes
allowedExtensions: allowedExtensionsList.length > 0 ? allowedExtensionsList : undefined
}
store = new SecureFileStore(config)
} else {
// Fallback to current working directory with security warnings
if (enforceWorkspaceBoundaries) {
const fallbackWorkspacePath = path.join(getUserHome(), '.flowise')
console.warn(`[ReadFile] No workspace path specified, using ${fallbackWorkspacePath} with security restrictions`)
store = new SecureFileStore({
workspacePath: fallbackWorkspacePath,
enforceWorkspaceBoundaries: true,
maxFileSize: maxFileSize ? maxFileSize * 1024 * 1024 : undefined,
allowedExtensions: allowedExtensionsList.length > 0 ? allowedExtensionsList : undefined
})
} else {
console.warn('[ReadFile] SECURITY WARNING: Workspace boundaries disabled - unrestricted file access enabled')
store = SecureFileStore.createUnsecure()
}
}
return new ReadFileTool({ store }) return new ReadFileTool({ store })
} }
} }

View File

@ -1,9 +1,10 @@
import { z } from 'zod' import { z } from 'zod'
import path from 'path'
import { StructuredTool, ToolParams } from '@langchain/core/tools' import { StructuredTool, ToolParams } from '@langchain/core/tools'
import { Serializable } from '@langchain/core/load/serializable' import { Serializable } from '@langchain/core/load/serializable'
import { NodeFileStore } from 'langchain/stores/file/node'
import { INode, INodeData, INodeParams } from '../../../src/Interface' import { INode, INodeData, INodeParams } from '../../../src/Interface'
import { getBaseClasses } from '../../../src/utils' import { getBaseClasses, getUserHome } from '../../../src/utils'
import { SecureFileStore, FileSecurityConfig } from '../../../src/SecureFileStore'
abstract class BaseFileStore extends Serializable { abstract class BaseFileStore extends Serializable {
abstract readFile(path: string): Promise<string> abstract readFile(path: string): Promise<string>
@ -20,30 +21,91 @@ class WriteFile_Tools implements INode {
category: string category: string
baseClasses: string[] baseClasses: string[]
inputs: INodeParams[] inputs: INodeParams[]
warning: string
constructor() { constructor() {
this.label = 'Write File' this.label = 'Write File'
this.name = 'writeFile' this.name = 'writeFile'
this.version = 1.0 this.version = 2.0
this.type = 'WriteFile' this.type = 'WriteFile'
this.icon = 'writefile.svg' this.icon = 'writefile.svg'
this.category = 'Tools' this.category = 'Tools'
this.warning = 'This tool can be used to write files to the disk. It is recommended to use this tool with caution.'
this.description = 'Write file to disk' this.description = 'Write file to disk'
this.baseClasses = [this.type, 'Tool', ...getBaseClasses(WriteFileTool)] this.baseClasses = [this.type, 'Tool', ...getBaseClasses(WriteFileTool)]
this.inputs = [ this.inputs = [
{ {
label: 'Base Path', label: 'Workspace Path',
name: 'basePath', name: 'workspacePath',
placeholder: `C:\\Users\\User\\Desktop`, placeholder: `C:\\Users\\User\\MyProject`,
type: 'string', type: 'string',
description: 'Base workspace directory for file operations. All file paths will be relative to this directory.',
optional: true
},
{
label: 'Enforce Workspace Boundaries',
name: 'enforceWorkspaceBoundaries',
type: 'boolean',
description: 'When enabled, restricts file access to the workspace directory for security. Recommended: true',
default: true,
optional: true
},
{
label: 'Max File Size (MB)',
name: 'maxFileSize',
type: 'number',
description: 'Maximum file size in megabytes that can be written',
default: 10,
optional: true
},
{
label: 'Allowed Extensions',
name: 'allowedExtensions',
type: 'string',
description: 'Comma-separated list of allowed file extensions (e.g., .txt,.json,.md). Leave empty to allow all.',
placeholder: '.txt,.json,.md,.py,.js',
optional: true optional: true
} }
] ]
} }
async init(nodeData: INodeData): Promise<any> { async init(nodeData: INodeData): Promise<any> {
const basePath = nodeData.inputs?.basePath as string const workspacePath = nodeData.inputs?.workspacePath as string
const store = basePath ? new NodeFileStore(basePath) : new NodeFileStore() const enforceWorkspaceBoundaries = nodeData.inputs?.enforceWorkspaceBoundaries !== false // Default to true
const maxFileSize = nodeData.inputs?.maxFileSize as number
const allowedExtensions = nodeData.inputs?.allowedExtensions as string
// Parse allowed extensions
const allowedExtensionsList = allowedExtensions ? allowedExtensions.split(',').map((ext) => ext.trim().toLowerCase()) : []
let store: BaseFileStore
if (workspacePath) {
// Create secure file store with workspace boundaries
const config: FileSecurityConfig = {
workspacePath,
enforceWorkspaceBoundaries,
maxFileSize: maxFileSize ? maxFileSize * 1024 * 1024 : undefined, // Convert MB to bytes
allowedExtensions: allowedExtensionsList.length > 0 ? allowedExtensionsList : undefined
}
store = new SecureFileStore(config)
} else {
// Fallback to current working directory with security warnings
if (enforceWorkspaceBoundaries) {
const fallbackWorkspacePath = path.join(getUserHome(), '.flowise')
console.warn(`[WriteFile] No workspace path specified, using ${fallbackWorkspacePath} with security restrictions`)
store = new SecureFileStore({
workspacePath: fallbackWorkspacePath,
enforceWorkspaceBoundaries: true,
maxFileSize: maxFileSize ? maxFileSize * 1024 * 1024 : undefined,
allowedExtensions: allowedExtensionsList.length > 0 ? allowedExtensionsList : undefined
})
} else {
console.warn('[WriteFile] SECURITY WARNING: Workspace boundaries disabled - unrestricted file access enabled')
store = SecureFileStore.createUnsecure()
}
}
return new WriteFileTool({ store }) return new WriteFileTool({ store })
} }
} }
@ -68,7 +130,7 @@ export class WriteFileTool extends StructuredTool {
name = 'write_file' name = 'write_file'
description = 'Write file from disk' description = 'Write file to disk'
store: BaseFileStore store: BaseFileStore
@ -80,7 +142,7 @@ export class WriteFileTool extends StructuredTool {
async _call({ file_path, text }: z.infer<typeof this.schema>) { async _call({ file_path, text }: z.infer<typeof this.schema>) {
await this.store.writeFile(file_path, text) await this.store.writeFile(file_path, text)
return 'File written to successfully.' return `File written to ${file_path} successfully.`
} }
} }

View File

@ -134,6 +134,7 @@ export interface INodeProperties {
documentation?: string documentation?: string
color?: string color?: string
hint?: string hint?: string
warning?: string
} }
export interface INode extends INodeProperties { export interface INode extends INodeProperties {

View File

@ -0,0 +1,167 @@
import { Serializable } from '@langchain/core/load/serializable'
import { NodeFileStore } from 'langchain/stores/file/node'
import { isUnsafeFilePath, isWithinWorkspace } from './validator'
import * as path from 'path'
import * as fs from 'fs'
/**
* Security configuration for file operations
*/
export interface FileSecurityConfig {
/** Base workspace path - all file operations are restricted to this directory */
workspacePath: string
/** Whether to enforce workspace boundaries (default: true) */
enforceWorkspaceBoundaries?: boolean
/** Maximum file size in bytes (default: 10MB) */
maxFileSize?: number
/** Allowed file extensions (if empty, all extensions allowed) */
allowedExtensions?: string[]
/** Blocked file extensions */
blockedExtensions?: string[]
}
/**
* Secure file store that enforces workspace boundaries and validates file operations
*/
export class SecureFileStore extends Serializable {
lc_namespace = ['flowise', 'components', 'stores', 'file']
private config: Required<FileSecurityConfig>
private nodeFileStore: NodeFileStore
constructor(config: FileSecurityConfig) {
super()
// Set default configuration
this.config = {
workspacePath: config.workspacePath,
enforceWorkspaceBoundaries: config.enforceWorkspaceBoundaries ?? true,
maxFileSize: config.maxFileSize ?? 10 * 1024 * 1024, // 10MB default
allowedExtensions: config.allowedExtensions ?? [],
blockedExtensions: config.blockedExtensions ?? [
'.exe',
'.bat',
'.cmd',
'.sh',
'.ps1',
'.vbs',
'.scr',
'.com',
'.pif',
'.dll',
'.sys',
'.msi',
'.jar'
]
}
// Validate workspace path
if (!this.config.workspacePath || !path.isAbsolute(this.config.workspacePath)) {
throw new Error('Workspace path must be an absolute path')
}
// Ensure workspace directory exists
if (!fs.existsSync(this.config.workspacePath)) {
throw new Error(`Workspace directory does not exist: ${this.config.workspacePath}`)
}
// Initialize the underlying NodeFileStore with workspace path
this.nodeFileStore = new NodeFileStore(this.config.workspacePath)
}
/**
* Validates a file path against security policies
*/
private validateFilePath(filePath: string): void {
// Check for unsafe path patterns
if (isUnsafeFilePath(filePath)) {
throw new Error(`Unsafe file path detected: ${filePath}`)
}
// Enforce workspace boundaries if enabled
if (this.config.enforceWorkspaceBoundaries) {
if (!isWithinWorkspace(filePath, this.config.workspacePath)) {
throw new Error(`File path outside workspace boundaries: ${filePath}`)
}
}
// Check file extension
const ext = path.extname(filePath).toLowerCase()
// Check blocked extensions
if (this.config.blockedExtensions.includes(ext)) {
throw new Error(`File extension not allowed: ${ext}`)
}
// Check allowed extensions (if specified)
if (this.config.allowedExtensions.length > 0 && !this.config.allowedExtensions.includes(ext)) {
throw new Error(`File extension not in allowed list: ${ext}`)
}
}
/**
* Validates file size
*/
private validateFileSize(content: string): void {
const sizeInBytes = Buffer.byteLength(content, 'utf8')
if (sizeInBytes > this.config.maxFileSize) {
throw new Error(`File size exceeds maximum allowed size: ${sizeInBytes} > ${this.config.maxFileSize}`)
}
}
/**
* Reads a file with security validation
*/
async readFile(filePath: string): Promise<string> {
this.validateFilePath(filePath)
try {
return await this.nodeFileStore.readFile(filePath)
} catch (error) {
// Provide generic error message to avoid information leakage
throw new Error(`Failed to read file: ${path.basename(filePath)}`)
}
}
/**
* Writes a file with security validation
*/
async writeFile(filePath: string, contents: string): Promise<void> {
this.validateFilePath(filePath)
this.validateFileSize(contents)
try {
// Ensure the directory exists
const dir = path.dirname(path.resolve(this.config.workspacePath, filePath))
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
await this.nodeFileStore.writeFile(filePath, contents)
} catch (error) {
// Provide generic error message to avoid information leakage
throw new Error(`Failed to write file: ${path.basename(filePath)}`)
}
}
/**
* Gets the workspace configuration
*/
getConfig(): Readonly<Required<FileSecurityConfig>> {
return { ...this.config }
}
/**
* Creates a secure file store with workspace enforcement disabled (for backward compatibility)
* WARNING: This should only be used when absolutely necessary and with proper user consent
*/
static createUnsecure(basePath?: string): SecureFileStore {
const workspacePath = basePath || process.cwd()
return new SecureFileStore({
workspacePath,
enforceWorkspaceBoundaries: false,
maxFileSize: 50 * 1024 * 1024, // 50MB for unsecure mode
blockedExtensions: [] // No extension restrictions in unsecure mode
})
}
}

View File

@ -41,3 +41,64 @@ export const isPathTraversal = (path: string): boolean => {
return dangerousPatterns.some((pattern) => path.toLowerCase().includes(pattern)) return dangerousPatterns.some((pattern) => path.toLowerCase().includes(pattern))
} }
/**
* Enhanced path validation for workspace-scoped file operations
* @param {string} filePath The file path to validate
* @returns {boolean} True if path traversal detected, false otherwise
*/
export const isUnsafeFilePath = (filePath: string): boolean => {
if (!filePath || typeof filePath !== 'string') {
return true
}
// Check for path traversal patterns
const dangerousPatterns = [
/\.\./, // Directory traversal (..)
/%2e%2e/i, // URL encoded ..
/%2f/i, // URL encoded /
/%5c/i, // URL encoded \
/\0/, // Null bytes
// eslint-disable-next-line no-control-regex
/[\x00-\x1f]/, // Control characters
/^\/[^/]/, // Absolute Unix paths (starting with /)
/^[a-zA-Z]:\\/, // Absolute Windows paths (C:\)
/^\\\\[^\\]/, // UNC paths (\\server\)
/^\\\\\?\\/ // Extended-length paths (\\?\)
]
return dangerousPatterns.some((pattern) => pattern.test(filePath))
}
/**
* Validates if a file path is within the allowed workspace boundaries
* @param {string} filePath The file path to validate
* @param {string} workspacePath The workspace base path
* @returns {boolean} True if path is within workspace, false otherwise
*/
export const isWithinWorkspace = (filePath: string, workspacePath: string): boolean => {
if (!filePath || !workspacePath) {
return false
}
try {
const path = require('path')
// Resolve both paths to absolute paths
const resolvedFilePath = path.resolve(workspacePath, filePath)
const resolvedWorkspacePath = path.resolve(workspacePath)
// Normalize paths to handle different separators
const normalizedFilePath = path.normalize(resolvedFilePath)
const normalizedWorkspacePath = path.normalize(resolvedWorkspacePath)
// Check if the file path starts with the workspace path
const relativePath = path.relative(normalizedWorkspacePath, normalizedFilePath)
// If relative path starts with '..' or is absolute, it's outside workspace
return !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
} catch (error) {
// If any error occurs during path resolution, deny access
return false
}
}

View File

@ -191,6 +191,8 @@ const AgentFlowNode = ({ data }) => {
componentNode?.deprecateMessage ?? componentNode?.deprecateMessage ??
'This node will be deprecated in the next release. Change to a new node tagged with NEW' 'This node will be deprecated in the next release. Change to a new node tagged with NEW'
) )
} else if (componentNode.warning) {
setWarningMessage(componentNode.warning)
} else { } else {
setWarningMessage('') setWarningMessage('')
} }

View File

@ -3,10 +3,10 @@ import PropTypes from 'prop-types'
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
// Material // Material
import { Accordion, AccordionSummary, AccordionDetails, Box, Typography } from '@mui/material' import { Accordion, AccordionSummary, AccordionDetails, Box, Typography, Tooltip, IconButton } from '@mui/material'
import { useTheme } from '@mui/material/styles' import { useTheme } from '@mui/material/styles'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { IconSettings } from '@tabler/icons-react' import { IconSettings, IconAlertTriangle } from '@tabler/icons-react'
// Project imports // Project imports
import NodeInputHandler from '../canvas/NodeInputHandler' import NodeInputHandler from '../canvas/NodeInputHandler'
@ -292,8 +292,21 @@ export const ConfigInput = ({ data, inputParam, disabled = false, arrayIndex = n
> >
<Accordion sx={{ background: 'transparent' }} expanded={expanded} onChange={handleAccordionChange}> <Accordion sx={{ background: 'transparent' }} expanded={expanded} onChange={handleAccordionChange}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ background: 'transparent' }}> <AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ background: 'transparent' }}>
<IconSettings stroke={1.5} size='1.3rem' /> <div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Typography sx={{ ml: 1 }}>{selectedComponentNodeData?.label} Parameters</Typography> <IconSettings stroke={1.5} size='1.3rem' />
<Typography sx={{ ml: 1 }}>{selectedComponentNodeData?.label} Parameters</Typography>
<div style={{ flexGrow: 1 }}></div>
{selectedComponentNodeData?.warning && (
<Tooltip
title={<span style={{ whiteSpace: 'pre-line' }}>{selectedComponentNodeData.warning}</span>}
placement='top'
>
<IconButton sx={{ height: 35, width: 35 }}>
<IconAlertTriangle size={20} color='orange' />
</IconButton>
</Tooltip>
)}
</div>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
{(selectedComponentNodeData.inputParams ?? []) {(selectedComponentNodeData.inputParams ?? [])

View File

@ -82,6 +82,8 @@ const CanvasNode = ({ data }) => {
componentNode?.deprecateMessage ?? componentNode?.deprecateMessage ??
'This node will be deprecated in the next release. Change to a new node tagged with NEW' 'This node will be deprecated in the next release. Change to a new node tagged with NEW'
) )
} else if (componentNode.warning) {
setWarningMessage(componentNode.warning)
} else { } else {
setWarningMessage('') setWarningMessage('')
} }