kailash 0.9.20__py3-none-any.whl → 0.9.22__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.
- kailash/__init__.py +1 -1
- kailash/api/tests/test_workflow_api_404.py +202 -0
- kailash/api/workflow_api.py +33 -1
- kailash/nodes/base.py +34 -5
- kailash/nodes/code/python.py +10 -4
- kailash/nodes/data/async_sql.py +76 -10
- kailash/security.py +63 -17
- kailash/workflow/graph.py +5 -5
- {kailash-0.9.20.dist-info → kailash-0.9.22.dist-info}/METADATA +10 -3
- {kailash-0.9.20.dist-info → kailash-0.9.22.dist-info}/RECORD +15 -14
- {kailash-0.9.20.dist-info → kailash-0.9.22.dist-info}/WHEEL +0 -0
- {kailash-0.9.20.dist-info → kailash-0.9.22.dist-info}/entry_points.txt +0 -0
- {kailash-0.9.20.dist-info → kailash-0.9.22.dist-info}/licenses/LICENSE +0 -0
- {kailash-0.9.20.dist-info → kailash-0.9.22.dist-info}/licenses/NOTICE +0 -0
- {kailash-0.9.20.dist-info → kailash-0.9.22.dist-info}/top_level.txt +0 -0
kailash/__init__.py
CHANGED
@@ -0,0 +1,202 @@
|
|
1
|
+
"""
|
2
|
+
Tests for WorkflowAPI Custom 404 Handler
|
3
|
+
|
4
|
+
Tests that WorkflowAPI provides helpful 404 error messages
|
5
|
+
with available endpoints when wrong paths are accessed.
|
6
|
+
|
7
|
+
These tests follow TDD: They will FAIL initially until the 404 handler is implemented.
|
8
|
+
This is expected behavior - we write tests FIRST, then implement.
|
9
|
+
"""
|
10
|
+
|
11
|
+
import pytest
|
12
|
+
from fastapi.testclient import TestClient
|
13
|
+
|
14
|
+
from kailash.api.workflow_api import WorkflowAPI
|
15
|
+
from kailash.workflow.builder import WorkflowBuilder
|
16
|
+
|
17
|
+
|
18
|
+
@pytest.fixture
|
19
|
+
def simple_workflow_api():
|
20
|
+
"""Simple WorkflowAPI for testing"""
|
21
|
+
workflow = WorkflowBuilder()
|
22
|
+
workflow.add_node(
|
23
|
+
"PythonCodeNode", "test", {"code": "result = {'message': 'test'}"}
|
24
|
+
)
|
25
|
+
api = WorkflowAPI(workflow.build())
|
26
|
+
return api
|
27
|
+
|
28
|
+
|
29
|
+
def test_404_returns_helpful_json(simple_workflow_api):
|
30
|
+
"""Test that 404 errors return helpful JSON with available endpoints."""
|
31
|
+
client = TestClient(simple_workflow_api.app)
|
32
|
+
|
33
|
+
# Try to access non-existent path
|
34
|
+
response = client.get("/nonexistent")
|
35
|
+
|
36
|
+
# Should return 404
|
37
|
+
assert response.status_code == 404
|
38
|
+
|
39
|
+
# Should be JSON
|
40
|
+
assert response.headers["content-type"] == "application/json"
|
41
|
+
|
42
|
+
# Should have helpful error message
|
43
|
+
data = response.json()
|
44
|
+
assert "error" in data or "detail" in data, "404 response should have error field"
|
45
|
+
|
46
|
+
# Should mention endpoints or provide helpful info
|
47
|
+
response_str = str(data).lower()
|
48
|
+
has_endpoint_info = (
|
49
|
+
"endpoint" in response_str
|
50
|
+
or "available" in response_str
|
51
|
+
or "path" in response_str
|
52
|
+
)
|
53
|
+
assert has_endpoint_info, "404 response should mention endpoints or paths"
|
54
|
+
|
55
|
+
|
56
|
+
def test_404_lists_available_endpoints(simple_workflow_api):
|
57
|
+
"""Test that 404 response includes list of available endpoints."""
|
58
|
+
client = TestClient(simple_workflow_api.app)
|
59
|
+
|
60
|
+
# Try to access non-existent path
|
61
|
+
response = client.get("/invalid_path")
|
62
|
+
|
63
|
+
assert response.status_code == 404
|
64
|
+
|
65
|
+
data = response.json()
|
66
|
+
|
67
|
+
# Should have some reference to available endpoints
|
68
|
+
response_str = str(data)
|
69
|
+
endpoints = []
|
70
|
+
|
71
|
+
# Check if standard endpoints are mentioned
|
72
|
+
if "/execute" in response_str:
|
73
|
+
endpoints.append("/execute")
|
74
|
+
if "/workflow/info" in response_str or "/info" in response_str:
|
75
|
+
endpoints.append("/workflow/info")
|
76
|
+
if "/health" in response_str:
|
77
|
+
endpoints.append("/health")
|
78
|
+
|
79
|
+
# Should mention at least 2 of the 3 standard endpoints
|
80
|
+
assert (
|
81
|
+
len(endpoints) >= 2
|
82
|
+
), f"404 response should mention available endpoints. Found: {endpoints}"
|
83
|
+
|
84
|
+
|
85
|
+
def test_404_provides_helpful_hint(simple_workflow_api):
|
86
|
+
"""Test that 404 response provides actionable hint."""
|
87
|
+
client = TestClient(simple_workflow_api.app)
|
88
|
+
|
89
|
+
# Try wrong path
|
90
|
+
response = client.get("/wrong")
|
91
|
+
|
92
|
+
assert response.status_code == 404
|
93
|
+
|
94
|
+
data = response.json()
|
95
|
+
response_str = str(data).lower()
|
96
|
+
|
97
|
+
# Should have hint, message, or suggestion
|
98
|
+
has_helpful_content = (
|
99
|
+
"hint" in response_str
|
100
|
+
or "try" in response_str
|
101
|
+
or "use" in response_str
|
102
|
+
or "most common" in response_str
|
103
|
+
or "suggestion" in response_str
|
104
|
+
or "did you mean" in response_str
|
105
|
+
)
|
106
|
+
|
107
|
+
assert (
|
108
|
+
has_helpful_content
|
109
|
+
), "404 response should provide helpful hints or suggestions"
|
110
|
+
|
111
|
+
|
112
|
+
def test_404_includes_documentation_link(simple_workflow_api):
|
113
|
+
"""Test that 404 response includes link to documentation."""
|
114
|
+
client = TestClient(simple_workflow_api.app)
|
115
|
+
|
116
|
+
response = client.get("/missing")
|
117
|
+
|
118
|
+
assert response.status_code == 404
|
119
|
+
data = response.json()
|
120
|
+
|
121
|
+
# Should have docs link or path
|
122
|
+
response_str = str(data).lower()
|
123
|
+
has_docs_reference = (
|
124
|
+
"docs" in response_str
|
125
|
+
or "documentation" in response_str
|
126
|
+
or "/docs" in response_str
|
127
|
+
)
|
128
|
+
assert has_docs_reference, "404 response should reference documentation"
|
129
|
+
|
130
|
+
|
131
|
+
def test_404_handler_for_root_path(simple_workflow_api):
|
132
|
+
"""Test 404 handler when accessing root path with wrong method."""
|
133
|
+
client = TestClient(simple_workflow_api.app)
|
134
|
+
|
135
|
+
# Try to GET root (only POST is typically defined for execute)
|
136
|
+
response = client.get("/")
|
137
|
+
|
138
|
+
# Could be 404 or 405 Method Not Allowed
|
139
|
+
assert response.status_code in [
|
140
|
+
404,
|
141
|
+
405,
|
142
|
+
], f"Expected 404 or 405, got {response.status_code}"
|
143
|
+
|
144
|
+
# If 404, should have helpful info
|
145
|
+
if response.status_code == 404:
|
146
|
+
data = response.json()
|
147
|
+
response_str = str(data).lower()
|
148
|
+
|
149
|
+
# Should mention execute or available methods
|
150
|
+
has_helpful_info = (
|
151
|
+
"execute" in response_str
|
152
|
+
or "post" in response_str
|
153
|
+
or "endpoint" in response_str
|
154
|
+
)
|
155
|
+
assert (
|
156
|
+
has_helpful_info
|
157
|
+
), "404 on root should mention execute endpoint or POST method"
|
158
|
+
|
159
|
+
|
160
|
+
def test_404_handler_preserves_fastapi_routes(simple_workflow_api):
|
161
|
+
"""Test that valid routes still work after adding 404 handler."""
|
162
|
+
client = TestClient(simple_workflow_api.app)
|
163
|
+
|
164
|
+
# Valid route should work
|
165
|
+
response = client.get("/health")
|
166
|
+
|
167
|
+
# Health endpoint should return 200
|
168
|
+
assert (
|
169
|
+
response.status_code == 200
|
170
|
+
), "Valid routes should still work after adding 404 handler"
|
171
|
+
|
172
|
+
# Should return JSON
|
173
|
+
assert response.headers["content-type"] == "application/json"
|
174
|
+
|
175
|
+
|
176
|
+
def test_404_response_format_consistency(simple_workflow_api):
|
177
|
+
"""Test that 404 responses have consistent format."""
|
178
|
+
client = TestClient(simple_workflow_api.app)
|
179
|
+
|
180
|
+
# Try multiple invalid paths
|
181
|
+
paths = ["/invalid1", "/wrong/path", "/nonexistent/endpoint"]
|
182
|
+
|
183
|
+
for path in paths:
|
184
|
+
response = client.get(path)
|
185
|
+
assert response.status_code == 404
|
186
|
+
|
187
|
+
# All should return JSON
|
188
|
+
assert (
|
189
|
+
response.headers["content-type"] == "application/json"
|
190
|
+
), f"404 response for {path} should be JSON"
|
191
|
+
|
192
|
+
# All should have error structure
|
193
|
+
data = response.json()
|
194
|
+
assert isinstance(
|
195
|
+
data, dict
|
196
|
+
), f"404 response for {path} should be a JSON object"
|
197
|
+
|
198
|
+
# Should have at least one error-related field
|
199
|
+
has_error_field = any(key in data for key in ["error", "detail", "message"])
|
200
|
+
assert (
|
201
|
+
has_error_field
|
202
|
+
), f"404 response for {path} should have error/detail/message field"
|
kailash/api/workflow_api.py
CHANGED
@@ -12,7 +12,7 @@ from typing import Any
|
|
12
12
|
|
13
13
|
import uvicorn
|
14
14
|
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
|
15
|
-
from fastapi.responses import StreamingResponse
|
15
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
16
16
|
from pydantic import BaseModel, Field
|
17
17
|
|
18
18
|
from kailash.runtime.local import LocalRuntime
|
@@ -129,6 +129,38 @@ class WorkflowAPI:
|
|
129
129
|
def _setup_routes(self):
|
130
130
|
"""Setup API routes dynamically based on workflow."""
|
131
131
|
|
132
|
+
# Custom 404 handler for helpful error messages
|
133
|
+
@self.app.exception_handler(404)
|
134
|
+
async def custom_404_handler(request: Request, exc):
|
135
|
+
"""Provide helpful 404 error with available endpoints."""
|
136
|
+
return JSONResponse(
|
137
|
+
status_code=404,
|
138
|
+
content={
|
139
|
+
"error": "Endpoint not found",
|
140
|
+
"path": request.url.path,
|
141
|
+
"message": "The requested endpoint does not exist for this workflow.",
|
142
|
+
"available_endpoints": [
|
143
|
+
{
|
144
|
+
"method": "POST",
|
145
|
+
"path": "/execute",
|
146
|
+
"description": "Execute the workflow with input parameters",
|
147
|
+
},
|
148
|
+
{
|
149
|
+
"method": "GET",
|
150
|
+
"path": "/workflow/info",
|
151
|
+
"description": "Get workflow metadata and structure",
|
152
|
+
},
|
153
|
+
{
|
154
|
+
"method": "GET",
|
155
|
+
"path": "/health",
|
156
|
+
"description": "Check workflow API health status",
|
157
|
+
},
|
158
|
+
],
|
159
|
+
"hint": "Most common: POST to /execute endpoint with JSON body containing 'inputs' field",
|
160
|
+
"documentation": "/docs",
|
161
|
+
},
|
162
|
+
)
|
163
|
+
|
132
164
|
# Root execution endpoint (convenience for direct workflow execution)
|
133
165
|
@self.app.post("/")
|
134
166
|
async def execute_workflow_root(
|
kailash/nodes/base.py
CHANGED
@@ -202,11 +202,13 @@ class Node(ABC):
|
|
202
202
|
- Validates parameters are correctly specified
|
203
203
|
"""
|
204
204
|
try:
|
205
|
-
|
205
|
+
# Use _node_id for internal node identifier (namespace separation)
|
206
|
+
# This prevents collision with user's 'id' parameter
|
207
|
+
self._node_id = kwargs.get("_node_id", self.__class__.__name__)
|
206
208
|
self.metadata = kwargs.get(
|
207
209
|
"metadata",
|
208
210
|
NodeMetadata(
|
209
|
-
id=self.id
|
211
|
+
id=self._node_id, # NodeMetadata still uses 'id' internally
|
210
212
|
name=kwargs.get("name", self.__class__.__name__),
|
211
213
|
description=kwargs.get("description", self.__doc__ or ""),
|
212
214
|
version=kwargs.get("version", "1.0.0"),
|
@@ -214,7 +216,7 @@ class Node(ABC):
|
|
214
216
|
tags=kwargs.get("tags", set()),
|
215
217
|
),
|
216
218
|
)
|
217
|
-
self.logger = logging.getLogger(f"kailash.nodes.{self.
|
219
|
+
self.logger = logging.getLogger(f"kailash.nodes.{self._node_id}")
|
218
220
|
|
219
221
|
# Filter out internal fields from config with comprehensive parameter handling
|
220
222
|
# Get parameter definitions once and cache for both filtering and validation
|
@@ -232,11 +234,12 @@ class Node(ABC):
|
|
232
234
|
|
233
235
|
# Comprehensive parameter filtering: handle ALL potential conflicts
|
234
236
|
# Fields that are always internal (never user parameters)
|
235
|
-
always_internal = {"metadata"}
|
237
|
+
always_internal = {"metadata", "_node_id"}
|
236
238
|
|
237
239
|
# Fields that can be either internal or user parameters
|
240
|
+
# Note: 'id' removed from this list - users can now use 'id' freely
|
241
|
+
# since node identifier is now '_node_id'
|
238
242
|
potentially_user_params = {
|
239
|
-
"id",
|
240
243
|
"name",
|
241
244
|
"description",
|
242
245
|
"version",
|
@@ -344,6 +347,32 @@ class Node(ABC):
|
|
344
347
|
self._workflow_context = {}
|
345
348
|
self._workflow_context[key] = value
|
346
349
|
|
350
|
+
@property
|
351
|
+
def id(self) -> str:
|
352
|
+
"""
|
353
|
+
Backward compatibility property for node identifier.
|
354
|
+
|
355
|
+
Returns the node's identifier (_node_id). This property maintains
|
356
|
+
backward compatibility for code that accesses node.id.
|
357
|
+
|
358
|
+
The internal identifier is now _node_id to prevent namespace collision
|
359
|
+
with user's 'id' parameter.
|
360
|
+
"""
|
361
|
+
return self._node_id
|
362
|
+
|
363
|
+
@id.setter
|
364
|
+
def id(self, value: str):
|
365
|
+
"""
|
366
|
+
Setter for backward compatibility with code that sets node.id.
|
367
|
+
|
368
|
+
This allows graph.py and other code to set the node identifier
|
369
|
+
while internally using _node_id for namespace separation.
|
370
|
+
|
371
|
+
Args:
|
372
|
+
value: The node identifier to set
|
373
|
+
"""
|
374
|
+
self._node_id = value
|
375
|
+
|
347
376
|
@abstractmethod
|
348
377
|
def get_parameters(self) -> dict[str, NodeParameter]:
|
349
378
|
"""Define the parameters this node accepts.
|
kailash/nodes/code/python.py
CHANGED
@@ -412,8 +412,11 @@ class CodeExecutor:
|
|
412
412
|
# Check code safety first
|
413
413
|
is_safe, violations, imports_found = self.check_code_safety(code)
|
414
414
|
|
415
|
-
# Sanitize inputs
|
416
|
-
|
415
|
+
# Sanitize inputs with python_exec context
|
416
|
+
# Python code execution via exec() does not need shell metacharacter sanitization
|
417
|
+
sanitized_inputs = validate_node_parameters(
|
418
|
+
inputs, self.security_config, context="python_exec"
|
419
|
+
)
|
417
420
|
|
418
421
|
# Create isolated namespace
|
419
422
|
import builtins
|
@@ -572,8 +575,11 @@ class CodeExecutor:
|
|
572
575
|
Raises:
|
573
576
|
NodeExecutionError: If function execution fails
|
574
577
|
"""
|
575
|
-
# Sanitize inputs for security
|
576
|
-
|
578
|
+
# Sanitize inputs for security with python_exec context
|
579
|
+
# Python function execution does not need shell metacharacter sanitization
|
580
|
+
sanitized_inputs = validate_node_parameters(
|
581
|
+
inputs, self.security_config, context="python_exec"
|
582
|
+
)
|
577
583
|
|
578
584
|
try:
|
579
585
|
# Get function signature
|
kailash/nodes/data/async_sql.py
CHANGED
@@ -3255,8 +3255,17 @@ class AsyncSQLDatabaseNode(AsyncNode):
|
|
3255
3255
|
|
3256
3256
|
def _generate_pool_key(self) -> str:
|
3257
3257
|
"""Generate a unique key for connection pool sharing."""
|
3258
|
-
#
|
3258
|
+
# Get event loop ID for isolation
|
3259
|
+
try:
|
3260
|
+
loop = asyncio.get_running_loop()
|
3261
|
+
loop_id = str(id(loop))
|
3262
|
+
except RuntimeError:
|
3263
|
+
# No running loop (initialization phase)
|
3264
|
+
loop_id = "no_loop"
|
3265
|
+
|
3266
|
+
# Create a unique key based on event loop and connection parameters
|
3259
3267
|
key_parts = [
|
3268
|
+
loop_id, # Event loop isolation
|
3260
3269
|
self.config.get("database_type", ""),
|
3261
3270
|
self.config.get("connection_string", "")
|
3262
3271
|
or (
|
@@ -3295,16 +3304,30 @@ class AsyncSQLDatabaseNode(AsyncNode):
|
|
3295
3304
|
):
|
3296
3305
|
|
3297
3306
|
if self._pool_key in self._shared_pools:
|
3298
|
-
#
|
3307
|
+
# Validate pool's event loop is still running before reuse
|
3299
3308
|
adapter, ref_count = self._shared_pools[self._pool_key]
|
3300
|
-
|
3301
|
-
|
3302
|
-
|
3303
|
-
|
3304
|
-
|
3305
|
-
|
3306
|
-
|
3307
|
-
|
3309
|
+
|
3310
|
+
try:
|
3311
|
+
# Check if we have a running event loop
|
3312
|
+
pool_loop = asyncio.get_running_loop()
|
3313
|
+
# If we got here, loop is running - safe to reuse
|
3314
|
+
self._shared_pools[self._pool_key] = (
|
3315
|
+
adapter,
|
3316
|
+
ref_count + 1,
|
3317
|
+
)
|
3318
|
+
self._adapter = adapter
|
3319
|
+
self._connected = True
|
3320
|
+
logger.debug(
|
3321
|
+
f"Using class-level shared pool for {self.id}"
|
3322
|
+
)
|
3323
|
+
return self._adapter
|
3324
|
+
except RuntimeError:
|
3325
|
+
# Loop is closed - remove stale pool
|
3326
|
+
logger.warning(
|
3327
|
+
f"Removing stale pool for {self._pool_key} - event loop closed"
|
3328
|
+
)
|
3329
|
+
del self._shared_pools[self._pool_key]
|
3330
|
+
# Fall through to create new pool
|
3308
3331
|
|
3309
3332
|
# Create new shared pool
|
3310
3333
|
self._adapter = await self._create_adapter()
|
@@ -4000,8 +4023,51 @@ class AsyncSQLDatabaseNode(AsyncNode):
|
|
4000
4023
|
|
4001
4024
|
metrics["pools"].append(pool_info)
|
4002
4025
|
|
4026
|
+
# Clean up stale pools from closed event loops
|
4027
|
+
cleaned_pools = cls._cleanup_closed_loop_pools()
|
4028
|
+
if cleaned_pools > 0:
|
4029
|
+
metrics["cleaned_stale_pools"] = cleaned_pools
|
4030
|
+
|
4003
4031
|
return metrics
|
4004
4032
|
|
4033
|
+
@classmethod
|
4034
|
+
def _cleanup_closed_loop_pools(cls) -> int:
|
4035
|
+
"""
|
4036
|
+
Clean up pools from closed event loops.
|
4037
|
+
|
4038
|
+
Returns:
|
4039
|
+
Number of pools removed
|
4040
|
+
"""
|
4041
|
+
removed_count = 0
|
4042
|
+
keys_to_remove = []
|
4043
|
+
|
4044
|
+
for pool_key in list(cls._shared_pools.keys()):
|
4045
|
+
# Extract loop ID from pool key (first part before "|")
|
4046
|
+
parts = pool_key.split("|")
|
4047
|
+
if len(parts) > 0:
|
4048
|
+
loop_id_str = parts[0]
|
4049
|
+
|
4050
|
+
# Check if this pool's event loop is still running
|
4051
|
+
try:
|
4052
|
+
current_loop = asyncio.get_running_loop()
|
4053
|
+
current_loop_id = str(id(current_loop))
|
4054
|
+
|
4055
|
+
# If loop IDs don't match and pool is stale, mark for removal
|
4056
|
+
if loop_id_str != current_loop_id and loop_id_str != "no_loop":
|
4057
|
+
keys_to_remove.append(pool_key)
|
4058
|
+
except RuntimeError:
|
4059
|
+
# No current loop - mark old pools for removal
|
4060
|
+
if loop_id_str != "no_loop":
|
4061
|
+
keys_to_remove.append(pool_key)
|
4062
|
+
|
4063
|
+
# Remove stale pools
|
4064
|
+
for key in keys_to_remove:
|
4065
|
+
if key in cls._shared_pools:
|
4066
|
+
del cls._shared_pools[key]
|
4067
|
+
removed_count += 1
|
4068
|
+
|
4069
|
+
return removed_count
|
4070
|
+
|
4005
4071
|
@classmethod
|
4006
4072
|
async def clear_shared_pools(cls) -> None:
|
4007
4073
|
"""Clear all shared connection pools. Use with caution!"""
|
kailash/security.py
CHANGED
@@ -475,6 +475,7 @@ def sanitize_input(
|
|
475
475
|
max_length: int = 10000,
|
476
476
|
allowed_types: list[type] | None = None,
|
477
477
|
config: SecurityConfig | None = None,
|
478
|
+
context: str = "generic",
|
478
479
|
) -> Any:
|
479
480
|
"""
|
480
481
|
Sanitize input values to prevent injection attacks.
|
@@ -484,12 +485,22 @@ def sanitize_input(
|
|
484
485
|
max_length: Maximum string length
|
485
486
|
allowed_types: List of allowed types
|
486
487
|
config: Security configuration
|
488
|
+
context: Execution context for context-aware sanitization.
|
489
|
+
- "generic": Default moderate sanitization (backward compatible)
|
490
|
+
- "python_exec": Python code execution (preserves shell metacharacters)
|
491
|
+
- "shell_exec": Shell command execution (removes all dangerous characters)
|
487
492
|
|
488
493
|
Returns:
|
489
494
|
Sanitized value
|
490
495
|
|
491
496
|
Raises:
|
492
497
|
SecurityError: If input fails validation
|
498
|
+
|
499
|
+
Note:
|
500
|
+
The context parameter allows for appropriate security measures based on
|
501
|
+
how the data will be used. Python code execution via exec() does not
|
502
|
+
need shell metacharacter sanitization since characters like $, ;, &, |
|
503
|
+
are regular Python string characters and not executed by a shell.
|
493
504
|
"""
|
494
505
|
if config is None:
|
495
506
|
config = get_security_config()
|
@@ -751,32 +762,59 @@ def sanitize_input(
|
|
751
762
|
if len(value) > max_length:
|
752
763
|
raise SecurityError(f"Input too long: {len(value)} > {max_length}")
|
753
764
|
|
754
|
-
#
|
755
|
-
|
756
|
-
|
757
|
-
|
758
|
-
|
759
|
-
|
760
|
-
|
765
|
+
# Context-aware sanitization
|
766
|
+
if context == "python_exec":
|
767
|
+
# Python execution context: Only remove XSS patterns, preserve shell metacharacters
|
768
|
+
# Python exec() does not execute shell commands, so $, ;, &, |, `, (, ) are safe
|
769
|
+
sanitized = re.sub(
|
770
|
+
r"<script.*?</script>", "", value, flags=re.IGNORECASE | re.DOTALL
|
771
|
+
)
|
772
|
+
sanitized = re.sub(r"javascript:", "", sanitized, flags=re.IGNORECASE)
|
773
|
+
# Remove only the most dangerous HTML tags for XSS prevention
|
774
|
+
sanitized = re.sub(
|
775
|
+
r"</?(?:script|iframe|object|embed).*?>",
|
776
|
+
"",
|
777
|
+
sanitized,
|
778
|
+
flags=re.IGNORECASE,
|
779
|
+
)
|
780
|
+
elif context == "shell_exec":
|
781
|
+
# Shell execution context: Remove all shell metacharacters
|
782
|
+
sanitized = re.sub(r"[<>;&|`$()]", "", value)
|
783
|
+
sanitized = re.sub(
|
784
|
+
r"<script.*?</script>", "", sanitized, flags=re.IGNORECASE | re.DOTALL
|
785
|
+
)
|
786
|
+
sanitized = re.sub(r"javascript:", "", sanitized, flags=re.IGNORECASE)
|
787
|
+
else:
|
788
|
+
# Generic context: Moderate sanitization (backward compatible)
|
789
|
+
# Remove only basic XSS patterns, preserve most characters
|
790
|
+
sanitized = re.sub(
|
791
|
+
r"<script.*?</script>", "", value, flags=re.IGNORECASE | re.DOTALL
|
792
|
+
)
|
793
|
+
sanitized = re.sub(r"javascript:", "", sanitized, flags=re.IGNORECASE)
|
794
|
+
# Remove angle brackets for basic XSS protection
|
795
|
+
sanitized = re.sub(r"[<>]", "", sanitized)
|
761
796
|
|
762
797
|
if sanitized != value and config.enable_audit_logging:
|
763
|
-
logger.warning(
|
798
|
+
logger.warning(
|
799
|
+
f"Input sanitized ({context}): {value[:50]}... -> {sanitized[:50]}..."
|
800
|
+
)
|
764
801
|
|
765
802
|
return sanitized
|
766
803
|
|
767
804
|
# Dictionary sanitization (recursive)
|
768
805
|
if isinstance(value, dict):
|
769
806
|
return {
|
770
|
-
sanitize_input(
|
771
|
-
|
772
|
-
)
|
807
|
+
sanitize_input(
|
808
|
+
k, max_length, allowed_types, config, context
|
809
|
+
): sanitize_input(v, max_length, allowed_types, config, context)
|
773
810
|
for k, v in value.items()
|
774
811
|
}
|
775
812
|
|
776
813
|
# List sanitization (recursive)
|
777
814
|
if isinstance(value, list):
|
778
815
|
return [
|
779
|
-
sanitize_input(item, max_length, allowed_types, config)
|
816
|
+
sanitize_input(item, max_length, allowed_types, config, context)
|
817
|
+
for item in value
|
780
818
|
]
|
781
819
|
|
782
820
|
return value
|
@@ -811,7 +849,9 @@ def create_secure_temp_dir(
|
|
811
849
|
|
812
850
|
|
813
851
|
def validate_node_parameters(
|
814
|
-
parameters: dict[str, Any],
|
852
|
+
parameters: dict[str, Any],
|
853
|
+
config: SecurityConfig | None = None,
|
854
|
+
context: str = "generic",
|
815
855
|
) -> dict[str, Any]:
|
816
856
|
"""
|
817
857
|
Validate and sanitize node parameters.
|
@@ -819,6 +859,10 @@ def validate_node_parameters(
|
|
819
859
|
Args:
|
820
860
|
parameters: Node parameters to validate
|
821
861
|
config: Security configuration
|
862
|
+
context: Execution context for context-aware sanitization
|
863
|
+
- "generic": Default moderate sanitization
|
864
|
+
- "python_exec": Python code execution (preserves shell metacharacters)
|
865
|
+
- "shell_exec": Shell command execution (removes all dangerous characters)
|
822
866
|
|
823
867
|
Returns:
|
824
868
|
Validated and sanitized parameters
|
@@ -833,20 +877,22 @@ def validate_node_parameters(
|
|
833
877
|
|
834
878
|
for key, value in parameters.items():
|
835
879
|
# Sanitize parameter key
|
836
|
-
clean_key = sanitize_input(key, config=config)
|
880
|
+
clean_key = sanitize_input(key, config=config, context=context)
|
837
881
|
|
838
882
|
# Special handling for file paths
|
839
883
|
if "path" in key.lower() or "file" in key.lower():
|
840
884
|
if isinstance(value, (str, Path)):
|
841
885
|
validated_value = validate_file_path(value, config, f"parameter {key}")
|
842
886
|
else:
|
843
|
-
validated_value = sanitize_input(value, config=config)
|
887
|
+
validated_value = sanitize_input(value, config=config, context=context)
|
844
888
|
else:
|
845
|
-
validated_value = sanitize_input(value, config=config)
|
889
|
+
validated_value = sanitize_input(value, config=config, context=context)
|
846
890
|
|
847
891
|
validated_params[clean_key] = validated_value
|
848
892
|
|
849
893
|
if config.enable_audit_logging:
|
850
|
-
logger.info(
|
894
|
+
logger.info(
|
895
|
+
f"Node parameters validated ({context}): {list(validated_params.keys())}"
|
896
|
+
)
|
851
897
|
|
852
898
|
return validated_params
|
kailash/workflow/graph.py
CHANGED
@@ -154,19 +154,19 @@ class Workflow:
|
|
154
154
|
|
155
155
|
try:
|
156
156
|
# Handle different constructor patterns
|
157
|
-
if "name" in params and "
|
157
|
+
if "name" in params and "_node_id" not in params:
|
158
158
|
# Node expects 'name' parameter (like PythonCodeNode)
|
159
159
|
if "name" not in config:
|
160
160
|
config = config.copy() # Don't modify original
|
161
161
|
config["name"] = node_id
|
162
162
|
return node_class(**config)
|
163
|
-
elif "
|
164
|
-
# Node expects '
|
165
|
-
return node_class(
|
163
|
+
elif "_node_id" in params:
|
164
|
+
# Node expects '_node_id' parameter (namespace-separated metadata)
|
165
|
+
return node_class(_node_id=node_id, **config)
|
166
166
|
else:
|
167
167
|
# Fallback: try both patterns
|
168
168
|
try:
|
169
|
-
return node_class(
|
169
|
+
return node_class(_node_id=node_id, **config)
|
170
170
|
except TypeError:
|
171
171
|
# Try with name parameter
|
172
172
|
config = config.copy()
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: kailash
|
3
|
-
Version: 0.9.
|
3
|
+
Version: 0.9.22
|
4
4
|
Summary: Python SDK for the Kailash container-node architecture
|
5
5
|
Home-page: https://github.com/integrum/kailash-python-sdk
|
6
6
|
Author: Integrum
|
@@ -122,11 +122,18 @@ Dynamic: requires-python
|
|
122
122
|
|
123
123
|
---
|
124
124
|
|
125
|
-
## 🔥 Latest Release: v0.9.
|
125
|
+
## 🔥 Latest Release: v0.9.21 (October 8, 2025)
|
126
126
|
|
127
|
-
**
|
127
|
+
**AsyncSQL Event Loop Isolation Fix**
|
128
128
|
|
129
129
|
### 🐛 Critical Bug Fixes
|
130
|
+
- **Event Loop Isolation**: Fixed "Event loop is closed" errors in AsyncSQLDatabaseNode
|
131
|
+
- Automatic connection pool isolation per event loop
|
132
|
+
- Prevents pool sharing across different event loops (FastAPI, sequential workflows)
|
133
|
+
- Backward compatible - no code changes required
|
134
|
+
- <5% performance overhead
|
135
|
+
|
136
|
+
### Previous Release: v0.9.20 (October 6, 2025)
|
130
137
|
- **Mock Provider Bypass**: Removed hardcoded `if provider == "mock"` logic from LLMAgentNode
|
131
138
|
- **Tool Execution Flow**: Unified provider response generation for all providers
|
132
139
|
- **Provider Registry**: All providers now use consistent registry path
|
@@ -1,10 +1,10 @@
|
|
1
|
-
kailash/__init__.py,sha256=
|
1
|
+
kailash/__init__.py,sha256=ZtUDcORH0Y5VBrpecs4mSSBKdt_N-ylzx-vJMSoj3sA,2928
|
2
2
|
kailash/__main__.py,sha256=vr7TVE5o16V6LsTmRFKG6RDKUXHpIWYdZ6Dok2HkHnI,198
|
3
3
|
kailash/access_control.py,sha256=MjKtkoQ2sg1Mgfe7ovGxVwhAbpJKvaepPWr8dxOueMA,26058
|
4
4
|
kailash/access_control_abac.py,sha256=FPfa_8PuDP3AxTjdWfiH3ntwWO8NodA0py9W8SE5dno,30263
|
5
5
|
kailash/manifest.py,sha256=qzOmeMGWz20Sp4IJitSH9gTVbGng7hlimc96VTW4KI8,24814
|
6
6
|
kailash/sdk_exceptions.py,sha256=MeFNmFzDzs5g9PveymivIBp1vN6PI7eenGv-Dj62Gog,10774
|
7
|
-
kailash/security.py,sha256=
|
7
|
+
kailash/security.py,sha256=O6snw6rKFWntKN_W1QqYxxfI3HHm2Qd-2VRvrC7LU6s,29634
|
8
8
|
kailash/access_control/__init__.py,sha256=ykR_zGJXQoCU_4gGNFbYzhVdUemxeAAoDQLhKIPVBGE,4018
|
9
9
|
kailash/access_control/managers.py,sha256=Vg2inaZqR2GBXsySvPZcEqQtFHgF94z7A_wUHMtA3qA,14431
|
10
10
|
kailash/access_control/rule_evaluators.py,sha256=niguhjThBjA0jIXvdKdGAXzdSM_bAd0ebphGgRrDFKU,15337
|
@@ -18,7 +18,8 @@ kailash/api/custom_nodes_secure.py,sha256=cMh1FbEtUAEoLmTb5ew_CeX5kjikBzmBsat1GO
|
|
18
18
|
kailash/api/gateway.py,sha256=BVEKyC53JRnBkOyg3YXAPGDzLm3zon0vbO1xiM6yNxU,12540
|
19
19
|
kailash/api/mcp_integration.py,sha256=xY5VjYXh4bFuNyyWBXEuTm4jjwUn8_9QxZpa4hh6a0Q,14521
|
20
20
|
kailash/api/studio.py,sha256=8Gj3R3p_-EpJerkWYLyrqVzJprKOQmTmK-1EgiLoXMc,34892
|
21
|
-
kailash/api/workflow_api.py,sha256=
|
21
|
+
kailash/api/workflow_api.py,sha256=xRbvqKH-pVbZeV2NqT3pfP1ykXqX38BvT5uheeyw3Hg,15805
|
22
|
+
kailash/api/tests/test_workflow_api_404.py,sha256=NDE-hu0pSCnn81Sbd6zHDF6ByEPXfO7WuwJnhkAZG6w,6301
|
22
23
|
kailash/channels/__init__.py,sha256=9YpGEpFbNgZ4Fp8fG3F4hEX9X94UR-xbHAQM6b0O1z8,642
|
23
24
|
kailash/channels/api_channel.py,sha256=stuJxNJRUobzj1CavwsHWVZWr2kYqyVLjv_ynt6Wy6A,14541
|
24
25
|
kailash/channels/base.py,sha256=8-cUYAVMlO67BMZCZscShy0FPYAK31s9bxk0JXA9wIM,7825
|
@@ -157,7 +158,7 @@ kailash/monitoring/asyncsql_metrics.py,sha256=jj9M8D5qHoS3zEFfZYsUCWsy5kb-J5-iYV
|
|
157
158
|
kailash/monitoring/metrics.py,sha256=SiAnL3o6K0QaJHgfAuWBa-0pTkW5zymhuPEsj4bgOgM,22022
|
158
159
|
kailash/nodes/__init__.py,sha256=dBnEwrop0cPblHxSOtVWAKCDzhRtcyQVv9j_YGWxczQ,6410
|
159
160
|
kailash/nodes/__init___original.py,sha256=p2KSo0dyUBCLClU123qpQ0tyv5S_36PTxosNyW58nyY,1031
|
160
|
-
kailash/nodes/base.py,sha256=
|
161
|
+
kailash/nodes/base.py,sha256=gDAajw-T7ktbUYfruzfGtoXytT7YF2Cw-3iVkNoox-Y,86550
|
161
162
|
kailash/nodes/base_async.py,sha256=whxepCiVplrltfzEQuabmnGCpEV5WgfqwgxbLdCyiDk,8864
|
162
163
|
kailash/nodes/base_cycle_aware.py,sha256=Xpze9xZzLepWeLpi9Y3tMn1dm2LVv-omr5TSQuGTtWo,13377
|
163
164
|
kailash/nodes/base_with_acl.py,sha256=ZfrkLPgrEBcNbG0LKvtq6glDxyOYOMRw3VXX4vWX6bI,11852
|
@@ -213,13 +214,13 @@ kailash/nodes/cache/cache_invalidation.py,sha256=IUvxrRj3K5EF29Z2EaKl7t6Uze_cssn
|
|
213
214
|
kailash/nodes/cache/redis_pool_manager.py,sha256=GR82GCWxo_gAzRE-091OB6AhKre8CTwM3OoePLb2gvE,21574
|
214
215
|
kailash/nodes/code/__init__.py,sha256=yhEwuMjUEPFfe6hMGMd4E4gZdLUuf2JEQ7knYapiM4o,1283
|
215
216
|
kailash/nodes/code/async_python.py,sha256=Ai-iMpmz-sAori73JBk0wZtqmwtmF2GNPDxqB04I2Ck,37058
|
216
|
-
kailash/nodes/code/python.py,sha256=
|
217
|
+
kailash/nodes/code/python.py,sha256=RbZTXQMNnXXROXKkDYhvKPyYO_6nZ7tJ4EVOeoiABuc,70537
|
217
218
|
kailash/nodes/compliance/__init__.py,sha256=6a_FL4ofc8MAVuZ-ARW5uYenZLS4mBFVM9AI2QsnoF8,214
|
218
219
|
kailash/nodes/compliance/data_retention.py,sha256=90bH_eGwlcDzUdklAJeXQM-RcuLUGQFQ5fgHOK8a4qk,69443
|
219
220
|
kailash/nodes/compliance/gdpr.py,sha256=ZMoHZjAo4QtGwtFCzGMrAUBFV3TbZOnJ5DZGZS87Bas,70548
|
220
221
|
kailash/nodes/data/__init__.py,sha256=f0h4ysvXxlyFcNJLvDyXrgJ0ixwDF1cS0pJ2QNPakhg,5213
|
221
222
|
kailash/nodes/data/async_connection.py,sha256=wfArHs9svU48bxGZIiixSV2YVn9cukNgEjagwTRu6J4,17250
|
222
|
-
kailash/nodes/data/async_sql.py,sha256=
|
223
|
+
kailash/nodes/data/async_sql.py,sha256=34jUqCiUaNMVynCSteTIT8mZUs2SanZoKdsyyyZf51E,191567
|
223
224
|
kailash/nodes/data/async_vector.py,sha256=HtwQLO25IXu8Vq80qzU8rMkUAKPQ2qM0x8YxjXHlygU,21005
|
224
225
|
kailash/nodes/data/bulk_operations.py,sha256=WVopmosVkIlweFxVt3boLdCPc93EqpYyQ1Ez9mCIt0c,34453
|
225
226
|
kailash/nodes/data/directory.py,sha256=fbfLqD_ijRubk-4xew3604QntPsyDxqaF4k6TpfyjDg,9923
|
@@ -410,7 +411,7 @@ kailash/workflow/cycle_profiler.py,sha256=aEWSCm0Xy15SjgLTpPooVJMzpFhtJWt4livR-3
|
|
410
411
|
kailash/workflow/cycle_state.py,sha256=hzRUvciRreWfS56Cf7ZLQPit_mlPTQDoNTawh8yi-2s,10747
|
411
412
|
kailash/workflow/cyclic_runner.py,sha256=Zo2Rakp-N5pPSarndYPdkkpWgY9yHpYrrpUh-znoCKQ,71246
|
412
413
|
kailash/workflow/edge_infrastructure.py,sha256=lQDzs0-KdoCMqI4KAXAGbhHbwadM6t-ffJEWLlRuSNo,12448
|
413
|
-
kailash/workflow/graph.py,sha256=
|
414
|
+
kailash/workflow/graph.py,sha256=MhTHRwgsI6KvuLbchsoR10YKGEwCgbQot6t5-RLJdv8,59311
|
414
415
|
kailash/workflow/input_handling.py,sha256=HrW--AmelYC8F18nkfmYlF_wXycA24RuNbDRjvM8rqk,6561
|
415
416
|
kailash/workflow/mermaid_visualizer.py,sha256=CAbLWSL9H-QeRlPNpPOJ3uxIBdcBEtx_KHHrrCdReCU,22290
|
416
417
|
kailash/workflow/migration.py,sha256=6WIGP5Y9kGgb0_3nLXVo8TPtuzsk66eoTSalWt7st_0,31463
|
@@ -423,10 +424,10 @@ kailash/workflow/templates.py,sha256=aZQzEPQD368nN0x0ICQlRKmAr2FqTxIOUa-7rb7EUWI
|
|
423
424
|
kailash/workflow/type_inference.py,sha256=i1F7Yd_Z3elTXrthsLpqGbOnQBIVVVEjhRpI0HrIjd0,24492
|
424
425
|
kailash/workflow/validation.py,sha256=LdbIPQSokCqSLfWTBhJR82pa_0va44pcVu9dpEM4rvY,45177
|
425
426
|
kailash/workflow/visualization.py,sha256=nHBW-Ai8QBMZtn2Nf3EE1_aiMGi9S6Ui_BfpA5KbJPU,23187
|
426
|
-
kailash-0.9.
|
427
|
-
kailash-0.9.
|
428
|
-
kailash-0.9.
|
429
|
-
kailash-0.9.
|
430
|
-
kailash-0.9.
|
431
|
-
kailash-0.9.
|
432
|
-
kailash-0.9.
|
427
|
+
kailash-0.9.22.dist-info/licenses/LICENSE,sha256=9GYZHXVUmx6FdFRNzOeE_w7a_aEGeYbqTVmFtJlrbGk,13438
|
428
|
+
kailash-0.9.22.dist-info/licenses/NOTICE,sha256=9ssIK4LcHSTFqriXGdteMpBPTS1rSLlYtjppZ_bsjZ0,723
|
429
|
+
kailash-0.9.22.dist-info/METADATA,sha256=bsANyjL_bARSOE93lZ0J9ZjeXPsP7kQ28pSl6zFu8hQ,24027
|
430
|
+
kailash-0.9.22.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
431
|
+
kailash-0.9.22.dist-info/entry_points.txt,sha256=M_q3b8PG5W4XbhSgESzIJjh3_4OBKtZFYFsOdkr2vO4,45
|
432
|
+
kailash-0.9.22.dist-info/top_level.txt,sha256=z7GzH2mxl66498pVf5HKwo5wwfPtt9Aq95uZUpH6JV0,8
|
433
|
+
kailash-0.9.22.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|