The Atlas AnyLegal OSS — documentation bound to its code
20 documents

Run it locally: services, sandbox, knowledge

From `docker compose up` to the hardened code sandbox and the optional knowledge sidecar — what boots, what to lock down, and what is opt-in.

backend/anylegal_oss/workspace/tools/python_tools.py837 lines · run_code L549–832
Outline 10 symbols
1"""
2Python Interpreter Tool
3
4Executes Python code in a sandboxed Docker container with pre-installed libraries.
5Used for complex document generation, data analysis, calculations, validation,
6and any task requiring programmatic logic.
7
8The sandbox has NO network access, 256MB memory limit, and 60-second timeout.
9Pre-installed: python-docx, openpyxl, pandas, lxml, matplotlib, reportlab,
10pymupdf4llm, python-dateutil, Pillow, tabulate, regex.
11"""
12
13import io
14import json
15import logging
16import os
17import re
18import shutil
19import subprocess
20import tempfile
21import time
22import xml.etree.ElementTree as ET
23import zipfile
24from pathlib import Path
25from typing import Dict, Any, Optional, List
26
27from ..session import WorkspaceSession
28
29logger = logging.getLogger(__name__)
30
31SANDBOX_IMAGE = os.getenv("SANDBOX_IMAGE", "anylegal-sandbox:latest")
32SANDBOX_TIMEOUT = int(os.getenv("SANDBOX_TIMEOUT", "120"))
33SANDBOX_MEMORY = os.getenv("SANDBOX_MEMORY", "512m")
34SANDBOX_CPUS = os.getenv("SANDBOX_CPUS", "1")
35MAX_INPUT_BYTES = 50 * 1024 * 1024
36MAX_OUTPUT_BYTES = 50 * 1024 * 1024
37MAX_STDOUT = 10000
38MAX_STDERR = 5000
39
40SANDBOX_TMPDIR = os.getenv("SANDBOX_TMPDIR", None)
41
42DOCX_EXTENSIONS = {".docx"}
43BINARY_EXTENSIONS = {".xlsx", ".pdf", ".png", ".jpg", ".jpeg", ".svg", ".pptx"}
44TEXT_EXTENSIONS = {".txt", ".md", ".csv", ".json", ".xml", ".html", ".yaml", ".yml"}
45
46_FORBIDDEN_FILENAME_SUFFIX = re.compile(
47 r'[_\-](Part\d+|Final|Draft|v\d+|Copy|Backup|Revised|Updated)(?=\.[^/.]+$|$)',
48 re.IGNORECASE,
49)
50
51def _strip_forbidden_suffix(name: str) -> str:
52 """Return the name with any forbidden fragment suffix removed."""
53 return _FORBIDDEN_FILENAME_SUFFIX.sub('', name, count=1)
54
55EXTENSION_MIMES = {
56 ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
57 ".pdf": "application/pdf",
58 ".png": "image/png",
59 ".jpg": "image/jpeg",
60 ".jpeg": "image/jpeg",
61 ".svg": "image/svg+xml",
62 ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
63 ".md": "text/markdown",
64 ".markdown": "text/markdown",
65 ".txt": "text/plain",
66 ".csv": "text/csv",
67 ".json": "application/json",
68 ".xml": "application/xml",
69 ".html": "text/html",
70 ".yaml": "application/yaml",
71 ".yml": "application/yaml",
72}
73
74def _check_docker_available() -> Optional[str]:
75 """Check if Docker is available. Returns error message or None."""
76 try:
77 result = subprocess.run(
78 ["docker", "info"],
79 capture_output=True, text=True, timeout=10
80 )
81 if result.returncode != 0:
82 return "Docker daemon is not running"
83 return None
84 except FileNotFoundError:
85 return "Docker is not installed or not in PATH"
86 except subprocess.TimeoutExpired:
87 return "Docker daemon is not responding"
88
89def _convert_doc_to_docx(blob: bytes, filename: str) -> Optional[bytes]:
90 """
91 Convert .doc binary to .docx via the LibreOffice service.
92 Returns .docx bytes or None if conversion fails.
93 """
94 try:
95 import requests as http_requests
96 libreoffice_url = os.environ.get("LIBREOFFICE_SERVICE_URL", "http://localhost:8002")
97 resp = http_requests.post(
98 f"{libreoffice_url}/convert",
99 files={"file": (filename, blob, "application/msword")},
100 params={"format": "docx"},
101 timeout=120,
102 )
103 if resp.status_code == 200 and resp.headers.get("content-type", "").startswith("application/"):
104 logger.info(f".doc → .docx conversion succeeded for {filename} ({len(resp.content)} bytes)")
105 return resp.content
106 except Exception as e:
107 logger.warning(f".doc → .docx conversion failed for {filename}: {e}")
108 return None
109
110def _extract_input_files(
111 session: WorkspaceSession,
112 input_files: List[str],
113 input_dir: str,
114) -> List[Dict[str, Any]]:
115 """
116 Extract requested workspace files to the sandbox input directory.
117 .doc files are auto-converted to .docx via LibreOffice service.
118 Returns list of extracted file metadata.
119 """
120 extracted = []
121 total_bytes = 0
122
123 for path in input_files:
124
125 doc = session.get_document(path)
126 if doc:
127 filename = Path(path).name
128
129 if not doc.docx_blob and doc.binary_blob:
130 try:
131 from .document_tools import _ensure_docx_blob
132 _ensure_docx_blob(doc, session)
133 if doc.docx_blob:
134 logger.info(f"On-demand .doc→.docx conversion for sandbox: {filename}")
135 except Exception as e:
136 logger.warning(f"_ensure_docx_blob failed for {filename}: {e}")
137
138 if doc.docx_blob:
139 data = doc.docx_blob
140
141 if not filename.lower().endswith('.docx'):
142 filename = Path(filename).stem + '.docx'
143 out_path = os.path.join(input_dir, filename)
144 with open(out_path, "wb") as f:
145 f.write(data)
146 total_bytes += len(data)
147 extracted.append({"path": path, "filename": filename, "size": len(data), "type": "docx"})
148 logger.info(f"Sandbox input: {filename} mounted as DOCX ({len(data)} bytes)")
149
150 elif doc.binary_blob:
151 data = doc.binary_blob
152
153 is_doc = filename.lower().endswith(('.doc', '.dot'))
154 mime = getattr(doc, "mime_type", "") or ""
155 if is_doc or mime in ("application/msword", "application/x-ole-storage"):
156 docx_data = _convert_doc_to_docx(data, filename)
157 if docx_data:
158 filename = Path(filename).stem + '.docx'
159 data = docx_data
160 file_type = "docx"
161 else:
162 logger.warning(f"Could not convert {filename} to .docx — mounting as-is")
163 file_type = "binary"
164 extracted.append({
165 "path": path, "filename": filename, "size": len(data),
166 "type": "binary",
167 "warning": (
168 f"'{filename}' is a .doc file that could not be converted to .docx. "
169 f"LibreOffice service at {os.environ.get('LIBREOFFICE_SERVICE_URL', 'http://localhost:8002')} "
170 f"may not be running. python-docx CANNOT open .doc files. "
171 f"Tell the user to either start the LibreOffice service or upload a .docx version."
172 ),
173 })
174 out_path = os.path.join(input_dir, filename)
175 with open(out_path, "wb") as f:
176 f.write(data)
177 total_bytes += len(data)
178 continue
179 else:
180 file_type = "binary"
181 out_path = os.path.join(input_dir, filename)
182 with open(out_path, "wb") as f:
183 f.write(data)
184 total_bytes += len(data)
185 extracted.append({"path": path, "filename": filename, "size": len(data), "type": file_type})
186
187 elif doc.content:
188 data = doc.content.encode("utf-8")
189
190 if not Path(filename).suffix:
191 filename += ".txt"
192 out_path = os.path.join(input_dir, filename)
193 with open(out_path, "wb") as f:
194 f.write(data)
195 total_bytes += len(data)
196 extracted.append({"path": path, "filename": filename, "size": len(data), "type": "text"})
197
198 else:
199 logger.warning(f"Document '{path}' has no readable content")
200 continue
201
202 else:
203
204 wf_content = session.workspace_files.get(path)
205 if wf_content:
206 filename = Path(path).name
207 data = wf_content.encode("utf-8")
208 out_path = os.path.join(input_dir, filename)
209 with open(out_path, "wb") as f:
210 f.write(data)
211 total_bytes += len(data)
212 extracted.append({"path": path, "filename": filename, "size": len(data), "type": "workspace_file"})
213 else:
214 available = list(session.documents.keys()) + list(session.workspace_files.keys())
215 logger.warning(f"File not found in workspace: {path}. Available: {available}")
216 extracted.append({
217 "path": path, "filename": Path(path).name, "size": 0,
218 "type": "missing",
219 "error": f"File '{path}' not found. Available documents: {available}"
220 })
221
222 if total_bytes > MAX_INPUT_BYTES:
223 logger.warning(f"Input file cap reached ({total_bytes} bytes). Skipping remaining files.")
224 break
225
226 return extracted
227
228def _import_output_files(
229 session: WorkspaceSession,
230 output_dir: str,
231) -> List[Dict[str, Any]]:
232 """
233 Import files from sandbox output directory back into the workspace.
234 Returns list of imported file metadata.
235 """
236 imported = []
237 total_bytes = 0
238
239 output_dir_real = os.path.realpath(output_dir)
240
241 for name in sorted(os.listdir(output_dir)):
242
243 if name == "_result.json":
244 continue
245
246 filepath = os.path.join(output_dir, name)
247 # Reject anything that escapes output_dir via symlink. A malicious
248 # script could ln -s /etc/passwd /sandbox/output/foo.txt — without
249 # this, the host would happily read it back into the workspace.
250 try:
251 real = os.path.realpath(filepath)
252 except OSError:
253 continue
254 if not real.startswith(output_dir_real + os.sep) and real != output_dir_real:
255 logger.warning(
256 f"Rejecting sandbox output {name!r}: realpath {real!r} "
257 f"outside {output_dir_real!r}"
258 )
259 continue
260 # lstat (not stat) so symlinks-to-files inside output_dir are also
261 # caught and rejected; legitimate scripts produce regular files.
262 try:
263 st = os.lstat(filepath)
264 except OSError:
265 continue
266 from stat import S_ISLNK, S_ISREG
267 if S_ISLNK(st.st_mode) or not S_ISREG(st.st_mode):
268 logger.warning(f"Rejecting sandbox output {name!r}: not a regular file")
269 continue
270
271 if _FORBIDDEN_FILENAME_SUFFIX.search(name):
272 base = _strip_forbidden_suffix(name)
273 size = os.path.getsize(filepath)
274 logger.warning(f"Rejecting forbidden filename: {name!r} → suggested base {base!r}")
275 imported.append({
276 "path": name,
277 "size": size,
278 "added_to_workspace": False,
279 "type": "rejected",
280 "error": (
281 f"Filename '{name}' was REJECTED. Suffixes like _Part1, _Final, "
282 f"_Draft, _v2, _Copy are not permitted — a logical document must "
283 f"live under one filename and grow across calls. "
284 f"Save under the base filename '{base}' instead. "
285 f"To append to an existing document, pass input_files=['{base}'] "
286 f"and save back to '/sandbox/output/{base}'. "
287 f"Do NOT create a second file to work around this."
288 ),
289 })
290 continue
291
292 size = os.path.getsize(filepath)
293 total_bytes += size
294 if total_bytes > MAX_OUTPUT_BYTES:
295 logger.warning(f"Output cap reached ({total_bytes} bytes). Skipping: {name}")
296 break
297
298 ext = Path(name).suffix.lower()
299 entry = {"path": name, "size": size, "added_to_workspace": False}
300
301 try:
302 if ext in DOCX_EXTENSIONS:
303 with open(filepath, "rb") as f:
304 docx_bytes = f.read()
305
306 target_name = name
307 existing = session.get_document(name)
308 if existing is not None and getattr(existing, "docx_blob", None):
309 from .document_tools import (
310 _strip_version_suffix,
311 _find_latest_version,
312 )
313 base = _strip_version_suffix(name)
314 _latest_path, next_version = _find_latest_version(session, base)
315 candidate = _latest_path
316
317 if candidate == base:
318 from .document_tools import _version_path
319 candidate = _version_path(base, next_version)
320 target_name = candidate
321 if target_name != name:
322 entry["routed_to"] = target_name
323 logger.info(
324 f"run_code DOCX import auto-clone: "
325 f"'{name}' would clobber existing source → routed to '{target_name}'"
326 )
327
328 if target_name not in session.documents:
329 session.add_document(
330 path=target_name,
331 content="",
332 description="Generated by run_python",
333 )
334 doc = session.get_document(target_name)
335 if doc:
336 doc.update_docx(docx_bytes)
337 doc.format = "docx"
338 entry["path"] = target_name
339 entry["added_to_workspace"] = True
340 entry["type"] = "docx"
341
342 elif ext in BINARY_EXTENSIONS:
343 with open(filepath, "rb") as f:
344 blob = f.read()
345
346 content = ""
347 if ext == ".xlsx":
348 try:
349 from .document_tools import extract_xlsx_text
350 content = extract_xlsx_text(blob, name)
351 except Exception:
352 pass
353 elif ext == ".pptx":
354 try:
355 from .document_tools import extract_pptx_text
356 content = extract_pptx_text(blob, name)
357 except Exception:
358 pass
359 session.add_document(path=name, content=content, description="Generated by run_python")
360 doc = session.get_document(name)
361 if doc:
362 doc.binary_blob = blob
363 doc.mime_type = EXTENSION_MIMES.get(ext, "application/octet-stream")
364 doc.format = ext.lstrip(".")
365 entry["added_to_workspace"] = True
366 entry["type"] = "binary"
367
368 elif ext in TEXT_EXTENSIONS:
369 with open(filepath, "r", encoding="utf-8", errors="replace") as f:
370 text = f.read()
371 session.add_document(path=name, content=text, description="Generated by run_python")
372 doc = session.get_document(name)
373 if doc:
374 doc.mime_type = EXTENSION_MIMES.get(ext, "text/plain")
375 doc.format = "markdown" if ext in (".md", ".markdown") else "text"
376 doc.binary_blob = text.encode('utf-8')
377 entry["added_to_workspace"] = True
378 entry["type"] = "text"
379
380 else:
381
382 with open(filepath, "rb") as f:
383 blob = f.read()
384 session.add_document(path=name, content="", description="Generated by run_python")
385 doc = session.get_document(name)
386 if doc:
387 doc.binary_blob = blob
388 doc.mime_type = "application/octet-stream"
389 doc.format = "other"
390 entry["added_to_workspace"] = True
391 entry["type"] = "binary"
392
393 except Exception as e:
394 logger.error(f"Failed to import output file '{name}': {e}")
395 entry["error"] = str(e)
396
397 imported.append(entry)
398
399 return imported
400
401SUPPORTED_LANGUAGES = {"python", "node"}
402
403_JS_SIGNALS = re.compile(
404 r"""
405 ^\s*(?:
406 const\s+\w+\s*= # const x = ...
407 | let\s+\w+\s*= # let x = ...
408 | var\s+\w+\s*= # var x = ...
409 | (?:async\s+)?function\s* # function / async function
410 | require\s*\( # require(...)
411 | import\s+[\w{,\s*}]+\s+from\s+['"] # import X from '...'
412 | module\.exports\s*= # module.exports = ...
413 )
414 """,
415 re.MULTILINE | re.VERBOSE,
416)
417_PY_SIGNALS = re.compile(
418 r"""
419 ^\s*(?:
420 from\s+[\w.]+\s+import\b # from X import Y
421 | import\s+[\w,\s]+$ # import X, Y (no `from`)
422 | def\s+\w+\s*\( # def name(
423 | class\s+\w+\s*[:\(] # class Name:
424 | print\s*\( # print(
425 | if\s+__name__\s*== # if __name__ ==
426 )
427 """,
428 re.MULTILINE | re.VERBOSE,
429)
430
431_UNICODE_BULLET_TEXTRUN = re.compile(
432 r"""new\s+TextRun\s*\(\s*['"`][•●]""",
433 re.VERBOSE,
434)
435_UNICODE_BULLET_CHILDREN = re.compile(
436 r"""children\s*:\s*\[\s*['"`][•●]""",
437 re.VERBOSE,
438)
439
440def _lint_docx_js_code(code: str) -> Optional[str]:
441 """Return an error string to reject the run, or None to allow.
442
443 Implements the SKILL.md "NEVER use unicode bullets" rule as a pre-
444 execution check on submitted Node code. The skill says to use
445 ``LevelFormat.BULLET`` with a ``numbering`` config, not unicode
446 bullet glyphs inside a TextRun.
447 """
448 for pattern in (_UNICODE_BULLET_TEXTRUN, _UNICODE_BULLET_CHILDREN):
449 if pattern.search(code):
450 return (
451 "Rejected: docx-js code contains a unicode bullet glyph "
452 "(`•` or `●`) as a list marker inside a TextRun or "
453 "children array. The SKILL.md rule is: use "
454 "`LevelFormat.BULLET` with a `numbering` config on the "
455 "Document, not hand-typed bullet characters. Replace "
456 "`new TextRun('• Item')` with a Paragraph that has "
457 "`numbering: { reference: 'bullets', level: 0 }` and "
458 "declare `numbering.config` on the Document per the skill's "
459 "'Lists (NEVER use unicode bullets)' example. Re-submit."
460 )
461 return None
462
463def _docx_is_valid(blob: bytes) -> bool:
464 """Return True if the blob unzips and has word/document.xml. Used to
465 decide whether a prior run_code write was 'good enough' that a second
466 overwrite is the split/skeleton anti-pattern rather than a legitimate
467 self-correction of a broken first attempt."""
468 try:
469 with zipfile.ZipFile(io.BytesIO(blob)) as zf:
470 if "word/document.xml" not in zf.namelist():
471 return False
472
473 try:
474 ET.fromstring(zf.read("word/document.xml"))
475 except ET.ParseError:
476 return False
477 except zipfile.BadZipFile:
478 return False
479 return True
480
481def _detect_split_write(session: "WorkspaceSession", code: str) -> Optional[str]:
482 """Return an error to reject, or None to allow.
483
484 Implements the SKILL.md "one document = one run_code call" rule.
485 Scans the incoming Node code for ``fs.writeFileSync('/sandbox/output/
486 FOO.docx', ...)`` targets. If FOO.docx already exists in the session
487 workspace as a valid DOCX, the incoming call is either a split
488 (writing Part 2 into the same file) or a skeleton-then-fill (rewriting
489 an already-good doc). Either is the anti-pattern and gets rejected.
490
491 If the existing doc fails ``_docx_is_valid`` the model is allowed to
492 rewrite — legitimate self-correction of a broken first attempt.
493 """
494
495 targets = re.findall(
496 r"""fs\.writeFileSync\s*\(\s*['"`](/sandbox/output/[^'"`]+\.docx)['"`]""",
497 code,
498 )
499 for target in targets:
500 name = target.rsplit("/", 1)[-1]
501 existing = session.get_document(name) if hasattr(session, "get_document") else None
502 blob = getattr(existing, "docx_blob", None) if existing else None
503 if not blob:
504 continue
505 if not _docx_is_valid(blob):
506
507 continue
508 return (
509 f"Rejected: `{name}` already exists in the workspace as a valid "
510 f"DOCX ({len(blob):,} bytes) from an earlier run_code call in "
511 f"this session. The SKILL.md rule is: one document = one "
512 f"run_code call, no exceptions. A second write to the same "
513 f"filename is the split-across-calls / skeleton-then-fill "
514 f"anti-pattern. If you need to add content, EXPAND the first "
515 f"call — do NOT overwrite an already-good document. If you "
516 f"need a separate logical document (e.g. a side letter), use "
517 f"a different filename. Re-submit."
518 )
519 return None
520
521def _detect_language_from_code(code: str) -> Optional[str]:
522 """Return ``"node"`` / ``"python"`` / ``None`` based on code content.
523
524 ``None`` means ambiguous — the caller should keep the user-supplied
525 language field (or the default). We only override when the signal is
526 one-sided: JS tokens + no Python tokens ⇒ Node.
527 """
528
529 sample_lines: List[str] = []
530 for ln in code.splitlines():
531 stripped = ln.strip()
532 if not stripped or stripped.startswith("#") or stripped.startswith("//"):
533 continue
534 sample_lines.append(ln)
535 if len(sample_lines) >= 20:
536 break
537 sample = "\n".join(sample_lines)
538 if not sample:
539 return None
540
541 has_js = bool(_JS_SIGNALS.search(sample))
542 has_py = bool(_PY_SIGNALS.search(sample))
543 if has_js and not has_py:
544 return "node"
545 if has_py and not has_js:
546 return "python"
547 return None
548
549def run_code(
550 session: WorkspaceSession,
551 code: str,
552 language: str = "python",
553 input_files: Optional[List[str]] = None,
554 description: str = "",
555 **kwargs
556) -> Dict[str, Any]:
557 """
558 Execute code in a sandboxed Docker container.
559
560 The sandbox image has Python 3.11 + Node.js 20 pre-installed. Other
561 interpreters are reachable via ``subprocess`` from within either language.
562
563 Args:
564 session: Workspace session (for file I/O).
565 code: Source code to execute.
566 language: ``"python"`` (default) or ``"node"``. Selects the interpreter
567 that runs ``code`` directly. For ``"python"`` the code runs
568 through ``/sandbox/run.py`` harness which captures stdout/
569 stderr into ``_result.json``. For ``"node"`` the code runs
570 directly under ``node``; stdout/stderr are captured from
571 the subprocess.
572 input_files: Optional list of workspace document paths to make
573 available under ``/sandbox/input/``.
574 description: Brief description of what the code does.
575
576 Returns:
577 Dict with stdout, stderr, exit_code, and any output files added to
578 the workspace.
579 """
580 start_time = time.time()
581
582 if not code or not code.strip():
583 return {
584 "success": False,
585 "error": (
586 "run_code requires a non-empty 'code' parameter (the "
587 "source code to execute). You called the tool with no "
588 "code — likely your tool_arguments object was empty. "
589 "Retry with the full schema: "
590 "run_code(language=\"python\"|\"node\", code=\"<your code>\", "
591 "input_files=[optional], description=\"<brief>\")."
592 ),
593 }
594
595 language = (language or "python").lower().strip()
596 if language not in SUPPORTED_LANGUAGES:
597 return {
598 "success": False,
599 "error": (
600 f"Unsupported language: {language!r}. "
601 f"Supported: {', '.join(sorted(SUPPORTED_LANGUAGES))}."
602 ),
603 }
604
605 detected = _detect_language_from_code(code)
606 if detected and detected != language:
607 logger.warning(
608 f"[run_code] language mismatch: caller passed {language!r} but "
609 f"code looks like {detected!r} — auto-flipping. First 60 chars: "
610 f"{code.strip()[:60]!r}"
611 )
612 language = detected
613
614 if language == "node":
615 lint_err = _lint_docx_js_code(code)
616 if lint_err:
617 return {"success": False, "error": lint_err}
618 split_err = _detect_split_write(session, code)
619 if split_err:
620 return {"success": False, "error": split_err}
621
622 docker_error = _check_docker_available()
623 if docker_error:
624 return {"success": False, "error": f"Cannot run sandbox: {docker_error}"}
625
626 tmpdir = None
627 try:
628
629 tmpdir = tempfile.mkdtemp(prefix="sandbox_", dir=SANDBOX_TMPDIR)
630 input_dir = os.path.join(tmpdir, "input")
631 output_dir = os.path.join(tmpdir, "output")
632 code_filename = "code.py" if language == "python" else "code.js"
633 code_path = os.path.join(tmpdir, code_filename)
634 os.makedirs(input_dir)
635 os.makedirs(output_dir, mode=0o777)
636 os.chmod(output_dir, 0o777)
637
638 extracted = []
639 if input_files:
640 extracted = _extract_input_files(session, input_files, input_dir)
641
642 with open(code_path, "w", encoding="utf-8") as f:
643 f.write(code)
644
645 # --read-only makes the root filesystem immutable; LibreOffice's
646 # profile dir write goes to /tmp via HOME, which lives on a tmpfs.
647 # The /sandbox/output bind is the only writable surface.
648 cmd = [
649 "docker", "run", "--rm",
650 "--read-only",
651 "--network", "none",
652 "--memory", SANDBOX_MEMORY,
653 "--cpus", SANDBOX_CPUS,
654 "--pids-limit", "128",
655 "--cap-drop", "ALL",
656 "--security-opt", "no-new-privileges",
657 "--tmpfs", "/tmp:size=64m",
658 "-e", "HOME=/tmp",
659 "-v", f"{input_dir}:/sandbox/input:ro",
660 "-v", f"{output_dir}:/sandbox/output",
661 "-v", f"{code_path}:/sandbox/{code_filename}:ro",
662 "--user", "1000:1000",
663 ]
664 if language == "node":
665 cmd += ["--entrypoint", "node", SANDBOX_IMAGE, f"/sandbox/{code_filename}"]
666 else:
667 cmd += [SANDBOX_IMAGE]
668
669 import concurrent.futures
670 try:
671 def _run_sandbox():
672 return subprocess.run(
673 cmd,
674 capture_output=True,
675 text=True,
676 timeout=SANDBOX_TIMEOUT,
677 )
678 with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
679 future = pool.submit(_run_sandbox)
680 proc = future.result(timeout=SANDBOX_TIMEOUT + 10)
681 except (subprocess.TimeoutExpired, concurrent.futures.TimeoutError):
682 return {
683 "success": False,
684 "error": f"Execution timed out after {SANDBOX_TIMEOUT} seconds",
685 "execution_time_ms": (time.time() - start_time) * 1000,
686 "description": description,
687 }
688
689 result_json_path = os.path.join(output_dir, "_result.json")
690 sandbox_result = None
691 if os.path.exists(result_json_path):
692 try:
693 with open(result_json_path, "r") as f:
694 sandbox_result = json.load(f)
695 except (json.JSONDecodeError, IOError):
696 pass
697
698 if sandbox_result:
699 stdout = sandbox_result.get("stdout", "")
700 stderr = sandbox_result.get("stderr", "")
701 exit_code = sandbox_result.get("exit_code", proc.returncode)
702 else:
703 stdout = proc.stdout or ""
704 stderr = proc.stderr or ""
705 exit_code = proc.returncode
706
707 files_created = _import_output_files(session, output_dir)
708
709 validation_errors = []
710 validation_warnings = []
711 for f_entry in files_created:
712 if f_entry.get("type") == "docx" and f_entry.get("added_to_workspace"):
713 try:
714 doc = session.get_document(f_entry["path"])
715 if doc and doc.docx_blob:
716
717 from .validators.docx_fixer import auto_fix_docx
718 is_new = not bool(input_files)
719 fixed_blob, fixes_applied = auto_fix_docx(doc.docx_blob, is_new_document=is_new)
720 if fixes_applied:
721 doc.update_docx(fixed_blob)
722 logger.info(f"Auto-fixed DOCX {f_entry['path']}: {fixes_applied}")
723
724 from .validators.docx_validator import validate_docx_output
725 validation = validate_docx_output(
726 doc.docx_blob,
727 level="full",
728 )
729 if validation.get("repaired_bytes"):
730 doc.update_docx(validation["repaired_bytes"])
731 logger.info(
732 f"XSD auto-repaired {validation['repairs_made']} issues "
733 f"in {f_entry['path']}"
734 )
735 if not validation.get("valid", True):
736 validation_errors.extend(validation.get("errors", []))
737 validation_warnings.extend(validation.get("warnings", []))
738 except ImportError:
739 pass
740 except Exception as e:
741 logger.warning(f"DOCX validation/fix failed for {f_entry['path']}: {e}")
742
743 if any(f.get("added_to_workspace") for f in files_created):
744 session.save()
745
746 execution_time_ms = (time.time() - start_time) * 1000
747 rejected_files = [f for f in files_created if f.get("type") == "rejected"]
748
749 success = exit_code == 0 and not rejected_files
750
751 ephemeral_hint = ""
752 if not success and stderr and "/sandbox/output/" in stderr and "FileNotFoundError" in stderr:
753 ephemeral_hint = (
754 "HINT: Each run_python execution uses a fresh ephemeral container. "
755 "Files from previous runs do NOT persist at /sandbox/output/. "
756 "To modify a previously created document, pass it via input_files "
757 "and open it from /sandbox/input/<filename> instead."
758 )
759
760 result = {
761 "success": success,
762 "stdout": stdout[:MAX_STDOUT],
763 "stderr": stderr[:MAX_STDERR] if stderr else "",
764 "exit_code": exit_code,
765 "execution_time_ms": round(execution_time_ms, 1),
766 "files_created": files_created,
767 "files_input": extracted,
768 "description": description,
769 }
770
771 if ephemeral_hint:
772 result["hint"] = ephemeral_hint
773
774 if not success:
775 parts: List[str] = []
776
777 runtime_label = "Node.js" if language == "node" else "Python"
778 tb_lines = [ln for ln in (stderr or "").splitlines() if ln.strip()]
779 if tb_lines:
780 tail = tb_lines[-1]
781 excerpt = "\n".join(tb_lines[-10:]) if len(tb_lines) > 1 else tail
782 parts.append(
783 f"{runtime_label} exited with code {exit_code}: {tail}\n\n"
784 f"Traceback tail:\n{excerpt}"
785 )
786 elif exit_code != 0:
787 parts.append(
788 f"{runtime_label} exited with code {exit_code} (no stderr output; check stdout)"
789 )
790
791 for rej in rejected_files:
792 parts.append(rej["error"])
793
794 missing_inputs = [f for f in extracted if f.get("type") == "missing"]
795 if missing_inputs:
796 parts.append(
797 "Missing input files: "
798 + ", ".join(f["path"] for f in missing_inputs)
799 + ". Pass the correct workspace path via input_files, "
800 "or omit input_files if creating from scratch."
801 )
802
803 if ephemeral_hint:
804 parts.append(ephemeral_hint)
805
806 result["error"] = "\n\n".join(parts) if parts else "Unknown failure"
807
808 if validation_errors:
809 result["validation_errors"] = validation_errors
810 if validation_warnings:
811 result["validation_warnings"] = validation_warnings
812
813 logger.info(f"DOCX formatting warnings for {[f['path'] for f in files_created]}: {validation_warnings}")
814
815 return result
816
817 except Exception as e:
818 logger.error(f"run_python tool error: {e}", exc_info=True)
819 return {
820 "success": False,
821 "error": f"Sandbox execution error: {str(e)}",
822 "execution_time_ms": round((time.time() - start_time) * 1000, 1),
823 "description": description,
824 }
825
826 finally:
827
828 if tmpdir and os.path.exists(tmpdir):
829 try:
830 shutil.rmtree(tmpdir)
831 except Exception as e:
832 logger.warning(f"Failed to clean up temp dir {tmpdir}: {e}")
833
834PYTHON_TOOLS = {
835 "run_code": run_code,
836}
837