r3d-agent 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. r3d/__init__.py +3 -0
  2. r3d/__main__.py +6 -0
  3. r3d/agent.py +638 -0
  4. r3d/audit.py +161 -0
  5. r3d/cli.py +722 -0
  6. r3d/config.py +308 -0
  7. r3d/context.py +218 -0
  8. r3d/data/SKILLS.md +212 -0
  9. r3d/data/__init__.py +0 -0
  10. r3d/errors.py +102 -0
  11. r3d/exploit.py +294 -0
  12. r3d/exploits/__init__.py +25 -0
  13. r3d/exploits/web/__init__.py +5 -0
  14. r3d/exploits/web/php_rce_arg_injection.py +67 -0
  15. r3d/logger.py +19 -0
  16. r3d/payloads.py +277 -0
  17. r3d/planner.py +127 -0
  18. r3d/providers/__init__.py +21 -0
  19. r3d/providers/anthropic_provider.py +165 -0
  20. r3d/providers/base.py +92 -0
  21. r3d/providers/openai_provider.py +216 -0
  22. r3d/providers/retry.py +121 -0
  23. r3d/redact.py +46 -0
  24. r3d/remote.py +230 -0
  25. r3d/repl.py +478 -0
  26. r3d/report.py +277 -0
  27. r3d/safety.py +153 -0
  28. r3d/skills.py +55 -0
  29. r3d/system_prompt.py +130 -0
  30. r3d/tools/__init__.py +20 -0
  31. r3d/tools/base.py +155 -0
  32. r3d/tools/exploit_tool.py +149 -0
  33. r3d/tools/file.py +118 -0
  34. r3d/tools/finding.py +76 -0
  35. r3d/tools/post.py +296 -0
  36. r3d/tools/registry.py +182 -0
  37. r3d/tools/shell.py +201 -0
  38. r3d/tools/web.py +119 -0
  39. r3d/ui/__init__.py +17 -0
  40. r3d/ui/branding.py +89 -0
  41. r3d/ui/commands.py +119 -0
  42. r3d/ui/layout.py +250 -0
  43. r3d/ui/messages.py +33 -0
  44. r3d/ui/panes/__init__.py +11 -0
  45. r3d/ui/panes/command.py +99 -0
  46. r3d/ui/panes/finding.py +132 -0
  47. r3d/ui/panes/output.py +122 -0
  48. r3d/ui/prompt.py +97 -0
  49. r3d/ui/renderer.py +782 -0
  50. r3d_agent-0.2.0.dist-info/METADATA +203 -0
  51. r3d_agent-0.2.0.dist-info/RECORD +54 -0
  52. r3d_agent-0.2.0.dist-info/WHEEL +4 -0
  53. r3d_agent-0.2.0.dist-info/entry_points.txt +2 -0
  54. r3d_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
