reverse3d 0.4.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 (50) hide show
  1. reverse3d/__init__.py +39 -0
  2. reverse3d/__main__.py +4 -0
  3. reverse3d/_about.py +15 -0
  4. reverse3d/_version.py +1 -0
  5. reverse3d/agent/__init__.py +5 -0
  6. reverse3d/agent/compact.py +110 -0
  7. reverse3d/agent/mcp_server.py +171 -0
  8. reverse3d/agent/prompts.py +173 -0
  9. reverse3d/agent/skill/SKILL.md +53 -0
  10. reverse3d/agent/tools.py +811 -0
  11. reverse3d/autopilot.py +355 -0
  12. reverse3d/cli.py +1054 -0
  13. reverse3d/config.py +167 -0
  14. reverse3d/doctor.py +113 -0
  15. reverse3d/errors.py +83 -0
  16. reverse3d/hardware.py +115 -0
  17. reverse3d/imaging.py +79 -0
  18. reverse3d/logging_utils.py +41 -0
  19. reverse3d/mesh_io.py +152 -0
  20. reverse3d/offline.py +313 -0
  21. reverse3d/profiles.py +33 -0
  22. reverse3d/py.typed +0 -0
  23. reverse3d/recipes.py +394 -0
  24. reverse3d/reconstruction/__init__.py +22 -0
  25. reverse3d/reconstruction/depth_estimation.py +263 -0
  26. reverse3d/reconstruction/photogrammetry.py +460 -0
  27. reverse3d/reconstruction/pointcloud.py +120 -0
  28. reverse3d/rendering/__init__.py +10 -0
  29. reverse3d/rendering/_blender_job.py +284 -0
  30. reverse3d/rendering/blender_render.py +215 -0
  31. reverse3d/rendering/preview.py +80 -0
  32. reverse3d/reverse_eng/__init__.py +33 -0
  33. reverse3d/reverse_eng/geometry_fit.py +478 -0
  34. reverse3d/reverse_eng/measure.py +87 -0
  35. reverse3d/reverse_eng/mesh_cleaner.py +304 -0
  36. reverse3d/reverse_eng/parametric.py +154 -0
  37. reverse3d/security.py +294 -0
  38. reverse3d/setup_agents.py +517 -0
  39. reverse3d/userconfig.py +156 -0
  40. reverse3d/vision/__init__.py +12 -0
  41. reverse3d/vision/feature_matcher.py +272 -0
  42. reverse3d/vision/mask_extractor.py +352 -0
  43. reverse3d/watch.py +238 -0
  44. reverse3d-0.4.0.dist-info/METADATA +650 -0
  45. reverse3d-0.4.0.dist-info/RECORD +50 -0
  46. reverse3d-0.4.0.dist-info/WHEEL +5 -0
  47. reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
  48. reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
  49. reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
  50. reverse3d-0.4.0.dist-info/top_level.txt +1 -0
