lfx-toolguard 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """ToolGuard-powered Policies extension for Langflow."""
2
+
3
+ from lfx_toolguard.components.models_and_agents import PoliciesComponent
4
+
5
+ __all__ = ["PoliciesComponent"]
@@ -0,0 +1 @@
1
+ """Component packages shipped by lfx-toolguard."""
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from lfx.components._importing import import_mod
6
+
7
+ if TYPE_CHECKING:
8
+ from .policies_component import PoliciesComponent
9
+
10
+ __all__ = ["PoliciesComponent"]
11
+
12
+
13
+ def __getattr__(attr_name: str) -> Any:
14
+ if attr_name not in __all__:
15
+ msg = f"module '{__name__}' has no attribute '{attr_name}'"
16
+ raise AttributeError(msg)
17
+ result = import_mod(attr_name, "policies_component", __spec__.parent)
18
+ globals()[attr_name] = result
19
+ return result
20
+
21
+
22
+ def __dir__() -> list[str]:
23
+ return list(__all__)
@@ -0,0 +1,3 @@
1
+ # Supporting utilities for the ToolGuard Policies extension.
2
+
3
+ __all__ = []
@@ -0,0 +1,98 @@
1
+ """Synchronize generated ToolGuard code with component inputs."""
2
+
3
+ from pathlib import Path
4
+
5
+ from lfx.io import CodeInput
6
+ from lfx.log.logger import logger
7
+ from toolguard.runtime.runtime import RESULTS_FILENAME
8
+
9
+ GENERATED_GUARD_INFO_PREFIX = "Auto-generated ToolGuard code for "
10
+
11
+
12
+ def is_generated_guard_field(field: dict) -> bool:
13
+ """Check if a field represents a generated guard code input.
14
+
15
+ Args:
16
+ field: Dictionary representing a component field/input
17
+
18
+ Returns:
19
+ True if the field is a generated guard code field, False otherwise
20
+ """
21
+ if not isinstance(field, dict):
22
+ return False
23
+ return (
24
+ field.get("type") == "code"
25
+ and field.get("dynamic") is True
26
+ and isinstance(field.get("info"), str)
27
+ and field.get("info", "").startswith(GENERATED_GUARD_INFO_PREFIX)
28
+ )
29
+
30
+
31
+ def sync_generated_guard_code_inputs(
32
+ build_config: dict,
33
+ work_dir: Path,
34
+ step2_subdir: str,
35
+ project_name: str,
36
+ ) -> dict:
37
+ """Synchronize generated guard code files with component code inputs.
38
+
39
+ Scans the generated guard code directory and creates/updates CodeInput fields
40
+ in the build config for each Python file found. Removes stale fields for files
41
+ that no longer exist.
42
+
43
+ Args:
44
+ build_config: The component's build configuration dictionary
45
+ work_dir: Base working directory for the toolguard project
46
+ step2_subdir: Subdirectory name containing generated code (e.g., "Step_2")
47
+ project_name: Name of the project in snake_case format
48
+
49
+ Returns:
50
+ Updated build_config dictionary with synchronized code inputs
51
+ """
52
+ logger.debug("Syncing generated guard code files...")
53
+ generated_field_names = {key for key, value in build_config.items() if is_generated_guard_field(value)}
54
+
55
+ step2_dir = work_dir / step2_subdir
56
+ logger.debug(f"step2_dir = {step2_dir}")
57
+ logger.debug(f"step2_dir.exists() = {step2_dir.exists()}")
58
+ logger.debug(f"step2_dir.is_dir() = {step2_dir.is_dir()}")
59
+ if not step2_dir.exists() or not step2_dir.is_dir():
60
+ for field_name in generated_field_names:
61
+ build_config.pop(field_name, None)
62
+ return build_config
63
+
64
+ files = sorted(path for path in step2_dir.rglob("*") if path.is_file())
65
+
66
+ def include_file(relative_name) -> bool:
67
+ if relative_name.startswith(project_name) and relative_name.endswith(".py"):
68
+ return True
69
+ return relative_name == str(RESULTS_FILENAME)
70
+
71
+ next_generated_names: set[str] = set()
72
+
73
+ for file_path in files:
74
+ relative_name = file_path.relative_to(step2_dir).as_posix()
75
+ if not include_file(relative_name):
76
+ continue
77
+ logger.debug(f"Processing generated file: {relative_name}")
78
+ next_generated_names.add(relative_name)
79
+ try:
80
+ code_value = file_path.read_text(encoding="utf-8")
81
+ except OSError:
82
+ code_value = ""
83
+
84
+ code_input = CodeInput(
85
+ name=relative_name,
86
+ display_name=relative_name,
87
+ value=code_value,
88
+ info=f"{GENERATED_GUARD_INFO_PREFIX}{relative_name}",
89
+ dynamic=True,
90
+ advanced=True,
91
+ )
92
+ build_config[relative_name] = code_input.to_dict()
93
+
94
+ stale_field_names = generated_field_names - next_generated_names
95
+ for field_name in stale_field_names:
96
+ build_config.pop(field_name, None)
97
+
98
+ return build_config
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import TYPE_CHECKING
5
+
6
+ from lfx.field_typing import Tool
7
+ from lfx.log.logger import logger
8
+
9
+ if TYPE_CHECKING:
10
+ from langchain_core.messages import ToolCall
11
+ from toolguard.runtime.runtime import ToolguardRuntime
12
+
13
+ from lfx_toolguard.components.models_and_agents.policies.tool_invoker import ToolInvoker
14
+
15
+
16
+ def _is_policy_violation_exception(exc: Exception) -> bool:
17
+ try:
18
+ from toolguard.runtime import PolicyViolationException
19
+ except ImportError:
20
+ return False
21
+
22
+ return isinstance(exc, PolicyViolationException)
23
+
24
+
25
+ class GuardedTool(Tool):
26
+ """A tool wrapper that applies ToolGuard policy validation before execution.
27
+
28
+ This component requires async execution as ToolGuard operates asynchronously.
29
+ The synchronous `run()` method is not supported and will raise NotImplementedError.
30
+ Always use `arun()` or async invocation methods.
31
+ """
32
+
33
+ _orig_tool: Tool
34
+ _tool_invoker: ToolInvoker
35
+ _toolguard: ToolguardRuntime
36
+
37
+ def __init__(self, tool: Tool, all_tools: list[Tool], toolguard: ToolguardRuntime):
38
+ from lfx_toolguard.components.models_and_agents.policies.tool_invoker import ToolInvoker
39
+
40
+ super().__init__(
41
+ name=tool.name,
42
+ description=tool.description,
43
+ args_schema=getattr(tool, "args_schema", None),
44
+ return_direct=getattr(tool, "return_direct", False),
45
+ func=self.run,
46
+ coroutine=self.arun,
47
+ tags=tool.tags,
48
+ metadata=tool.metadata,
49
+ verbose=True,
50
+ )
51
+ self._orig_tool = tool
52
+ self._tool_invoker = ToolInvoker(all_tools)
53
+ self._toolguard = toolguard
54
+
55
+ @property
56
+ def args(self) -> dict:
57
+ return self._orig_tool.args
58
+
59
+ def _parse_string_to_dict(self, value: str) -> dict:
60
+ """Parse a string as JSON, or wrap it in a dict if parsing fails."""
61
+ try:
62
+ return json.loads(value)
63
+ except json.JSONDecodeError:
64
+ return {"input": value}
65
+
66
+ def parse_input(self, tool_input: str | dict | ToolCall) -> dict:
67
+ # Handle string input - try to parse as JSON, fallback to wrapped input
68
+ if isinstance(tool_input, str):
69
+ return self._parse_string_to_dict(tool_input)
70
+
71
+ # Handle ToolCall dict format - extract and parse args
72
+ if isinstance(tool_input, dict) and "args" in tool_input:
73
+ args = tool_input["args"]
74
+ if isinstance(args, str):
75
+ return self._parse_string_to_dict(args)
76
+ return args if isinstance(args, dict) else {}
77
+
78
+ # Return dict as-is or empty dict for None/other types
79
+ return tool_input if isinstance(tool_input, dict) else {}
80
+
81
+ def run(self, tool_input: str | dict | ToolCall, config=None, **kwargs):
82
+ """Synchronous execution is not supported for GuardedTool.
83
+
84
+ ToolGuard requires async execution for policy validation. Please use the
85
+ async version `arun()` instead, or ensure your execution context supports
86
+ async tool invocation.
87
+
88
+ Raises:
89
+ NotImplementedError: Always raised as sync execution is not supported.
90
+ """
91
+ msg = (
92
+ "GuardedTool does not support synchronous execution. "
93
+ "ToolGuard requires async execution for policy validation. "
94
+ "Please use `arun()` instead or ensure your execution context supports async tool invocation."
95
+ )
96
+ raise NotImplementedError(msg)
97
+
98
+ async def arun(self, tool_input: str | dict | ToolCall, config=None, **kwargs):
99
+ args = self.parse_input(tool_input)
100
+ logger.debug(f"running toolguard for {self.name}")
101
+
102
+ with self._toolguard:
103
+ try:
104
+ await self._toolguard.guard_toolcall(self.name, args=args, delegate=self._tool_invoker)
105
+ return await self._orig_tool.arun(tool_input=args, config=config, **kwargs)
106
+ except Exception as ex:
107
+ if not _is_policy_violation_exception(ex):
108
+ logger.exception("Unhandled exception in class GuardedTool.arun()")
109
+ raise
110
+
111
+ message = getattr(ex, "message", str(ex))
112
+ logger.debug(f"exception: {message}")
113
+ return {
114
+ "ok": False,
115
+ "error": {
116
+ "type": "PolicyViolationException",
117
+ "code": "FAILURE",
118
+ "message": message,
119
+ "retryable": True,
120
+ },
121
+ }
@@ -0,0 +1,184 @@
1
+ """Language-model adapter used by the ToolGuard Policies extension."""
2
+
3
+ from typing import Any
4
+
5
+ from langchain_core.language_models.chat_models import BaseChatModel
6
+ from langchain_core.messages import messages_from_dict
7
+ from toolguard.buildtime.llm import LanguageModelBase
8
+
9
+
10
+ class LangchainModelWrapper(LanguageModelBase):
11
+ """Wrapper for Langchain chat models to work with ToolGuard.
12
+
13
+ This wrapper handles:
14
+ - Message format conversion between ToolGuard and Langchain
15
+ - Automatic continuation when max tokens are reached
16
+ - Safe error handling and validation
17
+ """
18
+
19
+ MAX_CONTINUATIONS = 5 # Prevent infinite recursion
20
+ DEFAULT_MAX_OUT_TOKENS = 16000
21
+
22
+ def __init__(self, langchain_model: BaseChatModel):
23
+ """Initialize the wrapper with a Langchain chat model.
24
+
25
+ Args:
26
+ langchain_model: A Langchain BaseChatModel instance
27
+ """
28
+ self.langchain_model = langchain_model
29
+ if hasattr(self.langchain_model, "max_tokens") and getattr(self.langchain_model, "max_tokens", None) is None:
30
+ self.langchain_model.max_tokens = self.DEFAULT_MAX_OUT_TOKENS
31
+
32
+ def _convert_role(self, role: str) -> str:
33
+ """Convert ToolGuard role to Langchain message type.
34
+
35
+ Args:
36
+ role: The role from ToolGuard format ("user", "assistant", "system")
37
+
38
+ Returns:
39
+ Langchain message type ("human", "ai", "system")
40
+ """
41
+ role_mapping = {
42
+ "user": "human",
43
+ "assistant": "ai",
44
+ "system": "system",
45
+ }
46
+ return role_mapping.get(role, "system")
47
+
48
+ def _validate_messages(self, messages: list[dict]) -> None:
49
+ """Validate message format.
50
+
51
+ Args:
52
+ messages: List of message dictionaries
53
+
54
+ Raises:
55
+ ValueError: If messages are invalid
56
+ """
57
+ if not isinstance(messages, list):
58
+ msg = f"Messages must be a list, got {type(messages)}"
59
+ raise TypeError(msg)
60
+
61
+ for i, msg in enumerate(messages):
62
+ if not isinstance(msg, dict):
63
+ error_msg = f"Message at index {i} must be a dict, got {type(msg)}"
64
+ raise TypeError(error_msg)
65
+
66
+ if "role" not in msg:
67
+ error_msg = f"Message at index {i} missing 'role' field"
68
+ raise ValueError(error_msg)
69
+
70
+ if "content" not in msg:
71
+ error_msg = f"Message at index {i} missing 'content' field"
72
+ raise ValueError(error_msg)
73
+
74
+ def _extract_content(self, content: Any) -> str:
75
+ """Safely extract string content from various types.
76
+
77
+ Args:
78
+ content: Content that could be str, list, tuple, or None
79
+
80
+ Returns:
81
+ String representation of the content
82
+ """
83
+ if content is None:
84
+ return ""
85
+
86
+ if isinstance(content, str):
87
+ return content
88
+
89
+ if isinstance(content, (list, tuple)):
90
+ # Join list/tuple elements with space
91
+ return " ".join(str(item) for item in content)
92
+
93
+ return str(content)
94
+
95
+ async def generate(self, messages: list[dict], _recursion_depth: int = 0) -> str:
96
+ """Generate a response from the language model.
97
+
98
+ Args:
99
+ messages: List of message dicts with 'role' and 'content' keys
100
+ _recursion_depth: Internal counter to prevent infinite recursion
101
+
102
+ Returns:
103
+ Generated text response
104
+
105
+ Raises:
106
+ ValueError: If messages are invalid or response is malformed
107
+ RuntimeError: If max continuations exceeded or API call fails
108
+ """
109
+ # Validate inputs
110
+ self._validate_messages(messages)
111
+
112
+ # Check recursion depth
113
+ if _recursion_depth >= self.MAX_CONTINUATIONS:
114
+ msg = f"Maximum continuation depth ({self.MAX_CONTINUATIONS}) exceeded"
115
+ raise RuntimeError(msg)
116
+
117
+ # Convert messages to Langchain format
118
+ converted_messages = [
119
+ {
120
+ "type": self._convert_role(msg.get("role", "system")),
121
+ "data": {"content": self._extract_content(msg.get("content"))},
122
+ }
123
+ for msg in messages
124
+ ]
125
+
126
+ try:
127
+ lc_messages = messages_from_dict(converted_messages)
128
+ except Exception as exc:
129
+ msg = f"Failed to convert messages to Langchain format: {exc}"
130
+ raise ValueError(msg) from exc
131
+
132
+ # Call the language model
133
+ try:
134
+ response = await self.langchain_model.agenerate(
135
+ messages=[lc_messages],
136
+ )
137
+ except Exception as exc:
138
+ msg = f"Language model API call failed: {exc}"
139
+ raise RuntimeError(msg) from exc
140
+
141
+ # Safely extract response
142
+ if not response.generations or not response.generations[0]:
143
+ msg = "Empty response from language model"
144
+ raise ValueError(msg)
145
+
146
+ choice0 = response.generations[0][0]
147
+
148
+ if not hasattr(choice0, "message") or not hasattr(choice0.message, "content"):
149
+ msg = "Malformed response from language model"
150
+ raise ValueError(msg)
151
+
152
+ chunk = self._extract_content(choice0.text)
153
+
154
+ # Check if we need to continue due to max tokens
155
+ generation_info = getattr(choice0, "generation_info", None)
156
+ if generation_info and isinstance(generation_info, dict):
157
+ finish_reason = generation_info.get("finish_reason")
158
+
159
+ if finish_reason == "length": # max tokens reached
160
+ resp_msg = {
161
+ "role": "assistant",
162
+ "content": chunk,
163
+ }
164
+ continue_msg = {
165
+ "role": "user",
166
+ "content": (
167
+ "Continue the previous answer starting exactly from the last incomplete sentence. "
168
+ "Do not repeat anything. Do not add any prefix."
169
+ ),
170
+ }
171
+ next_messages = [
172
+ *messages,
173
+ resp_msg,
174
+ continue_msg,
175
+ ]
176
+
177
+ # Recursive call with depth tracking
178
+ continuation = await self.generate(next_messages, _recursion_depth + 1)
179
+ return chunk + continuation
180
+
181
+ return chunk
182
+
183
+
184
+ # Made with Bob
@@ -0,0 +1,22 @@
1
+ """Module-management helpers for the ToolGuard Policies extension."""
2
+
3
+ import sys
4
+
5
+
6
+ def unload_module(name: str) -> None:
7
+ """Remove a module and all its submodules from sys.modules.
8
+
9
+ This ensures complete cleanup of dynamically generated modules,
10
+ including any nested imports that may have been created.
11
+
12
+ Args:
13
+ name: The name of the module to unload
14
+ """
15
+ # Remove the main module
16
+ if name in sys.modules:
17
+ del sys.modules[name]
18
+
19
+ # Remove all submodules (e.g., module.submodule)
20
+ modules_to_remove = [mod_name for mod_name in sys.modules if mod_name.startswith(f"{name}.")]
21
+ for mod_name in modules_to_remove:
22
+ del sys.modules[mod_name]
@@ -0,0 +1,59 @@
1
+ """Tool invocation adapters for the ToolGuard Policies extension."""
2
+
3
+ from typing import Any, TypeVar
4
+
5
+ from langchain_core.messages import ToolMessage
6
+ from lfx.field_typing.constants import BaseTool
7
+ from lfx.log.logger import logger
8
+ from mcp.types import CallToolResult
9
+ from pydantic import BaseModel
10
+ from toolguard.runtime import IToolInvoker
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ class ToolInvoker(IToolInvoker):
16
+ _tools: dict[str, BaseTool]
17
+
18
+ def __init__(self, tools: list[BaseTool]) -> None:
19
+ self._tools = {tool.name: tool for tool in tools}
20
+
21
+ async def invoke(self, toolname: str, arguments: dict[str, Any], return_type: type[T]) -> T:
22
+ tool = self._tools.get(toolname)
23
+ if tool:
24
+ logger.info(f"invoking {toolname} internally")
25
+ res = await tool.ainvoke(input=arguments)
26
+
27
+ # ToolInvoker calls ainvoke without tool_call_id, so MCPStructuredTool
28
+ # returns the raw CallToolResult. The ToolMessage branch below is a
29
+ # defensive fallback in case that ever changes.
30
+ if isinstance(res, ToolMessage) and isinstance(res.artifact, CallToolResult):
31
+ res_dict = res.artifact.structuredContent
32
+ elif isinstance(res, CallToolResult):
33
+ res_dict = res.structuredContent
34
+ elif isinstance(res, list):
35
+ # Multimodal content (e.g. images) — no structured extraction applies.
36
+ return res # type: ignore[return-value]
37
+ elif isinstance(res, dict) and "value" in res:
38
+ res_dict = res["value"]
39
+ else:
40
+ res_dict = res
41
+
42
+ # Only try to extract "result" key if res_dict is a dictionary
43
+ if isinstance(res_dict, dict):
44
+ res_dict = res_dict.get("result", res_dict)
45
+
46
+ if isinstance(res_dict, BaseModel):
47
+ res_dict = res_dict.model_dump()
48
+
49
+ if issubclass(return_type, BaseModel):
50
+ return return_type.model_validate(res_dict)
51
+ if return_type in (int, float, str, bool):
52
+ return return_type(res_dict)
53
+ return res_dict
54
+
55
+ msg = f"unknown tool {toolname}"
56
+ raise ValueError(msg)
57
+
58
+
59
+ # Made with Bob
@@ -0,0 +1,496 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, cast
8
+ from uuid import uuid4
9
+
10
+ from lfx.base.models import LCModelComponent
11
+ from lfx.base.models.unified_models import (
12
+ get_language_model_options,
13
+ get_llm,
14
+ update_model_options_in_build_config,
15
+ )
16
+ from lfx.field_typing import LanguageModel, Tool
17
+ from lfx.io import (
18
+ BoolInput,
19
+ HandleInput,
20
+ ModelInput,
21
+ MultilineInput,
22
+ Output,
23
+ SecretStrInput,
24
+ StrInput,
25
+ TabInput,
26
+ )
27
+ from lfx.log.logger import logger
28
+
29
+ from lfx_toolguard.components.models_and_agents.policies.module_utils import unload_module
30
+
31
+ if TYPE_CHECKING:
32
+ from lfx.inputs.inputs import InputTypes
33
+ from toolguard.buildtime import ToolGuardsCodeGenerationResult, ToolGuardSpec
34
+
35
+
36
+ TOOLGUARD_WORK_DIR = Path(os.getenv("TOOLGUARD_WORK_DIR") or "tmp_toolguard")
37
+ BUILDTIME_MODELS = ["gpt-5", "claude-sonnet"] # currently inactive, we recommend but do not enforce
38
+ STEP1 = "Step_1"
39
+ STEP2 = "Step_2"
40
+ MODE_GENERATE = "🛠️ Generate"
41
+ MODE_GUARD = "🛡️ Guard"
42
+ GENERATED_GUARD_INFO_PREFIX = "Auto-generated ToolGuard code for "
43
+
44
+ _TOOLGUARD_INSTALL_HINT = (
45
+ "The 'toolguard' package is required to use PoliciesComponent. "
46
+ "Install the extension extra: `pip install 'lfx[toolguard]'`."
47
+ )
48
+
49
+
50
+ class PoliciesComponent(LCModelComponent):
51
+ """Component for building tool protection code from textual business policies and instructions.
52
+
53
+ This component uses ToolGuard to generate and apply policy-based guards to tools,
54
+ ensuring that tool execution complies with defined business policies.
55
+ Powered by ALTK ToolGuard (https://github.com/AgentToolkit/toolguard).
56
+
57
+ `toolguard` is supplied by the `lfx-toolguard` extension; imports happen
58
+ lazily inside methods so this component can be discovered and inspected even
59
+ when the extra isn't installed.
60
+ """
61
+
62
+ model_provider_policy_mode = "delegate"
63
+ display_name = "Policies"
64
+ description = """Component for building tool protection code from textual business policies and instructions.
65
+ Powered by [ALTK ToolGuard](https://github.com/AgentToolkit/toolguard )"""
66
+ documentation: str = "https://github.com/AgentToolkit/toolguard"
67
+ icon = "shield-check"
68
+ name = "policies"
69
+ beta = True
70
+
71
+ inputs = cast(
72
+ "list[InputTypes]",
73
+ [
74
+ BoolInput(
75
+ name="enabled",
76
+ display_name="Enabled",
77
+ info="If `true` - guards tool calls. If `false`, skip policy validation.",
78
+ value=True,
79
+ ),
80
+ TabInput(
81
+ name="mode",
82
+ display_name="Activity",
83
+ options=[MODE_GENERATE, MODE_GUARD],
84
+ info=(
85
+ "Generate new guard code or apply existing guard. "
86
+ "Review generated files in the details panel on the right."
87
+ ),
88
+ value=MODE_GENERATE,
89
+ real_time_refresh=True,
90
+ tool_mode=True,
91
+ ),
92
+ MultilineInput(
93
+ name="project",
94
+ display_name="Policies Project",
95
+ info="Folder name of the generated code",
96
+ value="my_project",
97
+ # required=True,
98
+ ),
99
+ HandleInput(
100
+ name="in_tools",
101
+ display_name="Tools",
102
+ input_types=["Tool"],
103
+ is_list=True,
104
+ required=True,
105
+ info="These are the tools that the agent can use to help with tasks.",
106
+ ),
107
+ StrInput(
108
+ name="policies",
109
+ display_name="Policies",
110
+ info="One or more clear, well-defined and self-contained business policies",
111
+ is_list=True,
112
+ tool_mode=True,
113
+ placeholder="Add business policy...",
114
+ list_add_label="Add Policy",
115
+ # input_types=[],
116
+ ),
117
+ ModelInput(
118
+ name="model",
119
+ display_name="Language Model",
120
+ info=(
121
+ "Select LLM for Policies buildtime. We recommend using "
122
+ "Anthropic Claude-Sonnet series for this task."
123
+ ),
124
+ real_time_refresh=True,
125
+ required=True,
126
+ ),
127
+ SecretStrInput(
128
+ name="api_key",
129
+ display_name="API Key",
130
+ info="Model Provider API key",
131
+ required=False,
132
+ advanced=True,
133
+ ),
134
+ ],
135
+ )
136
+ outputs = [
137
+ Output(
138
+ display_name="Guarded Tools",
139
+ type_=Tool,
140
+ name="guarded_tools",
141
+ method="guard_tools",
142
+ # group_outputs=True,
143
+ ),
144
+ ]
145
+
146
+ @staticmethod
147
+ def _import_toolguard():
148
+ """Lazily import `toolguard` and the sibling helpers that depend on it.
149
+
150
+ Defined as a static method so it survives custom-component re-execution
151
+ via `create_class`, which only re-executes the class body, not arbitrary
152
+ module-level statements such as `try/except` import guards.
153
+ """
154
+ try:
155
+ from toolguard.buildtime import (
156
+ PolicySpecOptions,
157
+ ToolGuardsCodeGenerationResult,
158
+ generate_guard_specs,
159
+ generate_guards_code,
160
+ )
161
+ from toolguard.extra.langchain_to_oas import langchain_tools_to_openapi
162
+ from toolguard.runtime import load_toolguards, load_toolguards_from_memory
163
+ from toolguard.runtime.runtime import RESULTS_FILENAME
164
+
165
+ from lfx_toolguard.components.models_and_agents.policies.guard_sync_utils import (
166
+ sync_generated_guard_code_inputs,
167
+ )
168
+ from lfx_toolguard.components.models_and_agents.policies.guarded_tool import GuardedTool
169
+ from lfx_toolguard.components.models_and_agents.policies.llm_wrapper import LangchainModelWrapper
170
+ except ModuleNotFoundError as e:
171
+ raise ImportError(_TOOLGUARD_INSTALL_HINT) from e
172
+ return {
173
+ "PolicySpecOptions": PolicySpecOptions,
174
+ "ToolGuardsCodeGenerationResult": ToolGuardsCodeGenerationResult,
175
+ "generate_guard_specs": generate_guard_specs,
176
+ "generate_guards_code": generate_guards_code,
177
+ "langchain_tools_to_openapi": langchain_tools_to_openapi,
178
+ "load_toolguards": load_toolguards,
179
+ "load_toolguards_from_memory": load_toolguards_from_memory,
180
+ "RESULTS_FILENAME": RESULTS_FILENAME,
181
+ "sync_generated_guard_code_inputs": sync_generated_guard_code_inputs,
182
+ "GuardedTool": GuardedTool,
183
+ "LangchainModelWrapper": LangchainModelWrapper,
184
+ }
185
+
186
+ @property
187
+ def work_dir(self) -> Path:
188
+ """Return a path isolated by user, flow, component, and project."""
189
+ try:
190
+ user_id = self.user_id
191
+ except (AttributeError, ValueError):
192
+ user_id = None
193
+ try:
194
+ flow_id = self.flow_id
195
+ except (AttributeError, ValueError):
196
+ flow_id = None
197
+
198
+ vertex = getattr(self, "_vertex", None)
199
+ component_id = getattr(self, "_id", None) or getattr(vertex, "id", None)
200
+
201
+ user_namespace = self._to_snake_case(str(user_id)) if user_id and str(user_id) != "None" else "anonymous"
202
+ flow_namespace = self._to_snake_case(str(flow_id)) if flow_id else "standalone"
203
+ if component_id:
204
+ component_namespace = self._to_snake_case(str(component_id))
205
+ else:
206
+ instance_id = getattr(self, "_toolguard_instance_id", None)
207
+ if instance_id is None:
208
+ instance_id = uuid4().hex
209
+ self._toolguard_instance_id = instance_id
210
+ component_namespace = f"component_{instance_id}"
211
+ project_namespace = self._to_snake_case(self.project)
212
+ return TOOLGUARD_WORK_DIR / user_namespace / flow_namespace / component_namespace / project_namespace
213
+
214
+ def build_model(self) -> LanguageModel:
215
+ llm_model = get_llm(
216
+ model=self.model,
217
+ user_id=self.user_id,
218
+ api_key=self.api_key,
219
+ stream=False,
220
+ )
221
+ if llm_model is None:
222
+ msg = "No language model selected. Please choose a model to proceed."
223
+ raise ValueError(msg)
224
+ return llm_model
225
+
226
+ def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):
227
+ """Dynamically update build config with user-filtered model options."""
228
+ updated_build_config = update_model_options_in_build_config(
229
+ component=self,
230
+ build_config=build_config,
231
+ cache_key_prefix="language_model_options",
232
+ get_options_func=get_language_model_options,
233
+ field_name=field_name,
234
+ field_value=field_value,
235
+ )
236
+ tg = self._import_toolguard()
237
+ py_module = self._to_snake_case(self.project)
238
+ return tg["sync_generated_guard_code_inputs"](
239
+ build_config=updated_build_config,
240
+ work_dir=self.work_dir,
241
+ step2_subdir=STEP2,
242
+ project_name=py_module,
243
+ )
244
+
245
+ async def _generate_guard_specs(self) -> list[ToolGuardSpec]:
246
+ tg = self._import_toolguard()
247
+ logger.debug("Starting step 1")
248
+ logger.debug(f"model = {self.model}")
249
+ llm = tg["LangchainModelWrapper"](self.build_model())
250
+ out_dir = self.work_dir / STEP1
251
+ if out_dir.exists():
252
+ shutil.rmtree(out_dir)
253
+ policy_text = "\n * ".join(self.policies)
254
+ open_api = tg["langchain_tools_to_openapi"](self.in_tools)
255
+
256
+ options = tg["PolicySpecOptions"](example_number=4)
257
+ specs = await tg["generate_guard_specs"](
258
+ policy_text=policy_text, tools=open_api, llm=llm, work_dir=out_dir, options=options
259
+ )
260
+ logger.debug("Step 1 Done")
261
+ return specs
262
+
263
+ async def _generate_guard_code(self, specs: list[ToolGuardSpec]) -> ToolGuardsCodeGenerationResult:
264
+ tg = self._import_toolguard()
265
+ logger.debug("Starting step 2")
266
+ out_dir = self.work_dir / STEP2
267
+ if out_dir.exists():
268
+ shutil.rmtree(out_dir)
269
+ llm = tg["LangchainModelWrapper"](self.build_model())
270
+ app_name = self._to_snake_case(self.project)
271
+ open_api = tg["langchain_tools_to_openapi"](self.in_tools)
272
+
273
+ gen_result = await tg["generate_guards_code"](
274
+ tools=open_api, tool_specs=specs, work_dir=out_dir, llm=llm, app_name=app_name
275
+ )
276
+ logger.debug("Step 2 Done")
277
+ return gen_result
278
+
279
+ def in_recommended_models(self, model_name: str):
280
+ return any(recommended in model_name for recommended in BUILDTIME_MODELS)
281
+
282
+ def validate_before_generate(self) -> None:
283
+ """Validate required inputs before generating guard code."""
284
+ if not self.project:
285
+ msg = "Policies: project cannot be empty!"
286
+ raise ValueError(msg)
287
+
288
+ if not any(self.policies):
289
+ msg = "Policies: policies cannot be empty!"
290
+ raise ValueError(msg)
291
+
292
+ if not self.in_tools:
293
+ msg = "Policies: in_tools cannot be empty!"
294
+ raise ValueError(msg)
295
+
296
+ # Only the model selection is mandatory. ``api_key`` is declared optional
297
+ # (required=False, advanced=True) and is frequently supplied by the model
298
+ # connection, an environment variable, or a global variable rather than by
299
+ # this field — requiring it here wrongly blocked valid setups with
300
+ # "model or api_key cannot be empty!". When credentials really are missing,
301
+ # ``build_model`` -> ``get_llm`` raises a clear provider-specific error.
302
+ if not self.model:
303
+ msg = "Policies: model cannot be empty!"
304
+ raise ValueError(msg)
305
+
306
+ # uncomment if willing to enforce certain models for buildtime
307
+ # if not self.in_recommended_models(self.model[0]["name"]):
308
+ # msg = f"Policies: model {self.model[0]['name']} is not in recommended models: {BUILDTIME_MODELS}"
309
+ # raise ValueError(msg)
310
+
311
+ async def generate(self):
312
+ specs = await self._generate_guard_specs()
313
+ res = await self._generate_guard_code(specs)
314
+
315
+ # if there was a previous version of the guard, remove it from python cache
316
+ unload_module(res.domain.app_name)
317
+
318
+ def _verify_cached_guards(self, code_dir: Path) -> None:
319
+ tg = self._import_toolguard()
320
+ # Validate cache exists before attempting to load
321
+ if not code_dir.exists():
322
+ msg = (
323
+ f"Policies: Cache directory not found at '{code_dir}'. "
324
+ f"Please run in 'Generate' mode first to create the guard code, "
325
+ f"or verify the project name is correct."
326
+ )
327
+ raise ValueError(msg)
328
+
329
+ try:
330
+ tg["load_toolguards"](code_dir)
331
+ except FileNotFoundError as exc:
332
+ msg = (
333
+ f"Policies: Required guard code files missing in '{code_dir}'. "
334
+ f"Please run in 'Generate' mode to create the guard code."
335
+ )
336
+ raise ValueError(msg) from exc
337
+ except Exception as exc:
338
+ msg = (
339
+ f"Policies: Failed to load guard code from '{code_dir}'. "
340
+ f"The cached code may be invalid or corrupted. "
341
+ f"Try running in 'Generate' mode to rebuild the guard code. "
342
+ f"Error: {exc!s}"
343
+ )
344
+ raise ValueError(msg) from exc
345
+
346
+ def _validate_before_using_cache(self, code_dir: Path) -> None:
347
+ if not self.in_tools:
348
+ msg = "Policies: in_tools cannot be empty!"
349
+ raise ValueError(msg)
350
+
351
+ self._verify_cached_guards(code_dir)
352
+
353
+ @staticmethod
354
+ def _template_field_key(file_name: str | Path) -> str:
355
+ r"""Normalize a generated file name to its node-template field key.
356
+
357
+ ``sync_generated_guard_code_inputs`` keys every generated CodeInput by the
358
+ file's POSIX relative path (``Path.relative_to(...).as_posix()``), so the
359
+ keys always use forward slashes. The toolguard result model stores
360
+ ``file_name`` as a :class:`pathlib.Path`, whose ``str()`` uses the OS
361
+ separator — backslashes on Windows. Reading back with ``str(file_name)``
362
+ therefore misses every key on Windows, ``attrs.get(...)`` returns ``None``
363
+ and the subsequent ``["value"]`` raised the cryptic
364
+ ``'NoneType' object is not subscriptable`` (issue #13727).
365
+
366
+ Normalizing through ``as_posix()`` is the exact inverse of how the keys are
367
+ written, so lookups match on every platform. ``replace("\\", "/")`` is a
368
+ belt-and-suspenders guard for the rare case where ``file_name`` is already a
369
+ string carrying Windows separators (e.g. a flow generated on Windows and
370
+ opened on POSIX, where ``PurePosixPath`` would not split on backslashes).
371
+ """
372
+ return Path(str(file_name).replace("\\", "/")).as_posix()
373
+
374
+ def make_toolguard_result(self) -> ToolGuardsCodeGenerationResult:
375
+ tg = self._import_toolguard()
376
+ attrs = self.get_vertex().data["node"]["template"]
377
+ if not attrs:
378
+ msg = "Policies: component template data is missing. This may indicate a corrupted flow state."
379
+ raise ValueError(msg)
380
+
381
+ def read_content(file_name: str | Path) -> str:
382
+ """Fetch a generated file's stored source from the node template.
383
+
384
+ Raises a clear, actionable error when the field is absent instead of
385
+ letting a missing key surface as ``'NoneType' object is not
386
+ subscriptable``. A missing field means the guard code was never
387
+ generated (or only the ``pass # FIXME`` scaffold was produced), so the
388
+ fix is to re-run Generate.
389
+ """
390
+ key = self._template_field_key(file_name)
391
+ field = attrs.get(key)
392
+ if field is None:
393
+ msg = (
394
+ f"Policies: generated guard file '{key}' is missing from the component. "
395
+ f"Re-run in 'Generate' mode to (re)build the guard code before guarding."
396
+ )
397
+ raise ValueError(msg)
398
+ return field["value"]
399
+
400
+ result_str = read_content(tg["RESULTS_FILENAME"])
401
+ result = tg["ToolGuardsCodeGenerationResult"].model_validate_json(result_str)
402
+
403
+ result.domain.app_types.content = read_content(result.domain.app_types.file_name)
404
+ result.domain.app_api.content = read_content(result.domain.app_api.file_name)
405
+ result.domain.app_api_impl.content = read_content(result.domain.app_api_impl.file_name)
406
+
407
+ for tool in result.tools.values():
408
+ tool.guard_file.content = read_content(tool.guard_file.file_name)
409
+ for tool_item in tool.item_guard_files:
410
+ tool_item.content = read_content(tool_item.file_name)
411
+
412
+ return result
413
+
414
+ @staticmethod
415
+ def _code_execution_allowed() -> bool:
416
+ """Whether executing guard code is permitted by the deployment policy.
417
+
418
+ ToolGuard runs guard Python whose source comes from the component's
419
+ client-editable CodeInput template values (make_toolguard_result reads
420
+ attrs[...]["value"]) — these are NOT covered by the custom-component hash
421
+ gate. So when an operator locks the deployment down with
422
+ allow_custom_components=False, running that code must be refused too.
423
+
424
+ Fails closed to match validate_flow_for_current_settings: when the
425
+ settings layer is present but the service is unavailable (returns None),
426
+ execution is denied. Fail-open is reserved for the truly standalone case
427
+ where the settings layer cannot be imported at all (lfx used as a bare
428
+ library), which is a local/trusted context.
429
+ """
430
+ try:
431
+ from lfx.services.deps import get_settings_service
432
+ except ImportError:
433
+ # No settings layer at all (lfx used as a bare library) -> local/trusted.
434
+ return True
435
+
436
+ settings_service = get_settings_service()
437
+ if settings_service is None:
438
+ # Settings layer present but service unavailable: fail closed, matching
439
+ # validate_flow_for_current_settings (which raises in this case).
440
+ return False
441
+ return bool(getattr(settings_service.settings, "allow_custom_components", False))
442
+
443
+ async def guard_tools(self) -> list[Tool]:
444
+ if self.enabled:
445
+ # Refuse to execute guard code when allow_custom_components is disabled.
446
+ # Checked before importing/loading any toolguard runtime so
447
+ # the client-supplied CodeInput guard values are never exec'd.
448
+ if not self._code_execution_allowed():
449
+ msg = (
450
+ "Policies/ToolGuard executes guard code, which is disabled because "
451
+ "allow_custom_components is False. Set LANGFLOW_ALLOW_CUSTOM_COMPONENTS=true "
452
+ "to enable this component."
453
+ )
454
+ raise ValueError(msg)
455
+ tg = self._import_toolguard()
456
+ mode = getattr(self, "mode", MODE_GENERATE)
457
+ if mode == MODE_GENERATE:
458
+ self.log(f"Start generating guard code at {self.work_dir}", name="info")
459
+ self.validate_before_generate()
460
+ await self.generate()
461
+ self.log(f"Policies code generation saved to {self.work_dir}", name="info")
462
+ self.log("Review the generated files in the details panel on the right.", name="info")
463
+
464
+ else: # mode == "guard"
465
+ self.log(f"using cache from {self.work_dir}", name="info")
466
+ code_dir = self.work_dir / STEP2
467
+ self._validate_before_using_cache(code_dir)
468
+ try:
469
+ tg_result = self.make_toolguard_result()
470
+ tg_runtime = tg["load_toolguards_from_memory"](tg_result)
471
+ guarded_tools = [tg["GuardedTool"](tool, self.in_tools, tg_runtime) for tool in self.in_tools]
472
+ return cast("list[Tool]", guarded_tools)
473
+ except Exception as e:
474
+ logger.exception(e)
475
+ raise
476
+
477
+ return self.in_tools
478
+
479
+ @staticmethod
480
+ def _to_snake_case(human_name: str) -> str:
481
+ """Convert human-readable name to snake_case, sanitizing path traversal attempts."""
482
+ # Convert to lowercase
483
+ result = human_name.lower()
484
+
485
+ # Replace any non-alphanumeric character (including path traversal chars) with underscore
486
+ result = re.sub(r"[^a-z0-9]+", "_", result)
487
+
488
+ # Strip leading/trailing underscores
489
+ result = result.strip("_")
490
+
491
+ # Ensure the result contains at least one alphanumeric character
492
+ if not result or not re.search(r"[a-z0-9]", result):
493
+ msg = "Project name must contain at least one alphanumeric character"
494
+ raise ValueError(msg)
495
+
496
+ return result
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "https://schemas.langflow.org/extension/v1.json",
3
+ "id": "lfx-toolguard",
4
+ "version": "0.1.0",
5
+ "name": "ToolGuard",
6
+ "description": "Langflow Policies component powered by ToolGuard.",
7
+ "lfx": {
8
+ "compat": ["1"]
9
+ },
10
+ "bundles": [
11
+ {
12
+ "name": "toolguard",
13
+ "path": "components/models_and_agents"
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: lfx-toolguard
3
+ Version: 0.1.0
4
+ Summary: Langflow Policies component powered by ToolGuard.
5
+ Project-URL: Homepage, https://github.com/langflow-ai/langflow
6
+ Project-URL: Documentation, https://docs.langflow.org/extensions
7
+ Project-URL: Repository, https://github.com/langflow-ai/langflow
8
+ Author-email: Langflow <contact@langflow.org>
9
+ License: MIT
10
+ Keywords: extension,langflow,lfx,policies,toolguard
11
+ Requires-Python: <3.15,>=3.10
12
+ Requires-Dist: lfx<2.0.0,>=1.12.0.dev0
13
+ Requires-Dist: toolguard<1.0.0,>=0.2.20
14
+ Description-Content-Type: text/markdown
15
+
16
+ # lfx-toolguard
17
+
18
+ `lfx-toolguard` is the standalone Langflow extension that provides the
19
+ `PoliciesComponent` and its ToolGuard runtime integration.
20
+
21
+ It is installed by the full `langflow` distribution. Users of `lfx` or
22
+ `langflow-base` can opt in with the compatibility extras:
23
+
24
+ ```bash
25
+ uv pip install "lfx[toolguard]"
26
+ uv pip install "langflow-base[toolguard]"
27
+ ```
@@ -0,0 +1,15 @@
1
+ lfx_toolguard/__init__.py,sha256=qreGtNzZSuF6k7MonGtOBgXKb9hLtoZkMnTFNWjsbbs,164
2
+ lfx_toolguard/extension.json,sha256=yj4JsDC4Pshgvc1Gn0A-F3qzyeXIJbFv3XH4kifTS_4,346
3
+ lfx_toolguard/components/__init__.py,sha256=WBE0wiENOGy-akUpgu7CIt6xY0qrlV5TqY36qqY8Z48,51
4
+ lfx_toolguard/components/models_and_agents/__init__.py,sha256=6dkEWM-dulGNLUXvEMmovECXx9k_XCGJYFlyW9qLA_k,588
5
+ lfx_toolguard/components/models_and_agents/policies_component.py,sha256=LLAuT97rNFXIW4ynQgIVVBCXIr8WBZ61JRsMGr7m7EM,21195
6
+ lfx_toolguard/components/models_and_agents/policies/__init__.py,sha256=Cl918DYZNMCENqmS2vN9KjrNE9b0KU-ssKIFb0b_Uz0,75
7
+ lfx_toolguard/components/models_and_agents/policies/guard_sync_utils.py,sha256=l1s2vmhM3phFlrF9eVn5CfC8JoGZlAaJ4nWVGmYSj2A,3431
8
+ lfx_toolguard/components/models_and_agents/policies/guarded_tool.py,sha256=AZrOB48gJCeoq8kWOTGYGDu56l9FJNp4CM2wY1KJmuw,4591
9
+ lfx_toolguard/components/models_and_agents/policies/llm_wrapper.py,sha256=9vbYZHbCgXzBs2CPSLObbeboBemkEGRUZTQMHaDLRrY,6351
10
+ lfx_toolguard/components/models_and_agents/policies/module_utils.py,sha256=W6r8qmc4Ii8RKm9nU9NSgqtl3KilJm5uB32UxLQUrQg,694
11
+ lfx_toolguard/components/models_and_agents/policies/tool_invoker.py,sha256=cq47Et5uNwciqnj_HfCbOOUaPPBIR26dl88O1EbErWM,2224
12
+ lfx_toolguard-0.1.0.dist-info/METADATA,sha256=m1Uf99ANFoOmJ59NRZhAQvBWOWuNDVSi-ZEAy_2nlag,934
13
+ lfx_toolguard-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
14
+ lfx_toolguard-0.1.0.dist-info/entry_points.txt,sha256=yA5YNui1QGLfAMvyE36XkEKS_GIKzhpYQjP8NDI6VcI,52
15
+ lfx_toolguard-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [langflow.extensions]
2
+ lfx-toolguard = lfx_toolguard