toolfence 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rik Banerjee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: toolfence
3
+ Version: 0.1.0
4
+ Summary: Deterministic runtime security for AI agent tools
5
+ License-File: LICENSE
6
+ Author: Rik Banerjee
7
+ Author-email: rikban@gmail.com
8
+ Requires-Python: >=3.10,<4.0
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Description-Content-Type: text/markdown
16
+
17
+ # ToolFence
18
+
19
+ **Deterministic runtime security for AI agent tools.**
20
+
21
+ LLMs can hallucinate and be prompt-engineered, allowing faulty agent actions to slip through. ToolFence helps solve this by enforcing strict and deterministic rules.
22
+
23
+ ToolFence is a lightweight Python framework that sits between your LLM and your tool functions. When an agent calls a tool, ToolFence intercepts the call, evaluates your rules, and either passes it through, blocks it, or escalates it for user approval — all before your tool function runs. Rules are Python code, not LLM instructions, so they cannot be overridden by a clever prompt.
24
+
25
+ ---
26
+
27
+ ## Why ToolFence
28
+
29
+ LLMs are good at deciding *what* to do. They are not reliable enforcers of *what is allowed*. A well-crafted prompt can convince an LLM to ignore its own safety instructions. ToolFence allows a quick and simple way to enforce policy at the code layer — outside the prompt, outside the model, and outside the reach of any user input.
30
+
31
+ ```
32
+ User prompt → LLM → tool call → [ToolFence] → tool execution
33
+
34
+ deterministic rules run here
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pip install toolfence
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Quick example
48
+
49
+ ```python
50
+ from toolfence import secure, AgentManager, Rule, BlockedToolCall
51
+
52
+ manager = AgentManager()
53
+ manager.set_default_approval_handler(lambda ctx: input(f'Do you approve {ctx.tool_call.tool} to execute? ') == 'yes')
54
+
55
+ manager.set_rules(
56
+ tool = "transfer_funds",
57
+ rules=[
58
+ # Hard block — never transfer over $10,000
59
+ Rule(
60
+ id="transfer-hard-limit",
61
+ description="Transfers over $10,000 are never permitted.",
62
+ condition=lambda ctx: ctx.tool_call.arguments.amount > 10000,
63
+ action="block"
64
+ ),
65
+ # Escalate — transfers over $1,000 need user approval
66
+ Rule(
67
+ id="transfer-large-escalate",
68
+ description="Transfers over $1,000 require approval.",
69
+ condition=lambda ctx: ctx.tool_call.arguments.amount > 1000,
70
+ action="escalate"
71
+ ),
72
+ ],
73
+ )
74
+
75
+ @secure(manager)
76
+ def transfer_funds(from_account: str, to_account: str, amount: float) -> dict:
77
+ return {"status": "transferred", "amount": amount}
78
+
79
+ manager.validate()
80
+
81
+ try:
82
+ transfer_funds("ACC-001", "ACC-002", 15000.0)
83
+ except BlockedToolCall as e:
84
+ print(e.message) # "Transfers over $10,000 are never permitted."
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Features
90
+
91
+ - **Hard block rules** — write Python conditions that unconditionally prevent a tool call from executing
92
+ - **Escalation rules** — pause execution and require user approval before proceeding
93
+ - **Evidence verification** — load trusted data (e.g., database, API) and verify LLM-supplied arguments against it, catching hallucinations and prompt injection
94
+ - **Argument checking** — validate argument presence and types before rules even run
95
+ - **Async support** — works with async tools and async approval handlers
96
+ - **Config validation** — catch misconfigured rules, missing handlers, and configuration mistakes before your agent runs
97
+ - **Structured errors** — every blocked tool call raises `BlockedToolCall` with a message for your agent's LLM
98
+ - **Full history** — every tool call (passed or blocked) is recorded in `AgentManager.history`
99
+ - **Easy Integration** — easily integrates with other frameworks and workflows such as LangChain
100
+
101
+ ---
102
+
103
+ ## How it works
104
+
105
+ ```
106
+ @secure(manager)
107
+ def my_tool(arg: str) -> dict: ...
108
+ ```
109
+
110
+ Decorating a function with `@secure` registers it with the `AgentManager` and wraps it with the ToolFence interceptor. Every call to `my_tool` now runs through:
111
+
112
+ 1. **Argument checking** — are all required arguments present and correctly typed?
113
+ 2. **Block rules** — does any block rule condition return `True`? If so, raise `BlockedToolCall`.
114
+ 3. **Escalation rules** — does any escalation rule condition return `True`? If so, call the approval handler. If denied, raise `BlockedToolCall`.
115
+ 4. **Execute** — all checks passed, run the real tool function.
116
+ 5. **Record** — log the call to `AgentManager.history`.
117
+
118
+ ---
119
+
120
+ ## Documentation
121
+
122
+ - [Getting Started](https://github.com/Rik-Banerjee/toolfence/blob/main/docs/getting-started.md)
123
+ - [Core Concepts](https://github.com/Rik-Banerjee/toolfence/blob/main/docs/core-concepts.md)
124
+ - [Rules](https://github.com/Rik-Banerjee/toolfence/blob/main/docs/rules.md)
125
+ - [Evidence](https://github.com/Rik-Banerjee/toolfence/blob/main/docs/evidence.md)
126
+ - [Approval Handlers](https://github.com/Rik-Banerjee/toolfence/blob/main/docs/approval-handlers.md)
127
+ - [API Reference](https://github.com/Rik-Banerjee/toolfence/blob/main/docs/api-reference.md)
128
+
129
+ ---
130
+
131
+ ## Examples
132
+
133
+ - [`examples/example_basic.py`](https://github.com/Rik-Banerjee/toolfence/blob/main/examples/example_basic.py) — minimal setup with block and escalation rules
134
+ - [`examples/example_evidence.py`](https://github.com/Rik-Banerjee/toolfence/blob/main/examples/example_evidence.py) — evidence verification and prompt injection prevention
135
+
@@ -0,0 +1,118 @@
1
+ # ToolFence
2
+
3
+ **Deterministic runtime security for AI agent tools.**
4
+
5
+ LLMs can hallucinate and be prompt-engineered, allowing faulty agent actions to slip through. ToolFence helps solve this by enforcing strict and deterministic rules.
6
+
7
+ ToolFence is a lightweight Python framework that sits between your LLM and your tool functions. When an agent calls a tool, ToolFence intercepts the call, evaluates your rules, and either passes it through, blocks it, or escalates it for user approval — all before your tool function runs. Rules are Python code, not LLM instructions, so they cannot be overridden by a clever prompt.
8
+
9
+ ---
10
+
11
+ ## Why ToolFence
12
+
13
+ LLMs are good at deciding *what* to do. They are not reliable enforcers of *what is allowed*. A well-crafted prompt can convince an LLM to ignore its own safety instructions. ToolFence allows a quick and simple way to enforce policy at the code layer — outside the prompt, outside the model, and outside the reach of any user input.
14
+
15
+ ```
16
+ User prompt → LLM → tool call → [ToolFence] → tool execution
17
+
18
+ deterministic rules run here
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install toolfence
27
+ ```
28
+
29
+ ---
30
+
31
+ ## Quick example
32
+
33
+ ```python
34
+ from toolfence import secure, AgentManager, Rule, BlockedToolCall
35
+
36
+ manager = AgentManager()
37
+ manager.set_default_approval_handler(lambda ctx: input(f'Do you approve {ctx.tool_call.tool} to execute? ') == 'yes')
38
+
39
+ manager.set_rules(
40
+ tool = "transfer_funds",
41
+ rules=[
42
+ # Hard block — never transfer over $10,000
43
+ Rule(
44
+ id="transfer-hard-limit",
45
+ description="Transfers over $10,000 are never permitted.",
46
+ condition=lambda ctx: ctx.tool_call.arguments.amount > 10000,
47
+ action="block"
48
+ ),
49
+ # Escalate — transfers over $1,000 need user approval
50
+ Rule(
51
+ id="transfer-large-escalate",
52
+ description="Transfers over $1,000 require approval.",
53
+ condition=lambda ctx: ctx.tool_call.arguments.amount > 1000,
54
+ action="escalate"
55
+ ),
56
+ ],
57
+ )
58
+
59
+ @secure(manager)
60
+ def transfer_funds(from_account: str, to_account: str, amount: float) -> dict:
61
+ return {"status": "transferred", "amount": amount}
62
+
63
+ manager.validate()
64
+
65
+ try:
66
+ transfer_funds("ACC-001", "ACC-002", 15000.0)
67
+ except BlockedToolCall as e:
68
+ print(e.message) # "Transfers over $10,000 are never permitted."
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Features
74
+
75
+ - **Hard block rules** — write Python conditions that unconditionally prevent a tool call from executing
76
+ - **Escalation rules** — pause execution and require user approval before proceeding
77
+ - **Evidence verification** — load trusted data (e.g., database, API) and verify LLM-supplied arguments against it, catching hallucinations and prompt injection
78
+ - **Argument checking** — validate argument presence and types before rules even run
79
+ - **Async support** — works with async tools and async approval handlers
80
+ - **Config validation** — catch misconfigured rules, missing handlers, and configuration mistakes before your agent runs
81
+ - **Structured errors** — every blocked tool call raises `BlockedToolCall` with a message for your agent's LLM
82
+ - **Full history** — every tool call (passed or blocked) is recorded in `AgentManager.history`
83
+ - **Easy Integration** — easily integrates with other frameworks and workflows such as LangChain
84
+
85
+ ---
86
+
87
+ ## How it works
88
+
89
+ ```
90
+ @secure(manager)
91
+ def my_tool(arg: str) -> dict: ...
92
+ ```
93
+
94
+ Decorating a function with `@secure` registers it with the `AgentManager` and wraps it with the ToolFence interceptor. Every call to `my_tool` now runs through:
95
+
96
+ 1. **Argument checking** — are all required arguments present and correctly typed?
97
+ 2. **Block rules** — does any block rule condition return `True`? If so, raise `BlockedToolCall`.
98
+ 3. **Escalation rules** — does any escalation rule condition return `True`? If so, call the approval handler. If denied, raise `BlockedToolCall`.
99
+ 4. **Execute** — all checks passed, run the real tool function.
100
+ 5. **Record** — log the call to `AgentManager.history`.
101
+
102
+ ---
103
+
104
+ ## Documentation
105
+
106
+ - [Getting Started](https://github.com/Rik-Banerjee/toolfence/blob/main/docs/getting-started.md)
107
+ - [Core Concepts](https://github.com/Rik-Banerjee/toolfence/blob/main/docs/core-concepts.md)
108
+ - [Rules](https://github.com/Rik-Banerjee/toolfence/blob/main/docs/rules.md)
109
+ - [Evidence](https://github.com/Rik-Banerjee/toolfence/blob/main/docs/evidence.md)
110
+ - [Approval Handlers](https://github.com/Rik-Banerjee/toolfence/blob/main/docs/approval-handlers.md)
111
+ - [API Reference](https://github.com/Rik-Banerjee/toolfence/blob/main/docs/api-reference.md)
112
+
113
+ ---
114
+
115
+ ## Examples
116
+
117
+ - [`examples/example_basic.py`](https://github.com/Rik-Banerjee/toolfence/blob/main/examples/example_basic.py) — minimal setup with block and escalation rules
118
+ - [`examples/example_evidence.py`](https://github.com/Rik-Banerjee/toolfence/blob/main/examples/example_evidence.py) — evidence verification and prompt injection prevention
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "toolfence"
3
+ version = "0.1.0"
4
+ description = "Deterministic runtime security for AI agent tools"
5
+ authors = [
6
+ {name = "Rik Banerjee",email = "rikban@gmail.com"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.10,<4.0"
10
+ dependencies = [
11
+ ]
12
+
13
+
14
+ [build-system]
15
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
16
+ build-backend = "poetry.core.masonry.api"
17
+
18
+ [dependency-groups]
19
+ test = [
20
+ "pytest (>=9.0.2,<10.0.0)",
21
+ "pytest-asyncio (>=1.3.0,<2.0.0)"
22
+ ]
@@ -0,0 +1,4 @@
1
+ from .toolfence import AgentManager
2
+ from .toolfence_secure import secure
3
+ from .toolfence_data import Rule, BlockedToolCall, BlockReason, ToolCallRecord
4
+ from .toolfence_config import ToolFenceSetupError
@@ -0,0 +1,136 @@
1
+ import inspect
2
+ from typing import Callable, Dict, List, Set
3
+
4
+ from .toolfence_data import (
5
+ Context,
6
+ DynamicData,
7
+ Rule,
8
+ Tool,
9
+ ToolCall,
10
+ ToolCallRecord,
11
+ ValidationResult,
12
+ _create_tool_call_record,
13
+ )
14
+ from .toolfence_validation import run_block_rules, run_escalation_rules
15
+ from .toolfence_config import (
16
+ ConfigIssue,
17
+ ToolFenceSetupError,
18
+ check_approval_handler,
19
+ check_default_approval_handler,
20
+ check_rule,
21
+ validate_manager,
22
+ )
23
+
24
+
25
+ class AgentManager:
26
+ def __init__(self, version: str = "default"):
27
+ self.version = version
28
+
29
+ self.tools: Dict[str, Tool] = {}
30
+ self.history: List[ToolCallRecord] = []
31
+ self.evidence: DynamicData = DynamicData()
32
+
33
+ self.block_rules: Dict[str, List[Rule]] = {}
34
+ self.escalation_rules: Dict[str, List[Rule]] = {}
35
+ self.arg_checking_tools: Set[str] = set()
36
+
37
+ self.default_approval_handler: Callable[[Context], bool] | None = None
38
+ self.tool_approval_handlers: Dict[str, Callable[[Context], bool] | str] = {}
39
+
40
+ self.rule_ids: Dict[str, str] = {}
41
+
42
+ def set_evidence(self, key: str, value) -> None:
43
+ setattr(self.evidence, key, value)
44
+
45
+
46
+ def set_rules(
47
+ self,
48
+ tool: str,
49
+ rules: List[Rule] = [],
50
+ approval_handler: Callable[[Context], bool] | str = "default",
51
+ arg_checking: bool = True,
52
+ ) -> None:
53
+
54
+ # Structural check
55
+ issues: List[ConfigIssue] = []
56
+ duplicates = set()
57
+
58
+ for rule in rules:
59
+ # Duplicate ID check
60
+ if isinstance(rule.id, str) and rule.id.strip():
61
+ if rule.id in self.rule_ids or rule.id in duplicates:
62
+ issues.append(ConfigIssue(
63
+ level="error",
64
+ location=f"rule:{rule.id} (tool:{tool})",
65
+ message=(
66
+ f'Rule ID "{rule.id}" is already used by tool '
67
+ f'"{tool if rule.id in duplicates else self.rule_ids[rule.id]}". Rule IDs must be unique.'
68
+ ),
69
+ ))
70
+ continue
71
+ else:
72
+ duplicates.add(rule.id)
73
+
74
+ issues.extend(check_rule(rule, tool))
75
+
76
+ if approval_handler != "default":
77
+ issues.extend(check_approval_handler(approval_handler, tool))
78
+
79
+ errors = [i for i in issues if i.level == "error"]
80
+ warnings = [i for i in issues if i.level == "warning"]
81
+
82
+ if warnings:
83
+ print(f'ToolFence configuration warnings for "{tool}":')
84
+ for w in warnings:
85
+ print(w)
86
+
87
+ if errors:
88
+
89
+ error_lines = "\n".join(str(e) for e in errors)
90
+ raise ToolFenceSetupError(
91
+ f'ToolFence configuration errors in set_rules("{tool}") — '
92
+ f'fix these before running the agent:\n{error_lines}'
93
+ )
94
+
95
+ self.tool_approval_handlers[tool] = approval_handler
96
+
97
+ if arg_checking:
98
+ self.arg_checking_tools.add(tool)
99
+
100
+ block_rules = []
101
+ escalation_rules = []
102
+
103
+ for rule in rules:
104
+ self.rule_ids[rule.id] = tool
105
+ if rule.action == "block":
106
+ block_rules.append(rule)
107
+ else:
108
+ escalation_rules.append(rule)
109
+
110
+ self.block_rules[tool] = block_rules
111
+ self.escalation_rules[tool] = escalation_rules
112
+
113
+ def set_default_approval_handler(self, handler: Callable[[Context], bool]) -> None:
114
+ # Structural check
115
+ issues = check_default_approval_handler(handler)
116
+
117
+ errors = [i for i in issues if i.level == "error"]
118
+ warnings = [i for i in issues if i.level == "warning"]
119
+
120
+ if warnings:
121
+ print("ToolFence configuration warnings for default approval handler:")
122
+ for w in warnings:
123
+ print(w)
124
+
125
+ if errors:
126
+ error_lines = "\n".join(str(e) for e in errors)
127
+ raise ToolFenceSetupError(
128
+ f"ToolFence configuration errors in set_default_approval_handler() — "
129
+ f"fix these before running the agent:\n{error_lines}"
130
+ )
131
+
132
+ self.default_approval_handler = handler
133
+
134
+ # Full ToolFence configuration check
135
+ def validate(self) -> None:
136
+ validate_manager(self)
@@ -0,0 +1,226 @@
1
+ import inspect
2
+ from dataclasses import dataclass
3
+ from typing import TYPE_CHECKING, List
4
+
5
+ from .toolfence_data import Rule
6
+
7
+ if TYPE_CHECKING:
8
+ from .toolfence import AgentManager
9
+
10
+
11
+
12
+
13
+ class ToolFenceSetupError(Exception):
14
+ def __init__(self, message: str):
15
+ self.message = message
16
+ super().__init__(message)
17
+
18
+
19
+ @dataclass
20
+ class ConfigIssue:
21
+ level: str # "error" or "warning"
22
+ location: str # e.g. "tool:transfer_funds", "rule:transfer-limit", "handler:default"
23
+ message: str
24
+
25
+ def __str__(self) -> str:
26
+ return f" [{self.level.upper()}] {self.location}: {self.message}"
27
+
28
+ # Checks for number of params the handler accepts
29
+ def _handler_param_count(handler) -> int:
30
+ if inspect.isfunction(handler) or inspect.isbuiltin(handler):
31
+ return len(inspect.signature(handler).parameters)
32
+
33
+ if inspect.ismethod(handler):
34
+ return len(inspect.signature(handler).parameters)
35
+
36
+ if hasattr(handler, '__call__'):
37
+ params = list(inspect.signature(handler.__call__).parameters.values())
38
+ return len([p for p in params if p.name != 'self'])
39
+
40
+ return -1
41
+
42
+
43
+ # Validate a rule
44
+ def check_rule(rule: Rule, tool: str) -> List[ConfigIssue]:
45
+ issues = []
46
+ location = f"rule:{rule.id} (tool:{tool})"
47
+
48
+ # Rule ID must be non-empty, non-whitespace
49
+ if not isinstance(rule.id, str) or not rule.id.strip():
50
+ issues.append(ConfigIssue(
51
+ level="error",
52
+ location=f"rule (tool:{tool})",
53
+ message="Rule ID must be a non-empty, non-whitespace string.",
54
+ ))
55
+ return issues
56
+
57
+ # Condition must be callable
58
+ if not callable(rule.condition):
59
+ issues.append(ConfigIssue(
60
+ level="error",
61
+ location=location,
62
+ message=f"Condition is not callable. Got {type(rule.condition).__name__}.",
63
+ ))
64
+ return issues
65
+
66
+ # Condition must accept exactly one argument (context)
67
+ param_count = _handler_param_count(rule.condition)
68
+ if param_count != 1:
69
+ issues.append(ConfigIssue(
70
+ level="error",
71
+ location=location,
72
+ message=(
73
+ f"Condition must accept exactly one argument (context). "
74
+ f"Got {param_count} parameter(s)."
75
+ ),
76
+ ))
77
+
78
+ # Action must be "block" or "escalate"
79
+ if rule.action not in ("block", "escalate"):
80
+ issues.append(ConfigIssue(
81
+ level="error",
82
+ location=location,
83
+ message=(
84
+ f'Invalid action "{rule.action}". '
85
+ f'Valid actions are "block" or "escalate".'
86
+ ),
87
+ ))
88
+
89
+ return issues
90
+
91
+ # Validate approval handler
92
+ def check_approval_handler(handler, tool: str) -> List[ConfigIssue]:
93
+ issues = []
94
+ location = f"handler (tool:{tool})"
95
+
96
+ if not callable(handler):
97
+ issues.append(ConfigIssue(
98
+ level="error",
99
+ location=location,
100
+ message=f"Approval handler is not callable. Got {type(handler).__name__}.",
101
+ ))
102
+ return issues
103
+
104
+ # Handler must accept exactly one argument (context)
105
+ param_count = _handler_param_count(handler)
106
+ if param_count != 1:
107
+ issues.append(ConfigIssue(
108
+ level="error",
109
+ location=location,
110
+ message=(
111
+ f"Approval handler must accept exactly one argument (context). "
112
+ f"Got {param_count} parameter(s)."
113
+ ),
114
+ ))
115
+
116
+ return issues
117
+
118
+ # Validate default handler
119
+ def check_default_approval_handler(handler) -> List[ConfigIssue]:
120
+ return check_approval_handler(handler, tool="default")
121
+
122
+
123
+
124
+ # Run full AgentManager configuration checks
125
+ def validate_manager(manager: "AgentManager") -> None:
126
+ issues: List[ConfigIssue] = []
127
+
128
+ registered_tools = set(manager.tools.keys())
129
+ configured_tools = set(manager.block_rules.keys()) | set(manager.escalation_rules.keys())
130
+
131
+ # Escalation rules must have approval handler
132
+ for tool, rules in manager.escalation_rules.items():
133
+ if not rules:
134
+ continue
135
+
136
+ handler = manager.tool_approval_handlers.get(tool)
137
+
138
+ if handler is None:
139
+ issues.append(ConfigIssue(
140
+ level="error",
141
+ location=f"tool:{tool}",
142
+ message=(
143
+ 'Tool has escalation rule(s) but no approval handler configured. '
144
+ 'Pass approval_handler= to set_rules() or call set_default_approval_handler().'
145
+ ),
146
+ ))
147
+ elif handler == "default" and manager.default_approval_handler is None:
148
+ issues.append(ConfigIssue(
149
+ level="error",
150
+ location=f"tool:{tool}",
151
+ message=(
152
+ 'Tool has escalation rule(s) that rely on the default approval handler, '
153
+ 'but no default handler has been set. '
154
+ 'Call set_default_approval_handler() before running the agent.'
155
+ ),
156
+ ))
157
+
158
+ # Check for async handlers assigned to sync tools
159
+ for tool, handler in manager.tool_approval_handlers.items():
160
+ if handler == "default":
161
+ handler = manager.default_approval_handler
162
+ if handler is None or not inspect.iscoroutinefunction(handler):
163
+ continue
164
+
165
+ tool_obj = manager.tools.get(tool)
166
+ if tool_obj is not None and not tool_obj.is_async:
167
+ issues.append(ConfigIssue(
168
+ level="error",
169
+ location=f"tool:{tool}",
170
+ message=(
171
+ f'Approval handler is async but "{tool}" is a sync tool. '
172
+ f'Use a sync handler or convert the tool to async.'
173
+ ),
174
+ ))
175
+
176
+ # set_rules called for a tool not decorated with @secure
177
+ for tool in configured_tools:
178
+ if tool not in registered_tools:
179
+ issues.append(ConfigIssue(
180
+ level="warning",
181
+ location=f"tool:{tool}",
182
+ message=(
183
+ f'set_rules() was called for "{tool}" but no @secure-decorated function '
184
+ f'with that name has been registered. '
185
+ f'Check for a name mismatch or missing @secure decorator.'
186
+ ),
187
+ ))
188
+
189
+ # @secure tools with no set_rules call
190
+ for tool in registered_tools:
191
+ if tool not in configured_tools:
192
+ issues.append(ConfigIssue(
193
+ level="warning",
194
+ location=f"tool:{tool}",
195
+ message=(
196
+ f'"{tool}" is decorated with @secure but set_rules() was never called for it. '
197
+ f'It will run with no rules and no arg checking.'
198
+ ),
199
+ ))
200
+
201
+ # Default handler set but no escalation rules anywhere
202
+ has_any_escalation = any(bool(rules) for rules in manager.escalation_rules.values())
203
+ if manager.default_approval_handler is not None and not has_any_escalation:
204
+ issues.append(ConfigIssue(
205
+ level="warning",
206
+ location="handler:default",
207
+ message=(
208
+ "A default approval handler is set but no escalation rules exist anywhere. "
209
+ "The handler will never be called."
210
+ ),
211
+ ))
212
+
213
+ errors = [i for i in issues if i.level == "error"]
214
+ warnings = [i for i in issues if i.level == "warning"]
215
+
216
+ if warnings:
217
+ print("ToolFence configuration warnings:")
218
+ for w in warnings:
219
+ print(w)
220
+
221
+ if errors:
222
+ error_lines = "\n".join(str(e) for e in errors)
223
+ raise ToolFenceSetupError(
224
+ f"ToolFence configuration errors found — fix these before running the agent:\n"
225
+ f"{error_lines}"
226
+ )
@@ -0,0 +1,89 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from typing import Any, Dict, Callable, List
5
+ import uuid
6
+
7
+
8
+ class DynamicData:
9
+ pass
10
+
11
+ @dataclass(frozen=True)
12
+ class ToolCall:
13
+ tool: str
14
+ arguments: DynamicData
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Tool:
19
+ name: str
20
+ parameters: Dict[str, Dict]
21
+ is_async: bool
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ToolCallRecord:
26
+ uuid: str
27
+ tool: str
28
+ arguments: DynamicData
29
+ timestamp: datetime
30
+ blocked: bool
31
+ reason: str
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Context:
36
+ tool_call: ToolCall
37
+ evidence: DynamicData
38
+ history: List[ToolCallRecord]
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Rule:
43
+ id: str
44
+ description: str
45
+ condition: Callable[[Context], bool]
46
+ action: str # "block" | "escalate"
47
+
48
+ @dataclass(frozen=True)
49
+ class ValidationResult:
50
+ blocked: bool
51
+ requires_approval: bool
52
+ message: str
53
+ rule_id: str | None = None
54
+
55
+
56
+ class BlockReason(str, Enum):
57
+ RULE_TRIGGERED = "RULE_TRIGGERED"
58
+ APPROVAL_DENIED = "APPROVAL_DENIED"
59
+ NO_APPROVAL_HANDLER = "NO_APPROVAL_HANDLER"
60
+ ASYNC_HANDLER_MISMATCH = "ASYNC_HANDLER_MISMATCH"
61
+ MISSING_ARGUMENT = "MISSING_ARGUMENT"
62
+ INVALID_ARGUMENT_TYPE = "INVALID_ARGUMENT_TYPE"
63
+
64
+
65
+
66
+ class BlockedToolCall(Exception):
67
+ def __init__(self, tool: str, reason: BlockReason, message: str, record: ToolCallRecord):
68
+ self.tool = tool
69
+ self.reason = reason
70
+ self.message = message
71
+ self.record = record
72
+ super().__init__(f"[{reason}] {tool}: {message}")
73
+
74
+
75
+
76
+ def _create_tool_call_record(
77
+ tool_call: ToolCall,
78
+ blocked: bool,
79
+ reason: str,
80
+ ) -> ToolCallRecord:
81
+ return ToolCallRecord(
82
+ uuid=str(uuid.uuid4()),
83
+ tool=tool_call.tool,
84
+ arguments=tool_call.arguments,
85
+ timestamp=datetime.utcnow(),
86
+ blocked=blocked,
87
+ reason=reason,
88
+ )
89
+
@@ -0,0 +1,230 @@
1
+ import inspect
2
+ from functools import wraps
3
+ from typing import Callable, Dict, List
4
+
5
+ from .toolfence_data import (
6
+ BlockReason,
7
+ BlockedToolCall,
8
+ Context,
9
+ DynamicData,
10
+ Rule,
11
+ Tool,
12
+ ToolCall,
13
+ ToolCallRecord,
14
+ ValidationResult,
15
+ _create_tool_call_record,
16
+ )
17
+ from .toolfence_validation import run_block_rules, run_escalation_rules
18
+ from .toolfence import AgentManager
19
+
20
+
21
+
22
+ def _block(manager: AgentManager, tool_call: ToolCall, reason: BlockReason, message: str) -> None:
23
+ record = _create_tool_call_record(tool_call, True, reason.value)
24
+ manager.history.append(record)
25
+ raise BlockedToolCall(tool_call.tool, reason, message, record)
26
+
27
+
28
+ def _pass(manager: AgentManager, tool_call: ToolCall) -> None:
29
+ record = _create_tool_call_record(tool_call, False, "")
30
+ manager.history.append(record)
31
+
32
+
33
+ def _build_tool_call(fn_name: str, signature, args, kwargs) -> ToolCall:
34
+ bound = signature.bind_partial(*args, **kwargs)
35
+ bound.apply_defaults()
36
+
37
+ arguments = DynamicData()
38
+ for key, value in bound.arguments.items():
39
+ setattr(arguments, key, value)
40
+
41
+ return ToolCall(tool=fn_name, arguments=arguments)
42
+
43
+
44
+ def _build_context(manager: AgentManager, tool_call: ToolCall) -> Context:
45
+ return Context(
46
+ tool_call=tool_call,
47
+ evidence=manager.evidence,
48
+ history=manager.history,
49
+ )
50
+
51
+
52
+ def _build_tool(tool_name: str, parameters: Dict, is_async: bool) -> Tool:
53
+ filtered_parameters = {}
54
+
55
+ for parameter in parameters:
56
+ parameter_data = {'type': 'no_type', 'hasDefault': False}
57
+
58
+ annotation = parameters[parameter].annotation
59
+ default = parameters[parameter].default
60
+
61
+ if annotation != inspect._empty:
62
+ parameter_data['type'] = annotation
63
+
64
+ if default != inspect._empty:
65
+ parameter_data['hasDefault'] = True
66
+
67
+ filtered_parameters[parameter] = parameter_data
68
+
69
+ return Tool(
70
+ name=tool_name,
71
+ parameters=filtered_parameters,
72
+ is_async=is_async,
73
+ )
74
+
75
+
76
+ def _validate_approval_handler(
77
+ manager: AgentManager,
78
+ tool_call: ToolCall,
79
+ is_async_tool: bool,
80
+ ) -> Callable:
81
+ approval_handler = manager.tool_approval_handlers.get(tool_call.tool)
82
+
83
+ if approval_handler == "default":
84
+ if manager.default_approval_handler is None:
85
+ _block(
86
+ manager, tool_call,
87
+ BlockReason.NO_APPROVAL_HANDLER,
88
+ f'No default approval handler set. Provide one via set_default_approval_handler().',
89
+ )
90
+ approval_handler = manager.default_approval_handler
91
+
92
+ if not is_async_tool and inspect.iscoroutinefunction(approval_handler):
93
+ _block(
94
+ manager, tool_call,
95
+ BlockReason.ASYNC_HANDLER_MISMATCH,
96
+ f'Approval handler for "{tool_call.tool}" is async but the tool is sync. '
97
+ f'Use a sync handler or make the tool async.',
98
+ )
99
+
100
+ return approval_handler
101
+
102
+
103
+ def _execute_validation(manager: AgentManager, tool_call: ToolCall, context: Context) -> ValidationResult:
104
+ block_result = run_block_rules(
105
+ context,
106
+ manager.block_rules.get(tool_call.tool, []),
107
+ )
108
+
109
+ if block_result.blocked:
110
+ return block_result
111
+
112
+ return run_escalation_rules(
113
+ context,
114
+ manager.escalation_rules.get(tool_call.tool, []),
115
+ )
116
+
117
+
118
+ def _arg_checking(manager: AgentManager, tool_name: str, tool_call: ToolCall) -> None:
119
+ if tool_name not in manager.arg_checking_tools:
120
+ return
121
+
122
+ arguments = dict(vars(tool_call.arguments).items())
123
+ parameters = manager.tools[tool_name].parameters
124
+
125
+
126
+ for parameter in parameters:
127
+ if parameter not in arguments:
128
+ _block(
129
+ manager, tool_call,
130
+ BlockReason.MISSING_ARGUMENT,
131
+ f'"{tool_call.tool}" is missing required argument "{parameter}".',
132
+ )
133
+ else:
134
+ expected_type = parameters[parameter]['type']
135
+ if expected_type != 'no_type':
136
+ if not isinstance(arguments[parameter], expected_type):
137
+ _block(
138
+ manager, tool_call,
139
+ BlockReason.INVALID_ARGUMENT_TYPE,
140
+ f'"{tool_call.tool}" received invalid type for argument "{parameter}". '
141
+ f'Expected {expected_type.__name__}, got {type(arguments[parameter]).__name__}.',
142
+ )
143
+
144
+
145
+
146
+ # @secure decorator
147
+ def secure(manager: AgentManager):
148
+ def decorator(fn):
149
+ signature = inspect.signature(fn)
150
+ tool_name = fn.__name__
151
+ parameters = dict(signature.parameters)
152
+ is_async = inspect.iscoroutinefunction(fn)
153
+
154
+ manager.tools[tool_name] = _build_tool(tool_name, parameters, is_async)
155
+
156
+ # Build sync wrapper
157
+ if not is_async:
158
+
159
+ @wraps(fn)
160
+ def wrapper(*args, **kwargs):
161
+ tool_call = _build_tool_call(tool_name, signature, args, kwargs)
162
+ _arg_checking(manager, tool_name, tool_call)
163
+
164
+ context = _build_context(manager, tool_call)
165
+ result = _execute_validation(manager, tool_call, context)
166
+
167
+ if result.blocked:
168
+ _block(
169
+ manager, tool_call,
170
+ BlockReason.RULE_TRIGGERED,
171
+ f'Rule "{result.rule_id}" blocked this call: {result.message}',
172
+ )
173
+
174
+ if result.requires_approval:
175
+ approval_handler = _validate_approval_handler(manager, tool_call, False)
176
+ if not approval_handler(context):
177
+ _block(
178
+ manager, tool_call,
179
+ BlockReason.APPROVAL_DENIED,
180
+ f'"{tool_call.tool}" was denied by the approval handler.',
181
+ )
182
+
183
+ result = fn(*args, **kwargs)
184
+ _pass(manager, tool_call)
185
+ return result
186
+
187
+ return wrapper
188
+
189
+ # Build async wrapper
190
+ else:
191
+
192
+ @wraps(fn)
193
+ async def wrapper(*args, **kwargs):
194
+ tool_call = _build_tool_call(tool_name, signature, args, kwargs)
195
+ _arg_checking(manager, tool_name, tool_call)
196
+
197
+ context = _build_context(manager, tool_call)
198
+ result = _execute_validation(manager, tool_call, context)
199
+
200
+ if result.blocked:
201
+ _block(
202
+ manager, tool_call,
203
+ BlockReason.RULE_TRIGGERED,
204
+ f'Rule "{result.rule_id}" blocked this call: {result.message}',
205
+ )
206
+
207
+ if result.requires_approval:
208
+ approval_handler = _validate_approval_handler(manager, tool_call, True)
209
+ is_async_handler = inspect.iscoroutinefunction(approval_handler)
210
+
211
+ approved = (
212
+ await approval_handler(context)
213
+ if is_async_handler
214
+ else approval_handler(context)
215
+ )
216
+
217
+ if not approved:
218
+ _block(
219
+ manager, tool_call,
220
+ BlockReason.APPROVAL_DENIED,
221
+ f'"{tool_call.tool}" was denied by the approval handler.',
222
+ )
223
+
224
+ result = await fn(*args, **kwargs)
225
+ _pass(manager, tool_call)
226
+ return result
227
+
228
+ return wrapper
229
+
230
+ return decorator
@@ -0,0 +1,53 @@
1
+ from typing import List
2
+
3
+ from .toolfence_data import Context, Rule, ValidationResult
4
+
5
+
6
+ def _evaluate_rule(rule: Rule, context: Context) -> bool:
7
+ return rule.condition(context)
8
+
9
+
10
+ def run_block_rules(context: Context, rules: List[Rule]) -> ValidationResult:
11
+ for rule in rules:
12
+ try:
13
+ triggered = _evaluate_rule(rule, context)
14
+ except Exception as e:
15
+ return ValidationResult(
16
+ blocked=True,
17
+ requires_approval=False,
18
+ message=f'Rule "{rule.id}" raised an exception: {e}',
19
+ rule_id=rule.id,
20
+ )
21
+
22
+ if triggered:
23
+ return ValidationResult(
24
+ blocked=True,
25
+ requires_approval=False,
26
+ message=rule.description,
27
+ rule_id=rule.id,
28
+ )
29
+
30
+ return ValidationResult(blocked=False, requires_approval=False, message="")
31
+
32
+
33
+ def run_escalation_rules(context: Context, rules: List[Rule]) -> ValidationResult:
34
+ for rule in rules:
35
+ try:
36
+ triggered = _evaluate_rule(rule, context)
37
+ except Exception as e:
38
+ return ValidationResult(
39
+ blocked=True,
40
+ requires_approval=False,
41
+ message=f'Rule "{rule.id}" raised an exception: {e}',
42
+ rule_id=rule.id,
43
+ )
44
+
45
+ if triggered:
46
+ return ValidationResult(
47
+ blocked=False,
48
+ requires_approval=True,
49
+ message=rule.description,
50
+ rule_id=rule.id,
51
+ )
52
+
53
+ return ValidationResult(blocked=False, requires_approval=False, message="")