ragleap-tools 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,32 @@
1
+ # Environment
2
+ .env
3
+ *.env.local
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.egg-info/
9
+ venv/
10
+ .venv/
11
+
12
+ # Node
13
+ node_modules/
14
+
15
+ # Docker
16
+ docker-compose.override.yml
17
+
18
+ # IDE
19
+ .vscode/
20
+ .idea/
21
+
22
+ # OS
23
+ .DS_Store
24
+ Thumbs.db
25
+
26
+ # Logs
27
+ *.log
28
+ .env
29
+
30
+ # Package build artifacts
31
+ dist/
32
+ build/
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ All notable changes to `ragleap-tools` are documented here. Format
4
+ loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] - 2026-09-16
9
+
10
+ ### Added
11
+
12
+ - Initial release. `Tool`/`ToolResult` base abstraction with OpenAI
13
+ and Gemini function-calling schema generation. Deliberately does not
14
+ own a tool-calling execution loop - that's `ragleap-agents`' scope,
15
+ per the project roadmap's own split.
16
+ - 12 stateless tools: calculator (safe AST-based expression
17
+ evaluation, never `eval()`/`exec()`), `get_current_datetime`,
18
+ `add_to_date`, `date_difference`, `convert_length`,
19
+ `convert_weight`, `convert_temperature`, `parse_json`, `parse_csv`,
20
+ `regex_extract`, `word_count`, `text_case_transform`.
21
+ - 3 sandboxed file-op tools (`read_file`, `write_file`, `list_files`)
22
+ via `FileOpsConfig(root_dir=...)` - confined entirely to a
23
+ caller-specified root directory. Path resolution follows symlinks
24
+ before checking containment, so symlink-based sandbox escapes are
25
+ rejected, not just naive `../` string checking.
26
+ - 1 optional ingestion tool (`make_ingest_tool`, needs the `ingest`
27
+ extra: `pip install ragleap-tools[ingest]`) wrapping `ragleap-rag`'s
28
+ already-tested `ingest_text()` - no new ingestion logic.
29
+ - Explicitly out of scope for this release: code execution, web
30
+ search, HTTP fetch, and database/business-system connectors - each
31
+ documented in the README as needing its own security-focused design
32
+ pass rather than a rushed inclusion.
33
+
34
+ ### Verified
35
+
36
+ - 51 tests, all passing. Includes real security verification, not
37
+ just documentation: the calculator's AST whitelist is tested against
38
+ real code-injection attempts (`__import__`, attribute access, list
39
+ comprehensions, multi-statement injection - all correctly rejected),
40
+ and file ops' sandboxing is tested against real path-traversal
41
+ attempts AND a real symlink-escape attempt (a file outside the
42
+ sandbox, symlinked from inside it - correctly rejected).
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.5
2
+ Name: ragleap-tools
3
+ Version: 0.1.0
4
+ Summary: Standalone, dependency-light tool implementations (calculator, file ops, datetime, unit conversion, JSON/CSV, text utilities) exposing OpenAI/Gemini-style function-calling schemas. No tool-calling execution loop - wire these into your own LLM tool-calling code, or into ragleap-agents when that ships. BYOK, no vendor lock-in.
5
+ Project-URL: Homepage, https://github.com/antonyrag/ragleap-core
6
+ Project-URL: Repository, https://github.com/antonyrag/ragleap-core
7
+ Project-URL: Documentation, https://packages.ragleap.com/docs/ragleap-tools.html
8
+ Project-URL: Issues, https://github.com/antonyrag/ragleap-core/issues
9
+ Author-email: Antony <antony@ragleap.com>
10
+ License-Expression: MIT
11
+ Keywords: agents,byok,calculator,file-ops,function-calling,llm,self-hosted,tool-calling
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Provides-Extra: ingest
22
+ Requires-Dist: ragleap-rag>=0.12.0; extra == 'ingest'
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=8.0.0; extra == 'test'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # ragleap-tools
28
+
29
+ Standalone, dependency-light tool implementations for LLM tool-calling.
30
+ Exposes OpenAI/Gemini-style function-calling schemas.
31
+
32
+ ```bash
33
+ pip install ragleap-tools
34
+ ```
35
+
36
+ ## What this is (and isn't)
37
+
38
+ `ragleap-tools` provides `Tool` objects - a name, a description, a JSON
39
+ Schema for parameters, and a safe handler function. It does **not** own
40
+ a tool-calling execution loop (deciding when to call a tool, running
41
+ it, feeding the result back to the model) - that's `ragleap-agents`'
42
+ job, per the project roadmap's own split. Wire these tools into your
43
+ own tool-calling code, or into `ragleap-agents` once that ships.
44
+
45
+ ## Quickstart
46
+
47
+ ```python
48
+ from ragleap_tools import STATELESS_TOOLS, CALCULATOR_TOOL
49
+
50
+ # Give these to your LLM provider's tools= parameter:
51
+ openai_tools = [t.to_openai_schema() for t in STATELESS_TOOLS]
52
+ gemini_tools = [t.to_gemini_schema() for t in STATELESS_TOOLS]
53
+
54
+ # When the model calls one, invoke the handler yourself:
55
+ result = CALCULATOR_TOOL.call(expression="2 + 2 * sqrt(16)")
56
+ print(result.success, result.result) # True 10.0
57
+ ```
58
+
59
+ ## The 12 stateless tools
60
+
61
+ No configuration needed - import and use directly.
62
+
63
+ - `CALCULATOR_TOOL` - safe arithmetic (AST-based whitelist, never `eval()`)
64
+ - `CURRENT_DATETIME_TOOL`, `ADD_TO_DATE_TOOL`, `DATE_DIFFERENCE_TOOL` - date/time math
65
+ - `CONVERT_LENGTH_TOOL`, `CONVERT_WEIGHT_TOOL`, `CONVERT_TEMPERATURE_TOOL` - unit conversion
66
+ - `PARSE_JSON_TOOL`, `PARSE_CSV_TOOL` - structured data parsing
67
+ - `REGEX_EXTRACT_TOOL`, `WORD_COUNT_TOOL`, `TEXT_CASE_TRANSFORM_TOOL` - text utilities
68
+
69
+ ## File operations (sandboxed, needs configuration)
70
+
71
+ ```python
72
+ from ragleap_tools import FileOpsConfig, make_file_tools
73
+
74
+ config = FileOpsConfig(root_dir="/path/to/a/safe/directory")
75
+ read_tool, write_tool, list_tool = make_file_tools(config)
76
+ ```
77
+
78
+ Every operation is confined to `root_dir` - both `../` path traversal
79
+ and symlink-based escapes are rejected (verified via real security
80
+ tests, not just documented), not just naive string-prefix checking.
81
+ There is no unsandboxed mode.
82
+
83
+ ## Document ingestion (optional, needs ragleap-rag)
84
+
85
+ ```bash
86
+ pip install ragleap-tools[ingest]
87
+ ```
88
+
89
+ ```python
90
+ from ragleap import RagLeap, ProviderConfig, EmbeddingConfig
91
+ from ragleap_tools import IngestConfig, make_ingest_tool
92
+
93
+ rag = RagLeap(database_url="...", primary=ProviderConfig(...), embedder=EmbeddingConfig(...))
94
+ ingest_tool = make_ingest_tool(IngestConfig(rag=rag))
95
+ ```
96
+
97
+ Wraps `ragleap-rag`'s already-tested `ingest_text()` - no new ingestion
98
+ logic, just a tool schema on top of the real 28-format-capable
99
+ pipeline. `ragleap-rag` owns the actual ingestion; this is a thin
100
+ adapter, same pattern `ragleap-graph` uses for its own optional
101
+ `ragleap-rag` dependency.
102
+
103
+ ## Deliberately out of scope for v0.1.0
104
+
105
+ Each of these needs its own security-focused design pass, not a
106
+ rushed inclusion here:
107
+
108
+ - **Code execution** - a real sandboxing/resource-limit design
109
+ decision, not something to bolt on alongside a calculator.
110
+ - **Web search** - needs a pluggable-provider decision (this
111
+ ecosystem's BYOK philosophy applies here too), not a single
112
+ hardcoded provider.
113
+ - **HTTP fetch** - letting an LLM request arbitrary URLs carries real
114
+ SSRF risk, same care level as code execution.
115
+ - **Database/business-system connectors** (SQL, CRM, payment
116
+ processors, etc.) - some of what this ecosystem already has
117
+ elsewhere (e.g. a live payment processor) would be a materially
118
+ different risk if exposed to LLM tool-calling without deliberate
119
+ guardrails (dry-run modes, confirmation steps, scoped permissions).
120
+
121
+ ## Status
122
+
123
+ v0.1.0. 51 tests, all passing, including real security verification
124
+ for the two risk-sensitive tools (calculator's code-injection
125
+ rejection, file ops' path-traversal and symlink-escape rejection) -
126
+ not just documented as safe, actually tested against real attack
127
+ vectors.
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,105 @@
1
+ # ragleap-tools
2
+
3
+ Standalone, dependency-light tool implementations for LLM tool-calling.
4
+ Exposes OpenAI/Gemini-style function-calling schemas.
5
+
6
+ ```bash
7
+ pip install ragleap-tools
8
+ ```
9
+
10
+ ## What this is (and isn't)
11
+
12
+ `ragleap-tools` provides `Tool` objects - a name, a description, a JSON
13
+ Schema for parameters, and a safe handler function. It does **not** own
14
+ a tool-calling execution loop (deciding when to call a tool, running
15
+ it, feeding the result back to the model) - that's `ragleap-agents`'
16
+ job, per the project roadmap's own split. Wire these tools into your
17
+ own tool-calling code, or into `ragleap-agents` once that ships.
18
+
19
+ ## Quickstart
20
+
21
+ ```python
22
+ from ragleap_tools import STATELESS_TOOLS, CALCULATOR_TOOL
23
+
24
+ # Give these to your LLM provider's tools= parameter:
25
+ openai_tools = [t.to_openai_schema() for t in STATELESS_TOOLS]
26
+ gemini_tools = [t.to_gemini_schema() for t in STATELESS_TOOLS]
27
+
28
+ # When the model calls one, invoke the handler yourself:
29
+ result = CALCULATOR_TOOL.call(expression="2 + 2 * sqrt(16)")
30
+ print(result.success, result.result) # True 10.0
31
+ ```
32
+
33
+ ## The 12 stateless tools
34
+
35
+ No configuration needed - import and use directly.
36
+
37
+ - `CALCULATOR_TOOL` - safe arithmetic (AST-based whitelist, never `eval()`)
38
+ - `CURRENT_DATETIME_TOOL`, `ADD_TO_DATE_TOOL`, `DATE_DIFFERENCE_TOOL` - date/time math
39
+ - `CONVERT_LENGTH_TOOL`, `CONVERT_WEIGHT_TOOL`, `CONVERT_TEMPERATURE_TOOL` - unit conversion
40
+ - `PARSE_JSON_TOOL`, `PARSE_CSV_TOOL` - structured data parsing
41
+ - `REGEX_EXTRACT_TOOL`, `WORD_COUNT_TOOL`, `TEXT_CASE_TRANSFORM_TOOL` - text utilities
42
+
43
+ ## File operations (sandboxed, needs configuration)
44
+
45
+ ```python
46
+ from ragleap_tools import FileOpsConfig, make_file_tools
47
+
48
+ config = FileOpsConfig(root_dir="/path/to/a/safe/directory")
49
+ read_tool, write_tool, list_tool = make_file_tools(config)
50
+ ```
51
+
52
+ Every operation is confined to `root_dir` - both `../` path traversal
53
+ and symlink-based escapes are rejected (verified via real security
54
+ tests, not just documented), not just naive string-prefix checking.
55
+ There is no unsandboxed mode.
56
+
57
+ ## Document ingestion (optional, needs ragleap-rag)
58
+
59
+ ```bash
60
+ pip install ragleap-tools[ingest]
61
+ ```
62
+
63
+ ```python
64
+ from ragleap import RagLeap, ProviderConfig, EmbeddingConfig
65
+ from ragleap_tools import IngestConfig, make_ingest_tool
66
+
67
+ rag = RagLeap(database_url="...", primary=ProviderConfig(...), embedder=EmbeddingConfig(...))
68
+ ingest_tool = make_ingest_tool(IngestConfig(rag=rag))
69
+ ```
70
+
71
+ Wraps `ragleap-rag`'s already-tested `ingest_text()` - no new ingestion
72
+ logic, just a tool schema on top of the real 28-format-capable
73
+ pipeline. `ragleap-rag` owns the actual ingestion; this is a thin
74
+ adapter, same pattern `ragleap-graph` uses for its own optional
75
+ `ragleap-rag` dependency.
76
+
77
+ ## Deliberately out of scope for v0.1.0
78
+
79
+ Each of these needs its own security-focused design pass, not a
80
+ rushed inclusion here:
81
+
82
+ - **Code execution** - a real sandboxing/resource-limit design
83
+ decision, not something to bolt on alongside a calculator.
84
+ - **Web search** - needs a pluggable-provider decision (this
85
+ ecosystem's BYOK philosophy applies here too), not a single
86
+ hardcoded provider.
87
+ - **HTTP fetch** - letting an LLM request arbitrary URLs carries real
88
+ SSRF risk, same care level as code execution.
89
+ - **Database/business-system connectors** (SQL, CRM, payment
90
+ processors, etc.) - some of what this ecosystem already has
91
+ elsewhere (e.g. a live payment processor) would be a materially
92
+ different risk if exposed to LLM tool-calling without deliberate
93
+ guardrails (dry-run modes, confirmation steps, scoped permissions).
94
+
95
+ ## Status
96
+
97
+ v0.1.0. 51 tests, all passing, including real security verification
98
+ for the two risk-sensitive tools (calculator's code-injection
99
+ rejection, file ops' path-traversal and symlink-escape rejection) -
100
+ not just documented as safe, actually tested against real attack
101
+ vectors.
102
+
103
+ ## License
104
+
105
+ MIT
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ragleap-tools"
7
+ version = "0.1.0"
8
+ description = "Standalone, dependency-light tool implementations (calculator, file ops, datetime, unit conversion, JSON/CSV, text utilities) exposing OpenAI/Gemini-style function-calling schemas. No tool-calling execution loop - wire these into your own LLM tool-calling code, or into ragleap-agents when that ships. BYOK, no vendor lock-in."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Antony", email = "antony@ragleap.com" }
14
+ ]
15
+ keywords = ["llm", "tool-calling", "function-calling", "agents", "calculator", "file-ops", "self-hosted", "byok"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ ]
26
+
27
+ dependencies = []
28
+
29
+ [project.optional-dependencies]
30
+ test = ["pytest>=8.0.0"]
31
+ ingest = ["ragleap-rag>=0.12.0"]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/antonyrag/ragleap-core"
35
+ Repository = "https://github.com/antonyrag/ragleap-core"
36
+ Documentation = "https://packages.ragleap.com/docs/ragleap-tools.html"
37
+ Issues = "https://github.com/antonyrag/ragleap-core/issues"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/ragleap_tools"]
@@ -0,0 +1,74 @@
1
+ """
2
+ ragleap_tools - standalone, dependency-light tool implementations for
3
+ LLM tool-calling. Exposes OpenAI/Gemini-style function-calling schemas.
4
+
5
+ Deliberately does NOT own a tool-calling execution loop - that's
6
+ ragleap-agents' job (per the project roadmap's own split). This package
7
+ is just the tools: name, description, JSON Schema parameters, and a
8
+ safe handler function you invoke yourself once your LLM picks a tool.
9
+
10
+ v0.1.0 - 7 tools:
11
+ calculator, file ops (read/write/list), get_current_datetime,
12
+ add_to_date, date_difference, convert_length, convert_weight,
13
+ convert_temperature, parse_json, parse_csv, regex_extract,
14
+ word_count, text_case_transform, ingest_document (needs the
15
+ optional ragleap-rag dependency: pip install ragleap-tools[ingest])
16
+
17
+ Deliberately out of scope for now (each needs its own security-focused
18
+ design pass, not a rushed inclusion here): code execution, web search,
19
+ HTTP fetch, and database/business-system connectors (SQL, CRM,
20
+ payment processors, etc.) - the last of those specifically because
21
+ some of the connectors this ecosystem already has elsewhere (e.g. a
22
+ live payment processor) would be a materially different risk if
23
+ exposed to LLM tool-calling without deliberate guardrails.
24
+ """
25
+
26
+ from ragleap_tools.base import Tool, ToolResult
27
+ from ragleap_tools.calculator import CALCULATOR_TOOL
28
+ from ragleap_tools.datetime_tools import ADD_TO_DATE_TOOL, CURRENT_DATETIME_TOOL, DATE_DIFFERENCE_TOOL
29
+ from ragleap_tools.file_ops import FileOpsConfig, make_file_tools
30
+ from ragleap_tools.ingest import IngestConfig, make_ingest_tool
31
+ from ragleap_tools.json_csv import PARSE_CSV_TOOL, PARSE_JSON_TOOL
32
+ from ragleap_tools.text_utils import REGEX_EXTRACT_TOOL, TEXT_CASE_TRANSFORM_TOOL, WORD_COUNT_TOOL
33
+ from ragleap_tools.unit_conversion import CONVERT_LENGTH_TOOL, CONVERT_TEMPERATURE_TOOL, CONVERT_WEIGHT_TOOL
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ # Tools with no config/state needed - usable directly.
38
+ STATELESS_TOOLS = [
39
+ CALCULATOR_TOOL,
40
+ CURRENT_DATETIME_TOOL,
41
+ ADD_TO_DATE_TOOL,
42
+ DATE_DIFFERENCE_TOOL,
43
+ CONVERT_LENGTH_TOOL,
44
+ CONVERT_WEIGHT_TOOL,
45
+ CONVERT_TEMPERATURE_TOOL,
46
+ PARSE_JSON_TOOL,
47
+ PARSE_CSV_TOOL,
48
+ REGEX_EXTRACT_TOOL,
49
+ WORD_COUNT_TOOL,
50
+ TEXT_CASE_TRANSFORM_TOOL,
51
+ ]
52
+
53
+ __all__ = [
54
+ "Tool",
55
+ "ToolResult",
56
+ "STATELESS_TOOLS",
57
+ "CALCULATOR_TOOL",
58
+ "CURRENT_DATETIME_TOOL",
59
+ "ADD_TO_DATE_TOOL",
60
+ "DATE_DIFFERENCE_TOOL",
61
+ "CONVERT_LENGTH_TOOL",
62
+ "CONVERT_WEIGHT_TOOL",
63
+ "CONVERT_TEMPERATURE_TOOL",
64
+ "PARSE_JSON_TOOL",
65
+ "PARSE_CSV_TOOL",
66
+ "REGEX_EXTRACT_TOOL",
67
+ "WORD_COUNT_TOOL",
68
+ "TEXT_CASE_TRANSFORM_TOOL",
69
+ "FileOpsConfig",
70
+ "make_file_tools",
71
+ "IngestConfig",
72
+ "make_ingest_tool",
73
+ "__version__",
74
+ ]
@@ -0,0 +1,79 @@
1
+ """
2
+ ragleap_tools.base
3
+
4
+ Core Tool/ToolResult abstraction every tool implementation in this
5
+ package uses. Deliberately minimal - a name, a description, a JSON
6
+ Schema for parameters (the OpenAI/Gemini function-calling convention,
7
+ so no adapter layer is needed for either provider), and a handler
8
+ callable.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Callable, Dict, Optional
15
+
16
+
17
+ @dataclass
18
+ class ToolResult:
19
+ """Result of calling a tool's handler.
20
+
21
+ success=False + error=<message> on any failure - handlers should
22
+ never raise for expected failure modes (bad input, file not found,
23
+ etc.); they should catch and return a ToolResult so a caller's
24
+ tool-calling loop can feed a clean error back to the LLM instead of
25
+ crashing. Handlers MAY still raise for genuinely unexpected
26
+ programming errors - that's a real bug, not user input to explain
27
+ to the model.
28
+ """
29
+
30
+ success: bool
31
+ result: Any = None
32
+ error: Optional[str] = None
33
+
34
+
35
+ @dataclass
36
+ class Tool:
37
+ """A single callable tool with an OpenAI/Gemini-compatible schema.
38
+
39
+ parameters: a JSON Schema object (the same shape both OpenAI's
40
+ `tools=[{"type": "function", "function": {...}}]` and Gemini's
41
+ `FunctionDeclaration` expect for their own `parameters` field) -
42
+ e.g. {"type": "object", "properties": {...}, "required": [...]}.
43
+
44
+ handler: called with the tool call's arguments as keyword
45
+ arguments (already parsed from whatever JSON the model returned -
46
+ parsing that JSON is the caller's responsibility, not this
47
+ package's). Must return a ToolResult, never raise for expected
48
+ failure modes (see ToolResult's docstring).
49
+ """
50
+
51
+ name: str
52
+ description: str
53
+ parameters: Dict[str, Any]
54
+ handler: Callable[..., ToolResult]
55
+
56
+ def to_openai_schema(self) -> Dict[str, Any]:
57
+ """Returns the {"type": "function", "function": {...}} shape
58
+ OpenAI's `tools=` parameter expects."""
59
+ return {
60
+ "type": "function",
61
+ "function": {
62
+ "name": self.name,
63
+ "description": self.description,
64
+ "parameters": self.parameters,
65
+ },
66
+ }
67
+
68
+ def to_gemini_schema(self) -> Dict[str, Any]:
69
+ """Returns the shape google-genai's FunctionDeclaration expects
70
+ (name/description/parameters, same JSON Schema convention)."""
71
+ return {
72
+ "name": self.name,
73
+ "description": self.description,
74
+ "parameters": self.parameters,
75
+ }
76
+
77
+ def call(self, **kwargs: Any) -> ToolResult:
78
+ """Invokes the handler with the given keyword arguments."""
79
+ return self.handler(**kwargs)
@@ -0,0 +1,139 @@
1
+ """
2
+ ragleap_tools.calculator
3
+
4
+ Safe arithmetic expression evaluation - deliberately NOT using eval()
5
+ or exec(). Parses the expression into a real Python AST, then walks it
6
+ recursively, rejecting anything that isn't an explicitly whitelisted
7
+ node type (numbers, basic binary/unary math operators, and a small
8
+ fixed set of math functions/constants). No name lookups beyond that
9
+ fixed set, no attribute access, no function calls beyond the
10
+ whitelist, no comprehensions, no imports - none of eval()'s real
11
+ attack surface exists here at all.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+ import math
18
+ import operator
19
+ from typing import Any, Dict
20
+
21
+ from ragleap_tools.base import Tool, ToolResult
22
+
23
+ _ALLOWED_BINOPS = {
24
+ ast.Add: operator.add,
25
+ ast.Sub: operator.sub,
26
+ ast.Mult: operator.mul,
27
+ ast.Div: operator.truediv,
28
+ ast.FloorDiv: operator.floordiv,
29
+ ast.Mod: operator.mod,
30
+ ast.Pow: operator.pow,
31
+ }
32
+
33
+ _ALLOWED_UNARYOPS = {
34
+ ast.UAdd: operator.pos,
35
+ ast.USub: operator.neg,
36
+ }
37
+
38
+ _ALLOWED_FUNCTIONS = {
39
+ "abs": abs,
40
+ "round": round,
41
+ "min": min,
42
+ "max": max,
43
+ "sqrt": math.sqrt,
44
+ "sin": math.sin,
45
+ "cos": math.cos,
46
+ "tan": math.tan,
47
+ "log": math.log,
48
+ "log10": math.log10,
49
+ "log2": math.log2,
50
+ "exp": math.exp,
51
+ "floor": math.floor,
52
+ "ceil": math.ceil,
53
+ }
54
+
55
+ _ALLOWED_CONSTANTS = {
56
+ "pi": math.pi,
57
+ "e": math.e,
58
+ }
59
+
60
+
61
+ class UnsafeExpressionError(ValueError):
62
+ """Raised when the expression contains anything outside the
63
+ explicit whitelist - not a math error, a rejected-input error."""
64
+
65
+
66
+ def _eval_node(node: ast.AST) -> Any:
67
+ if isinstance(node, ast.Expression):
68
+ return _eval_node(node.body)
69
+ if isinstance(node, ast.Constant):
70
+ if isinstance(node.value, (int, float)):
71
+ return node.value
72
+ raise UnsafeExpressionError(f"Constant type not allowed: {type(node.value).__name__}")
73
+ if isinstance(node, ast.BinOp):
74
+ op_type = type(node.op)
75
+ if op_type not in _ALLOWED_BINOPS:
76
+ raise UnsafeExpressionError(f"Operator not allowed: {op_type.__name__}")
77
+ return _ALLOWED_BINOPS[op_type](_eval_node(node.left), _eval_node(node.right))
78
+ if isinstance(node, ast.UnaryOp):
79
+ op_type = type(node.op)
80
+ if op_type not in _ALLOWED_UNARYOPS:
81
+ raise UnsafeExpressionError(f"Unary operator not allowed: {op_type.__name__}")
82
+ return _ALLOWED_UNARYOPS[op_type](_eval_node(node.operand))
83
+ if isinstance(node, ast.Name):
84
+ if node.id in _ALLOWED_CONSTANTS:
85
+ return _ALLOWED_CONSTANTS[node.id]
86
+ raise UnsafeExpressionError(f"Name not allowed: {node.id!r}")
87
+ if isinstance(node, ast.Call):
88
+ if not isinstance(node.func, ast.Name) or node.func.id not in _ALLOWED_FUNCTIONS:
89
+ raise UnsafeExpressionError("Only whitelisted functions may be called")
90
+ if node.keywords:
91
+ raise UnsafeExpressionError("Keyword arguments are not allowed")
92
+ args = [_eval_node(a) for a in node.args]
93
+ return _ALLOWED_FUNCTIONS[node.func.id](*args)
94
+ raise UnsafeExpressionError(f"Expression node type not allowed: {type(node).__name__}")
95
+
96
+
97
+ def safe_eval_expression(expression: str) -> float:
98
+ """Parses and evaluates a math expression using only the
99
+ whitelisted grammar above. Raises UnsafeExpressionError for
100
+ anything outside that whitelist, SyntaxError for invalid syntax,
101
+ ZeroDivisionError/ValueError/OverflowError for real math errors -
102
+ the caller (calculate()) catches all of these and returns a
103
+ ToolResult rather than letting them propagate."""
104
+ if not expression or not expression.strip():
105
+ raise ValueError("Expression is empty")
106
+ if len(expression) > 200:
107
+ raise UnsafeExpressionError("Expression too long (max 200 characters)")
108
+ parsed = ast.parse(expression, mode="eval")
109
+ return _eval_node(parsed)
110
+
111
+
112
+ def calculate(expression: str) -> ToolResult:
113
+ try:
114
+ result = safe_eval_expression(expression)
115
+ return ToolResult(success=True, result=result)
116
+ except (UnsafeExpressionError, SyntaxError, ValueError, ZeroDivisionError, OverflowError, TypeError) as e:
117
+ return ToolResult(success=False, error=f"{type(e).__name__}: {e}")
118
+
119
+
120
+ CALCULATOR_TOOL = Tool(
121
+ name="calculator",
122
+ description=(
123
+ "Evaluate a mathematical expression. Supports +, -, *, /, //, %, ** "
124
+ "and the functions abs, round, min, max, sqrt, sin, cos, tan, log, "
125
+ "log10, log2, exp, floor, ceil, plus the constants pi and e. "
126
+ "No variables, no string operations, no other Python syntax."
127
+ ),
128
+ parameters={
129
+ "type": "object",
130
+ "properties": {
131
+ "expression": {
132
+ "type": "string",
133
+ "description": "A math expression, e.g. '2 + 2 * sqrt(16)'",
134
+ },
135
+ },
136
+ "required": ["expression"],
137
+ },
138
+ handler=calculate,
139
+ )