diff --git a/src/backend/base/langflow/agentic/flows/LangflowAssistant.json b/src/backend/base/langflow/agentic/flows/LangflowAssistant.json index 2d79a004d086..aafd88c66dc0 100644 --- a/src/backend/base/langflow/agentic/flows/LangflowAssistant.json +++ b/src/backend/base/langflow/agentic/flows/LangflowAssistant.json @@ -90,35 +90,8 @@ "animated": false, "data": { "sourceHandle": { - "dataType": "Directory", - "id": "Directory-JiAX8", - "name": "dataframe", - "output_types": [ - "Table" - ] - }, - "targetHandle": { - "fieldName": "dataframe", - "id": "DataFrameKeywordSearch-b8MUN", - "inputTypes": [ - "DataFrame" - ], - "type": "other" - } - }, - "id": "reactflow__edge-Directory-JiAX8{œdataTypeœ:œDirectoryœ,œidœ:œDirectory-JiAX8œ,œnameœ:œdataframeœ,œoutput_typesœ:[œTableœ]}-DataFrameKeywordSearch-b8MUN{œfieldNameœ:œdataframeœ,œidœ:œDataFrameKeywordSearch-b8MUNœ,œinputTypesœ:[œDataFrameœ],œtypeœ:œotherœ}", - "selected": false, - "source": "Directory-JiAX8", - "sourceHandle": "{œdataTypeœ:œDirectoryœ,œidœ:œDirectory-JiAX8œ,œnameœ:œdataframeœ,œoutput_typesœ:[œTableœ]}", - "target": "DataFrameKeywordSearch-b8MUN", - "targetHandle": "{œfieldNameœ:œdataframeœ,œidœ:œDataFrameKeywordSearch-b8MUNœ,œinputTypesœ:[œDataFrameœ],œtypeœ:œotherœ}" - }, - { - "animated": false, - "data": { - "sourceHandle": { - "dataType": "DataFrameKeywordSearch", - "id": "DataFrameKeywordSearch-b8MUN", + "dataType": "ComponentLibrarySearch", + "id": "ComponentLibrarySearch-b8MUN", "name": "component_as_tool", "output_types": [ "Tool" @@ -133,10 +106,10 @@ "type": "other" } }, - "id": "reactflow__edge-DataFrameKeywordSearch-b8MUN{œdataTypeœ:œDataFrameKeywordSearchœ,œidœ:œDataFrameKeywordSearch-b8MUNœ,œnameœ:œcomponent_as_toolœ,œoutput_typesœ:[œToolœ]}-Agent-M69JY{œfieldNameœ:œtoolsœ,œidœ:œAgent-M69JYœ,œinputTypesœ:[œToolœ],œtypeœ:œotherœ}", + "id": "reactflow__edge-ComponentLibrarySearch-b8MUN{œdataTypeœ:œComponentLibrarySearchœ,œidœ:œComponentLibrarySearch-b8MUNœ,œnameœ:œcomponent_as_toolœ,œoutput_typesœ:[œToolœ]}-Agent-M69JY{œfieldNameœ:œtoolsœ,œidœ:œAgent-M69JYœ,œinputTypesœ:[œToolœ],œtypeœ:œotherœ}", "selected": false, - "source": "DataFrameKeywordSearch-b8MUN", - "sourceHandle": "{œdataTypeœ:œDataFrameKeywordSearchœ,œidœ:œDataFrameKeywordSearch-b8MUNœ,œnameœ:œcomponent_as_toolœ,œoutput_typesœ:[œToolœ]}", + "source": "ComponentLibrarySearch-b8MUN", + "sourceHandle": "{œdataTypeœ:œComponentLibrarySearchœ,œidœ:œComponentLibrarySearch-b8MUNœ,œnameœ:œcomponent_as_toolœ,œoutput_typesœ:[œToolœ]}", "target": "Agent-M69JY", "targetHandle": "{œfieldNameœ:œtoolsœ,œidœ:œAgent-M69JYœ,œinputTypesœ:[œToolœ],œtypeœ:œotherœ}" }, @@ -2049,301 +2022,7 @@ }, { "data": { - "id": "Directory-JiAX8", - "node": { - "base_classes": [ - "Table" - ], - "beta": false, - "conditional_paths": [], - "custom_fields": {}, - "description": "Recursively load files from a directory.", - "display_name": "Directory", - "documentation": "https://docs.langflow.org/directory", - "edited": false, - "field_order": [ - "path", - "types", - "depth", - "max_concurrency", - "load_hidden", - "recursive", - "silent_errors", - "use_multithreading" - ], - "frozen": false, - "icon": "folder", - "legacy": true, - "metadata": { - "code_hash": "e05a90393ad4", - "dependencies": { - "dependencies": [ - { - "name": "lfx", - "version": null - } - ], - "total_dependencies": 1 - }, - "module": "custom_components.directory" - }, - "minimized": false, - "output_types": [], - "outputs": [ - { - "allows_loop": false, - "cache": true, - "display_name": "Loaded Files", - "group_outputs": false, - "loop_types": null, - "method": "as_dataframe", - "name": "dataframe", - "options": null, - "required_inputs": null, - "selected": "Table", - "tool_mode": true, - "types": [ - "Table" - ], - "value": "__UNDEFINED__" - } - ], - "pinned": false, - "replacement": [ - "data.File" - ], - "template": { - "_type": "Component", - "code": { - "advanced": true, - "dynamic": true, - "fileTypes": [], - "file_path": "", - "info": "", - "list": false, - "load_from_db": false, - "multiline": true, - "name": "code", - "password": false, - "placeholder": "", - "required": true, - "show": true, - "title_case": false, - "type": "code", - "value": "from pathlib import Path, PurePath, PureWindowsPath\n\nfrom lfx.base.data.utils import TEXT_FILE_TYPES, parallel_load_data, parse_text_file_to_data, retrieve_file_paths\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.io import BoolInput, IntInput, MessageTextInput, MultiselectInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.services.deps import get_settings_service\nfrom lfx.template.field.base import Output\nfrom lfx.utils.file_path_security import component_file_access_scopes, enforce_local_file_access\n\n\nclass DirectoryComponent(Component):\n display_name = \"Directory\"\n description = \"Recursively load files from a directory.\"\n documentation: str = \"https://docs.langflow.org/directory\"\n icon = \"folder\"\n name = \"Directory\"\n legacy = True\n replacement = [\"data.File\"]\n\n inputs = [\n MessageTextInput(\n name=\"path\",\n display_name=\"Path\",\n info=\"Path to the directory to load files from. Defaults to current directory ('.')\",\n value=\".\",\n tool_mode=True,\n ),\n MultiselectInput(\n name=\"types\",\n display_name=\"File Types\",\n info=\"File types to load. Select one or more types or leave empty to load all supported types.\",\n options=TEXT_FILE_TYPES,\n value=[],\n ),\n IntInput(\n name=\"depth\",\n display_name=\"Depth\",\n info=\"Depth to search for files.\",\n value=0,\n ),\n IntInput(\n name=\"max_concurrency\",\n display_name=\"Max Concurrency\",\n advanced=True,\n info=\"Maximum concurrency for loading files.\",\n value=2,\n ),\n BoolInput(\n name=\"load_hidden\",\n display_name=\"Load Hidden\",\n advanced=True,\n info=\"If true, hidden files will be loaded.\",\n ),\n BoolInput(\n name=\"recursive\",\n display_name=\"Recursive\",\n advanced=True,\n info=\"If true, the search will be recursive.\",\n ),\n BoolInput(\n name=\"silent_errors\",\n display_name=\"Silent Errors\",\n advanced=True,\n info=\"If true, errors will not raise an exception.\",\n ),\n BoolInput(\n name=\"use_multithreading\",\n display_name=\"Use Multithreading\",\n advanced=True,\n info=\"If true, multithreading will be used.\",\n ),\n ]\n\n outputs = [\n Output(display_name=\"Loaded Files\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n @staticmethod\n def _has_parent_reference(path: str) -> bool:\n path_parts = (*PurePath(path).parts, *PureWindowsPath(path).parts)\n return any(part == \"..\" for part in path_parts)\n\n def _allowed_roots(self) -> list[Path]:\n roots = {Path.cwd().resolve()}\n configured_roots = getattr(get_settings_service().settings, \"directory_component_allowed_roots\", []) or []\n for root in configured_roots:\n if root:\n roots.add(Path(root).expanduser().resolve())\n return list(roots)\n\n def _resolve_directory_path(self, path: str) -> str:\n path = str(path or \".\").strip()\n # Reject null bytes and parent references outright. A drive-absolute or UNC\n # path (e.g. ``D:\\\\shared\\\\docs``) is NOT blanket-rejected here: canonicalization\n # plus the ``_allowed_roots()`` containment check below decides whether it is\n # allowed, so operator-configured roots on another drive remain reachable.\n if \"\\x00\" in path or self._has_parent_reference(path):\n msg = \"Directory path escapes the allowed root.\"\n raise ValueError(msg)\n\n resolved_path = Path(self.resolve_path(path)).expanduser().resolve()\n if not any(resolved_path == root or resolved_path.is_relative_to(root) for root in self._allowed_roots()):\n msg = \"Directory path escapes the allowed root.\"\n raise ValueError(msg)\n return str(resolved_path)\n\n def load_directory(self) -> list[Data]:\n path = self.path\n types = self.types\n depth = self.depth\n max_concurrency = self.max_concurrency\n load_hidden = self.load_hidden\n recursive = self.recursive\n silent_errors = self.silent_errors\n use_multithreading = self.use_multithreading\n\n resolved_path = self._resolve_directory_path(path)\n\n # Security: confine directory reads to the storage dir in restricted (multi-tenant)\n # mode so a tenant cannot recursively read arbitrary server directories.\n resolved_path = str(enforce_local_file_access(resolved_path, scope_ids=component_file_access_scopes(self)))\n\n # If no types are specified, use all supported types\n if not types:\n types = TEXT_FILE_TYPES\n\n # Check if all specified types are valid\n invalid_types = [t for t in types if t not in TEXT_FILE_TYPES]\n if invalid_types:\n msg = f\"Invalid file types specified: {invalid_types}. Valid types are: {TEXT_FILE_TYPES}\"\n raise ValueError(msg)\n\n valid_types = types\n\n file_paths = retrieve_file_paths(\n resolved_path, load_hidden=load_hidden, recursive=recursive, depth=depth, types=valid_types\n )\n\n loaded_data = []\n if use_multithreading:\n loaded_data = parallel_load_data(file_paths, silent_errors=silent_errors, max_concurrency=max_concurrency)\n else:\n loaded_data = [parse_text_file_to_data(file_path, silent_errors=silent_errors) for file_path in file_paths]\n\n valid_data = [x for x in loaded_data if x is not None and isinstance(x, Data)]\n self.status = valid_data\n return valid_data\n\n def as_dataframe(self) -> DataFrame:\n return DataFrame(self.load_directory())\n" - }, - "depth": { - "_input_type": "IntInput", - "advanced": false, - "display_name": "Depth", - "dynamic": false, - "info": "Depth to search for files.", - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "depth", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "int", - "value": 2 - }, - "load_hidden": { - "_input_type": "BoolInput", - "advanced": true, - "display_name": "Load Hidden", - "dynamic": false, - "info": "If true, hidden files will be loaded.", - "list": false, - "list_add_label": "Add More", - "name": "load_hidden", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "bool", - "value": false - }, - "max_concurrency": { - "_input_type": "IntInput", - "advanced": true, - "display_name": "Max Concurrency", - "dynamic": false, - "info": "Maximum concurrency for loading files.", - "list": false, - "list_add_label": "Add More", - "name": "max_concurrency", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "int", - "value": 2 - }, - "path": { - "_input_type": "MessageTextInput", - "advanced": false, - "display_name": "Path", - "dynamic": false, - "info": "Path to the directory to load files from. Defaults to current directory ('.')", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "path", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": true, - "trace_as_input": true, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "./src/lfx/src/lfx/components/" - }, - "recursive": { - "_input_type": "BoolInput", - "advanced": true, - "display_name": "Recursive", - "dynamic": false, - "info": "If true, the search will be recursive.", - "list": false, - "list_add_label": "Add More", - "name": "recursive", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "bool", - "value": false - }, - "silent_errors": { - "_input_type": "BoolInput", - "advanced": true, - "display_name": "Silent Errors", - "dynamic": false, - "info": "If true, errors will not raise an exception.", - "list": false, - "list_add_label": "Add More", - "name": "silent_errors", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "bool", - "value": false - }, - "types": { - "_input_type": "MultiselectInput", - "advanced": false, - "combobox": false, - "display_name": "File Types", - "dynamic": false, - "info": "File types to load. Select one or more types or leave empty to load all supported types.", - "list": true, - "list_add_label": "Add More", - "load_from_db": false, - "name": "types", - "options": [ - "csv", - "json", - "pdf", - "txt", - "md", - "mdx", - "yaml", - "yml", - "xml", - "html", - "htm", - "docx", - "py", - "sh", - "sql", - "js", - "ts", - "tsx" - ], - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "toggle": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": [ - "py" - ] - }, - "use_multithreading": { - "_input_type": "BoolInput", - "advanced": true, - "display_name": "Use Multithreading", - "dynamic": false, - "info": "If true, multithreading will be used.", - "list": false, - "list_add_label": "Add More", - "name": "use_multithreading", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "bool", - "value": false - } - }, - "tool_mode": false - }, - "showNode": true, - "type": "Directory" - }, - "id": "Directory-JiAX8", - "measured": { - "height": 448, - "width": 320 - }, - "position": { - "x": 1326, - "y": 1101.5263019016602 - }, - "selected": false, - "type": "genericNode" - }, - { - "data": { - "id": "DataFrameKeywordSearch-b8MUN", + "id": "ComponentLibrarySearch-b8MUN", "node": { "base_classes": [ "DataFrame" @@ -2351,12 +2030,11 @@ "beta": false, "conditional_paths": [], "custom_fields": {}, - "description": "Search for keywords in a DataFrame column using any/all/coverage matching", - "display_name": "Keyword Search", + "description": "Search for keywords in the installed component library using any/all/coverage matching", + "display_name": "Component Library Search", "documentation": "", - "edited": true, + "edited": false, "field_order": [ - "dataframe", "column", "keywords", "match_type", @@ -2369,11 +2047,10 @@ "legacy": false, "lf_version": "1.8.0", "metadata": { - "code_hash": "972eaf9de4dc", "dependencies": { "dependencies": [ { - "name": "langflow", + "name": "lfx", "version": null }, { @@ -2383,7 +2060,7 @@ ], "total_dependencies": 2 }, - "module": "custom_components.keyword_search" + "module": "lfx.components.processing.component_library_search.ComponentLibrarySearch" }, "minimized": false, "output_types": [], @@ -2409,12 +2086,6 @@ ], "pinned": false, "template": { - "_frontend_node_flow_id": { - "value": "136f2c03-ca2c-4309-8024-655ae22974ab" - }, - "_frontend_node_folder_id": { - "value": "6308a83c-b798-4906-8f33-ba623fd01110" - }, "_type": "Component", "case_sensitive": { "_input_type": "BoolInput", @@ -2452,7 +2123,7 @@ "show": true, "title_case": false, "type": "code", - "value": "from langflow.custom import Component\nfrom langflow.io import DataFrameInput, MessageTextInput, DropdownInput, BoolInput, Output\nfrom langflow.schema import DataFrame\nimport pandas as pd\n\n\nclass DataFrameKeywordSearch(Component):\n display_name = \"Keyword Search\"\n description = \"Search for keywords in a DataFrame column using any/all/coverage matching\"\n icon = \"Search\"\n\n inputs = [\n DataFrameInput(\n name=\"dataframe\",\n display_name=\"DataFrame\",\n info=\"Input DataFrame to search\",\n ),\n MessageTextInput(\n name=\"column\",\n display_name=\"Column\",\n info=\"Column name to search in. Valid columns: 'file_path' (component file path) and 'text' (component source code).\",\n tool_mode=True,\n ),\n MessageTextInput(\n name=\"keywords\",\n display_name=\"Keywords\",\n info=\"Keywords to search for\",\n is_list=True,\n tool_mode=True,\n ),\n DropdownInput(\n name=\"match_type\",\n display_name=\"Match Type\",\n options=[\"any\", \"all\", \"coverage\"],\n value=\"any\",\n info=\"'any' = OR logic, 'all' = AND logic, 'coverage' = keyword coverage ranking\",\n ),\n BoolInput(\n name=\"case_sensitive\",\n display_name=\"Case Sensitive\",\n value=False,\n advanced=True,\n info=\"Whether search is case-sensitive\",\n ),\n IntInput(\n name=\"number_candidates\",\n display_name=\"Number of Candidates\",\n value=10,\n info=\"The number of candidates to filter and return in the dataframe output\")\n ]\n\n outputs = [\n Output(name=\"result\", display_name=\"Filtered DataFrame\", method=\"search\"),\n ]\n\n def search(self) -> DataFrame:\n df = self.dataframe\n column = self.column\n keywords = self.keywords if isinstance(self.keywords, list) else []\n match_type = self.match_type\n case_sensitive = self.case_sensitive\n\n if df is None or len(df) == 0:\n return DataFrame(pd.DataFrame())\n\n if column not in df.columns:\n available = \", \".join(str(c) for c in df.columns)\n msg = f\"Column '{column}' not found. Available columns: {available}\"\n raise ValueError(msg)\n\n keywords = [str(k).strip() for k in keywords if k]\n if not keywords:\n return DataFrame(df)\n\n text_series = df[column].fillna(\"\").astype(str)\n\n if not case_sensitive:\n text_series = text_series.str.lower()\n keywords = [k.lower() for k in keywords]\n\n if match_type == \"any\":\n mask = pd.Series([False] * len(df), index=df.index)\n for keyword in keywords:\n mask = mask | text_series.str.contains(keyword, regex=False)\n result = df[mask]\n\n elif match_type == \"all\":\n mask = pd.Series([True] * len(df), index=df.index)\n for keyword in keywords:\n mask = mask & text_series.str.contains(keyword, regex=False)\n result = df[mask]\n\n elif match_type == \"coverage\":\n scores = []\n total_keywords = len(keywords)\n\n for text in text_series:\n keywords_found = sum(1 for keyword in keywords if keyword in text)\n score = keywords_found / total_keywords\n scores.append(score)\n\n result = df.copy()\n result[\"_score\"] = scores\n result = result[result[\"_score\"] > 0].sort_values(\"_score\", ascending=False)\n\n return DataFrame(result.reset_index(drop=True)).head(self.number_candidates)" + "value": "from pathlib import Path\n\nimport pandas as pd\n\nimport lfx\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output\nfrom lfx.schema.dataframe import DataFrame\n\n\nclass ComponentLibrarySearch(Component):\n \"\"\"Keyword search over the installed Langflow component library.\n\n The packaged assistant flow used to build this table with a ``Directory`` node pointed at\n the installed ``lfx/components`` directory. That path is outside every user's storage\n scope, so ``restrict_local_file_access`` denied it, and the node's search partner was\n inline flow code with no registered server counterpart, so ``allow_custom_components``\n denied that. Both denials were about the flow reaching for tenant-gated machinery to read\n first-party product source.\n\n Reading the library here removes the need for either exemption. There is no path input --\n the root is derived from this package -- so no tenant-controlled path exists for\n ``enforce_local_file_access`` to gate, and the component is registered like any other, so\n the custom-component gate resolves it normally. The source it returns is the same source\n ``GET /api/v1/all`` already serves to every authenticated user.\n \"\"\"\n\n display_name = \"Component Library Search\"\n description = \"Search for keywords in the installed component library using any/all/coverage matching\"\n icon = \"Search\"\n\n inputs = [\n MessageTextInput(\n name=\"column\",\n display_name=\"Column\",\n info=\"Column name to search in. Valid columns: 'file_path' (component file path) \"\n \"and 'text' (component source code).\",\n value=\"file_path\",\n tool_mode=True,\n ),\n MessageTextInput(\n name=\"keywords\",\n display_name=\"Keywords\",\n info=\"Keywords to search for\",\n is_list=True,\n tool_mode=True,\n ),\n DropdownInput(\n name=\"match_type\",\n display_name=\"Match Type\",\n options=[\"any\", \"all\", \"coverage\"],\n value=\"any\",\n info=\"'any' = OR logic, 'all' = AND logic, 'coverage' = keyword coverage ranking\",\n ),\n BoolInput(\n name=\"case_sensitive\",\n display_name=\"Case Sensitive\",\n value=False,\n advanced=True,\n info=\"Whether search is case-sensitive\",\n ),\n IntInput(\n name=\"number_candidates\",\n display_name=\"Number of Candidates\",\n value=10,\n info=\"The number of candidates to filter and return in the dataframe output\",\n ),\n ]\n\n outputs = [\n Output(name=\"result\", display_name=\"Filtered DataFrame\", method=\"search\"),\n ]\n\n def _component_library(self) -> pd.DataFrame:\n \"\"\"Read the installed component library into ``file_path`` / ``text`` columns.\n\n Mirrors what the flow's ``Directory`` node read before: the same ``*/*.py`` shape at\n depth 2, minus package ``__init__`` files, which carry only re-export boilerplate.\n Errors propagate rather than yielding a partial table -- an unreadable component file\n means a broken install, and a silently short search result reads as \"no such\n component\", which is a wrong answer rather than a visible failure.\n \"\"\"\n root = (Path(lfx.__file__).parent / \"components\").resolve()\n if not root.is_dir():\n msg = f\"Component library not found at {root}.\"\n raise ValueError(msg)\n\n rows = []\n for path in sorted(root.glob(\"*/*.py\")):\n if path.name == \"__init__.py\":\n continue\n # ``glob`` follows symlinks, so a link named ``*.py`` inside the package would be\n # read from wherever it points. Nothing ships one, and creating one needs write\n # access to site-packages -- which is already game over -- but staying inside the\n # root we advertise is a one-line invariant rather than a trusted assumption.\n if not path.resolve().is_relative_to(root):\n continue\n rows.append({\"file_path\": str(path), \"text\": path.read_text(encoding=\"utf-8\")})\n if not rows:\n # Reporting an empty library is the GH #13618 symptom this component exists to\n # avoid: the agent reads it as \"no such component\" and says so confidently.\n msg = f\"Component library at {root} contains no readable component source.\"\n raise ValueError(msg)\n return pd.DataFrame(rows, columns=[\"file_path\", \"text\"])\n\n @staticmethod\n def _normalized_keywords(raw: object) -> list[str]:\n \"\"\"Coerce the tool's ``keywords`` argument to a non-empty list of search terms.\n\n A model routinely passes a bare string for a list-typed argument, which is an\n unambiguous single keyword rather than an error. Anything else is a malformed call:\n raise so the agent can correct itself, as the invalid-column path already does.\n Returning the unfiltered library instead would present arbitrary components as\n matches -- a confidently wrong answer rather than a visible failure.\n \"\"\"\n if isinstance(raw, str):\n raw = [raw]\n elif not isinstance(raw, list):\n msg = f\"keywords must be a list of strings, got {type(raw).__name__}.\"\n raise TypeError(msg)\n\n # Strip before testing for emptiness: a whitespace-only keyword strips to \"\" and\n # ``str.contains(\"\")`` matches every row.\n keywords = [stripped for stripped in (str(k).strip() for k in raw) if stripped]\n if not keywords:\n msg = \"keywords must contain at least one non-empty search term.\"\n raise ValueError(msg)\n return keywords\n\n def search(self) -> DataFrame:\n df = self._component_library()\n column = self.column\n match_type = self.match_type\n case_sensitive = self.case_sensitive\n\n if column not in df.columns:\n available = \", \".join(str(c) for c in df.columns)\n msg = f\"Column '{column}' not found. Available columns: {available}\"\n raise ValueError(msg)\n\n keywords = self._normalized_keywords(self.keywords)\n\n text_series = df[column].fillna(\"\").astype(str)\n\n if not case_sensitive:\n text_series = text_series.str.lower()\n keywords = [k.lower() for k in keywords]\n\n if match_type == \"any\":\n mask = pd.Series([False] * len(df), index=df.index)\n for keyword in keywords:\n mask = mask | text_series.str.contains(keyword, regex=False)\n result = df[mask]\n\n elif match_type == \"all\":\n mask = pd.Series([True] * len(df), index=df.index)\n for keyword in keywords:\n mask = mask & text_series.str.contains(keyword, regex=False)\n result = df[mask]\n\n elif match_type == \"coverage\":\n scores = []\n total_keywords = len(keywords)\n\n for text in text_series:\n keywords_found = sum(1 for keyword in keywords if keyword in text)\n score = keywords_found / total_keywords\n scores.append(score)\n\n result = df.copy()\n result[\"_score\"] = scores\n # Rank by score, then drop it: the documented output columns are file_path and\n # text, and coverage is the mode the shipped flow uses, so leaking the internal\n # ranking artifact would spend agent context on every returned row.\n result = result[result[\"_score\"] > 0].sort_values(\"_score\", ascending=False)\n result = result.drop(columns=[\"_score\"])\n\n else:\n # Unreachable through the tool, which exposes only column and keywords, but a hand\n # edited flow can set anything. Say so rather than raising UnboundLocalError below.\n msg = f\"Unknown match_type '{match_type}'. Expected one of: any, all, coverage.\"\n raise ValueError(msg)\n\n return DataFrame(result.reset_index(drop=True)).head(self.number_candidates)\n" }, "column": { "_input_type": "MessageTextInput", @@ -2479,30 +2150,6 @@ "type": "str", "value": "file_path" }, - "dataframe": { - "_input_type": "DataFrameInput", - "advanced": false, - "display_name": "DataFrame", - "dynamic": false, - "info": "Input DataFrame to search", - "input_types": [ - "DataFrame" - ], - "list": false, - "list_add_label": "Add More", - "name": "dataframe", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "other", - "value": "" - }, "is_refresh": false, "keywords": { "_input_type": "MessageTextInput", @@ -2620,7 +2267,7 @@ } }, "description": "Search for Langflow component code to use as reference based on keyword matching. Use at least 3 keywords.", - "display_description": "Search for keywords in a DataFrame column using any/all/coverage matching", + "display_description": "Search for keywords in the installed component library using any/all/coverage matching", "display_name": "search", "name": "component_code_search", "readonly": false, @@ -2635,9 +2282,9 @@ "tool_mode": true }, "showNode": true, - "type": "DataFrameKeywordSearch" + "type": "ComponentLibrarySearch" }, - "id": "DataFrameKeywordSearch-b8MUN", + "id": "ComponentLibrarySearch-b8MUN", "measured": { "height": 435, "width": 320 diff --git a/src/backend/base/langflow/agentic/services/flow_preparation.py b/src/backend/base/langflow/agentic/services/flow_preparation.py index 42900de978f9..3b9adf271005 100644 --- a/src/backend/base/langflow/agentic/services/flow_preparation.py +++ b/src/backend/base/langflow/agentic/services/flow_preparation.py @@ -9,13 +9,8 @@ from lfx.base.models.model_metadata import MODEL_PROVIDER_METADATA, get_provider_param_mapping -import lfx from langflow.agentic.helpers.assistant_workspace import resolve_assistant_fs_root -# Resolves only from the monorepo root; inject_lfx_components_path rewrites it to -# an absolute path at runtime so packaged installs (Desktop, pip, Docker) work. -LFX_COMPONENTS_PATH_SENTINEL = "./src/lfx/src/lfx/components/" - logger = logging.getLogger(__name__) @@ -231,32 +226,6 @@ def inject_model_into_flow( return flow_data -def inject_lfx_components_path(flow_data: dict) -> dict: - """Rewrite Directory nodes targeting bundled lfx components to an absolute path. - - The bundled LangflowAssistant flow hardcodes a relative path that only - resolves from the monorepo root. In any packaged install the process CWD - is different and the Directory component raises "Path ... must exist and - be a directory.", causing the Langflow Assistant to fail with - "An internal error occurred while executing the flow." on first use. - - This function walks the flow nodes and, for each Directory node whose - `path` value equals LFX_COMPONENTS_PATH_SENTINEL, replaces it with the - absolute path derived from the installed lfx package. - """ - absolute_path = str(Path(lfx.__file__).parent / "components") - - for node in flow_data.get("data", {}).get("nodes", []): - node_data = node.get("data", {}) - if node_data.get("type") != "Directory": - continue - path_field = node_data.get("node", {}).get("template", {}).get("path") - if path_field and path_field.get("value") == LFX_COMPONENTS_PATH_SENTINEL: - path_field["value"] = absolute_path - - return flow_data - - def inject_assistant_fs_root(flow_data: dict) -> dict: """Replace empty FileSystemTool.root_path with the resolved sandbox path. @@ -352,7 +321,6 @@ def load_and_prepare_flow( iterations = int(raw_iterations) flow_data = inject_iterations_into_flow(flow_data, iterations) - flow_data = inject_lfx_components_path(flow_data) flow_data = inject_assistant_fs_root(flow_data) return json.dumps(flow_data) diff --git a/src/backend/base/langflow/locales/en.json b/src/backend/base/langflow/locales/en.json index 7c2d7239427f..e21f2a8a18fd 100644 --- a/src/backend/base/langflow/locales/en.json +++ b/src/backend/base/langflow/locales/en.json @@ -659,6 +659,19 @@ "components.combinetext.inputs.text2.display_name.0afb0b33": "Second Text", "components.combinetext.inputs.text2.info.e6f3e8ac": "The second text input to concatenate.", "components.combinetext.outputs.combined_text.display_name.49bb559d": "Combined Text", + "components.componentlibrarysearch.description.f504d1c6": "Search for keywords in the installed component library using any/all/coverage matching", + "components.componentlibrarysearch.display_name.df58fa6d": "Component Library Search", + "components.componentlibrarysearch.inputs.case_sensitive.display_name.fc30a318": "Case Sensitive", + "components.componentlibrarysearch.inputs.case_sensitive.info.3f2d60f0": "Whether search is case-sensitive", + "components.componentlibrarysearch.inputs.column.display_name.3fdc9a58": "Column", + "components.componentlibrarysearch.inputs.column.info.2309f8aa": "Column name to search in. Valid columns: 'file_path' (component file path) and 'text' (component source code).", + "components.componentlibrarysearch.inputs.keywords.display_name.8bfdb93c": "Keywords", + "components.componentlibrarysearch.inputs.keywords.info.fd8538c6": "Keywords to search for", + "components.componentlibrarysearch.inputs.match_type.display_name.09403682": "Match Type", + "components.componentlibrarysearch.inputs.match_type.info.1e18b0a5": "'any' = OR logic, 'all' = AND logic, 'coverage' = keyword coverage ranking", + "components.componentlibrarysearch.inputs.number_candidates.display_name.d6963cd4": "Number of Candidates", + "components.componentlibrarysearch.inputs.number_candidates.info.7dc47c56": "The number of candidates to filter and return in the dataframe output", + "components.componentlibrarysearch.outputs.result.display_name.91c835d5": "Filtered DataFrame", "components.conditionalrouter.description.451ee326": "Routes an input message to a corresponding output based on text comparison.", "components.conditionalrouter.display_name.6a953de9": "If-Else", "components.conditionalrouter.inputs.case_sensitive.display_name.fc30a318": "Case Sensitive", diff --git a/src/backend/tests/unit/agentic/flows/test_component_code_search_tool.py b/src/backend/tests/unit/agentic/flows/test_component_code_search_tool.py index fdb14651e045..0c3564288d2f 100644 --- a/src/backend/tests/unit/agentic/flows/test_component_code_search_tool.py +++ b/src/backend/tests/unit/agentic/flows/test_component_code_search_tool.py @@ -1,8 +1,8 @@ """GH #13618 — component_code_search tool silently reports an empty library. -The inline ``DataFrameKeywordSearch`` component in ``LangflowAssistant.json`` -combines three defects into a confidently wrong "the component library is -empty" answer: +The ``ComponentLibrarySearch`` component behind ``LangflowAssistant.json``'s +component_code_search tool combined three defects into a confidently wrong +"the component library is empty" answer: 1. An unknown ``column`` returns an empty DataFrame instead of raising, so the agent cannot self-correct (the DataFrame has only ``file_path`` and @@ -11,16 +11,16 @@ 3. ``number_candidates`` ships as 2, making enumeration questions unanswerable over a ~500-file index with no truncation signal. -These tests load the REAL inline code from the flow JSON (the same loader -path production uses) and pin the corrected behavior. +These tests load the REAL code from the flow JSON (the same loader path +production uses) and pin the corrected behavior. The component sources the +installed component library itself, so there is no DataFrame to inject -- +the search below runs against the real library. """ import json from pathlib import Path -import pandas as pd import pytest -from langflow.schema import DataFrame from lfx.custom.eval import eval_custom_component_code FLOW_PATH = Path(__file__).parents[4] / "base" / "langflow" / "agentic" / "flows" / "LangflowAssistant.json" @@ -32,24 +32,15 @@ def _keyword_search_template() -> dict: data = json.loads(FLOW_PATH.read_text(encoding="utf-8")) for node in data["data"]["nodes"]: node_data = node.get("data", {}) - if node_data.get("type") == "DataFrameKeywordSearch": + if node_data.get("type") == "ComponentLibrarySearch": return node_data["node"]["template"] - msg = "DataFrameKeywordSearch node not found in LangflowAssistant.json" + msg = "ComponentLibrarySearch node not found in LangflowAssistant.json" raise AssertionError(msg) def _component_instance(): component_class = eval_custom_component_code(_keyword_search_template()["code"]["value"]) instance = component_class() - instance.dataframe = DataFrame( - pd.DataFrame( - [ - {"file_path": "openai.py", "text": "class OpenAIModel(Component): build()..."}, - {"file_path": "chat_input.py", "text": "class ChatInput(Component): build()..."}, - {"file_path": "agent.py", "text": "class Agent(Component): tools..."}, - ] - ) - ) instance.match_type = "any" instance.case_sensitive = False instance.number_candidates = 10 @@ -95,3 +86,67 @@ def test_should_ship_an_enumeration_friendly_candidate_cap(self): assert configured >= MIN_ENUMERATION_CANDIDATES, ( f"number_candidates={configured} cannot answer enumeration questions over ~500 indexed files" ) + + +class TestMalformedToolCallsFailVisibly: + """A malformed call must raise so the agent can self-correct, never return arbitrary rows. + + Each case below previously returned the *entire* library -- 158 rows of component source, + uncapped -- which the agent reads as a result set. That is the confidently-wrong failure + mode GH #13618 is about, and it also bypassed ``number_candidates`` straight into context. + """ + + def test_should_raise_when_keywords_are_empty(self): + instance = _component_instance() + instance.column = "text" + instance.keywords = [] + + with pytest.raises(ValueError, match="at least one non-empty search term"): + instance.search() + + def test_should_raise_when_keywords_are_only_whitespace(self): + """A blank keyword strips to "" and ``str.contains("")`` matches every row.""" + instance = _component_instance() + instance.column = "text" + instance.keywords = [" "] + + with pytest.raises(ValueError, match="at least one non-empty search term"): + instance.search() + + def test_should_accept_a_bare_string_as_a_single_keyword(self): + """Models routinely send a string for a list argument; that is unambiguous, not an error.""" + instance = _component_instance() + instance.column = "text" + instance.keywords = "ChatInput" + + result = instance.search() + + assert 0 < len(result) <= instance.number_candidates + + def test_should_raise_on_a_keywords_type_it_cannot_interpret(self): + instance = _component_instance() + instance.column = "text" + instance.keywords = {"not": "a list"} + + with pytest.raises(TypeError, match="must be a list of strings"): + instance.search() + + +class TestOutputSchemaIsStable: + @pytest.mark.parametrize("match_type", ["any", "all", "coverage"]) + def test_should_return_only_the_documented_columns(self, match_type): + """``coverage`` -- the mode the shipped flow uses -- leaked its internal ``_score``.""" + instance = _component_instance() + instance.column = "text" + instance.keywords = ["Component"] + instance.match_type = match_type + + assert list(instance.search().columns) == ["file_path", "text"] + + def test_should_respect_the_candidate_cap(self): + instance = _component_instance() + instance.column = "text" + instance.keywords = ["a"] + instance.number_candidates = 3 + + assert len(instance.search()) <= 3 diff --git a/src/backend/tests/unit/agentic/flows/test_shipped_flow_policy_compliance.py b/src/backend/tests/unit/agentic/flows/test_shipped_flow_policy_compliance.py new file mode 100644 index 000000000000..ee4a3438e923 --- /dev/null +++ b/src/backend/tests/unit/agentic/flows/test_shipped_flow_policy_compliance.py @@ -0,0 +1,139 @@ +"""The shipped Langflow Assistant flow must run under the hardened enterprise settings. + +``LangflowAssistant.json`` is first-party content, but it loads through the same gates as +tenant-supplied flows and used to fail them: + +* ``LANGFLOW_ALLOW_CUSTOM_COMPONENTS=false`` blocked the flow's own inline keyword-search + node, which had no registered server counterpart. +* ``LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS=true`` blocked the flow's ``Directory`` node, which + read the installed component library -- a path outside every user's storage scope. + +Both settings are baked into the enterprise image, so the assistant returned the same error +to every message, including "hi", for every user. + +The fix removes the two nodes that needed exemptions rather than exempting them: one +registered ``ComponentLibrarySearch`` component reads its own package source directly. It +takes no path input, so no tenant-controlled path exists for ``enforce_local_file_access`` +to gate, and it resolves through the ordinary registered-component path. + +``test_shipped_flow_builds_under_hardened_settings`` is the regression test that matters: +validation alone passed even while the build gate rejected the flow, so a test that only +validates cannot catch this class of bug. +""" + +import json +from pathlib import Path + +import pytest +from langflow.agentic.services.flow_preparation import load_and_prepare_flow +from lfx.components.processing import component_library_search +from lfx.interface.components import get_and_cache_all_types_dict +from lfx.load import aload_flow_from_json +from lfx.services.deps import get_settings_service +from lfx.utils.flow_validation import get_component_hash_lookups_for_validation + +FLOWS_DIR = Path(__file__).parents[4] / "base" / "langflow" / "agentic" / "flows" +FLOW_PATH = FLOWS_DIR / "LangflowAssistant.json" + + +@pytest.fixture +def hardened_settings(): + """The settings the enterprise image bakes in.""" + settings = get_settings_service().settings + saved = ( + settings.allow_custom_components, + settings.block_code_interpreter_components, + settings.restrict_local_file_access, + ) + settings.allow_custom_components = False + settings.block_code_interpreter_components = True + settings.restrict_local_file_access = True + try: + yield settings + finally: + ( + settings.allow_custom_components, + settings.block_code_interpreter_components, + settings.restrict_local_file_access, + ) = saved + + +def _prepared(flow_path: Path) -> dict: + return json.loads(load_and_prepare_flow(flow_path, None, None, None)) + + +@pytest.mark.usefixtures("hardened_settings") +class TestShippedAssistantFlowRunsHardened: + async def test_shipped_flow_builds_under_hardened_settings(self): + """Builds, not just validates -- the build gate is a second, separate check. + + ``Graph.from_payload`` runs code substitution, validation, and component + instantiation. Instantiation calls ``resolve_trusted_code_for_build``, which fails + closed for any code with no registered server counterpart. A flow can clear + validation and still be refused here, which is exactly how the assistant broke. + """ + await get_and_cache_all_types_dict(get_settings_service()) + graph = await aload_flow_from_json(_prepared(FLOW_PATH), disable_logs=True) + assert graph.vertices, "shipped flow built no vertices" + + async def test_search_component_reads_the_library_with_no_scope_or_exemption(self): + """No user scope, no flow scope, no graph marker -- and no exemption needed.""" + await get_and_cache_all_types_dict(get_settings_service()) + graph = await aload_flow_from_json(_prepared(FLOW_PATH), disable_logs=True) + component = next(v.custom_component for v in graph.vertices if v.id.startswith("ComponentLibrarySearch")) + component.column = "text" + component.keywords = ["ChatInput"] + component.match_type = "any" + component.case_sensitive = False + component.number_candidates = 5 + + results = component.search() + assert len(results) > 0, "component library search returned nothing" + assert set(results.columns) >= {"file_path", "text"} + + +class TestShippedFlowsStayRegistered: + """Fails loudly if a shipped flow regains a component the server does not know. + + Keyed to the registry rather than to a component name, so it catches *any* future inline + node -- the previous guard tested for one hardcoded type and would have missed a second. + """ + + async def test_shipped_flows_carry_no_unregistered_component_types(self): + known = await get_and_cache_all_types_dict(get_settings_service()) and ( + get_component_hash_lookups_for_validation() or {} + ) + unregistered = [] + for flow_file in sorted(FLOWS_DIR.glob("*.json")): + data = json.loads(flow_file.read_text(encoding="utf-8")) + for node in data.get("data", data).get("nodes", []): + node_data = node.get("data", {}) + code = (node_data.get("node", {}).get("template", {}).get("code") or {}).get("value") + component_type = node_data.get("type") + if code and component_type not in known: + unregistered.append(f"{flow_file.name}: {component_type}") + + assert not unregistered, ( + "shipped flows carry component types with no registered server counterpart; they will be " + "blocked under LANGFLOW_ALLOW_CUSTOM_COMPONENTS=false:\n" + "\n".join(unregistered) + ) + + def test_search_node_embeds_the_installed_component_source(self): + """The flow's stored copy must equal the component on disk. + + Only restricted mode substitutes the server's copy at build time; with custom + components allowed (the default) the node's *stored* bytes are what execute. A + previous fix edited the component and left the flow's embedded copy behind, so the + change never ran in the deployment it targeted. + """ + installed = Path(component_library_search.__file__).read_text(encoding="utf-8") + flow = json.loads(FLOW_PATH.read_text(encoding="utf-8")) + embedded = next( + node["data"]["node"]["template"]["code"]["value"] + for node in flow["data"]["nodes"] + if node["data"].get("type") == "ComponentLibrarySearch" + ) + assert embedded == installed, ( + f"{FLOW_PATH.name} embeds a stale copy of component_library_search.py; " + "re-copy the file into the node's template.code.value" + ) diff --git a/src/backend/tests/unit/agentic/services/test_flow_preparation.py b/src/backend/tests/unit/agentic/services/test_flow_preparation.py index a15740f73add..125714c73633 100644 --- a/src/backend/tests/unit/agentic/services/test_flow_preparation.py +++ b/src/backend/tests/unit/agentic/services/test_flow_preparation.py @@ -5,20 +5,15 @@ """ import json -from pathlib import Path from unittest.mock import patch from langflow.agentic.services.flow_preparation import ( - LFX_COMPONENTS_PATH_SENTINEL, available_model_providers, inject_assistant_fs_root, - inject_lfx_components_path, inject_model_into_flow, load_and_prepare_flow, ) -import lfx - MODULE = "langflow.agentic.services.flow_preparation" OPENAI_CONFIG = { @@ -388,95 +383,6 @@ def test_should_return_original_when_no_provider(self, tmp_path): assert result["data"]["nodes"][0]["data"]["node"]["template"]["model"]["value"] == [] -def _make_directory_flow(path_value: str) -> dict: - """Build a minimal flow dict with a single Directory node using the given path value.""" - return { - "data": { - "nodes": [ - { - "id": "Directory-test", - "data": { - "type": "Directory", - "node": { - "template": { - "path": { - "_input_type": "MessageTextInput", - "name": "path", - "type": "str", - "value": path_value, - }, - }, - }, - }, - }, - ], - }, - } - - -class TestInjectLfxComponentsPath: - """Tests for inject_lfx_components_path. - - Regression guard for the Langflow Desktop bug where the LangflowAssistant - flow embedded a relative path './src/lfx/src/lfx/components/' which only - resolved correctly when the sidecar CWD was the monorepo root. On Desktop - the CWD is the data dir, so the Directory component raised - 'Path ... must exist and be a directory.' - """ - - def test_should_rewrite_directory_path_to_absolute_lfx_components_when_path_matches_sentinel(self): - flow_data = _make_directory_flow(LFX_COMPONENTS_PATH_SENTINEL) - - result = inject_lfx_components_path(flow_data) - - rewritten = result["data"]["nodes"][0]["data"]["node"]["template"]["path"]["value"] - expected = str(Path(lfx.__file__).parent / "components") - assert rewritten == expected - # Must be absolute — the whole point of the fix. - assert Path(rewritten).is_absolute() - # The rewritten path must actually exist in the installed lfx package. - assert Path(rewritten).is_dir() - - def test_should_not_modify_directory_path_when_value_is_not_sentinel(self): - flow_data = _make_directory_flow("/custom/user/path") - - result = inject_lfx_components_path(flow_data) - - assert result["data"]["nodes"][0]["data"]["node"]["template"]["path"]["value"] == "/custom/user/path" - - def test_should_not_modify_non_directory_nodes(self): - flow_data = _make_flow_data(["Agent"]) - - result = inject_lfx_components_path(flow_data) - - # Agent node template untouched. - assert "path" not in result["data"]["nodes"][0]["data"]["node"]["template"] - - def test_should_handle_flow_without_nodes(self): - flow_data: dict = {"data": {"nodes": []}} - - result = inject_lfx_components_path(flow_data) - - assert result == {"data": {"nodes": []}} - - def test_should_rewrite_sentinel_path_when_loading_assistant_flow(self, tmp_path): - """Rewrite the sentinel path when loading the assistant flow. - - load_and_prepare_flow must apply the lfx path injection so that - Desktop (and any non-monorepo CWD) can execute the assistant flow. - """ - flow_data = _make_directory_flow(LFX_COMPONENTS_PATH_SENTINEL) - flow_file = tmp_path / "LangflowAssistant.json" - flow_file.write_text(json.dumps(flow_data)) - - result_json = load_and_prepare_flow(flow_file, None, None, None) - result = json.loads(result_json) - - rewritten = result["data"]["nodes"][0]["data"]["node"]["template"]["path"]["value"] - expected = str(Path(lfx.__file__).parent / "components") - assert rewritten == expected - - def _make_filesystem_flow(root_path_value: str) -> dict: """Build a minimal flow with a single FileSystemTool node at the given root_path.""" return { diff --git a/src/lfx/src/lfx/_assets/component_index.json b/src/lfx/src/lfx/_assets/component_index.json index b64ca3f2f355..20c7fa8a4317 100644 --- a/src/lfx/src/lfx/_assets/component_index.json +++ b/src/lfx/src/lfx/_assets/component_index.json @@ -22396,6 +22396,210 @@ }, "tool_mode": false }, + "ComponentLibrarySearch": { + "base_classes": [ + "Table" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Search for keywords in the installed component library using any/all/coverage matching", + "display_name": "Component Library Search", + "documentation": "", + "edited": false, + "field_order": [ + "column", + "keywords", + "match_type", + "case_sensitive", + "number_candidates" + ], + "frozen": false, + "icon": "Search", + "legacy": false, + "metadata": { + "code_hash": "284fd639c05e", + "dependencies": { + "dependencies": [ + { + "name": "pandas", + "version": "2.3.3" + }, + { + "name": "lfx", + "version": null + } + ], + "total_dependencies": 2 + }, + "module": "lfx.components.processing.component_library_search.ComponentLibrarySearch" + }, + "minimized": false, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Filtered DataFrame", + "group_outputs": false, + "method": "search", + "name": "result", + "selected": "Table", + "tool_mode": true, + "types": [ + "Table" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "case_sensitive": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Case Sensitive", + "dynamic": false, + "info": "Whether search is case-sensitive", + "list": false, + "list_add_label": "Add More", + "name": "case_sensitive", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": false + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from pathlib import Path\n\nimport pandas as pd\n\nimport lfx\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output\nfrom lfx.schema.dataframe import DataFrame\n\n\nclass ComponentLibrarySearch(Component):\n \"\"\"Keyword search over the installed Langflow component library.\n\n The packaged assistant flow used to build this table with a ``Directory`` node pointed at\n the installed ``lfx/components`` directory. That path is outside every user's storage\n scope, so ``restrict_local_file_access`` denied it, and the node's search partner was\n inline flow code with no registered server counterpart, so ``allow_custom_components``\n denied that. Both denials were about the flow reaching for tenant-gated machinery to read\n first-party product source.\n\n Reading the library here removes the need for either exemption. There is no path input --\n the root is derived from this package -- so no tenant-controlled path exists for\n ``enforce_local_file_access`` to gate, and the component is registered like any other, so\n the custom-component gate resolves it normally. The source it returns is the same source\n ``GET /api/v1/all`` already serves to every authenticated user.\n \"\"\"\n\n display_name = \"Component Library Search\"\n description = \"Search for keywords in the installed component library using any/all/coverage matching\"\n icon = \"Search\"\n\n inputs = [\n MessageTextInput(\n name=\"column\",\n display_name=\"Column\",\n info=\"Column name to search in. Valid columns: 'file_path' (component file path) \"\n \"and 'text' (component source code).\",\n value=\"file_path\",\n tool_mode=True,\n ),\n MessageTextInput(\n name=\"keywords\",\n display_name=\"Keywords\",\n info=\"Keywords to search for\",\n is_list=True,\n tool_mode=True,\n ),\n DropdownInput(\n name=\"match_type\",\n display_name=\"Match Type\",\n options=[\"any\", \"all\", \"coverage\"],\n value=\"any\",\n info=\"'any' = OR logic, 'all' = AND logic, 'coverage' = keyword coverage ranking\",\n ),\n BoolInput(\n name=\"case_sensitive\",\n display_name=\"Case Sensitive\",\n value=False,\n advanced=True,\n info=\"Whether search is case-sensitive\",\n ),\n IntInput(\n name=\"number_candidates\",\n display_name=\"Number of Candidates\",\n value=10,\n info=\"The number of candidates to filter and return in the dataframe output\",\n ),\n ]\n\n outputs = [\n Output(name=\"result\", display_name=\"Filtered DataFrame\", method=\"search\"),\n ]\n\n def _component_library(self) -> pd.DataFrame:\n \"\"\"Read the installed component library into ``file_path`` / ``text`` columns.\n\n Mirrors what the flow's ``Directory`` node read before: the same ``*/*.py`` shape at\n depth 2, minus package ``__init__`` files, which carry only re-export boilerplate.\n Errors propagate rather than yielding a partial table -- an unreadable component file\n means a broken install, and a silently short search result reads as \"no such\n component\", which is a wrong answer rather than a visible failure.\n \"\"\"\n root = (Path(lfx.__file__).parent / \"components\").resolve()\n if not root.is_dir():\n msg = f\"Component library not found at {root}.\"\n raise ValueError(msg)\n\n rows = []\n for path in sorted(root.glob(\"*/*.py\")):\n if path.name == \"__init__.py\":\n continue\n # ``glob`` follows symlinks, so a link named ``*.py`` inside the package would be\n # read from wherever it points. Nothing ships one, and creating one needs write\n # access to site-packages -- which is already game over -- but staying inside the\n # root we advertise is a one-line invariant rather than a trusted assumption.\n if not path.resolve().is_relative_to(root):\n continue\n rows.append({\"file_path\": str(path), \"text\": path.read_text(encoding=\"utf-8\")})\n if not rows:\n # Reporting an empty library is the GH #13618 symptom this component exists to\n # avoid: the agent reads it as \"no such component\" and says so confidently.\n msg = f\"Component library at {root} contains no readable component source.\"\n raise ValueError(msg)\n return pd.DataFrame(rows, columns=[\"file_path\", \"text\"])\n\n @staticmethod\n def _normalized_keywords(raw: object) -> list[str]:\n \"\"\"Coerce the tool's ``keywords`` argument to a non-empty list of search terms.\n\n A model routinely passes a bare string for a list-typed argument, which is an\n unambiguous single keyword rather than an error. Anything else is a malformed call:\n raise so the agent can correct itself, as the invalid-column path already does.\n Returning the unfiltered library instead would present arbitrary components as\n matches -- a confidently wrong answer rather than a visible failure.\n \"\"\"\n if isinstance(raw, str):\n raw = [raw]\n elif not isinstance(raw, list):\n msg = f\"keywords must be a list of strings, got {type(raw).__name__}.\"\n raise TypeError(msg)\n\n # Strip before testing for emptiness: a whitespace-only keyword strips to \"\" and\n # ``str.contains(\"\")`` matches every row.\n keywords = [stripped for stripped in (str(k).strip() for k in raw) if stripped]\n if not keywords:\n msg = \"keywords must contain at least one non-empty search term.\"\n raise ValueError(msg)\n return keywords\n\n def search(self) -> DataFrame:\n df = self._component_library()\n column = self.column\n match_type = self.match_type\n case_sensitive = self.case_sensitive\n\n if column not in df.columns:\n available = \", \".join(str(c) for c in df.columns)\n msg = f\"Column '{column}' not found. Available columns: {available}\"\n raise ValueError(msg)\n\n keywords = self._normalized_keywords(self.keywords)\n\n text_series = df[column].fillna(\"\").astype(str)\n\n if not case_sensitive:\n text_series = text_series.str.lower()\n keywords = [k.lower() for k in keywords]\n\n if match_type == \"any\":\n mask = pd.Series([False] * len(df), index=df.index)\n for keyword in keywords:\n mask = mask | text_series.str.contains(keyword, regex=False)\n result = df[mask]\n\n elif match_type == \"all\":\n mask = pd.Series([True] * len(df), index=df.index)\n for keyword in keywords:\n mask = mask & text_series.str.contains(keyword, regex=False)\n result = df[mask]\n\n elif match_type == \"coverage\":\n scores = []\n total_keywords = len(keywords)\n\n for text in text_series:\n keywords_found = sum(1 for keyword in keywords if keyword in text)\n score = keywords_found / total_keywords\n scores.append(score)\n\n result = df.copy()\n result[\"_score\"] = scores\n # Rank by score, then drop it: the documented output columns are file_path and\n # text, and coverage is the mode the shipped flow uses, so leaking the internal\n # ranking artifact would spend agent context on every returned row.\n result = result[result[\"_score\"] > 0].sort_values(\"_score\", ascending=False)\n result = result.drop(columns=[\"_score\"])\n\n else:\n # Unreachable through the tool, which exposes only column and keywords, but a hand\n # edited flow can set anything. Say so rather than raising UnboundLocalError below.\n msg = f\"Unknown match_type '{match_type}'. Expected one of: any, all, coverage.\"\n raise ValueError(msg)\n\n return DataFrame(result.reset_index(drop=True)).head(self.number_candidates)\n" + }, + "column": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Column", + "dynamic": false, + "info": "Column name to search in. Valid columns: 'file_path' (component file path) and 'text' (component source code).", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "column", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": true, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "file_path" + }, + "keywords": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Keywords", + "dynamic": false, + "info": "Keywords to search for", + "input_types": [ + "Message" + ], + "list": true, + "list_add_label": "Add More", + "load_from_db": false, + "name": "keywords", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": true, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "match_type": { + "_input_type": "DropdownInput", + "advanced": false, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Match Type", + "dynamic": false, + "external_options": {}, + "info": "'any' = OR logic, 'all' = AND logic, 'coverage' = keyword coverage ranking", + "name": "match_type", + "options": [ + "any", + "all", + "coverage" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "any" + }, + "number_candidates": { + "_input_type": "IntInput", + "advanced": false, + "api_editable": false, + "display_name": "Number of Candidates", + "dynamic": false, + "info": "The number of candidates to filter and return in the dataframe output", + "list": false, + "list_add_label": "Add More", + "name": "number_candidates", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "int", + "value": 10 + } + }, + "tool_mode": false + }, "CreateData": { "base_classes": [ "JSON" @@ -31031,9 +31235,9 @@ ] ], "metadata": { - "num_components": 127, + "num_components": 128, "num_modules": 16 }, - "sha256": "fce387c18431b9327bb81c1099689fbde42c8bddcbc0588d856ff304c6570c51", + "sha256": "f922afe5a702330974e55acff8b770dc8be7d8795eb496fbb78aeb4f0096955b", "version": "1.12.1" } diff --git a/src/lfx/src/lfx/components/processing/__init__.py b/src/lfx/src/lfx/components/processing/__init__.py index 813cac646fdf..fc58e0ced43a 100644 --- a/src/lfx/src/lfx/components/processing/__init__.py +++ b/src/lfx/src/lfx/components/processing/__init__.py @@ -8,6 +8,7 @@ if TYPE_CHECKING: from lfx.components.processing.combine_text import CombineTextComponent + from lfx.components.processing.component_library_search import ComponentLibrarySearch from lfx.components.processing.converter import TypeConverterComponent from lfx.components.processing.create_list import CreateListComponent from lfx.components.processing.data_operations import DataOperationsComponent @@ -23,6 +24,7 @@ _dynamic_imports = { "CombineTextComponent": "combine_text", + "ComponentLibrarySearch": "component_library_search", "TypeConverterComponent": "converter", "CreateListComponent": "create_list", "DataOperationsComponent": "data_operations", @@ -39,6 +41,7 @@ __all__ = [ "CombineTextComponent", + "ComponentLibrarySearch", "CreateListComponent", "DataFrameOperationsComponent", "DataOperationsComponent", diff --git a/src/lfx/src/lfx/components/processing/component_library_search.py b/src/lfx/src/lfx/components/processing/component_library_search.py new file mode 100644 index 000000000000..f7ee351966e5 --- /dev/null +++ b/src/lfx/src/lfx/components/processing/component_library_search.py @@ -0,0 +1,184 @@ +from pathlib import Path + +import pandas as pd + +import lfx +from lfx.custom.custom_component.component import Component +from lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output +from lfx.schema.dataframe import DataFrame + + +class ComponentLibrarySearch(Component): + """Keyword search over the installed Langflow component library. + + The packaged assistant flow used to build this table with a ``Directory`` node pointed at + the installed ``lfx/components`` directory. That path is outside every user's storage + scope, so ``restrict_local_file_access`` denied it, and the node's search partner was + inline flow code with no registered server counterpart, so ``allow_custom_components`` + denied that. Both denials were about the flow reaching for tenant-gated machinery to read + first-party product source. + + Reading the library here removes the need for either exemption. There is no path input -- + the root is derived from this package -- so no tenant-controlled path exists for + ``enforce_local_file_access`` to gate, and the component is registered like any other, so + the custom-component gate resolves it normally. The source it returns is the same source + ``GET /api/v1/all`` already serves to every authenticated user. + """ + + display_name = "Component Library Search" + description = "Search for keywords in the installed component library using any/all/coverage matching" + icon = "Search" + + inputs = [ + MessageTextInput( + name="column", + display_name="Column", + info="Column name to search in. Valid columns: 'file_path' (component file path) " + "and 'text' (component source code).", + value="file_path", + tool_mode=True, + ), + MessageTextInput( + name="keywords", + display_name="Keywords", + info="Keywords to search for", + is_list=True, + tool_mode=True, + ), + DropdownInput( + name="match_type", + display_name="Match Type", + options=["any", "all", "coverage"], + value="any", + info="'any' = OR logic, 'all' = AND logic, 'coverage' = keyword coverage ranking", + ), + BoolInput( + name="case_sensitive", + display_name="Case Sensitive", + value=False, + advanced=True, + info="Whether search is case-sensitive", + ), + IntInput( + name="number_candidates", + display_name="Number of Candidates", + value=10, + info="The number of candidates to filter and return in the dataframe output", + ), + ] + + outputs = [ + Output(name="result", display_name="Filtered DataFrame", method="search"), + ] + + def _component_library(self) -> pd.DataFrame: + """Read the installed component library into ``file_path`` / ``text`` columns. + + Mirrors what the flow's ``Directory`` node read before: the same ``*/*.py`` shape at + depth 2, minus package ``__init__`` files, which carry only re-export boilerplate. + Errors propagate rather than yielding a partial table -- an unreadable component file + means a broken install, and a silently short search result reads as "no such + component", which is a wrong answer rather than a visible failure. + """ + root = (Path(lfx.__file__).parent / "components").resolve() + if not root.is_dir(): + msg = f"Component library not found at {root}." + raise ValueError(msg) + + rows = [] + for path in sorted(root.glob("*/*.py")): + if path.name == "__init__.py": + continue + # ``glob`` follows symlinks, so a link named ``*.py`` inside the package would be + # read from wherever it points. Nothing ships one, and creating one needs write + # access to site-packages -- which is already game over -- but staying inside the + # root we advertise is a one-line invariant rather than a trusted assumption. + if not path.resolve().is_relative_to(root): + continue + rows.append({"file_path": str(path), "text": path.read_text(encoding="utf-8")}) + if not rows: + # Reporting an empty library is the GH #13618 symptom this component exists to + # avoid: the agent reads it as "no such component" and says so confidently. + msg = f"Component library at {root} contains no readable component source." + raise ValueError(msg) + return pd.DataFrame(rows, columns=["file_path", "text"]) + + @staticmethod + def _normalized_keywords(raw: object) -> list[str]: + """Coerce the tool's ``keywords`` argument to a non-empty list of search terms. + + A model routinely passes a bare string for a list-typed argument, which is an + unambiguous single keyword rather than an error. Anything else is a malformed call: + raise so the agent can correct itself, as the invalid-column path already does. + Returning the unfiltered library instead would present arbitrary components as + matches -- a confidently wrong answer rather than a visible failure. + """ + if isinstance(raw, str): + raw = [raw] + elif not isinstance(raw, list): + msg = f"keywords must be a list of strings, got {type(raw).__name__}." + raise TypeError(msg) + + # Strip before testing for emptiness: a whitespace-only keyword strips to "" and + # ``str.contains("")`` matches every row. + keywords = [stripped for stripped in (str(k).strip() for k in raw) if stripped] + if not keywords: + msg = "keywords must contain at least one non-empty search term." + raise ValueError(msg) + return keywords + + def search(self) -> DataFrame: + df = self._component_library() + column = self.column + match_type = self.match_type + case_sensitive = self.case_sensitive + + if column not in df.columns: + available = ", ".join(str(c) for c in df.columns) + msg = f"Column '{column}' not found. Available columns: {available}" + raise ValueError(msg) + + keywords = self._normalized_keywords(self.keywords) + + text_series = df[column].fillna("").astype(str) + + if not case_sensitive: + text_series = text_series.str.lower() + keywords = [k.lower() for k in keywords] + + if match_type == "any": + mask = pd.Series([False] * len(df), index=df.index) + for keyword in keywords: + mask = mask | text_series.str.contains(keyword, regex=False) + result = df[mask] + + elif match_type == "all": + mask = pd.Series([True] * len(df), index=df.index) + for keyword in keywords: + mask = mask & text_series.str.contains(keyword, regex=False) + result = df[mask] + + elif match_type == "coverage": + scores = [] + total_keywords = len(keywords) + + for text in text_series: + keywords_found = sum(1 for keyword in keywords if keyword in text) + score = keywords_found / total_keywords + scores.append(score) + + result = df.copy() + result["_score"] = scores + # Rank by score, then drop it: the documented output columns are file_path and + # text, and coverage is the mode the shipped flow uses, so leaking the internal + # ranking artifact would spend agent context on every returned row. + result = result[result["_score"] > 0].sort_values("_score", ascending=False) + result = result.drop(columns=["_score"]) + + else: + # Unreachable through the tool, which exposes only column and keywords, but a hand + # edited flow can set anything. Say so rather than raising UnboundLocalError below. + msg = f"Unknown match_type '{match_type}'. Expected one of: any, all, coverage." + raise ValueError(msg) + + return DataFrame(result.reset_index(drop=True)).head(self.number_candidates) diff --git a/src/lfx/src/lfx/utils/file_path_security.py b/src/lfx/src/lfx/utils/file_path_security.py index 2c29389a204c..798612f47dd1 100644 --- a/src/lfx/src/lfx/utils/file_path_security.py +++ b/src/lfx/src/lfx/utils/file_path_security.py @@ -214,10 +214,8 @@ def _scope_roots( roots.append(data_dir) if not roots: - msg = ( - "Local-file access requires an authenticated user or flow scope " - "when LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS=true." - ) + logger.warning("Local-file access denied: no user or flow scope (LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS=true).") + msg = "Local-file access requires an authenticated user or flow scope." raise LocalFileAccessError(msg) return tuple(roots) @@ -265,9 +263,14 @@ def enforce_local_file_access( raise LocalFileAccessError(msg) from e if not any(candidate == root or candidate.is_relative_to(root) for root in allowed_roots): + logger.warning( + "Local-file access denied for %s: outside the caller's storage scope " + "(LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS=true).", + candidate, + ) msg = ( - "Access to local file paths outside the authenticated user's storage scope is disabled " - "(LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS=true). Use an uploaded file instead." + "Access to local file paths outside the authenticated user's storage scope is disabled. " + "Use an uploaded file, or ask your administrator." ) raise LocalFileAccessError(msg) @@ -278,6 +281,10 @@ def enforce_local_file_access( # DB out of reach, not a redundant second line. Covered by # test_read_file_bytes_denies_reserved_secret_key. if candidate in _reserved_secret_paths(data_dir): - msg = "Access to this server-managed file is not permitted (LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS=true)." + logger.warning( + "Local-file access denied for reserved server file %s (LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS=true).", + candidate, + ) + msg = "Access to this server-managed file is not permitted. Ask your administrator if you need access." raise LocalFileAccessError(msg) return candidate diff --git a/src/lfx/src/lfx/utils/ssrf_protection.py b/src/lfx/src/lfx/utils/ssrf_protection.py index 298e62a56e5f..e84206466953 100644 --- a/src/lfx/src/lfx/utils/ssrf_protection.py +++ b/src/lfx/src/lfx/utils/ssrf_protection.py @@ -716,9 +716,10 @@ def validate_database_url_for_ssrf(url: str, *, validate_network_host: bool = Tr dialect, _separator, driver = (parsed.scheme or "").lower().partition("+") if dialect in _LOCAL_FILE_DB_DIALECTS: if file_restricted: + logger.warning("Local-file database dialect %r denied (LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS=true).", dialect) msg = ( - f"Database dialect '{dialect}' accesses the local filesystem and is not permitted " - "(LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS=true). Use a network database (e.g. postgresql, mysql)." + f"Database dialect '{dialect}' accesses the local filesystem and is not permitted. " + "Use a network database (e.g. postgresql, mysql)." ) raise SSRFProtectionError(msg) # Not restricted: local-file DBs are allowed (single-tenant default). diff --git a/src/lfx/tests/unit/utils/test_denial_messages_hide_settings.py b/src/lfx/tests/unit/utils/test_denial_messages_hide_settings.py new file mode 100644 index 000000000000..ced1fcb187c2 --- /dev/null +++ b/src/lfx/tests/unit/utils/test_denial_messages_hide_settings.py @@ -0,0 +1,92 @@ +"""Policy denials must not name operator-only settings in the message they raise. + +Reproduced from alpha customer feedback: a non-admin evaluator who had +uploaded no file was shown + + Access to local file paths outside the authenticated user's storage scope is + disabled (LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS=true). Use an uploaded file... + +They cannot read that setting, cannot change it, and have no documentation to look +it up in. It also arrived truncated at 150 chars by the assistant's error handling, +cutting off the one remediation the message carried. + +The setting name belongs in the server log, which is where the operator who *can* +change it looks. These tests pin that split at the raise site, so it holds for every +consumer of these errors rather than only for the one that happened to sanitize. +""" + +import uuid +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from lfx.utils.file_path_security import LocalFileAccessError, enforce_local_file_access +from lfx.utils.ssrf_protection import SSRFProtectionError, validate_database_url_for_ssrf + +SCOPE = str(uuid.uuid4()) + + +@pytest.fixture +def restricted(tmp_path): + settings = MagicMock() + settings.settings.restrict_local_file_access = True + settings.settings.config_dir = str(tmp_path) + with patch("lfx.utils.file_path_security.get_settings_service", return_value=settings): + (tmp_path / SCOPE).mkdir(parents=True, exist_ok=True) + yield tmp_path + + +@pytest.mark.usefixtures("restricted") +class TestLocalFileDenials: + def test_outside_scope_denial_names_no_setting(self): + with pytest.raises(LocalFileAccessError) as exc: + enforce_local_file_access("/etc/passwd", scope_ids=(SCOPE,)) + assert "LANGFLOW_" not in str(exc.value) + + def test_outside_scope_denial_keeps_its_remediation(self): + with pytest.raises(LocalFileAccessError) as exc: + enforce_local_file_access("/etc/passwd", scope_ids=(SCOPE,)) + message = str(exc.value) + assert "Use an uploaded file" in message + assert "administrator" in message + + def test_missing_scope_denial_names_no_setting(self): + with pytest.raises(LocalFileAccessError) as exc: + enforce_local_file_access("/etc/passwd", scope_ids=()) + assert "LANGFLOW_" not in str(exc.value) + + def test_reserved_file_denial_names_no_setting(self, restricted): + secret = Path(restricted) / "secret_key" + secret.write_text("x", encoding="utf-8") + with pytest.raises(LocalFileAccessError) as exc: + enforce_local_file_access(str(secret), scope_ids=(SCOPE,), allow_storage_root=True) + assert "LANGFLOW_" not in str(exc.value) + + def test_denial_stays_short_enough_to_survive_downstream_truncation(self): + """The assistant truncates at 150 chars, which used to eat the remediation.""" + with pytest.raises(LocalFileAccessError) as exc: + enforce_local_file_access("/etc/passwd", scope_ids=(SCOPE,)) + assert len(str(exc.value)) <= 150 + + def test_operator_still_gets_the_setting_name_in_the_log(self): + with patch("lfx.utils.file_path_security.logger") as log, pytest.raises(LocalFileAccessError): + enforce_local_file_access("/etc/passwd", scope_ids=(SCOPE,)) + logged = " ".join(str(call) for call in log.warning.call_args_list) + assert "LANGFLOW_RESTRICT_LOCAL_FILE_ACCESS" in logged + + +class TestDatabaseDialectDenial: + def test_dialect_denial_names_no_setting_but_keeps_remediation(self): + # file_restricted comes from is_local_file_access_restricted(), which reads the + # settings service through file_path_security -- patch it there. + settings = MagicMock() + settings.settings.restrict_local_file_access = True + with ( + patch("lfx.utils.file_path_security.get_settings_service", return_value=settings), + pytest.raises(SSRFProtectionError) as exc, + ): + validate_database_url_for_ssrf("sqlite:////tmp/x.db") + message = str(exc.value) + assert "LANGFLOW_" not in message + # Its own remediation, not a canned one -- uploading a file does not fix this. + assert "network database" in message