prompture 0.0.50.dev1__py3-none-any.whl → 0.0.51.dev1__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.
- prompture/__init__.py +62 -0
- prompture/_version.py +2 -2
- prompture/agent.py +61 -2
- prompture/agent_types.py +98 -0
- prompture/analysis/__init__.py +19 -0
- prompture/analysis/analyzer.py +142 -0
- prompture/analysis/ast_visitors.py +302 -0
- prompture/analysis/risk_scoring.py +219 -0
- prompture/history.py +299 -0
- prompture/sandbox/__init__.py +31 -0
- prompture/sandbox/exceptions.py +54 -0
- prompture/sandbox/resource_limits.py +128 -0
- prompture/sandbox/restrictions.py +292 -0
- prompture/sandbox/sandbox.py +406 -0
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/METADATA +1 -1
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/RECORD +20 -10
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/WHEEL +0 -0
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/entry_points.txt +0 -0
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/licenses/LICENSE +0 -0
- {prompture-0.0.50.dev1.dist-info → prompture-0.0.51.dev1.dist-info}/top_level.txt +0 -0
prompture/__init__.py
CHANGED
|
@@ -8,6 +8,7 @@ from .agent_types import (
|
|
|
8
8
|
AgentResult,
|
|
9
9
|
AgentState,
|
|
10
10
|
AgentStep,
|
|
11
|
+
ApprovalRequired,
|
|
11
12
|
GuardrailError,
|
|
12
13
|
ModelRetry,
|
|
13
14
|
RunContext,
|
|
@@ -15,6 +16,15 @@ from .agent_types import (
|
|
|
15
16
|
StreamEvent,
|
|
16
17
|
StreamEventType,
|
|
17
18
|
)
|
|
19
|
+
from .analysis import (
|
|
20
|
+
CodeAnalysis,
|
|
21
|
+
CodeFeatures,
|
|
22
|
+
FeatureExtractor,
|
|
23
|
+
RiskAssessment,
|
|
24
|
+
RiskLevel,
|
|
25
|
+
analyze_python,
|
|
26
|
+
calculate_risk,
|
|
27
|
+
)
|
|
18
28
|
from .async_agent import AsyncAgent, AsyncAgentIterator, AsyncStreamedAgentResult
|
|
19
29
|
from .async_conversation import AsyncConversation
|
|
20
30
|
from .async_driver import AsyncDriver
|
|
@@ -106,6 +116,14 @@ from .groups import (
|
|
|
106
116
|
RouterAgent,
|
|
107
117
|
SequentialGroup,
|
|
108
118
|
)
|
|
119
|
+
from .history import (
|
|
120
|
+
calculate_cost_breakdown,
|
|
121
|
+
export_result_json,
|
|
122
|
+
filter_steps,
|
|
123
|
+
get_tool_call_summary,
|
|
124
|
+
result_to_dict,
|
|
125
|
+
search_messages,
|
|
126
|
+
)
|
|
109
127
|
from .image import (
|
|
110
128
|
ImageContent,
|
|
111
129
|
ImageInput,
|
|
@@ -141,6 +159,20 @@ from .persona import (
|
|
|
141
159
|
reset_trait_registry,
|
|
142
160
|
)
|
|
143
161
|
from .runner import run_suite_from_spec
|
|
162
|
+
from .sandbox import (
|
|
163
|
+
ALWAYS_BLOCKED_IMPORTS,
|
|
164
|
+
ImportRestrictions,
|
|
165
|
+
ImportViolationError,
|
|
166
|
+
PathRestrictions,
|
|
167
|
+
PathViolationError,
|
|
168
|
+
PythonSandbox,
|
|
169
|
+
ResourceContext,
|
|
170
|
+
ResourceLimitError,
|
|
171
|
+
ResourceLimits,
|
|
172
|
+
SandboxError,
|
|
173
|
+
SandboxResult,
|
|
174
|
+
SandboxTimeoutError,
|
|
175
|
+
)
|
|
144
176
|
from .serialization import (
|
|
145
177
|
EXPORT_VERSION,
|
|
146
178
|
export_conversation,
|
|
@@ -183,6 +215,9 @@ except Exception:
|
|
|
183
215
|
__version__ = "0.0.0"
|
|
184
216
|
|
|
185
217
|
__all__ = [
|
|
218
|
+
# Sandbox module
|
|
219
|
+
"ALWAYS_BLOCKED_IMPORTS",
|
|
220
|
+
# Core exports
|
|
186
221
|
"EXPORT_VERSION",
|
|
187
222
|
"FIELD_DEFINITIONS",
|
|
188
223
|
"PERSONAS",
|
|
@@ -194,6 +229,7 @@ __all__ = [
|
|
|
194
229
|
"AgentState",
|
|
195
230
|
"AgentStep",
|
|
196
231
|
"AirLLMDriver",
|
|
232
|
+
"ApprovalRequired",
|
|
197
233
|
"AsyncAgent",
|
|
198
234
|
"AsyncAgentIterator",
|
|
199
235
|
"AsyncConversation",
|
|
@@ -205,11 +241,15 @@ __all__ = [
|
|
|
205
241
|
"AzureDriver",
|
|
206
242
|
"CacheBackend",
|
|
207
243
|
"ClaudeDriver",
|
|
244
|
+
# Analysis module
|
|
245
|
+
"CodeAnalysis",
|
|
246
|
+
"CodeFeatures",
|
|
208
247
|
"Conversation",
|
|
209
248
|
"ConversationStore",
|
|
210
249
|
"Driver",
|
|
211
250
|
"DriverCallbacks",
|
|
212
251
|
"ErrorPolicy",
|
|
252
|
+
"FeatureExtractor",
|
|
213
253
|
"GoogleDriver",
|
|
214
254
|
"GrokDriver",
|
|
215
255
|
"GroqDriver",
|
|
@@ -220,6 +260,8 @@ __all__ = [
|
|
|
220
260
|
"GuardrailError",
|
|
221
261
|
"ImageContent",
|
|
222
262
|
"ImageInput",
|
|
263
|
+
"ImportRestrictions",
|
|
264
|
+
"ImportViolationError",
|
|
223
265
|
"JSONFormatter",
|
|
224
266
|
"LMStudioDriver",
|
|
225
267
|
"LocalHTTPDriver",
|
|
@@ -232,12 +274,23 @@ __all__ = [
|
|
|
232
274
|
"OpenAIDriver",
|
|
233
275
|
"OpenRouterDriver",
|
|
234
276
|
"ParallelGroup",
|
|
277
|
+
"PathRestrictions",
|
|
278
|
+
"PathViolationError",
|
|
235
279
|
"Persona",
|
|
280
|
+
"PythonSandbox",
|
|
236
281
|
"RedisCacheBackend",
|
|
282
|
+
"ResourceContext",
|
|
283
|
+
"ResourceLimitError",
|
|
284
|
+
"ResourceLimits",
|
|
237
285
|
"ResponseCache",
|
|
286
|
+
"RiskAssessment",
|
|
287
|
+
"RiskLevel",
|
|
238
288
|
"RouterAgent",
|
|
239
289
|
"RunContext",
|
|
240
290
|
"SQLiteCacheBackend",
|
|
291
|
+
"SandboxError",
|
|
292
|
+
"SandboxResult",
|
|
293
|
+
"SandboxTimeoutError",
|
|
241
294
|
"SequentialGroup",
|
|
242
295
|
"StepType",
|
|
243
296
|
"StreamEvent",
|
|
@@ -248,7 +301,11 @@ __all__ = [
|
|
|
248
301
|
"UsageSession",
|
|
249
302
|
"add_field_definition",
|
|
250
303
|
"add_field_definitions",
|
|
304
|
+
"analyze_python",
|
|
251
305
|
"ask_for_json",
|
|
306
|
+
# History module
|
|
307
|
+
"calculate_cost_breakdown",
|
|
308
|
+
"calculate_risk",
|
|
252
309
|
"clean_json_text",
|
|
253
310
|
"clean_json_text_with_ai",
|
|
254
311
|
"clean_toon_text",
|
|
@@ -258,12 +315,14 @@ __all__ = [
|
|
|
258
315
|
"configure_cache",
|
|
259
316
|
"configure_logging",
|
|
260
317
|
"export_conversation",
|
|
318
|
+
"export_result_json",
|
|
261
319
|
"export_usage_session",
|
|
262
320
|
"extract_and_jsonify",
|
|
263
321
|
"extract_from_data",
|
|
264
322
|
"extract_from_pandas",
|
|
265
323
|
"extract_with_model",
|
|
266
324
|
"field_from_registry",
|
|
325
|
+
"filter_steps",
|
|
267
326
|
"get_available_models",
|
|
268
327
|
"get_cache",
|
|
269
328
|
"get_driver",
|
|
@@ -279,6 +338,7 @@ __all__ = [
|
|
|
279
338
|
"get_recently_used_models",
|
|
280
339
|
"get_registry_snapshot",
|
|
281
340
|
"get_required_fields",
|
|
341
|
+
"get_tool_call_summary",
|
|
282
342
|
"get_trait",
|
|
283
343
|
"get_trait_names",
|
|
284
344
|
"image_from_base64",
|
|
@@ -307,7 +367,9 @@ __all__ = [
|
|
|
307
367
|
"reset_persona_registry",
|
|
308
368
|
"reset_registry",
|
|
309
369
|
"reset_trait_registry",
|
|
370
|
+
"result_to_dict",
|
|
310
371
|
"run_suite_from_spec",
|
|
372
|
+
"search_messages",
|
|
311
373
|
"set_azure_config_resolver",
|
|
312
374
|
"stepwise_extract_with_model",
|
|
313
375
|
"tool_from_function",
|
prompture/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 0,
|
|
31
|
+
__version__ = version = '0.0.51.dev1'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 51, 'dev1')
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
prompture/agent.py
CHANGED
|
@@ -30,6 +30,7 @@ from .agent_types import (
|
|
|
30
30
|
AgentResult,
|
|
31
31
|
AgentState,
|
|
32
32
|
AgentStep,
|
|
33
|
+
ApprovalRequired,
|
|
33
34
|
DepsType,
|
|
34
35
|
ModelRetry,
|
|
35
36
|
RunContext,
|
|
@@ -338,6 +339,26 @@ class Agent(Generic[DepsType]):
|
|
|
338
339
|
result = _fn(ctx, **kwargs)
|
|
339
340
|
else:
|
|
340
341
|
result = _fn(**kwargs)
|
|
342
|
+
except ApprovalRequired as exc:
|
|
343
|
+
# Handle approval request
|
|
344
|
+
if _cb.on_approval_needed:
|
|
345
|
+
approved = _cb.on_approval_needed(exc.tool_name, exc.action, exc.details)
|
|
346
|
+
if approved:
|
|
347
|
+
# Retry the tool call after approval
|
|
348
|
+
try:
|
|
349
|
+
if _wants:
|
|
350
|
+
result = _fn(ctx, **kwargs)
|
|
351
|
+
else:
|
|
352
|
+
result = _fn(**kwargs)
|
|
353
|
+
except ApprovalRequired:
|
|
354
|
+
# Tool raised ApprovalRequired again - don't loop
|
|
355
|
+
result = f"Error: Tool '{_name}' requires approval but approval was already granted"
|
|
356
|
+
except ModelRetry as retry_exc:
|
|
357
|
+
result = f"Error: {retry_exc.message}"
|
|
358
|
+
else:
|
|
359
|
+
result = f"Error: Tool '{_name}' execution denied - approval required: {exc.action}"
|
|
360
|
+
else:
|
|
361
|
+
result = f"Error: Tool '{_name}' requires approval but no approval handler is configured"
|
|
341
362
|
except ModelRetry as exc:
|
|
342
363
|
result = f"Error: {exc.message}"
|
|
343
364
|
if _cb.on_tool_end:
|
|
@@ -607,12 +628,28 @@ class Agent(Generic[DepsType]):
|
|
|
607
628
|
all_tool_calls: list[dict[str, Any]],
|
|
608
629
|
) -> None:
|
|
609
630
|
"""Scan conversation messages and populate steps and tool_calls."""
|
|
631
|
+
|
|
610
632
|
now = time.time()
|
|
611
633
|
|
|
612
634
|
for msg in messages:
|
|
613
635
|
role = msg.get("role", "")
|
|
614
636
|
|
|
615
637
|
if role == "assistant":
|
|
638
|
+
content = msg.get("content", "") or ""
|
|
639
|
+
|
|
640
|
+
# Extract thinking content from <think> tags
|
|
641
|
+
thinking_text = self._extract_thinking(content)
|
|
642
|
+
if thinking_text and self._agent_callbacks.on_thinking:
|
|
643
|
+
self._agent_callbacks.on_thinking(thinking_text)
|
|
644
|
+
# Also record as a think step
|
|
645
|
+
steps.append(
|
|
646
|
+
AgentStep(
|
|
647
|
+
step_type=StepType.think,
|
|
648
|
+
timestamp=now,
|
|
649
|
+
content=thinking_text,
|
|
650
|
+
)
|
|
651
|
+
)
|
|
652
|
+
|
|
616
653
|
tc_list = msg.get("tool_calls", [])
|
|
617
654
|
if tc_list:
|
|
618
655
|
# Assistant message with tool calls
|
|
@@ -632,7 +669,7 @@ class Agent(Generic[DepsType]):
|
|
|
632
669
|
AgentStep(
|
|
633
670
|
step_type=StepType.tool_call,
|
|
634
671
|
timestamp=now,
|
|
635
|
-
content=
|
|
672
|
+
content=content,
|
|
636
673
|
tool_name=name,
|
|
637
674
|
tool_args=args,
|
|
638
675
|
)
|
|
@@ -644,7 +681,7 @@ class Agent(Generic[DepsType]):
|
|
|
644
681
|
AgentStep(
|
|
645
682
|
step_type=StepType.output,
|
|
646
683
|
timestamp=now,
|
|
647
|
-
content=
|
|
684
|
+
content=content,
|
|
648
685
|
)
|
|
649
686
|
)
|
|
650
687
|
|
|
@@ -658,6 +695,28 @@ class Agent(Generic[DepsType]):
|
|
|
658
695
|
)
|
|
659
696
|
)
|
|
660
697
|
|
|
698
|
+
def _extract_thinking(self, content: str) -> str | None:
|
|
699
|
+
"""Extract thinking content from <think> tags.
|
|
700
|
+
|
|
701
|
+
Some models (like DeepSeek, Qwen) emit chain-of-thought reasoning
|
|
702
|
+
within <think>...</think> tags. This method extracts that content.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
content: The assistant message content.
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
The thinking text if found, None otherwise.
|
|
709
|
+
"""
|
|
710
|
+
import re
|
|
711
|
+
|
|
712
|
+
# Match <think>...</think> tags (case-insensitive, allows multiline)
|
|
713
|
+
pattern = r"<think>(.*?)</think>"
|
|
714
|
+
matches = re.findall(pattern, content, re.DOTALL | re.IGNORECASE)
|
|
715
|
+
if matches:
|
|
716
|
+
# Join multiple thinking blocks with newlines
|
|
717
|
+
return "\n".join(match.strip() for match in matches)
|
|
718
|
+
return None
|
|
719
|
+
|
|
661
720
|
def _parse_output(
|
|
662
721
|
self,
|
|
663
722
|
conv: Conversation,
|
prompture/agent_types.py
CHANGED
|
@@ -6,8 +6,10 @@ Defines enums, dataclasses, and exceptions used by :class:`~prompture.agent.Agen
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import enum
|
|
9
|
+
import json
|
|
9
10
|
from collections.abc import Callable
|
|
10
11
|
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
11
13
|
from typing import Any, Generic, TypeVar
|
|
12
14
|
|
|
13
15
|
DepsType = TypeVar("DepsType")
|
|
@@ -51,6 +53,33 @@ class GuardrailError(Exception):
|
|
|
51
53
|
super().__init__(message)
|
|
52
54
|
|
|
53
55
|
|
|
56
|
+
class ApprovalRequired(Exception):
|
|
57
|
+
"""Raised by tools that require human approval before execution.
|
|
58
|
+
|
|
59
|
+
When a tool raises this exception, the agent will invoke the
|
|
60
|
+
``on_approval_needed`` callback if configured. If the callback
|
|
61
|
+
returns True, the tool will be executed; if False, the tool
|
|
62
|
+
execution will be skipped and an error message returned to the LLM.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
tool_name: Name of the tool requesting approval.
|
|
66
|
+
action: Description of the action requiring approval.
|
|
67
|
+
details: Additional details about what will be executed.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
tool_name: str,
|
|
73
|
+
action: str,
|
|
74
|
+
details: dict[str, Any] | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.tool_name = tool_name
|
|
77
|
+
self.action = action
|
|
78
|
+
self.details = details or {}
|
|
79
|
+
message = f"Tool '{tool_name}' requires approval: {action}"
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
|
|
82
|
+
|
|
54
83
|
@dataclass
|
|
55
84
|
class RunContext(Generic[DepsType]):
|
|
56
85
|
"""Dependency-injection context available to tools and guardrails.
|
|
@@ -83,6 +112,19 @@ class AgentCallbacks:
|
|
|
83
112
|
Fired at the logical agent layer (steps, tool invocations, output),
|
|
84
113
|
separate from :class:`~prompture.callbacks.DriverCallbacks` which
|
|
85
114
|
fires at the HTTP/driver layer.
|
|
115
|
+
|
|
116
|
+
Attributes:
|
|
117
|
+
on_step: Called for each step during execution.
|
|
118
|
+
on_tool_start: Called before a tool is invoked with (name, args).
|
|
119
|
+
on_tool_end: Called after a tool completes with (name, result).
|
|
120
|
+
on_iteration: Called at the start of each iteration with the index.
|
|
121
|
+
on_output: Called when the agent produces final output.
|
|
122
|
+
on_thinking: Called when the agent emits thinking/reasoning content.
|
|
123
|
+
The callback receives the thinking text (e.g., content within
|
|
124
|
+
<think> tags for models that support chain-of-thought).
|
|
125
|
+
on_approval_needed: Called when a tool raises ApprovalRequired.
|
|
126
|
+
The callback receives (tool_name, action, details) and should
|
|
127
|
+
return True to approve execution or False to deny.
|
|
86
128
|
"""
|
|
87
129
|
|
|
88
130
|
on_step: Callable[[AgentStep], None] | None = None
|
|
@@ -90,6 +132,8 @@ class AgentCallbacks:
|
|
|
90
132
|
on_tool_end: Callable[[str, Any], None] | None = None
|
|
91
133
|
on_iteration: Callable[[int], None] | None = None
|
|
92
134
|
on_output: Callable[[AgentResult], None] | None = None
|
|
135
|
+
on_thinking: Callable[[str], None] | None = None
|
|
136
|
+
on_approval_needed: Callable[[str, str, dict[str, Any]], bool] | None = None
|
|
93
137
|
|
|
94
138
|
|
|
95
139
|
@dataclass
|
|
@@ -130,6 +174,60 @@ class AgentResult:
|
|
|
130
174
|
state: AgentState = AgentState.idle
|
|
131
175
|
run_usage: dict[str, Any] = field(default_factory=dict)
|
|
132
176
|
|
|
177
|
+
def to_dict(self, include_messages: bool = True) -> dict[str, Any]:
|
|
178
|
+
"""Convert this result to a dictionary for serialization.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
include_messages: Whether to include the full message history.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Dictionary representation of this result.
|
|
185
|
+
"""
|
|
186
|
+
data: dict[str, Any] = {
|
|
187
|
+
"output": str(self.output) if self.output is not None else None,
|
|
188
|
+
"output_text": self.output_text,
|
|
189
|
+
"state": self.state.value if hasattr(self.state, "value") else str(self.state),
|
|
190
|
+
"usage": self.usage,
|
|
191
|
+
"run_usage": self.run_usage,
|
|
192
|
+
"steps": [
|
|
193
|
+
{
|
|
194
|
+
"step_type": s.step_type.value if hasattr(s.step_type, "value") else str(s.step_type),
|
|
195
|
+
"timestamp": s.timestamp,
|
|
196
|
+
"content": s.content,
|
|
197
|
+
"tool_name": s.tool_name,
|
|
198
|
+
"tool_args": s.tool_args,
|
|
199
|
+
"tool_result": s.tool_result,
|
|
200
|
+
"duration_ms": s.duration_ms,
|
|
201
|
+
}
|
|
202
|
+
for s in self.steps
|
|
203
|
+
],
|
|
204
|
+
"all_tool_calls": self.all_tool_calls,
|
|
205
|
+
"exported_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if include_messages:
|
|
209
|
+
data["messages"] = self.messages
|
|
210
|
+
|
|
211
|
+
return data
|
|
212
|
+
|
|
213
|
+
def export_json(self, include_messages: bool = True) -> str:
|
|
214
|
+
"""Export this result to a JSON string.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
include_messages: Whether to include the full message history.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
JSON string representation of this result.
|
|
221
|
+
|
|
222
|
+
Example::
|
|
223
|
+
|
|
224
|
+
result = agent.run("What is 2+2?")
|
|
225
|
+
json_str = result.export_json()
|
|
226
|
+
with open("agent_history.json", "w") as f:
|
|
227
|
+
f.write(json_str)
|
|
228
|
+
"""
|
|
229
|
+
return json.dumps(self.to_dict(include_messages=include_messages), indent=2, default=str)
|
|
230
|
+
|
|
133
231
|
|
|
134
232
|
class StreamEventType(str, enum.Enum):
|
|
135
233
|
"""Classification for events emitted during streaming agent execution."""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Code analysis module for Python code security assessment.
|
|
2
|
+
|
|
3
|
+
Provides AST-based analysis to detect potentially dangerous operations
|
|
4
|
+
and calculate risk scores for code execution in sandboxed environments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .analyzer import CodeAnalysis, analyze_python
|
|
8
|
+
from .ast_visitors import CodeFeatures, FeatureExtractor
|
|
9
|
+
from .risk_scoring import RiskAssessment, RiskLevel, calculate_risk
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CodeAnalysis",
|
|
13
|
+
"CodeFeatures",
|
|
14
|
+
"FeatureExtractor",
|
|
15
|
+
"RiskAssessment",
|
|
16
|
+
"RiskLevel",
|
|
17
|
+
"analyze_python",
|
|
18
|
+
"calculate_risk",
|
|
19
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Main code analysis interface.
|
|
2
|
+
|
|
3
|
+
Provides the primary analyze_python() function for code security assessment.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from .ast_visitors import CodeFeatures, extract_features
|
|
11
|
+
from .risk_scoring import RiskAssessment, RiskLevel, calculate_risk
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CodeAnalysis:
|
|
16
|
+
"""Complete analysis result for Python code.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
source: The original source code analyzed.
|
|
20
|
+
features: Extracted code features from AST analysis.
|
|
21
|
+
risk: Risk assessment with level, score, and reasons.
|
|
22
|
+
is_safe: Whether the code is considered safe for execution.
|
|
23
|
+
syntax_valid: Whether the code has valid Python syntax.
|
|
24
|
+
syntax_error: Syntax error message if parsing failed.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
source: str
|
|
28
|
+
features: CodeFeatures
|
|
29
|
+
risk: RiskAssessment
|
|
30
|
+
is_safe: bool
|
|
31
|
+
syntax_valid: bool = True
|
|
32
|
+
syntax_error: str | None = None
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def risk_level(self) -> RiskLevel:
|
|
36
|
+
"""Convenience property to access risk level directly."""
|
|
37
|
+
return self.risk.level
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def risk_score(self) -> int:
|
|
41
|
+
"""Convenience property to access risk score directly."""
|
|
42
|
+
return self.risk.score
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict:
|
|
45
|
+
"""Convert analysis to a dictionary for serialization."""
|
|
46
|
+
return {
|
|
47
|
+
"source": self.source,
|
|
48
|
+
"syntax_valid": self.syntax_valid,
|
|
49
|
+
"syntax_error": self.syntax_error,
|
|
50
|
+
"is_safe": self.is_safe,
|
|
51
|
+
"risk": {
|
|
52
|
+
"level": self.risk.level.value,
|
|
53
|
+
"score": self.risk.score,
|
|
54
|
+
"reasons": self.risk.reasons,
|
|
55
|
+
"warnings": self.risk.warnings,
|
|
56
|
+
"blocked_imports": list(self.risk.blocked_imports),
|
|
57
|
+
},
|
|
58
|
+
"features": {
|
|
59
|
+
"imports": list(self.features.imports),
|
|
60
|
+
"file_operations": self.features.file_operations,
|
|
61
|
+
"network_calls": self.features.network_calls,
|
|
62
|
+
"system_calls": self.features.system_calls,
|
|
63
|
+
"exec_eval_usage": self.features.exec_eval_usage,
|
|
64
|
+
"dangerous_builtins": list(self.features.dangerous_builtins),
|
|
65
|
+
"function_calls": list(self.features.function_calls),
|
|
66
|
+
"has_global_statements": self.features.has_global_statements,
|
|
67
|
+
"has_nonlocal_statements": self.features.has_nonlocal_statements,
|
|
68
|
+
"class_definitions": list(self.features.class_definitions),
|
|
69
|
+
"async_operations": self.features.async_operations,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def analyze_python(
|
|
75
|
+
source: str,
|
|
76
|
+
*,
|
|
77
|
+
safe_threshold: RiskLevel = RiskLevel.MEDIUM,
|
|
78
|
+
) -> CodeAnalysis:
|
|
79
|
+
"""Analyze Python source code for security risks.
|
|
80
|
+
|
|
81
|
+
Performs AST-based analysis to detect potentially dangerous operations
|
|
82
|
+
and calculates a risk score.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
source: Python source code as a string.
|
|
86
|
+
safe_threshold: Maximum risk level considered safe.
|
|
87
|
+
Defaults to MEDIUM (allowing LOW and MEDIUM risk code).
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
CodeAnalysis with features, risk assessment, and safety determination.
|
|
91
|
+
|
|
92
|
+
Example::
|
|
93
|
+
|
|
94
|
+
from prompture.analysis import analyze_python, RiskLevel
|
|
95
|
+
|
|
96
|
+
analysis = analyze_python("import subprocess; subprocess.run(['ls'])")
|
|
97
|
+
print(f"Risk: {analysis.risk_level}") # RiskLevel.CRITICAL
|
|
98
|
+
print(f"Safe: {analysis.is_safe}") # False
|
|
99
|
+
|
|
100
|
+
# More permissive threshold
|
|
101
|
+
analysis = analyze_python("import json", safe_threshold=RiskLevel.HIGH)
|
|
102
|
+
print(f"Safe: {analysis.is_safe}") # True
|
|
103
|
+
"""
|
|
104
|
+
# Try to parse the source code
|
|
105
|
+
try:
|
|
106
|
+
features = extract_features(source)
|
|
107
|
+
syntax_valid = True
|
|
108
|
+
syntax_error = None
|
|
109
|
+
except SyntaxError as e:
|
|
110
|
+
# Return analysis with syntax error
|
|
111
|
+
features = CodeFeatures()
|
|
112
|
+
risk = RiskAssessment(
|
|
113
|
+
level=RiskLevel.CRITICAL,
|
|
114
|
+
score=100,
|
|
115
|
+
reasons=[f"Syntax error: {e}"],
|
|
116
|
+
)
|
|
117
|
+
return CodeAnalysis(
|
|
118
|
+
source=source,
|
|
119
|
+
features=features,
|
|
120
|
+
risk=risk,
|
|
121
|
+
is_safe=False,
|
|
122
|
+
syntax_valid=False,
|
|
123
|
+
syntax_error=str(e),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Calculate risk
|
|
127
|
+
risk = calculate_risk(features)
|
|
128
|
+
|
|
129
|
+
# Determine safety based on threshold
|
|
130
|
+
threshold_order = [RiskLevel.LOW, RiskLevel.MEDIUM, RiskLevel.HIGH, RiskLevel.CRITICAL]
|
|
131
|
+
risk_index = threshold_order.index(risk.level)
|
|
132
|
+
threshold_index = threshold_order.index(safe_threshold)
|
|
133
|
+
is_safe = risk_index <= threshold_index
|
|
134
|
+
|
|
135
|
+
return CodeAnalysis(
|
|
136
|
+
source=source,
|
|
137
|
+
features=features,
|
|
138
|
+
risk=risk,
|
|
139
|
+
is_safe=is_safe,
|
|
140
|
+
syntax_valid=syntax_valid,
|
|
141
|
+
syntax_error=syntax_error,
|
|
142
|
+
)
|