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.
- reverse3d/__init__.py +39 -0
- reverse3d/__main__.py +4 -0
- reverse3d/_about.py +15 -0
- reverse3d/_version.py +1 -0
- reverse3d/agent/__init__.py +5 -0
- reverse3d/agent/compact.py +110 -0
- reverse3d/agent/mcp_server.py +171 -0
- reverse3d/agent/prompts.py +173 -0
- reverse3d/agent/skill/SKILL.md +53 -0
- reverse3d/agent/tools.py +811 -0
- reverse3d/autopilot.py +355 -0
- reverse3d/cli.py +1054 -0
- reverse3d/config.py +167 -0
- reverse3d/doctor.py +113 -0
- reverse3d/errors.py +83 -0
- reverse3d/hardware.py +115 -0
- reverse3d/imaging.py +79 -0
- reverse3d/logging_utils.py +41 -0
- reverse3d/mesh_io.py +152 -0
- reverse3d/offline.py +313 -0
- reverse3d/profiles.py +33 -0
- reverse3d/py.typed +0 -0
- reverse3d/recipes.py +394 -0
- reverse3d/reconstruction/__init__.py +22 -0
- reverse3d/reconstruction/depth_estimation.py +263 -0
- reverse3d/reconstruction/photogrammetry.py +460 -0
- reverse3d/reconstruction/pointcloud.py +120 -0
- reverse3d/rendering/__init__.py +10 -0
- reverse3d/rendering/_blender_job.py +284 -0
- reverse3d/rendering/blender_render.py +215 -0
- reverse3d/rendering/preview.py +80 -0
- reverse3d/reverse_eng/__init__.py +33 -0
- reverse3d/reverse_eng/geometry_fit.py +478 -0
- reverse3d/reverse_eng/measure.py +87 -0
- reverse3d/reverse_eng/mesh_cleaner.py +304 -0
- reverse3d/reverse_eng/parametric.py +154 -0
- reverse3d/security.py +294 -0
- reverse3d/setup_agents.py +517 -0
- reverse3d/userconfig.py +156 -0
- reverse3d/vision/__init__.py +12 -0
- reverse3d/vision/feature_matcher.py +272 -0
- reverse3d/vision/mask_extractor.py +352 -0
- reverse3d/watch.py +238 -0
- reverse3d-0.4.0.dist-info/METADATA +650 -0
- reverse3d-0.4.0.dist-info/RECORD +50 -0
- reverse3d-0.4.0.dist-info/WHEEL +5 -0
- reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
- reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
- reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
- 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
|