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.
- lfx_toolguard/__init__.py +5 -0
- lfx_toolguard/components/__init__.py +1 -0
- lfx_toolguard/components/models_and_agents/__init__.py +23 -0
- lfx_toolguard/components/models_and_agents/policies/__init__.py +3 -0
- lfx_toolguard/components/models_and_agents/policies/guard_sync_utils.py +98 -0
- lfx_toolguard/components/models_and_agents/policies/guarded_tool.py +121 -0
- lfx_toolguard/components/models_and_agents/policies/llm_wrapper.py +184 -0
- lfx_toolguard/components/models_and_agents/policies/module_utils.py +22 -0
- lfx_toolguard/components/models_and_agents/policies/tool_invoker.py +59 -0
- lfx_toolguard/components/models_and_agents/policies_component.py +496 -0
- lfx_toolguard/extension.json +16 -0
- lfx_toolguard-0.1.0.dist-info/METADATA +27 -0
- lfx_toolguard-0.1.0.dist-info/RECORD +15 -0
- lfx_toolguard-0.1.0.dist-info/WHEEL +4 -0
- lfx_toolguard-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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,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,,
|