diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Knowledge Retrieval.json b/src/backend/base/langflow/initial_setup/starter_projects/Knowledge Retrieval.json index b61f422157..3a7cd8b30c 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Knowledge Retrieval.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Knowledge Retrieval.json @@ -703,7 +703,7 @@ }, { "name": "cryptography", - "version": "49.0.0" + "version": "48.0.1" }, { "name": "langchain_chroma", diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json index 9e0e665fcb..d02b7ef884 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json @@ -464,7 +464,7 @@ }, { "name": "OpenDsStar", - "version": null + "version": "1.0.26" } ], "total_dependencies": 4 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json b/src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json index 3c7be291a5..01ac23225e 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json @@ -1620,7 +1620,7 @@ }, { "name": "cryptography", - "version": "49.0.0" + "version": "48.0.1" }, { "name": "langchain_chroma", diff --git a/src/backend/tests/unit/components/models_and_agents/policies/test_policies_component.py b/src/backend/tests/unit/components/models_and_agents/policies/test_policies_component.py index 2c7630d144..20a808caf3 100644 --- a/src/backend/tests/unit/components/models_and_agents/policies/test_policies_component.py +++ b/src/backend/tests/unit/components/models_and_agents/policies/test_policies_component.py @@ -265,7 +265,7 @@ async def test_generate_mode_validation_errors(mock_component): # Test missing model mock_component.in_tools = [MagicMock()] mock_component.model = None - with pytest.raises(ValueError, match="model or api_key cannot be empty"): + with pytest.raises(ValueError, match="model cannot be empty"): await mock_component.guard_tools() # # Test non-recommended model @@ -368,4 +368,145 @@ async def test_verify_cached_guards_error_messages(mock_component): assert "Unexpected error" in str(exc_info.value) +def test_template_field_key_normalizes_separators(): + """Node-template keys are POSIX; OS-separator file names must normalize to match. + + Regression for #13727: on Windows the toolguard result stores ``file_name`` as + a Path whose ``str()`` uses backslashes, while ``sync_generated_guard_code_inputs`` + keys the template by ``Path.as_posix()`` (forward slashes). The lookup must + normalize so it matches on every platform. + """ + # Forward-slash input is unchanged. + assert PoliciesComponent._template_field_key("proj/fetch_content/guard.py") == "proj/fetch_content/guard.py" + # Backslash input (what str(WindowsPath(...)) yields) normalizes to forward slashes. + assert PoliciesComponent._template_field_key("proj\\fetch_content\\guard.py") == "proj/fetch_content/guard.py" + # Path inputs normalize too. + assert PoliciesComponent._template_field_key(Path("proj/fetch_content/guard.py")) == "proj/fetch_content/guard.py" + # Single-segment names (e.g. result.json) are unaffected. + assert PoliciesComponent._template_field_key("result.json") == "result.json" + + +def _fake_file_twin(file_name): + """A stand-in for toolguard's FileTwin: a ``file_name`` plus assignable ``content``.""" + from types import SimpleNamespace + + return SimpleNamespace(file_name=file_name, content=None) + + +def test_make_toolguard_result_reads_posix_keyed_fields(mock_component): + """make_toolguard_result must read fields the sync step keyed by POSIX path. + + Regression for #13727 (the 'NoneType' object is not subscriptable crash on + Windows). The toolguard result hands back ``file_name`` values using OS + separators (here simulated with backslashes, exactly as ``str(WindowsPath)`` + produces), while the node template is keyed by forward-slash POSIX paths. The + read path must normalize and match instead of returning ``None``. + """ + from types import SimpleNamespace + + # file_name values as they arrive from the result model on Windows (backslashes). + types_fn = "proj\\proj_types.py" + api_fn = "proj\\proj_api.py" + impl_fn = "proj\\proj_api_impl.py" + guard_fn = "proj\\fetch_content\\guard.py" + item_fn = "proj\\fetch_content\\guard_allowed_url_domains.py" + + fake_result = SimpleNamespace( + domain=SimpleNamespace( + app_types=_fake_file_twin(types_fn), + app_api=_fake_file_twin(api_fn), + app_api_impl=_fake_file_twin(impl_fn), + ), + tools={ + "fetch_content": SimpleNamespace( + guard_file=_fake_file_twin(guard_fn), + item_guard_files=[_fake_file_twin(item_fn)], + ) + }, + ) + + # Template keyed by POSIX paths, exactly as sync_generated_guard_code_inputs writes them. + attrs = { + "result.json": {"value": "{}"}, + "proj/proj_types.py": {"value": "types-content"}, + "proj/proj_api.py": {"value": "api-content"}, + "proj/proj_api_impl.py": {"value": "impl-content"}, + "proj/fetch_content/guard.py": {"value": "guard-content"}, + "proj/fetch_content/guard_allowed_url_domains.py": {"value": "item-content"}, + } + + fake_tg = _make_fake_tg(RESULTS_FILENAME="result.json") + fake_tg["ToolGuardsCodeGenerationResult"].model_validate_json.return_value = fake_result + + mock_component.get_vertex = MagicMock(return_value=SimpleNamespace(data={"node": {"template": attrs}})) + + with patch.object(PoliciesComponent, "_import_toolguard", return_value=fake_tg): + result = mock_component.make_toolguard_result() + + assert result is fake_result + assert result.domain.app_types.content == "types-content" + assert result.domain.app_api.content == "api-content" + assert result.domain.app_api_impl.content == "impl-content" + assert result.tools["fetch_content"].guard_file.content == "guard-content" + assert result.tools["fetch_content"].item_guard_files[0].content == "item-content" + + +def test_make_toolguard_result_missing_field_raises_clear_error(mock_component): + """A missing generated field yields a clear ValueError, not a NoneType subscript. + + Before #13727's fix a missing key surfaced as + ``'NoneType' object is not subscriptable``; it must now point the user at + Generate mode instead. + """ + from types import SimpleNamespace + + fake_result = SimpleNamespace( + domain=SimpleNamespace( + app_types=_fake_file_twin("proj/proj_types.py"), + app_api=_fake_file_twin("proj/proj_api.py"), + app_api_impl=_fake_file_twin("proj/proj_api_impl.py"), + ), + tools={}, + ) + + # app_types key is intentionally absent from the template. + attrs = { + "result.json": {"value": "{}"}, + "proj/proj_api.py": {"value": "api-content"}, + "proj/proj_api_impl.py": {"value": "impl-content"}, + } + + fake_tg = _make_fake_tg(RESULTS_FILENAME="result.json") + fake_tg["ToolGuardsCodeGenerationResult"].model_validate_json.return_value = fake_result + + mock_component.get_vertex = MagicMock(return_value=SimpleNamespace(data={"node": {"template": attrs}})) + + with ( + patch.object(PoliciesComponent, "_import_toolguard", return_value=fake_tg), + pytest.raises(ValueError, match="missing from the component"), + ): + mock_component.make_toolguard_result() + + +def test_validate_before_generate_allows_empty_api_key(mock_component): + """api_key is optional: validation passes when only the model is set. + + The field is declared required=False/advanced=True and credentials often come + from the model connection or environment, so requiring api_key here wrongly + blocked valid setups (the "model or api_key cannot be empty!" wart in #13727). + """ + mock_component.api_key = "" + mock_component.model = [{"name": "gpt-5.1", "provider": "OpenAI"}] + # Should not raise. + mock_component.validate_before_generate() + + +def test_validate_before_generate_still_requires_model(mock_component): + """A missing model is still rejected after relaxing the api_key requirement.""" + mock_component.api_key = "" + mock_component.model = None + with pytest.raises(ValueError, match="model cannot be empty"): + mock_component.validate_before_generate() + + # Made with Bob diff --git a/src/lfx/src/lfx/_assets/component_index.json b/src/lfx/src/lfx/_assets/component_index.json index d23c6d1007..24de26f5b8 100644 --- a/src/lfx/src/lfx/_assets/component_index.json +++ b/src/lfx/src/lfx/_assets/component_index.json @@ -11993,7 +11993,7 @@ }, { "name": "OpenDsStar", - "version": null + "version": "1.0.26" } ], "total_dependencies": 4 @@ -12370,7 +12370,7 @@ }, { "name": "OpenDsStar", - "version": null + "version": "1.0.26" } ], "total_dependencies": 4 @@ -56715,7 +56715,7 @@ }, { "name": "cuga", - "version": null + "version": "0.2.28" } ], "total_dependencies": 3 @@ -68870,7 +68870,7 @@ }, { "name": "cryptography", - "version": "49.0.0" + "version": "48.0.1" }, { "name": "langchain_chroma", @@ -93245,7 +93245,7 @@ "icon": "shield-check", "legacy": false, "metadata": { - "code_hash": "e5480cfcd95c", + "code_hash": "1c913edea4a2", "dependencies": { "dependencies": [ { @@ -93323,7 +93323,7 @@ "show": true, "title_case": false, "type": "code", - "value": "from __future__ import annotations\n\nimport os\nimport re\nimport shutil\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, cast\n\nfrom lfx.base.models import LCModelComponent\nfrom lfx.base.models.unified_models import (\n get_language_model_options,\n get_llm,\n update_model_options_in_build_config,\n)\nfrom lfx.components.models_and_agents.policies.module_utils import unload_module\nfrom lfx.field_typing import LanguageModel, Tool\nfrom lfx.io import (\n BoolInput,\n HandleInput,\n ModelInput,\n MultilineInput,\n Output,\n SecretStrInput,\n StrInput,\n TabInput,\n)\nfrom lfx.log.logger import logger\n\nif TYPE_CHECKING:\n from toolguard.buildtime import ToolGuardsCodeGenerationResult, ToolGuardSpec\n\n from lfx.inputs.inputs import InputTypes\n\n\nTOOLGUARD_WORK_DIR = Path(os.getenv(\"TOOLGUARD_WORK_DIR\") or \"tmp_toolguard\")\nBUILDTIME_MODELS = [\"gpt-5\", \"claude-sonnet\"] # currently inactive, we recommend but do not enforce\nSTEP1 = \"Step_1\"\nSTEP2 = \"Step_2\"\nMODE_GENERATE = \"🛠️ Generate\"\nMODE_GUARD = \"🛡️ Guard\"\nGENERATED_GUARD_INFO_PREFIX = \"Auto-generated ToolGuard code for \"\n\n_TOOLGUARD_INSTALL_HINT = (\n \"The 'toolguard' package is required to use PoliciesComponent. \"\n \"Install the optional extra: `pip install 'langflow-base[toolguard]'`.\"\n)\n\n\nclass PoliciesComponent(LCModelComponent):\n \"\"\"Component for building tool protection code from textual business policies and instructions.\n\n This component uses ToolGuard to generate and apply policy-based guards to tools,\n ensuring that tool execution complies with defined business policies.\n Powered by ALTK ToolGuard (https://github.com/AgentToolkit/toolguard).\n\n `toolguard` is an optional extra (`langflow-base[toolguard]`); imports happen\n lazily inside methods so this component can be discovered and inspected even\n when the extra isn't installed.\n \"\"\"\n\n display_name = \"Policies\"\n description = \"\"\"Component for building tool protection code from textual business policies and instructions.\nPowered by [ALTK ToolGuard](https://github.com/AgentToolkit/toolguard )\"\"\"\n documentation: str = \"https://github.com/AgentToolkit/toolguard\"\n icon = \"shield-check\"\n name = \"policies\"\n beta = True\n\n inputs = cast(\n \"list[InputTypes]\",\n [\n BoolInput(\n name=\"enabled\",\n display_name=\"Enabled\",\n info=\"If `true` - guards tool calls. If `false`, skip policy validation.\",\n value=True,\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Activity\",\n options=[MODE_GENERATE, MODE_GUARD],\n info=(\n \"Generate new guard code or apply existing guard. \"\n \"Review generated files in the details panel on the right.\"\n ),\n value=MODE_GENERATE,\n real_time_refresh=True,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"project\",\n display_name=\"Policies Project\",\n info=\"Folder name of the generated code\",\n value=\"my_project\",\n # required=True,\n ),\n HandleInput(\n name=\"in_tools\",\n display_name=\"Tools\",\n input_types=[\"Tool\"],\n is_list=True,\n required=True,\n info=\"These are the tools that the agent can use to help with tasks.\",\n ),\n StrInput(\n name=\"policies\",\n display_name=\"Policies\",\n info=\"One or more clear, well-defined and self-contained business policies\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Add business policy...\",\n list_add_label=\"Add Policy\",\n # input_types=[],\n ),\n ModelInput(\n name=\"model\",\n display_name=\"Language Model\",\n info=(\n \"Select LLM for Policies buildtime. We recommend using \"\n \"Anthropic Claude-Sonnet series for this task.\"\n ),\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"API Key\",\n info=\"Model Provider API key\",\n required=False,\n advanced=True,\n ),\n ],\n )\n outputs = [\n Output(\n display_name=\"Guarded Tools\",\n type_=Tool,\n name=\"guarded_tools\",\n method=\"guard_tools\",\n # group_outputs=True,\n ),\n ]\n\n @staticmethod\n def _import_toolguard():\n \"\"\"Lazily import `toolguard` and the sibling helpers that depend on it.\n\n Defined as a static method so it survives custom-component re-execution\n via `create_class`, which only re-executes the class body, not arbitrary\n module-level statements such as `try/except` import guards.\n \"\"\"\n try:\n from toolguard.buildtime import (\n PolicySpecOptions,\n ToolGuardsCodeGenerationResult,\n generate_guard_specs,\n generate_guards_code,\n )\n from toolguard.extra.langchain_to_oas import langchain_tools_to_openapi\n from toolguard.runtime import load_toolguards, load_toolguards_from_memory\n from toolguard.runtime.runtime import RESULTS_FILENAME\n\n from lfx.components.models_and_agents.policies.guard_sync_utils import sync_generated_guard_code_inputs\n from lfx.components.models_and_agents.policies.guarded_tool import GuardedTool\n from lfx.components.models_and_agents.policies.llm_wrapper import LangchainModelWrapper\n except ModuleNotFoundError as e:\n raise ImportError(_TOOLGUARD_INSTALL_HINT) from e\n return {\n \"PolicySpecOptions\": PolicySpecOptions,\n \"ToolGuardsCodeGenerationResult\": ToolGuardsCodeGenerationResult,\n \"generate_guard_specs\": generate_guard_specs,\n \"generate_guards_code\": generate_guards_code,\n \"langchain_tools_to_openapi\": langchain_tools_to_openapi,\n \"load_toolguards\": load_toolguards,\n \"load_toolguards_from_memory\": load_toolguards_from_memory,\n \"RESULTS_FILENAME\": RESULTS_FILENAME,\n \"sync_generated_guard_code_inputs\": sync_generated_guard_code_inputs,\n \"GuardedTool\": GuardedTool,\n \"LangchainModelWrapper\": LangchainModelWrapper,\n }\n\n @property\n def work_dir(self) -> Path:\n return TOOLGUARD_WORK_DIR / self._to_snake_case(self.project)\n\n def build_model(self) -> LanguageModel:\n llm_model = get_llm(\n model=self.model,\n user_id=self.user_id,\n api_key=self.api_key,\n stream=False,\n )\n if llm_model is None:\n msg = \"No language model selected. Please choose a model to proceed.\"\n raise ValueError(msg)\n return llm_model\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):\n \"\"\"Dynamically update build config with user-filtered model options.\"\"\"\n updated_build_config = update_model_options_in_build_config(\n component=self,\n build_config=build_config,\n cache_key_prefix=\"language_model_options\",\n get_options_func=get_language_model_options,\n field_name=field_name,\n field_value=field_value,\n )\n tg = self._import_toolguard()\n py_module = self._to_snake_case(self.project)\n return tg[\"sync_generated_guard_code_inputs\"](\n build_config=updated_build_config,\n work_dir=self.work_dir,\n step2_subdir=STEP2,\n project_name=py_module,\n )\n\n async def _generate_guard_specs(self) -> list[ToolGuardSpec]:\n tg = self._import_toolguard()\n logger.debug(\"Starting step 1\")\n logger.debug(f\"model = {self.model}\")\n llm = tg[\"LangchainModelWrapper\"](self.build_model())\n out_dir = self.work_dir / STEP1\n if out_dir.exists():\n shutil.rmtree(out_dir)\n policy_text = \"\\n * \".join(self.policies)\n open_api = tg[\"langchain_tools_to_openapi\"](self.in_tools)\n\n options = tg[\"PolicySpecOptions\"](example_number=4)\n specs = await tg[\"generate_guard_specs\"](\n policy_text=policy_text, tools=open_api, llm=llm, work_dir=out_dir, options=options\n )\n logger.debug(\"Step 1 Done\")\n return specs\n\n async def _generate_guard_code(self, specs: list[ToolGuardSpec]) -> ToolGuardsCodeGenerationResult:\n tg = self._import_toolguard()\n logger.debug(\"Starting step 2\")\n out_dir = self.work_dir / STEP2\n if out_dir.exists():\n shutil.rmtree(out_dir)\n llm = tg[\"LangchainModelWrapper\"](self.build_model())\n app_name = self._to_snake_case(self.project)\n open_api = tg[\"langchain_tools_to_openapi\"](self.in_tools)\n\n gen_result = await tg[\"generate_guards_code\"](\n tools=open_api, tool_specs=specs, work_dir=out_dir, llm=llm, app_name=app_name\n )\n logger.debug(\"Step 2 Done\")\n return gen_result\n\n def in_recommended_models(self, model_name: str):\n return any(recommended in model_name for recommended in BUILDTIME_MODELS)\n\n def validate_before_generate(self) -> None:\n \"\"\"Validate required inputs before generating guard code.\"\"\"\n if not self.project:\n msg = \"Policies: project cannot be empty!\"\n raise ValueError(msg)\n\n if not any(self.policies):\n msg = \"Policies: policies cannot be empty!\"\n raise ValueError(msg)\n\n if not self.in_tools:\n msg = \"Policies: in_tools cannot be empty!\"\n raise ValueError(msg)\n\n if not self.model or not self.api_key:\n msg = \"Policies: model or api_key cannot be empty!\"\n raise ValueError(msg)\n\n # uncomment if willing to enforce certain models for buildtime\n # if not self.in_recommended_models(self.model[0][\"name\"]):\n # msg = f\"Policies: model {self.model[0]['name']} is not in recommended models: {BUILDTIME_MODELS}\"\n # raise ValueError(msg)\n\n async def generate(self):\n specs = await self._generate_guard_specs()\n res = await self._generate_guard_code(specs)\n\n # if there was a previous version of the guard, remove it from python cache\n unload_module(res.domain.app_name)\n\n def _verify_cached_guards(self, code_dir: Path) -> None:\n tg = self._import_toolguard()\n # Validate cache exists before attempting to load\n if not code_dir.exists():\n msg = (\n f\"Policies: Cache directory not found at '{code_dir}'. \"\n f\"Please run in 'Generate' mode first to create the guard code, \"\n f\"or verify the project name is correct.\"\n )\n raise ValueError(msg)\n\n try:\n tg[\"load_toolguards\"](code_dir)\n except FileNotFoundError as exc:\n msg = (\n f\"Policies: Required guard code files missing in '{code_dir}'. \"\n f\"Please run in 'Generate' mode to create the guard code.\"\n )\n raise ValueError(msg) from exc\n except Exception as exc:\n msg = (\n f\"Policies: Failed to load guard code from '{code_dir}'. \"\n f\"The cached code may be invalid or corrupted. \"\n f\"Try running in 'Generate' mode to rebuild the guard code. \"\n f\"Error: {exc!s}\"\n )\n raise ValueError(msg) from exc\n\n def _validate_before_using_cache(self, code_dir: Path) -> None:\n if not self.in_tools:\n msg = \"Policies: in_tools cannot be empty!\"\n raise ValueError(msg)\n\n self._verify_cached_guards(code_dir)\n\n def make_toolguard_result(self) -> ToolGuardsCodeGenerationResult:\n tg = self._import_toolguard()\n attrs = self.get_vertex().data[\"node\"][\"template\"]\n if not attrs:\n raise ValueError\n\n result_str = attrs[str(tg[\"RESULTS_FILENAME\"])][\"value\"]\n result = tg[\"ToolGuardsCodeGenerationResult\"].model_validate_json(result_str)\n\n result.domain.app_types.content = attrs.get(str(result.domain.app_types.file_name))[\"value\"]\n result.domain.app_api.content = attrs.get(str(result.domain.app_api.file_name))[\"value\"]\n result.domain.app_api_impl.content = attrs.get(str(result.domain.app_api_impl.file_name))[\"value\"]\n\n for tool in result.tools.values():\n tool.guard_file.content = attrs.get(str(tool.guard_file.file_name))[\"value\"]\n for tool_item in tool.item_guard_files:\n tool_item.content = attrs.get(str(tool_item.file_name))[\"value\"]\n\n return result\n\n @staticmethod\n def _code_execution_allowed() -> bool:\n \"\"\"Whether executing guard code is permitted by the deployment policy.\n\n ToolGuard runs guard Python whose source comes from the component's\n client-editable CodeInput template values (make_toolguard_result reads\n attrs[...][\"value\"]) — these are NOT covered by the custom-component hash\n gate. So when an operator locks the deployment down with\n allow_custom_components=False, running that code must be refused too.\n\n Fails closed to match validate_flow_for_current_settings: when the\n settings layer is present but the service is unavailable (returns None),\n execution is denied. Fail-open is reserved for the truly standalone case\n where the settings layer cannot be imported at all (lfx used as a bare\n library), which is a local/trusted context.\n \"\"\"\n try:\n from lfx.services.deps import get_settings_service\n except ImportError:\n # No settings layer at all (lfx used as a bare library) -> local/trusted.\n return True\n\n settings_service = get_settings_service()\n if settings_service is None:\n # Settings layer present but service unavailable: fail closed, matching\n # validate_flow_for_current_settings (which raises in this case).\n return False\n return bool(getattr(settings_service.settings, \"allow_custom_components\", True))\n\n async def guard_tools(self) -> list[Tool]:\n if self.enabled:\n # Refuse to execute guard code when allow_custom_components is disabled.\n # Checked before importing/loading any toolguard runtime so\n # the client-supplied CodeInput guard values are never exec'd.\n if not self._code_execution_allowed():\n msg = (\n \"Policies/ToolGuard executes guard code, which is disabled because \"\n \"allow_custom_components is False. Set LANGFLOW_ALLOW_CUSTOM_COMPONENTS=true \"\n \"to enable this component.\"\n )\n raise ValueError(msg)\n tg = self._import_toolguard()\n mode = getattr(self, \"mode\", MODE_GENERATE)\n if mode == MODE_GENERATE:\n self.log(f\"Start generating guard code at {self.work_dir}\", name=\"info\")\n self.validate_before_generate()\n await self.generate()\n self.log(f\"Policies code generation saved to {self.work_dir}\", name=\"info\")\n self.log(\"Review the generated files in the details panel on the right.\", name=\"info\")\n\n else: # mode == \"guard\"\n self.log(f\"using cache from {self.work_dir}\", name=\"info\")\n code_dir = self.work_dir / STEP2\n self._validate_before_using_cache(code_dir)\n try:\n tg_result = self.make_toolguard_result()\n tg_runtime = tg[\"load_toolguards_from_memory\"](tg_result)\n guarded_tools = [tg[\"GuardedTool\"](tool, self.in_tools, tg_runtime) for tool in self.in_tools]\n return cast(\"list[Tool]\", guarded_tools)\n except Exception as e:\n logger.exception(e)\n raise\n\n return self.in_tools\n\n @staticmethod\n def _to_snake_case(human_name: str) -> str:\n \"\"\"Convert human-readable name to snake_case, sanitizing path traversal attempts.\"\"\"\n # Convert to lowercase\n result = human_name.lower()\n\n # Replace any non-alphanumeric character (including path traversal chars) with underscore\n result = re.sub(r\"[^a-z0-9]+\", \"_\", result)\n\n # Strip leading/trailing underscores\n result = result.strip(\"_\")\n\n # Ensure the result contains at least one alphanumeric character\n if not result or not re.search(r\"[a-z0-9]\", result):\n msg = \"Project name must contain at least one alphanumeric character\"\n raise ValueError(msg)\n\n return result\n" + "value": "from __future__ import annotations\n\nimport os\nimport re\nimport shutil\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, cast\n\nfrom lfx.base.models import LCModelComponent\nfrom lfx.base.models.unified_models import (\n get_language_model_options,\n get_llm,\n update_model_options_in_build_config,\n)\nfrom lfx.components.models_and_agents.policies.module_utils import unload_module\nfrom lfx.field_typing import LanguageModel, Tool\nfrom lfx.io import (\n BoolInput,\n HandleInput,\n ModelInput,\n MultilineInput,\n Output,\n SecretStrInput,\n StrInput,\n TabInput,\n)\nfrom lfx.log.logger import logger\n\nif TYPE_CHECKING:\n from toolguard.buildtime import ToolGuardsCodeGenerationResult, ToolGuardSpec\n\n from lfx.inputs.inputs import InputTypes\n\n\nTOOLGUARD_WORK_DIR = Path(os.getenv(\"TOOLGUARD_WORK_DIR\") or \"tmp_toolguard\")\nBUILDTIME_MODELS = [\"gpt-5\", \"claude-sonnet\"] # currently inactive, we recommend but do not enforce\nSTEP1 = \"Step_1\"\nSTEP2 = \"Step_2\"\nMODE_GENERATE = \"🛠️ Generate\"\nMODE_GUARD = \"🛡️ Guard\"\nGENERATED_GUARD_INFO_PREFIX = \"Auto-generated ToolGuard code for \"\n\n_TOOLGUARD_INSTALL_HINT = (\n \"The 'toolguard' package is required to use PoliciesComponent. \"\n \"Install the optional extra: `pip install 'langflow-base[toolguard]'`.\"\n)\n\n\nclass PoliciesComponent(LCModelComponent):\n \"\"\"Component for building tool protection code from textual business policies and instructions.\n\n This component uses ToolGuard to generate and apply policy-based guards to tools,\n ensuring that tool execution complies with defined business policies.\n Powered by ALTK ToolGuard (https://github.com/AgentToolkit/toolguard).\n\n `toolguard` is an optional extra (`langflow-base[toolguard]`); imports happen\n lazily inside methods so this component can be discovered and inspected even\n when the extra isn't installed.\n \"\"\"\n\n display_name = \"Policies\"\n description = \"\"\"Component for building tool protection code from textual business policies and instructions.\nPowered by [ALTK ToolGuard](https://github.com/AgentToolkit/toolguard )\"\"\"\n documentation: str = \"https://github.com/AgentToolkit/toolguard\"\n icon = \"shield-check\"\n name = \"policies\"\n beta = True\n\n inputs = cast(\n \"list[InputTypes]\",\n [\n BoolInput(\n name=\"enabled\",\n display_name=\"Enabled\",\n info=\"If `true` - guards tool calls. If `false`, skip policy validation.\",\n value=True,\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Activity\",\n options=[MODE_GENERATE, MODE_GUARD],\n info=(\n \"Generate new guard code or apply existing guard. \"\n \"Review generated files in the details panel on the right.\"\n ),\n value=MODE_GENERATE,\n real_time_refresh=True,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"project\",\n display_name=\"Policies Project\",\n info=\"Folder name of the generated code\",\n value=\"my_project\",\n # required=True,\n ),\n HandleInput(\n name=\"in_tools\",\n display_name=\"Tools\",\n input_types=[\"Tool\"],\n is_list=True,\n required=True,\n info=\"These are the tools that the agent can use to help with tasks.\",\n ),\n StrInput(\n name=\"policies\",\n display_name=\"Policies\",\n info=\"One or more clear, well-defined and self-contained business policies\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Add business policy...\",\n list_add_label=\"Add Policy\",\n # input_types=[],\n ),\n ModelInput(\n name=\"model\",\n display_name=\"Language Model\",\n info=(\n \"Select LLM for Policies buildtime. We recommend using \"\n \"Anthropic Claude-Sonnet series for this task.\"\n ),\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"API Key\",\n info=\"Model Provider API key\",\n required=False,\n advanced=True,\n ),\n ],\n )\n outputs = [\n Output(\n display_name=\"Guarded Tools\",\n type_=Tool,\n name=\"guarded_tools\",\n method=\"guard_tools\",\n # group_outputs=True,\n ),\n ]\n\n @staticmethod\n def _import_toolguard():\n \"\"\"Lazily import `toolguard` and the sibling helpers that depend on it.\n\n Defined as a static method so it survives custom-component re-execution\n via `create_class`, which only re-executes the class body, not arbitrary\n module-level statements such as `try/except` import guards.\n \"\"\"\n try:\n from toolguard.buildtime import (\n PolicySpecOptions,\n ToolGuardsCodeGenerationResult,\n generate_guard_specs,\n generate_guards_code,\n )\n from toolguard.extra.langchain_to_oas import langchain_tools_to_openapi\n from toolguard.runtime import load_toolguards, load_toolguards_from_memory\n from toolguard.runtime.runtime import RESULTS_FILENAME\n\n from lfx.components.models_and_agents.policies.guard_sync_utils import sync_generated_guard_code_inputs\n from lfx.components.models_and_agents.policies.guarded_tool import GuardedTool\n from lfx.components.models_and_agents.policies.llm_wrapper import LangchainModelWrapper\n except ModuleNotFoundError as e:\n raise ImportError(_TOOLGUARD_INSTALL_HINT) from e\n return {\n \"PolicySpecOptions\": PolicySpecOptions,\n \"ToolGuardsCodeGenerationResult\": ToolGuardsCodeGenerationResult,\n \"generate_guard_specs\": generate_guard_specs,\n \"generate_guards_code\": generate_guards_code,\n \"langchain_tools_to_openapi\": langchain_tools_to_openapi,\n \"load_toolguards\": load_toolguards,\n \"load_toolguards_from_memory\": load_toolguards_from_memory,\n \"RESULTS_FILENAME\": RESULTS_FILENAME,\n \"sync_generated_guard_code_inputs\": sync_generated_guard_code_inputs,\n \"GuardedTool\": GuardedTool,\n \"LangchainModelWrapper\": LangchainModelWrapper,\n }\n\n @property\n def work_dir(self) -> Path:\n return TOOLGUARD_WORK_DIR / self._to_snake_case(self.project)\n\n def build_model(self) -> LanguageModel:\n llm_model = get_llm(\n model=self.model,\n user_id=self.user_id,\n api_key=self.api_key,\n stream=False,\n )\n if llm_model is None:\n msg = \"No language model selected. Please choose a model to proceed.\"\n raise ValueError(msg)\n return llm_model\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):\n \"\"\"Dynamically update build config with user-filtered model options.\"\"\"\n updated_build_config = update_model_options_in_build_config(\n component=self,\n build_config=build_config,\n cache_key_prefix=\"language_model_options\",\n get_options_func=get_language_model_options,\n field_name=field_name,\n field_value=field_value,\n )\n tg = self._import_toolguard()\n py_module = self._to_snake_case(self.project)\n return tg[\"sync_generated_guard_code_inputs\"](\n build_config=updated_build_config,\n work_dir=self.work_dir,\n step2_subdir=STEP2,\n project_name=py_module,\n )\n\n async def _generate_guard_specs(self) -> list[ToolGuardSpec]:\n tg = self._import_toolguard()\n logger.debug(\"Starting step 1\")\n logger.debug(f\"model = {self.model}\")\n llm = tg[\"LangchainModelWrapper\"](self.build_model())\n out_dir = self.work_dir / STEP1\n if out_dir.exists():\n shutil.rmtree(out_dir)\n policy_text = \"\\n * \".join(self.policies)\n open_api = tg[\"langchain_tools_to_openapi\"](self.in_tools)\n\n options = tg[\"PolicySpecOptions\"](example_number=4)\n specs = await tg[\"generate_guard_specs\"](\n policy_text=policy_text, tools=open_api, llm=llm, work_dir=out_dir, options=options\n )\n logger.debug(\"Step 1 Done\")\n return specs\n\n async def _generate_guard_code(self, specs: list[ToolGuardSpec]) -> ToolGuardsCodeGenerationResult:\n tg = self._import_toolguard()\n logger.debug(\"Starting step 2\")\n out_dir = self.work_dir / STEP2\n if out_dir.exists():\n shutil.rmtree(out_dir)\n llm = tg[\"LangchainModelWrapper\"](self.build_model())\n app_name = self._to_snake_case(self.project)\n open_api = tg[\"langchain_tools_to_openapi\"](self.in_tools)\n\n gen_result = await tg[\"generate_guards_code\"](\n tools=open_api, tool_specs=specs, work_dir=out_dir, llm=llm, app_name=app_name\n )\n logger.debug(\"Step 2 Done\")\n return gen_result\n\n def in_recommended_models(self, model_name: str):\n return any(recommended in model_name for recommended in BUILDTIME_MODELS)\n\n def validate_before_generate(self) -> None:\n \"\"\"Validate required inputs before generating guard code.\"\"\"\n if not self.project:\n msg = \"Policies: project cannot be empty!\"\n raise ValueError(msg)\n\n if not any(self.policies):\n msg = \"Policies: policies cannot be empty!\"\n raise ValueError(msg)\n\n if not self.in_tools:\n msg = \"Policies: in_tools cannot be empty!\"\n raise ValueError(msg)\n\n # Only the model selection is mandatory. ``api_key`` is declared optional\n # (required=False, advanced=True) and is frequently supplied by the model\n # connection, an environment variable, or a global variable rather than by\n # this field — requiring it here wrongly blocked valid setups with\n # \"model or api_key cannot be empty!\". When credentials really are missing,\n # ``build_model`` -> ``get_llm`` raises a clear provider-specific error.\n if not self.model:\n msg = \"Policies: model cannot be empty!\"\n raise ValueError(msg)\n\n # uncomment if willing to enforce certain models for buildtime\n # if not self.in_recommended_models(self.model[0][\"name\"]):\n # msg = f\"Policies: model {self.model[0]['name']} is not in recommended models: {BUILDTIME_MODELS}\"\n # raise ValueError(msg)\n\n async def generate(self):\n specs = await self._generate_guard_specs()\n res = await self._generate_guard_code(specs)\n\n # if there was a previous version of the guard, remove it from python cache\n unload_module(res.domain.app_name)\n\n def _verify_cached_guards(self, code_dir: Path) -> None:\n tg = self._import_toolguard()\n # Validate cache exists before attempting to load\n if not code_dir.exists():\n msg = (\n f\"Policies: Cache directory not found at '{code_dir}'. \"\n f\"Please run in 'Generate' mode first to create the guard code, \"\n f\"or verify the project name is correct.\"\n )\n raise ValueError(msg)\n\n try:\n tg[\"load_toolguards\"](code_dir)\n except FileNotFoundError as exc:\n msg = (\n f\"Policies: Required guard code files missing in '{code_dir}'. \"\n f\"Please run in 'Generate' mode to create the guard code.\"\n )\n raise ValueError(msg) from exc\n except Exception as exc:\n msg = (\n f\"Policies: Failed to load guard code from '{code_dir}'. \"\n f\"The cached code may be invalid or corrupted. \"\n f\"Try running in 'Generate' mode to rebuild the guard code. \"\n f\"Error: {exc!s}\"\n )\n raise ValueError(msg) from exc\n\n def _validate_before_using_cache(self, code_dir: Path) -> None:\n if not self.in_tools:\n msg = \"Policies: in_tools cannot be empty!\"\n raise ValueError(msg)\n\n self._verify_cached_guards(code_dir)\n\n @staticmethod\n def _template_field_key(file_name: str | Path) -> str:\n r\"\"\"Normalize a generated file name to its node-template field key.\n\n ``sync_generated_guard_code_inputs`` keys every generated CodeInput by the\n file's POSIX relative path (``Path.relative_to(...).as_posix()``), so the\n keys always use forward slashes. The toolguard result model stores\n ``file_name`` as a :class:`pathlib.Path`, whose ``str()`` uses the OS\n separator — backslashes on Windows. Reading back with ``str(file_name)``\n therefore misses every key on Windows, ``attrs.get(...)`` returns ``None``\n and the subsequent ``[\"value\"]`` raised the cryptic\n ``'NoneType' object is not subscriptable`` (issue #13727).\n\n Normalizing through ``as_posix()`` is the exact inverse of how the keys are\n written, so lookups match on every platform. ``replace(\"\\\\\", \"/\")`` is a\n belt-and-suspenders guard for the rare case where ``file_name`` is already a\n string carrying Windows separators (e.g. a flow generated on Windows and\n opened on POSIX, where ``PurePosixPath`` would not split on backslashes).\n \"\"\"\n return Path(str(file_name).replace(\"\\\\\", \"/\")).as_posix()\n\n def make_toolguard_result(self) -> ToolGuardsCodeGenerationResult:\n tg = self._import_toolguard()\n attrs = self.get_vertex().data[\"node\"][\"template\"]\n if not attrs:\n msg = \"Policies: component template data is missing. This may indicate a corrupted flow state.\"\n raise ValueError(msg)\n\n def read_content(file_name: str | Path) -> str:\n \"\"\"Fetch a generated file's stored source from the node template.\n\n Raises a clear, actionable error when the field is absent instead of\n letting a missing key surface as ``'NoneType' object is not\n subscriptable``. A missing field means the guard code was never\n generated (or only the ``pass # FIXME`` scaffold was produced), so the\n fix is to re-run Generate.\n \"\"\"\n key = self._template_field_key(file_name)\n field = attrs.get(key)\n if field is None:\n msg = (\n f\"Policies: generated guard file '{key}' is missing from the component. \"\n f\"Re-run in 'Generate' mode to (re)build the guard code before guarding.\"\n )\n raise ValueError(msg)\n return field[\"value\"]\n\n result_str = read_content(tg[\"RESULTS_FILENAME\"])\n result = tg[\"ToolGuardsCodeGenerationResult\"].model_validate_json(result_str)\n\n result.domain.app_types.content = read_content(result.domain.app_types.file_name)\n result.domain.app_api.content = read_content(result.domain.app_api.file_name)\n result.domain.app_api_impl.content = read_content(result.domain.app_api_impl.file_name)\n\n for tool in result.tools.values():\n tool.guard_file.content = read_content(tool.guard_file.file_name)\n for tool_item in tool.item_guard_files:\n tool_item.content = read_content(tool_item.file_name)\n\n return result\n\n @staticmethod\n def _code_execution_allowed() -> bool:\n \"\"\"Whether executing guard code is permitted by the deployment policy.\n\n ToolGuard runs guard Python whose source comes from the component's\n client-editable CodeInput template values (make_toolguard_result reads\n attrs[...][\"value\"]) — these are NOT covered by the custom-component hash\n gate. So when an operator locks the deployment down with\n allow_custom_components=False, running that code must be refused too.\n\n Fails closed to match validate_flow_for_current_settings: when the\n settings layer is present but the service is unavailable (returns None),\n execution is denied. Fail-open is reserved for the truly standalone case\n where the settings layer cannot be imported at all (lfx used as a bare\n library), which is a local/trusted context.\n \"\"\"\n try:\n from lfx.services.deps import get_settings_service\n except ImportError:\n # No settings layer at all (lfx used as a bare library) -> local/trusted.\n return True\n\n settings_service = get_settings_service()\n if settings_service is None:\n # Settings layer present but service unavailable: fail closed, matching\n # validate_flow_for_current_settings (which raises in this case).\n return False\n return bool(getattr(settings_service.settings, \"allow_custom_components\", True))\n\n async def guard_tools(self) -> list[Tool]:\n if self.enabled:\n # Refuse to execute guard code when allow_custom_components is disabled.\n # Checked before importing/loading any toolguard runtime so\n # the client-supplied CodeInput guard values are never exec'd.\n if not self._code_execution_allowed():\n msg = (\n \"Policies/ToolGuard executes guard code, which is disabled because \"\n \"allow_custom_components is False. Set LANGFLOW_ALLOW_CUSTOM_COMPONENTS=true \"\n \"to enable this component.\"\n )\n raise ValueError(msg)\n tg = self._import_toolguard()\n mode = getattr(self, \"mode\", MODE_GENERATE)\n if mode == MODE_GENERATE:\n self.log(f\"Start generating guard code at {self.work_dir}\", name=\"info\")\n self.validate_before_generate()\n await self.generate()\n self.log(f\"Policies code generation saved to {self.work_dir}\", name=\"info\")\n self.log(\"Review the generated files in the details panel on the right.\", name=\"info\")\n\n else: # mode == \"guard\"\n self.log(f\"using cache from {self.work_dir}\", name=\"info\")\n code_dir = self.work_dir / STEP2\n self._validate_before_using_cache(code_dir)\n try:\n tg_result = self.make_toolguard_result()\n tg_runtime = tg[\"load_toolguards_from_memory\"](tg_result)\n guarded_tools = [tg[\"GuardedTool\"](tool, self.in_tools, tg_runtime) for tool in self.in_tools]\n return cast(\"list[Tool]\", guarded_tools)\n except Exception as e:\n logger.exception(e)\n raise\n\n return self.in_tools\n\n @staticmethod\n def _to_snake_case(human_name: str) -> str:\n \"\"\"Convert human-readable name to snake_case, sanitizing path traversal attempts.\"\"\"\n # Convert to lowercase\n result = human_name.lower()\n\n # Replace any non-alphanumeric character (including path traversal chars) with underscore\n result = re.sub(r\"[^a-z0-9]+\", \"_\", result)\n\n # Strip leading/trailing underscores\n result = result.strip(\"_\")\n\n # Ensure the result contains at least one alphanumeric character\n if not result or not re.search(r\"[a-z0-9]\", result):\n msg = \"Project name must contain at least one alphanumeric character\"\n raise ValueError(msg)\n\n return result\n" }, "enabled": { "_input_type": "BoolInput", @@ -99525,7 +99525,7 @@ }, { "name": "langchain_pinecone", - "version": null + "version": "0.2.13" } ], "total_dependencies": 4 @@ -118473,6 +118473,6 @@ "num_components": 354, "num_modules": 95 }, - "sha256": "b3a5f51d4475be25bcd419b772ae5ba1d44eb2f68947080f3da8a4ebe2a94049", + "sha256": "46144ce1de18502615f70ed2074c7a34deba85217ef333e91543e849995b2426", "version": "1.10.1" } diff --git a/src/lfx/src/lfx/components/models_and_agents/policies_component.py b/src/lfx/src/lfx/components/models_and_agents/policies_component.py index dac6f742e9..ab920fcb04 100644 --- a/src/lfx/src/lfx/components/models_and_agents/policies_component.py +++ b/src/lfx/src/lfx/components/models_and_agents/policies_component.py @@ -265,8 +265,14 @@ Powered by [ALTK ToolGuard](https://github.com/AgentToolkit/toolguard )""" msg = "Policies: in_tools cannot be empty!" raise ValueError(msg) - if not self.model or not self.api_key: - msg = "Policies: model or api_key cannot be empty!" + # Only the model selection is mandatory. ``api_key`` is declared optional + # (required=False, advanced=True) and is frequently supplied by the model + # connection, an environment variable, or a global variable rather than by + # this field — requiring it here wrongly blocked valid setups with + # "model or api_key cannot be empty!". When credentials really are missing, + # ``build_model`` -> ``get_llm`` raises a clear provider-specific error. + if not self.model: + msg = "Policies: model cannot be empty!" raise ValueError(msg) # uncomment if willing to enforce certain models for buildtime @@ -316,23 +322,64 @@ Powered by [ALTK ToolGuard](https://github.com/AgentToolkit/toolguard )""" self._verify_cached_guards(code_dir) + @staticmethod + def _template_field_key(file_name: str | Path) -> str: + r"""Normalize a generated file name to its node-template field key. + + ``sync_generated_guard_code_inputs`` keys every generated CodeInput by the + file's POSIX relative path (``Path.relative_to(...).as_posix()``), so the + keys always use forward slashes. The toolguard result model stores + ``file_name`` as a :class:`pathlib.Path`, whose ``str()`` uses the OS + separator — backslashes on Windows. Reading back with ``str(file_name)`` + therefore misses every key on Windows, ``attrs.get(...)`` returns ``None`` + and the subsequent ``["value"]`` raised the cryptic + ``'NoneType' object is not subscriptable`` (issue #13727). + + Normalizing through ``as_posix()`` is the exact inverse of how the keys are + written, so lookups match on every platform. ``replace("\\", "/")`` is a + belt-and-suspenders guard for the rare case where ``file_name`` is already a + string carrying Windows separators (e.g. a flow generated on Windows and + opened on POSIX, where ``PurePosixPath`` would not split on backslashes). + """ + return Path(str(file_name).replace("\\", "/")).as_posix() + def make_toolguard_result(self) -> ToolGuardsCodeGenerationResult: tg = self._import_toolguard() attrs = self.get_vertex().data["node"]["template"] if not attrs: - raise ValueError + msg = "Policies: component template data is missing. This may indicate a corrupted flow state." + raise ValueError(msg) - result_str = attrs[str(tg["RESULTS_FILENAME"])]["value"] + def read_content(file_name: str | Path) -> str: + """Fetch a generated file's stored source from the node template. + + Raises a clear, actionable error when the field is absent instead of + letting a missing key surface as ``'NoneType' object is not + subscriptable``. A missing field means the guard code was never + generated (or only the ``pass # FIXME`` scaffold was produced), so the + fix is to re-run Generate. + """ + key = self._template_field_key(file_name) + field = attrs.get(key) + if field is None: + msg = ( + f"Policies: generated guard file '{key}' is missing from the component. " + f"Re-run in 'Generate' mode to (re)build the guard code before guarding." + ) + raise ValueError(msg) + return field["value"] + + result_str = read_content(tg["RESULTS_FILENAME"]) result = tg["ToolGuardsCodeGenerationResult"].model_validate_json(result_str) - result.domain.app_types.content = attrs.get(str(result.domain.app_types.file_name))["value"] - result.domain.app_api.content = attrs.get(str(result.domain.app_api.file_name))["value"] - result.domain.app_api_impl.content = attrs.get(str(result.domain.app_api_impl.file_name))["value"] + result.domain.app_types.content = read_content(result.domain.app_types.file_name) + result.domain.app_api.content = read_content(result.domain.app_api.file_name) + result.domain.app_api_impl.content = read_content(result.domain.app_api_impl.file_name) for tool in result.tools.values(): - tool.guard_file.content = attrs.get(str(tool.guard_file.file_name))["value"] + tool.guard_file.content = read_content(tool.guard_file.file_name) for tool_item in tool.item_guard_files: - tool_item.content = attrs.get(str(tool_item.file_name))["value"] + tool_item.content = read_content(tool_item.file_name) return result