r3d/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """R3D — Autonomous AI Penetration Testing CLI Agent."""
2
+
3
+ __version__ = "0.2.0"
r3d/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running R3D via `python -m r3d`."""
2
+
3
+ from r3d.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
r3d/agent.py ADDED
@@ -0,0 +1,638 @@
1
+ """ReAct loop: plan → execute → observe → analyze → repeat (§4/§5 agent.py).
2
+
3
+ The core agent loop. Includes ToolCallState, _stream_with_failover,
4
+ and proper message building for all provider types.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import re
12
+ import signal
13
+ import sys
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from typing import TYPE_CHECKING, AsyncIterator
17
+
18
+ from r3d import redact
19
+ from r3d.audit import AuditEvent, AuditLog
20
+ from r3d.context import Session
21
+ from r3d.errors import FatalProviderError, RateLimitError, TransientProviderError, with_retry
22
+ from r3d.logger import Logger
23
+ from r3d.planner import Planner
24
+ from r3d.providers.base import (
25
+ ProviderEvent,
26
+ StreamDone,
27
+ TextDelta,
28
+ ToolCallComplete,
29
+ ToolCallDelta,
30
+ ToolCallStart,
31
+ )
32
+ from r3d.safety import SafetyChecker, SafetyDecision
33
+ from r3d.system_prompt import build_system_prompt
34
+ from r3d.tools.registry import ToolRegistry
35
+
36
+ if TYPE_CHECKING:
37
+ from r3d.config import Config
38
+ from r3d.providers.base import LLMProvider
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ @dataclass
44
+ class ToolCallState:
45
+ """Tracks a pending tool call during streaming."""
46
+ id: str
47
+ name: str
48
+ args_json: str = ""
49
+
50
+
51
+ @dataclass
52
+ class AgentResult:
53
+ goal: str
54
+ steps_taken: int
55
+ elapsed_seconds: float
56
+ stop_reason: str # done|step_limit|time_limit|token_limit|interrupted|provider_error
57
+ summary: str
58
+ session_id: str
59
+ findings: list = field(default_factory=list)
60
+ tool_calls: int = 0
61
+ tool_errors: int = 0
62
+
63
+
64
+ class Agent:
65
+ """The core ReAct agent."""
66
+
67
+ def __init__(
68
+ self,
69
+ provider: "LLMProvider",
70
+ tools: ToolRegistry,
71
+ session: Session,
72
+ log: Logger,
73
+ config: "Config",
74
+ safety: SafetyChecker,
75
+ audit: AuditLog,
76
+ failover_provider: "LLMProvider | None" = None,
77
+ ):
78
+ self._provider = provider
79
+ self._failover = failover_provider
80
+ self._tools = tools
81
+ self._session = session
82
+ self._logger = log
83
+ self._config = config
84
+ self._safety = safety
85
+ self._audit = audit
86
+ self._interrupted = False
87
+ self._total_tool_calls = 0
88
+ self._total_tool_errors = 0
89
+ self._replans_used = 0
90
+ self._created_files: list[str] = [] # track files created by agent
91
+
92
+ # ------------------------------------------------------------------ #
93
+ # Main entry point #
94
+ # ------------------------------------------------------------------ #
95
+
96
+ async def run(self, goal: str) -> AgentResult:
97
+ """Run the agent to completion."""
98
+ try:
99
+ signal.signal(signal.SIGINT, self._graceful_shutdown)
100
+ except (OSError, ValueError):
101
+ pass
102
+
103
+ # Snapshot files before run — for cleanup later
104
+ self._pre_snapshot = self._snapshot_files()
105
+
106
+ self._audit.record(AuditEvent(
107
+ ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
108
+ session_id=self._session.session_id,
109
+ kind="session", tool="-", target=goal[:80],
110
+ decision="started",
111
+ ))
112
+
113
+ # Phase 1: Planning — goal as first user message
114
+ self._session.add_message("user", f"Goal: {goal}")
115
+
116
+ if self._config.enable_planner:
117
+ try:
118
+ planner = Planner(self._provider, self._logger)
119
+ plan = await with_retry(
120
+ lambda: planner.make_plan(goal),
121
+ max_retries=self._config.max_retries,
122
+ base_delay=self._config.retry_base_delay,
123
+ )
124
+ self._logger.plan_display(plan)
125
+ self._session.plan = plan.steps
126
+ self._session.add_message("assistant", plan.raw_text)
127
+ self._session.add_message(
128
+ "user",
129
+ f"ดำเนินการตามแผนข้างต้นเพื่อบรรลุเป้าหมาย: {goal}",
130
+ )
131
+ self._session.save()
132
+ except Exception as e:
133
+ self._logger.warn(f"Planning failed: {e} — proceeding without plan")
134
+
135
+ stop_reason = "done"
136
+ full_text = ""
137
+
138
+ # Phase 2: ReAct loop (runs until <DONE>, time, or token/step limit)
139
+ max_steps = self._config.max_steps or 0
140
+ steps_since_last_tool = 0
141
+ while True:
142
+ # Check step limit
143
+ if max_steps and self._session.step_count >= max_steps:
144
+ stop_reason = "step_limit"
145
+ break
146
+
147
+ reason = self._check_stop()
148
+ if reason:
149
+ stop_reason = reason
150
+ break
151
+
152
+ self._session.step_count += 1
153
+ elapsed = time.time() - self._session.created_at
154
+ self._logger.step_banner(
155
+ self._session.step_count,
156
+ elapsed, self._session.estimated_tokens,
157
+ )
158
+
159
+ if self._session.estimated_tokens > self._config.max_tokens * 0.8:
160
+ self._session.compact()
161
+ self._logger.warn("Context compacted — oldest messages removed.")
162
+
163
+ messages = self._build_messages()
164
+ system = build_system_prompt(self._tools.list_tools(), self._config)
165
+ schemas = self._tools.get_schemas(
166
+ "anthropic" if self._config.provider == "anthropic" else "openai"
167
+ )
168
+
169
+ # Stream with failover
170
+ full_text = ""
171
+ tool_calls_this_step: list[tuple[str, str, dict, str]] = []
172
+ pending: dict[str, ToolCallState] = {}
173
+
174
+ self._logger.start_thinking("กำลังคิด")
175
+
176
+ try:
177
+ async for event in self._stream_with_failover(
178
+ messages, system, schemas
179
+ ):
180
+ if isinstance(event, TextDelta):
181
+ self._logger.stream_text(event.text)
182
+ full_text += event.text
183
+
184
+ elif isinstance(event, ToolCallStart):
185
+ self._logger.stop_thinking()
186
+ self._logger.end_stream()
187
+ pending[event.id] = ToolCallState(
188
+ id=event.id, name=event.name
189
+ )
190
+
191
+ elif isinstance(event, ToolCallDelta):
192
+ if event.id in pending:
193
+ pending[event.id].args_json += event.json_fragment
194
+
195
+ elif isinstance(event, ToolCallComplete):
196
+ result_str = await self._handle_tool_call(
197
+ event.id, event.name, event.args
198
+ )
199
+ tool_calls_this_step.append(
200
+ (event.id, event.name, event.args, result_str)
201
+ )
202
+
203
+ elif isinstance(event, StreamDone):
204
+ self._session.estimated_tokens += (
205
+ event.input_tokens + event.output_tokens
206
+ )
207
+
208
+ except FatalProviderError as e:
209
+ self._logger.stop_thinking()
210
+ self._logger.error(f"Provider error (fatal): {e}")
211
+ stop_reason = "provider_error"
212
+ break
213
+ except Exception as e:
214
+ self._logger.stop_thinking()
215
+ self._logger.error(f"Provider error: {e}")
216
+ stop_reason = "provider_error"
217
+ break
218
+
219
+ self._logger.stop_thinking()
220
+ self._logger.end_stream()
221
+
222
+ # ── Text-based tool call fallback ──────────────────────
223
+ # When models don't support native function calling, they
224
+ # write tool calls as text: [Tool Call: name] {json_args}
225
+ # Parse and execute these as real tool calls.
226
+ if not tool_calls_this_step:
227
+ text_calls = self._parse_text_tool_calls(full_text)
228
+ if text_calls:
229
+ for tc_id, tc_name, tc_args in text_calls:
230
+ result_str = await self._handle_tool_call(
231
+ tc_id, tc_name, tc_args
232
+ )
233
+ tool_calls_this_step.append(
234
+ (tc_id, tc_name, tc_args, result_str)
235
+ )
236
+ else:
237
+ # No parseable tool calls found — check if model output
238
+ # contains only a <DONE> sentinel or natural language.
239
+ # If no <DONE> and no tool calls, we'll nudge below.
240
+ pass
241
+
242
+ # Persist
243
+ self._session.add_assistant_turn(full_text, tool_calls_this_step)
244
+ self._session.updated_at = time.time()
245
+ self._session.save()
246
+
247
+ # Sentinels
248
+ if "<DONE>" in full_text:
249
+ stop_reason = "done"
250
+ break
251
+
252
+ if (m := re.search(r"<REPLAN:\s*(.+?)>", full_text)) and self._replans_left():
253
+ try:
254
+ planner = Planner(self._provider, self._logger)
255
+ plan = await planner.replan(
256
+ goal, self._session.plan, [m.group(1)]
257
+ )
258
+ self._logger.plan_display(plan)
259
+ self._session.plan = plan.steps
260
+ self._replans_used += 1
261
+ except Exception:
262
+ pass
263
+
264
+ # Nudge if stalled — remind to use tools AND record findings
265
+ if not tool_calls_this_step and "<DONE>" not in full_text:
266
+ findings_count = len(self._session.findings)
267
+ if findings_count == 0:
268
+ self._session.add_message(
269
+ "user",
270
+ "ดำเนินการต่อ ใช้ tools เพื่อทำงาน "
271
+ "สำคัญ: เมื่อพบข้อมูลที่น่าสนใจหรือช่องโหว่ ให้ใช้ record_finding บันทึกทันที "
272
+ "อย่าลืมบันทึก findings ก่อนส่ง <DONE>",
273
+ )
274
+ else:
275
+ self._session.add_message(
276
+ "user",
277
+ "ดำเนินการต่อ ใช้ tools เพื่อทำงาน หรือส่ง <DONE> เมื่อเสร็จสิ้น",
278
+ )
279
+
280
+ # Summary
281
+ result = AgentResult(
282
+ goal=goal,
283
+ steps_taken=self._session.step_count,
284
+ elapsed_seconds=time.time() - self._session.created_at,
285
+ stop_reason=stop_reason,
286
+ summary=self._extract_summary(full_text),
287
+ session_id=self._session.session_id,
288
+ findings=self._session.findings,
289
+ tool_calls=self._total_tool_calls,
290
+ tool_errors=self._total_tool_errors,
291
+ )
292
+ self._logger.session_summary(result)
293
+ self._session.save()
294
+
295
+ self._audit.record(AuditEvent(
296
+ ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
297
+ session_id=self._session.session_id,
298
+ kind="session", tool="-", target=goal[:80],
299
+ decision="completed", note=stop_reason,
300
+ ))
301
+
302
+ # Cleanup files created by the agent
303
+ self._cleanup_files()
304
+
305
+ return result
306
+
307
+ # ------------------------------------------------------------------ #
308
+ # Stream with failover (§12 retry.py) #
309
+ # ------------------------------------------------------------------ #
310
+
311
+ async def _stream_with_failover(
312
+ self,
313
+ messages: list[dict],
314
+ system: str,
315
+ schemas: list[dict],
316
+ ) -> AsyncIterator[ProviderEvent]:
317
+ """Try primary provider, then failover on transient/rate errors."""
318
+ providers = [self._provider]
319
+ if self._failover:
320
+ providers.append(self._failover)
321
+
322
+ for i, provider in enumerate(providers):
323
+ try:
324
+ stream = provider.stream_with_tools(
325
+ messages, system, schemas,
326
+ temperature=self._config.temperature,
327
+ max_tokens=self._config.max_output_tokens,
328
+ )
329
+ async for event in stream:
330
+ yield event
331
+ return # success
332
+ except FatalProviderError:
333
+ raise # never retry fatal
334
+ except (RateLimitError, TransientProviderError) as e:
335
+ if i < len(providers) - 1:
336
+ self._logger.warn(f"Primary failed ({e}), switching to failover")
337
+ self._audit.record(AuditEvent(
338
+ ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
339
+ session_id=self._session.session_id,
340
+ kind="session", tool="-",
341
+ decision="allowed", note=f"failover: {e}",
342
+ ))
343
+ continue
344
+ raise
345
+
346
+ # ------------------------------------------------------------------ #
347
+ # Tool call handler #
348
+ # ------------------------------------------------------------------ #
349
+
350
+ async def _handle_tool_call(
351
+ self, tc_id: str, name: str, args: dict
352
+ ) -> str:
353
+ """Safety → audit → execute → audit."""
354
+ args_summary = redact.scrub_str(json.dumps(args, default=str))[:120]
355
+ self._logger.tool_call_box(name, args) # pass full dict, not truncated string
356
+ self._total_tool_calls += 1
357
+
358
+ # 1) Safety
359
+ decision = SafetyDecision(allowed=True, reason="no check needed")
360
+ if name == "run_cmd":
361
+ decision = self._safety.check_command(args.get("cmd", ""))
362
+ elif name == "file" and args.get("action") == "write":
363
+ decision = self._safety.check_file_write(args.get("path", ""))
364
+
365
+ if not decision.allowed:
366
+ self._logger.warn(f"BLOCKED: {decision.reason}")
367
+ self._audit.record(AuditEvent(
368
+ ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
369
+ session_id=self._session.session_id,
370
+ kind="denied", tool=name, args_summary=args_summary,
371
+ decision="denied", risk_level=decision.risk_level,
372
+ matched_pattern=decision.matched_pattern,
373
+ ))
374
+ self._total_tool_errors += 1
375
+ return f"ERROR: Blocked by safety policy — {decision.reason}"
376
+
377
+ if decision.requires_confirm:
378
+ if not self._logger.confirm_prompt(
379
+ f"[{decision.risk_level.upper()}] Allow: {args_summary[:80]}?"
380
+ ):
381
+ self._audit.record(AuditEvent(
382
+ ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
383
+ session_id=self._session.session_id,
384
+ kind="denied", tool=name, args_summary=args_summary,
385
+ decision="denied",
386
+ ))
387
+ return "ERROR: User declined this command."
388
+ self._audit.record(AuditEvent(
389
+ ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
390
+ session_id=self._session.session_id,
391
+ kind="confirm", tool=name, args_summary=args_summary,
392
+ decision="confirmed",
393
+ ))
394
+
395
+ # 2) Audit intent
396
+ self._audit.record(AuditEvent(
397
+ ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
398
+ session_id=self._session.session_id,
399
+ kind="intent", tool=name, args_summary=args_summary,
400
+ decision="allowed", risk_level=decision.risk_level,
401
+ ))
402
+
403
+ # 3) Execute
404
+ t0 = time.time()
405
+ try:
406
+ tool_result = await self._tools.dispatch(name, args)
407
+ elapsed = time.time() - t0
408
+ output = tool_result.format_output(max_lines=100)
409
+ self._logger.tool_result_box(
410
+ name, output, tool_result.truncated, elapsed
411
+ )
412
+ self._audit.record(AuditEvent(
413
+ ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
414
+ session_id=self._session.session_id,
415
+ kind="result", tool=name,
416
+ return_code=tool_result.return_code,
417
+ elapsed_ms=elapsed * 1000, decision="allowed",
418
+ ))
419
+ return output
420
+ except Exception as e:
421
+ err = f"ERROR: {type(e).__name__}: {e}"
422
+ self._logger.tool_error_box(name, err)
423
+ self._total_tool_errors += 1
424
+ self._audit.record(AuditEvent(
425
+ ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
426
+ session_id=self._session.session_id,
427
+ kind="result", tool=name,
428
+ return_code=-1, decision="allowed",
429
+ ))
430
+ return err
431
+
432
+ # ------------------------------------------------------------------ #
433
+ # Message building #
434
+ # ------------------------------------------------------------------ #
435
+
436
+ def _build_messages(self) -> list[dict]:
437
+ """Build messages for the provider.
438
+
439
+ Ensures:
440
+ 1. First message is always role=user
441
+ 2. Last message is always role=user
442
+ 3. Internal roles converted for API compatibility
443
+ 4. No consecutive same-role messages
444
+ """
445
+ messages = list(self._session.messages)
446
+ if not messages:
447
+ messages.append({"role": "user", "content": f"Goal: {self._session.goal}"})
448
+
449
+ if messages[0].get("role") != "user":
450
+ messages.insert(0, {"role": "user", "content": f"Goal: {self._session.goal}"})
451
+
452
+ if messages[-1].get("role") != "user":
453
+ messages.append({
454
+ "role": "user",
455
+ "content": "ดำเนินการต่อ ใช้ tools เพื่อทำงาน หรือส่ง <DONE> เมื่อเสร็จสิ้น",
456
+ })
457
+
458
+ # Convert internal roles
459
+ filtered = []
460
+ for m in messages:
461
+ role = m.get("role", "")
462
+ if role in ("user", "assistant", "system"):
463
+ filtered.append(m)
464
+ elif role == "tool_call":
465
+ name = m.get("name", "?")
466
+ args = m.get("args", {})
467
+ filtered.append({
468
+ "role": "assistant",
469
+ "content": f"[Tool Call: {name}] {json.dumps(args, default=str)[:200]}",
470
+ })
471
+ elif role == "tool_result":
472
+ content = m.get("content", "")
473
+ filtered.append({
474
+ "role": "user",
475
+ "content": f"[Tool Result] {content[:500]}",
476
+ })
477
+
478
+ # Merge consecutive same-role
479
+ merged: list[dict] = []
480
+ for m in filtered:
481
+ if merged and merged[-1]["role"] == m["role"]:
482
+ merged[-1]["content"] += "\n" + m.get("content", "")
483
+ else:
484
+ merged.append(dict(m))
485
+
486
+ return merged
487
+
488
+ # ------------------------------------------------------------------ #
489
+ # Stop / shutdown #
490
+ # ------------------------------------------------------------------ #
491
+
492
+ def _check_stop(self) -> str | None:
493
+ """Only stop on interrupt or token limit.
494
+
495
+ No time limit, no step limit — the agent runs until:
496
+ 1. Model emits <DONE>
497
+ 2. User presses Ctrl+C (interrupted)
498
+ 3. Token budget exceeded
499
+ 4. Provider error (handled in the stream loop)
500
+ """
501
+ if self._interrupted:
502
+ return "interrupted"
503
+ if self._session.estimated_tokens >= self._config.max_tokens:
504
+ return "token_limit"
505
+ return None
506
+
507
+ def _replans_left(self) -> bool:
508
+ return self._replans_used < self._config.max_replans
509
+
510
+ def _graceful_shutdown(self, sig: int, frame: object) -> None:
511
+ if self._interrupted:
512
+ sys.exit(1)
513
+ self._interrupted = True
514
+ self._logger.warn("Interrupted — finishing current step...")
515
+ self._session.save()
516
+
517
+ @staticmethod
518
+ def _extract_summary(text: str) -> str:
519
+ cleaned = text.replace("<DONE>", "").strip()
520
+ # Remove text-based tool call artifacts
521
+ cleaned = re.sub(r'\[Tool Call:.*?\]\s*\{[^}]*\}', '', cleaned)
522
+ cleaned = cleaned.strip()
523
+ if len(cleaned) > 500:
524
+ return cleaned[:500] + "..."
525
+ return cleaned or "(no summary)"
526
+
527
+ # ------------------------------------------------------------------ #
528
+ # File tracking & cleanup #
529
+ # ------------------------------------------------------------------ #
530
+
531
+ def _snapshot_files(self) -> set[str]:
532
+ """No-op — kept for compatibility. Cleanup now uses fixed /tmp/r3d."""
533
+ return set()
534
+
535
+ def _cleanup_files(self) -> None:
536
+ """Remove agent workdir — all agent files live here."""
537
+ import shutil
538
+ from pathlib import Path
539
+ from r3d.tools.shell import AGENT_WORKDIR
540
+
541
+ workdir = AGENT_WORKDIR
542
+ if not workdir.exists():
543
+ return
544
+
545
+ files: list[str] = []
546
+ try:
547
+ for f in workdir.rglob("*"):
548
+ if f.is_file():
549
+ files.append(f.name)
550
+ except Exception:
551
+ pass
552
+
553
+ if not files:
554
+ return
555
+
556
+ try:
557
+ shutil.rmtree(workdir, ignore_errors=True)
558
+ self._logger.success(f"🧹 ลบไฟล์ที่ agent สร้างใน {workdir}: {len(files)} ไฟล์")
559
+ for name in files[:10]:
560
+ self._logger.console.print(f" 🗑️ {name}", style="r3d.dim")
561
+ if len(files) > 10:
562
+ self._logger.console.print(
563
+ f" … และอีก {len(files) - 10} ไฟล์", style="r3d.dim"
564
+ )
565
+ except Exception as e:
566
+ self._logger.warn(f"ลบ {workdir} ไม่สำเร็จ: {e}")
567
+
568
+ # ------------------------------------------------------------------ #
569
+ # Text-based tool call parser #
570
+ # ------------------------------------------------------------------ #
571
+
572
+ @staticmethod
573
+ def _parse_text_tool_calls(text: str) -> list[tuple[str, str, dict]]:
574
+ """Parse tool calls written as text by models without native function calling.
575
+
576
+ Recognizes patterns like:
577
+ [Tool Call: run_cmd] {"cmd": "nmap ...", "timeout": 30}
578
+ [tool_call: file] {"action": "read", "path": "test.txt"}
579
+ ```json\n{"tool": "run_cmd", "cmd": "..."}\n```
580
+
581
+ Returns list of (id, name, args) tuples.
582
+ """
583
+ calls: list[tuple[str, str, dict]] = []
584
+ call_id = 0
585
+
586
+ # Pattern 1: [Tool Call: name] {json}
587
+ pattern1 = re.compile(
588
+ r'\[Tool\s*Call:\s*(\w+)\]\s*(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\})',
589
+ re.IGNORECASE | re.DOTALL,
590
+ )
591
+ for m in pattern1.finditer(text):
592
+ name = m.group(1).strip()
593
+ raw_json = m.group(2).strip()
594
+ try:
595
+ args = json.loads(raw_json)
596
+ call_id += 1
597
+ calls.append((f"text_call_{call_id}", name, args))
598
+ except json.JSONDecodeError:
599
+ continue
600
+
601
+ # Pattern 2: ```json {"tool": "name", ...} ``` or {"name": "run_cmd", "cmd": ...}
602
+ if not calls:
603
+ pattern2 = re.compile(
604
+ r'```(?:json)?\s*(\{[^`]+\})\s*```',
605
+ re.DOTALL,
606
+ )
607
+ for m in pattern2.finditer(text):
608
+ raw_json = m.group(1).strip()
609
+ try:
610
+ obj = json.loads(raw_json)
611
+ if isinstance(obj, dict):
612
+ name = obj.pop("tool", obj.pop("name", ""))
613
+ if name and isinstance(name, str):
614
+ call_id += 1
615
+ calls.append((f"text_call_{call_id}", name, obj))
616
+ except json.JSONDecodeError:
617
+ continue
618
+
619
+ # Pattern 3: Inline JSON with known tool names
620
+ if not calls:
621
+ known_tools = ("run_cmd", "file", "record_finding", "exploit",
622
+ "payload", "open_url", "web_search", "post")
623
+ for tool_name in known_tools:
624
+ # Match: run_cmd({"cmd": "..."}) or run_cmd {"cmd": "..."}
625
+ pat = re.compile(
626
+ rf'\b{tool_name}\s*\(?\s*(\{{[^{{}}]*(?:\{{[^{{}}]*\}}[^{{}}]*)*\}})\s*\)?',
627
+ re.DOTALL,
628
+ )
629
+ for m in pat.finditer(text):
630
+ raw_json = m.group(1).strip()
631
+ try:
632
+ args = json.loads(raw_json)
633
+ call_id += 1
634
+ calls.append((f"text_call_{call_id}", tool_name, args))
635
+ except json.JSONDecodeError:
636
+ continue
637
+
638
+ return calls