reverse3d/security.py ADDED
@@ -0,0 +1,294 @@
1
+ """Security primitives shared by every module.
2
+
3
+ Centralising these controls keeps the attack surface reviewable in one place:
4
+
5
+ * **Path sandboxing** — agent-supplied paths are resolved (symlinks included)
6
+ and must stay inside the configured workspace (CWE-22 path traversal).
7
+ * **Input validation** — extension allowlists and size limits for every file
8
+ that is parsed (CWE-400 resource exhaustion, decompression bombs).
9
+ * **Safe process execution** — external tools are launched with an argument
10
+ list, never through a shell (CWE-78 OS command injection), with a hard
11
+ timeout and a scrubbed environment (no credentials leak to child processes).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ import re
19
+ import shutil
20
+
21
+ # Justification for B404: subprocess is only used through run_command(), never with a shell.
22
+ import subprocess # nosec B404
23
+ import sys
24
+ from collections.abc import Iterable, Sequence
25
+ from pathlib import Path
26
+
27
+ from reverse3d.errors import (
28
+ DependencyMissingError,
29
+ ExternalToolError,
30
+ InputValidationError,
31
+ SecurityViolationError,
32
+ )
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"})
37
+ MESH_EXTENSIONS = frozenset({".stl", ".obj", ".ply", ".glb", ".gltf", ".off"})
38
+ POINT_CLOUD_EXTENSIONS = frozenset({".ply", ".xyz", ".pcd"})
39
+ JSON_EXTENSIONS = frozenset({".json"})
40
+
41
+ _STDERR_TAIL_CHARS = 2000
42
+
43
+ # Environment variables matching this pattern are removed before spawning
44
+ # external tools: COLMAP/Blender never need credentials, so a compromised or
45
+ # buggy child process must not be able to read them.
46
+ _SECRET_ENV_PATTERN = re.compile(
47
+ r"(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|SESSION|COOKIE)", re.IGNORECASE
48
+ )
49
+
50
+ # cmd.exe re-parses the command line of .bat/.cmd files, which makes argument
51
+ # lists injectable on Windows ("BatBadBut", CVE-2024-24576 class). We refuse
52
+ # arguments with cmd metacharacters when the target is a batch file.
53
+ _CMD_METACHARS = re.compile(r'[&|<>^%!"\r\n]')
54
+
55
+
56
+ # ─── Paths ────────────────────────────────────────────────────────────────────
57
+
58
+
59
+ def resolve_in_workspace(path: str | os.PathLike[str], workspace: Path) -> Path:
60
+ """Resolve ``path`` (relative paths are relative to ``workspace``) and make
61
+ sure the result lives inside ``workspace``.
62
+
63
+ Symlinks are resolved before the containment check, so a link pointing
64
+ outside the sandbox is rejected as well.
65
+
66
+ Raises:
67
+ SecurityViolationError: if the resolved path escapes the workspace.
68
+ """
69
+ root = workspace.resolve()
70
+ raw = os.fspath(path)
71
+ if os.sep == "/" and "\\" in raw:
72
+ # Agents often send Windows-style paths. Treat "\" as a separator on
73
+ # POSIX too, so "..\\..\\x" cannot pass as a literal file name.
74
+ raw = raw.replace("\\", "/")
75
+ candidate = Path(raw).expanduser()
76
+ if not candidate.is_absolute():
77
+ candidate = root / candidate
78
+ resolved = candidate.resolve()
79
+ if resolved != root and not resolved.is_relative_to(root):
80
+ raise SecurityViolationError(
81
+ "Path is outside the allowed workspace.",
82
+ hint="Use paths inside REVERSE3D_WORKSPACE.",
83
+ )
84
+ return resolved
85
+
86
+
87
+ def validate_input_file(
88
+ path: str | os.PathLike[str],
89
+ *,
90
+ allowed_extensions: Iterable[str],
91
+ max_bytes: int,
92
+ ) -> Path:
93
+ """Check that ``path`` is an existing regular file with an allowed extension
94
+ and a size below ``max_bytes``. Returns the resolved path.
95
+
96
+ Raises:
97
+ InputValidationError: on any violation.
98
+ """
99
+ resolved = Path(path).expanduser().resolve()
100
+ allowed = {e.lower() for e in allowed_extensions}
101
+ if not resolved.is_file():
102
+ raise InputValidationError(f"Input file not found: {resolved.name}")
103
+ if resolved.suffix.lower() not in allowed:
104
+ raise InputValidationError(
105
+ f"Unsupported file type '{resolved.suffix}'.",
106
+ hint=f"Allowed: {', '.join(sorted(allowed))}",
107
+ )
108
+ size = resolved.stat().st_size
109
+ if size == 0:
110
+ raise InputValidationError(f"Input file is empty: {resolved.name}")
111
+ if size > max_bytes:
112
+ raise InputValidationError(
113
+ f"Input file too large ({size / 1e6:.1f} MB > {max_bytes / 1e6:.1f} MB).",
114
+ hint="Raise REVERSE3D_MAX_INPUT_MB if this is expected.",
115
+ )
116
+ return resolved
117
+
118
+
119
+ def validate_input_dir(path: str | os.PathLike[str]) -> Path:
120
+ """Check that ``path`` is an existing directory and return it resolved."""
121
+ resolved = Path(path).expanduser().resolve()
122
+ if not resolved.is_dir():
123
+ raise InputValidationError(f"Input directory not found: {resolved.name}")
124
+ return resolved
125
+
126
+
127
+ def list_images(directory: Path, *, max_images: int, max_bytes: int) -> list[Path]:
128
+ """Return the validated, sorted image files directly inside ``directory``.
129
+
130
+ Raises:
131
+ InputValidationError: if there are no images or more than ``max_images``.
132
+ """
133
+ images = sorted(
134
+ p for p in directory.iterdir() if p.is_file() and p.suffix.lower() in IMAGE_EXTENSIONS
135
+ )
136
+ if not images:
137
+ raise InputValidationError(f"No images found in '{directory.name}'.")
138
+ if len(images) > max_images:
139
+ raise InputValidationError(
140
+ f"Too many images ({len(images)} > {max_images}).",
141
+ hint="Raise REVERSE3D_MAX_IMAGES if this is expected.",
142
+ )
143
+ return [
144
+ validate_input_file(p, allowed_extensions=IMAGE_EXTENSIONS, max_bytes=max_bytes)
145
+ for p in images
146
+ ]
147
+
148
+
149
+ def prepare_output_file(
150
+ path: str | os.PathLike[str],
151
+ *,
152
+ allowed_extensions: Iterable[str],
153
+ overwrite: bool = False,
154
+ ) -> Path:
155
+ """Validate an output file path, create its parent directory and refuse to
156
+ clobber an existing file unless ``overwrite`` is set.
157
+
158
+ Raises:
159
+ InputValidationError: wrong extension, existing file, or path is a dir.
160
+ """
161
+ resolved = Path(path).expanduser().resolve()
162
+ allowed = {e.lower() for e in allowed_extensions}
163
+ if resolved.suffix.lower() not in allowed:
164
+ raise InputValidationError(
165
+ f"Unsupported output type '{resolved.suffix}'.",
166
+ hint=f"Allowed: {', '.join(sorted(allowed))}",
167
+ )
168
+ if resolved.is_dir():
169
+ raise InputValidationError(f"Output path is a directory: {resolved.name}")
170
+ if resolved.exists() and not overwrite:
171
+ raise InputValidationError(
172
+ f"Output file already exists: {resolved.name}",
173
+ hint="Pass overwrite=true (CLI: --force) to replace it.",
174
+ )
175
+ resolved.parent.mkdir(parents=True, exist_ok=True)
176
+ return resolved
177
+
178
+
179
+ def prepare_output_dir(path: str | os.PathLike[str]) -> Path:
180
+ """Create (if needed) and return a resolved output directory."""
181
+ resolved = Path(path).expanduser().resolve()
182
+ if resolved.exists() and not resolved.is_dir():
183
+ raise InputValidationError(f"Output path is not a directory: {resolved.name}")
184
+ resolved.mkdir(parents=True, exist_ok=True)
185
+ return resolved
186
+
187
+
188
+ # ─── External processes ───────────────────────────────────────────────────────
189
+
190
+
191
+ def resolve_executable(names: Sequence[str], override: Path | None, *, tool: str) -> Path:
192
+ """Locate an external executable.
193
+
194
+ ``override`` (from settings) wins and must be an existing file. Otherwise each
195
+ name in ``names`` is searched on ``PATH``.
196
+
197
+ Raises:
198
+ DependencyMissingError: if the binary cannot be found.
199
+ """
200
+ if override is not None:
201
+ candidate = override.expanduser().resolve()
202
+ if not candidate.is_file():
203
+ raise DependencyMissingError(
204
+ f"{tool} executable configured but not found: {candidate}",
205
+ hint=f"Fix REVERSE3D_{tool.upper()} or unset it to search PATH.",
206
+ )
207
+ return candidate
208
+ for name in names:
209
+ found = shutil.which(name)
210
+ if found:
211
+ return Path(found).resolve()
212
+ raise DependencyMissingError(
213
+ f"{tool} was not found on PATH.",
214
+ hint=f"Install {tool} and add it to PATH, or set REVERSE3D_{tool.upper()}.",
215
+ )
216
+
217
+
218
+ def _scrubbed_env(extra: dict[str, str] | None = None) -> dict[str, str]:
219
+ env = {k: v for k, v in os.environ.items() if not _SECRET_ENV_PATTERN.search(k)}
220
+ if extra:
221
+ env.update(extra)
222
+ return env
223
+
224
+
225
+ def _check_batch_arguments(executable: Path, args: Sequence[str]) -> None:
226
+ if sys.platform != "win32" or executable.suffix.lower() not in {".bat", ".cmd"}:
227
+ return
228
+ for arg in args:
229
+ if _CMD_METACHARS.search(arg):
230
+ raise SecurityViolationError(
231
+ "Argument contains characters that are unsafe for a Windows batch launcher.",
232
+ hint="Rename files/folders to avoid & | < > ^ % ! and quotes, "
233
+ "or point REVERSE3D_COLMAP to the real .exe.",
234
+ )
235
+
236
+
237
+ def run_command(
238
+ executable: Path,
239
+ args: Sequence[str | os.PathLike[str]],
240
+ *,
241
+ timeout_s: int,
242
+ cwd: Path | None = None,
243
+ env: dict[str, str] | None = None,
244
+ tool: str = "external tool",
245
+ allow_nonzero: bool = False,
246
+ ) -> subprocess.CompletedProcess[str]:
247
+ """Run ``executable`` with ``args`` safely and return the completed process.
248
+
249
+ * No shell is involved; every argument is passed verbatim.
250
+ * The child inherits a scrubbed environment (no credential-like variables).
251
+ * A hard timeout kills the process tree root on expiry.
252
+
253
+ Args:
254
+ allow_nonzero: return instead of raising on a non-zero exit code
255
+ (used to read ``--help`` output of tools that exit with 1).
256
+
257
+ Raises:
258
+ ExternalToolError: on non-zero exit code (unless allowed) or timeout.
259
+ """
260
+ str_args = [os.fspath(a) for a in args]
261
+ _check_batch_arguments(executable, str_args)
262
+ cmd = [os.fspath(executable), *str_args]
263
+ logger.debug("Running %s with %d arguments", executable.name, len(str_args))
264
+ try:
265
+ # Justification for B603: argument list, shell=False, executable resolved by
266
+ # resolve_executable() and batch-file arguments checked above.
267
+ proc = subprocess.run( # nosec B603
268
+ cmd,
269
+ cwd=os.fspath(cwd) if cwd else None,
270
+ env=_scrubbed_env(env),
271
+ capture_output=True,
272
+ text=True,
273
+ encoding="utf-8",
274
+ errors="replace",
275
+ timeout=timeout_s,
276
+ check=False,
277
+ shell=False,
278
+ )
279
+ except subprocess.TimeoutExpired as exc:
280
+ raise ExternalToolError(
281
+ f"{tool} timed out after {timeout_s}s.",
282
+ hint="Use a lower quality preset or raise REVERSE3D_TIMEOUT_S.",
283
+ ) from exc
284
+ except OSError as exc:
285
+ raise ExternalToolError(f"Could not start {tool}: {exc.strerror}") from exc
286
+
287
+ if proc.returncode != 0 and not allow_nonzero:
288
+ tail = (proc.stderr or proc.stdout or "")[-_STDERR_TAIL_CHARS:]
289
+ raise ExternalToolError(
290
+ f"{tool} exited with code {proc.returncode}.",
291
+ returncode=proc.returncode,
292
+ stderr_tail=tail,
293
+ )
294
+ return proc