maf-sandbox-document 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.
- maf_sandbox_document/__init__.py +33 -0
- maf_sandbox_document/_guest.py +393 -0
- maf_sandbox_document/_pdf_guard.py +362 -0
- maf_sandbox_document/_tool.py +344 -0
- maf_sandbox_document/py.typed +1 -0
- maf_sandbox_document-0.2.0.dist-info/METADATA +91 -0
- maf_sandbox_document-0.2.0.dist-info/RECORD +9 -0
- maf_sandbox_document-0.2.0.dist-info/WHEEL +4 -0
- maf_sandbox_document-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Guarded document extraction in disposable sandboxes."""
|
|
2
|
+
|
|
3
|
+
import warnings as _warnings
|
|
4
|
+
|
|
5
|
+
from ._tool import (
|
|
6
|
+
DOCUMENT_KIND,
|
|
7
|
+
DOCUMENT_READ_TOOL_NAME,
|
|
8
|
+
document_sandbox_spec,
|
|
9
|
+
make_document_tools,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"DOCUMENT_KIND",
|
|
14
|
+
"DOCUMENT_READ_TOOL_NAME",
|
|
15
|
+
"MafSandboxDocumentExperimentalWarning",
|
|
16
|
+
"document_sandbox_spec",
|
|
17
|
+
"make_document_tools",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MafSandboxDocumentExperimentalWarning(UserWarning):
|
|
22
|
+
"""Warning category for the experimental document kind."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
_warnings.warn(
|
|
27
|
+
"maf_sandbox_document is experimental and may change or be removed without notice.",
|
|
28
|
+
category=MafSandboxDocumentExperimentalWarning,
|
|
29
|
+
stacklevel=2,
|
|
30
|
+
)
|
|
31
|
+
except MafSandboxDocumentExperimentalWarning:
|
|
32
|
+
# The pre-1.0 notice must not prevent imports under -W error.
|
|
33
|
+
pass
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Fixed document converter, executed only in a disposable POSIX guest."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
import struct
|
|
11
|
+
import sys
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
import xml.parsers.expat
|
|
14
|
+
import zipfile
|
|
15
|
+
import zlib
|
|
16
|
+
from contextlib import closing
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
MAX_INPUT_BYTES = 16 * 1024 * 1024
|
|
21
|
+
MAX_MEMBER_BYTES = 16 * 1024 * 1024
|
|
22
|
+
MAX_TOTAL_BYTES = 64 * 1024 * 1024
|
|
23
|
+
MAX_MEMBERS = 2048
|
|
24
|
+
MAX_RATIO = 200
|
|
25
|
+
MAX_PAGE_CHARS = 100_000
|
|
26
|
+
MAX_OUTPUT_BYTES = 32 * 1024 * 1024
|
|
27
|
+
MAX_OUTPUT_TOTAL = 64 * 1024 * 1024
|
|
28
|
+
MAX_METADATA_BYTES = 64 * 1024
|
|
29
|
+
MAX_DIAGNOSTIC = 2000
|
|
30
|
+
MEDIA_TYPES = {
|
|
31
|
+
"pdf": "application/pdf",
|
|
32
|
+
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
33
|
+
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
34
|
+
"rtf": "application/rtf",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Refused(ValueError):
|
|
39
|
+
"""A named document guard refused the input."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def selected_pages(total: int, pages: list[int] | None) -> list[int]:
|
|
43
|
+
"""Clamp a 1-based inclusive range to the available pages."""
|
|
44
|
+
start, stop = pages or [1, total]
|
|
45
|
+
return list(range(min(start, total), min(stop, total) + 1)) if total else []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _xml_guard(data: bytes) -> None:
|
|
49
|
+
def refuse(*args: Any) -> Any:
|
|
50
|
+
raise Refused("XML DTDs and entities are refused")
|
|
51
|
+
|
|
52
|
+
parser = xml.parsers.expat.ParserCreate(namespace_separator="}")
|
|
53
|
+
parser.StartDoctypeDeclHandler = refuse
|
|
54
|
+
parser.EntityDeclHandler = refuse
|
|
55
|
+
parser.ExternalEntityRefHandler = refuse
|
|
56
|
+
parser.SetParamEntityParsing(xml.parsers.expat.XML_PARAM_ENTITY_PARSING_NEVER)
|
|
57
|
+
parser.Parse(data, True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def guarded_zip(data: bytes, kind: str) -> io.BytesIO:
|
|
61
|
+
"""Verify metadata and actual expanded bytes, then rebuild a stored-only archive."""
|
|
62
|
+
rebuilt = io.BytesIO()
|
|
63
|
+
with zipfile.ZipFile(io.BytesIO(data)) as archive, zipfile.ZipFile(rebuilt, "w") as output:
|
|
64
|
+
members = archive.infolist()
|
|
65
|
+
if len(members) > MAX_MEMBERS:
|
|
66
|
+
raise Refused("max_zip_members exceeded")
|
|
67
|
+
total = 0
|
|
68
|
+
names: set[str] = set()
|
|
69
|
+
for member in members:
|
|
70
|
+
name = member.filename
|
|
71
|
+
path = PurePosixPath(name)
|
|
72
|
+
if (
|
|
73
|
+
name in names
|
|
74
|
+
or not name
|
|
75
|
+
or path.is_absolute()
|
|
76
|
+
or ".." in path.parts
|
|
77
|
+
or "\\" in name
|
|
78
|
+
or ":" in name
|
|
79
|
+
or "\x00" in name
|
|
80
|
+
or member.orig_filename != name
|
|
81
|
+
or (member.external_attr >> 16) & 0o170000 == 0o120000
|
|
82
|
+
):
|
|
83
|
+
raise Refused("unsafe ZIP member name")
|
|
84
|
+
names.add(name)
|
|
85
|
+
if member.file_size > MAX_MEMBER_BYTES:
|
|
86
|
+
raise Refused("max_zip_member_bytes exceeded")
|
|
87
|
+
total += member.file_size
|
|
88
|
+
if total > MAX_TOTAL_BYTES:
|
|
89
|
+
raise Refused("max_zip_total_bytes exceeded")
|
|
90
|
+
if member.file_size > max(1, member.compress_size) * MAX_RATIO:
|
|
91
|
+
raise Refused("max_zip_ratio exceeded")
|
|
92
|
+
if member.flag_bits & 1 or member.compress_type not in (0, 8):
|
|
93
|
+
raise Refused("unsupported ZIP compression or encryption")
|
|
94
|
+
if path.suffix.lower() in {".zip", ".docx", ".pptx", ".xlsx", ".jar", ".7z", ".gz"}:
|
|
95
|
+
raise Refused("nested archive members are refused")
|
|
96
|
+
actual_total = 0
|
|
97
|
+
for member in members:
|
|
98
|
+
value = _zip_member(data, archive, member)
|
|
99
|
+
actual_total += len(value)
|
|
100
|
+
if actual_total > MAX_TOTAL_BYTES:
|
|
101
|
+
raise Refused("max_zip_total_bytes exceeded")
|
|
102
|
+
if value.startswith((b"PK\x03\x04", b"PK\x05\x06", b"7z\xbc\xaf", b"\x1f\x8b")):
|
|
103
|
+
raise Refused("nested archive members are refused")
|
|
104
|
+
image = value.startswith((b"\x89PNG\r\n", b"\xff\xd8\xff", b"GIF8", b"BM"))
|
|
105
|
+
if value and not image and not member.is_dir():
|
|
106
|
+
try:
|
|
107
|
+
_xml_guard(value)
|
|
108
|
+
except xml.parsers.expat.ExpatError:
|
|
109
|
+
if member.filename.endswith((".xml", ".rels")):
|
|
110
|
+
raise
|
|
111
|
+
output.writestr(member.filename, value)
|
|
112
|
+
expected = "word/document.xml" if kind == "docx" else "ppt/presentation.xml"
|
|
113
|
+
if expected not in names or "[Content_Types].xml" not in names:
|
|
114
|
+
raise Refused("document type does not match OOXML content")
|
|
115
|
+
if ("ppt/presentation.xml" if kind == "docx" else "word/document.xml") in names:
|
|
116
|
+
raise Refused("ambiguous OOXML content")
|
|
117
|
+
# Both XML members above passed the DTD/entity guard before these structural reads.
|
|
118
|
+
document = ET.fromstring(archive.read(expected)) # nosec B314
|
|
119
|
+
namespace, local_name = (
|
|
120
|
+
("wordprocessingml/2006/main", "document")
|
|
121
|
+
if kind == "docx"
|
|
122
|
+
else ("presentationml/2006/main", "presentation")
|
|
123
|
+
)
|
|
124
|
+
if document.tag != f"{{http://schemas.openxmlformats.org/{namespace}}}{local_name}":
|
|
125
|
+
raise Refused("document type does not match OOXML root")
|
|
126
|
+
types = ET.fromstring(archive.read("[Content_Types].xml")) # nosec B314
|
|
127
|
+
expected_type = MEDIA_TYPES[kind].removesuffix(
|
|
128
|
+
".document" if kind == "docx" else ".presentation"
|
|
129
|
+
)
|
|
130
|
+
expected_type += ".document.main+xml" if kind == "docx" else ".presentation.main+xml"
|
|
131
|
+
declared_types = [
|
|
132
|
+
item.get("ContentType") for item in types if item.get("PartName") == "/" + expected
|
|
133
|
+
]
|
|
134
|
+
if declared_types != [expected_type]:
|
|
135
|
+
raise Refused("document type does not match OOXML content type")
|
|
136
|
+
rebuilt.seek(0)
|
|
137
|
+
return rebuilt
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _zip_member(data: bytes, archive: zipfile.ZipFile, member: zipfile.ZipInfo) -> bytes:
|
|
141
|
+
"""Bound expansion independently of the ZIP member's declared uncompressed size."""
|
|
142
|
+
# ZipFile validates local headers and overlap; ZipExtFile truncates to the declared size.
|
|
143
|
+
with archive.open(member):
|
|
144
|
+
offset = member.header_offset
|
|
145
|
+
name_size, extra_size = struct.unpack_from("<HH", data, offset + 26)
|
|
146
|
+
start = offset + 30 + name_size + extra_size
|
|
147
|
+
stop = start + member.compress_size
|
|
148
|
+
if not 0 <= start <= stop <= len(data):
|
|
149
|
+
raise Refused("invalid ZIP member bounds")
|
|
150
|
+
value = data[start:stop]
|
|
151
|
+
if member.compress_type == zipfile.ZIP_DEFLATED:
|
|
152
|
+
decoder = zlib.decompressobj(-zlib.MAX_WBITS)
|
|
153
|
+
value = decoder.decompress(value, MAX_MEMBER_BYTES + 1)
|
|
154
|
+
if len(value) > MAX_MEMBER_BYTES or decoder.unconsumed_tail:
|
|
155
|
+
raise Refused("max_zip_member_bytes exceeded")
|
|
156
|
+
if not decoder.eof or decoder.unused_data:
|
|
157
|
+
raise Refused("invalid ZIP compressed stream")
|
|
158
|
+
if len(value) > MAX_MEMBER_BYTES:
|
|
159
|
+
raise Refused("max_zip_member_bytes exceeded")
|
|
160
|
+
if len(value) != member.file_size:
|
|
161
|
+
raise Refused("ZIP member size mismatch")
|
|
162
|
+
if zlib.crc32(value) != member.CRC:
|
|
163
|
+
raise Refused("ZIP member CRC mismatch")
|
|
164
|
+
return value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _bounded_text(text: str, maximum: int, *, page: bool = False) -> str:
|
|
168
|
+
if page and len(text) > MAX_PAGE_CHARS:
|
|
169
|
+
raise Refused("max_page_chars exceeded")
|
|
170
|
+
if len(text) > maximum:
|
|
171
|
+
raise Refused("max_chars exceeded")
|
|
172
|
+
return text
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _pdf(data: bytes, config: dict[str, Any]) -> tuple[int, list[int], str]:
|
|
176
|
+
# -I excludes the call directory; load only the separately staged fixed guard.
|
|
177
|
+
import runpy
|
|
178
|
+
|
|
179
|
+
guard = runpy.run_path(str(Path(__file__).with_name("_pdf_guard.py")))
|
|
180
|
+
try:
|
|
181
|
+
guard["check_pdf"](data)
|
|
182
|
+
except ValueError as exc:
|
|
183
|
+
raise Refused(str(exc)) from exc
|
|
184
|
+
pdfium = importlib.import_module("pypdfium2")
|
|
185
|
+
with pdfium.PdfDocument(data) as document:
|
|
186
|
+
count = len(document)
|
|
187
|
+
if count > config["max_pages"]:
|
|
188
|
+
raise Refused("max_pages exceeded")
|
|
189
|
+
chosen = selected_pages(count, config["pages"])
|
|
190
|
+
if not chosen:
|
|
191
|
+
raise Refused("document has no pages")
|
|
192
|
+
if config["format"] == "images" and len(chosen) > config["max_image_pages"]:
|
|
193
|
+
raise Refused("max_image_pages exceeded")
|
|
194
|
+
chunks: list[str] = []
|
|
195
|
+
total = 0
|
|
196
|
+
image_bytes = 0
|
|
197
|
+
for number in chosen:
|
|
198
|
+
with closing(document[number - 1]) as page:
|
|
199
|
+
with closing(page.get_textpage()) as textpage:
|
|
200
|
+
if textpage.count_chars() > MAX_PAGE_CHARS:
|
|
201
|
+
raise Refused("max_page_chars exceeded")
|
|
202
|
+
if total + textpage.count_chars() > config["max_chars"]:
|
|
203
|
+
raise Refused("max_chars exceeded")
|
|
204
|
+
text = textpage.get_text_range()
|
|
205
|
+
total += len(text)
|
|
206
|
+
_bounded_text(text, config["max_chars"], page=True)
|
|
207
|
+
chunks.append(text)
|
|
208
|
+
if config["format"] == "images":
|
|
209
|
+
width, height = page.get_size()
|
|
210
|
+
scale = config["image_dpi"] / 72
|
|
211
|
+
if (
|
|
212
|
+
not all(math.isfinite(v) and v > 0 for v in (width, height))
|
|
213
|
+
or math.ceil(width * scale) * math.ceil(height * scale)
|
|
214
|
+
> config["max_image_pixels"]
|
|
215
|
+
):
|
|
216
|
+
raise Refused("max_image_pixels exceeded")
|
|
217
|
+
with closing(page.render(scale=scale)) as bitmap:
|
|
218
|
+
picture = bitmap.to_pil()
|
|
219
|
+
try:
|
|
220
|
+
picture.save(f"page-{number}.png", format="PNG")
|
|
221
|
+
finally:
|
|
222
|
+
picture.close()
|
|
223
|
+
size = Path(f"page-{number}.png").stat().st_size
|
|
224
|
+
if size > MAX_OUTPUT_BYTES:
|
|
225
|
+
raise Refused("max_output_file_bytes exceeded")
|
|
226
|
+
image_bytes += size
|
|
227
|
+
if image_bytes > MAX_OUTPUT_TOTAL - MAX_METADATA_BYTES:
|
|
228
|
+
raise Refused("max_output_total_bytes exceeded")
|
|
229
|
+
return count, chosen, _bounded_text("\n\n".join(chunks), config["max_chars"])
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _pptx(archive: io.BytesIO, config: dict[str, Any]) -> tuple[int, list[int], str]:
|
|
233
|
+
pptx = importlib.import_module("pptx")
|
|
234
|
+
oxml = importlib.import_module("pptx.oxml")
|
|
235
|
+
etree = importlib.import_module("lxml.etree")
|
|
236
|
+
parser = etree.XMLParser(
|
|
237
|
+
remove_blank_text=True, resolve_entities=False, load_dtd=False, no_network=True
|
|
238
|
+
)
|
|
239
|
+
parser.set_element_class_lookup(oxml.element_class_lookup)
|
|
240
|
+
setattr(oxml, "oxml_parser", parser)
|
|
241
|
+
presentation = pptx.Presentation(archive)
|
|
242
|
+
count = len(presentation.slides)
|
|
243
|
+
if count > config["max_pages"]:
|
|
244
|
+
raise Refused("max_pages exceeded")
|
|
245
|
+
chosen = selected_pages(count, config["pages"])
|
|
246
|
+
if not chosen:
|
|
247
|
+
raise Refused("document has no slides")
|
|
248
|
+
chunks: list[str] = []
|
|
249
|
+
for number in chosen:
|
|
250
|
+
slide = presentation.slides[number - 1]
|
|
251
|
+
parts: list[str] = []
|
|
252
|
+
pending = list(reversed(slide.shapes))
|
|
253
|
+
while pending:
|
|
254
|
+
shape = pending.pop()
|
|
255
|
+
if hasattr(shape, "shapes"):
|
|
256
|
+
pending.extend(reversed(shape.shapes))
|
|
257
|
+
if shape.has_text_frame:
|
|
258
|
+
parts.append(shape.text)
|
|
259
|
+
if shape.has_table:
|
|
260
|
+
for row in shape.table.rows:
|
|
261
|
+
parts.append("\t".join(cell.text for cell in row.cells))
|
|
262
|
+
if slide.has_notes_slide:
|
|
263
|
+
notes = slide.notes_slide.notes_text_frame
|
|
264
|
+
if notes is not None:
|
|
265
|
+
parts.append(notes.text)
|
|
266
|
+
text = _bounded_text("\n".join(parts), config["max_chars"], page=True)
|
|
267
|
+
chunks.append(f"## Slide {number}\n\n{text}" if config["format"] == "markdown" else text)
|
|
268
|
+
_bounded_text("\n\n".join(chunks), config["max_chars"])
|
|
269
|
+
return count, chosen, "\n\n".join(chunks)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _omit_image(image: Any) -> list[Any]:
|
|
273
|
+
return []
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def convert(data: bytes, config: dict[str, Any]) -> dict[str, Any]:
|
|
277
|
+
"""Write every requested output or refuse without reporting extraction success."""
|
|
278
|
+
if len(data) > MAX_INPUT_BYTES:
|
|
279
|
+
raise Refused("max_input_bytes exceeded")
|
|
280
|
+
kind = config["kind"]
|
|
281
|
+
if kind not in MEDIA_TYPES:
|
|
282
|
+
raise Refused("unsupported document format")
|
|
283
|
+
supported = ["text", "markdown"] + (["images"] if kind == "pdf" else [])
|
|
284
|
+
metadata: dict[str, Any] = {
|
|
285
|
+
"schema_version": 1,
|
|
286
|
+
"media_type": MEDIA_TYPES[kind],
|
|
287
|
+
"format": config["format"],
|
|
288
|
+
"supported_formats": supported,
|
|
289
|
+
"pages": None,
|
|
290
|
+
"selected_pages": [],
|
|
291
|
+
"characters": 0,
|
|
292
|
+
"truncated": False,
|
|
293
|
+
"range_clamped": False,
|
|
294
|
+
"verdict": "refused",
|
|
295
|
+
"reason": None,
|
|
296
|
+
"files": [],
|
|
297
|
+
}
|
|
298
|
+
try:
|
|
299
|
+
if config["format"] not in supported:
|
|
300
|
+
raise Refused("requested format is not supported for this document")
|
|
301
|
+
count, chosen = None, []
|
|
302
|
+
if kind == "pdf":
|
|
303
|
+
count, chosen, text = _pdf(data, config)
|
|
304
|
+
elif kind in ("docx", "pptx"):
|
|
305
|
+
archive = guarded_zip(data, kind)
|
|
306
|
+
if kind == "pptx":
|
|
307
|
+
count, chosen, text = _pptx(archive, config)
|
|
308
|
+
else:
|
|
309
|
+
if config["pages"] is not None:
|
|
310
|
+
raise Refused("DOCX page ranges require a pagination engine")
|
|
311
|
+
mammoth = importlib.import_module("mammoth")
|
|
312
|
+
if config["format"] == "markdown":
|
|
313
|
+
result = mammoth.convert_to_markdown(
|
|
314
|
+
archive,
|
|
315
|
+
external_file_access=False,
|
|
316
|
+
include_embedded_style_map=False,
|
|
317
|
+
convert_image=_omit_image,
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
result = mammoth.extract_raw_text(archive)
|
|
321
|
+
text = _bounded_text(result.value, config["max_chars"])
|
|
322
|
+
else:
|
|
323
|
+
if not re.match(rb"\{\\rtf[1-9]", data):
|
|
324
|
+
raise Refused("document type does not match RTF content")
|
|
325
|
+
rtf = importlib.import_module("striprtf.striprtf")
|
|
326
|
+
text = _bounded_text(rtf.rtf_to_text(data.decode("latin-1")), config["max_chars"])
|
|
327
|
+
names = [f"page-{number}.png" for number in chosen]
|
|
328
|
+
if config["format"] != "images":
|
|
329
|
+
names = ["document.md" if config["format"] == "markdown" else "document.txt"]
|
|
330
|
+
Path(names[0]).write_text(text, encoding="utf-8")
|
|
331
|
+
sizes = [Path(name).stat().st_size for name in names]
|
|
332
|
+
if any(size > MAX_OUTPUT_BYTES for size in sizes):
|
|
333
|
+
raise Refused("max_output_file_bytes exceeded")
|
|
334
|
+
if sum(sizes) > MAX_OUTPUT_TOTAL - MAX_METADATA_BYTES:
|
|
335
|
+
raise Refused("max_output_total_bytes exceeded")
|
|
336
|
+
metadata.update(
|
|
337
|
+
pages=count,
|
|
338
|
+
selected_pages=chosen,
|
|
339
|
+
characters=len(text),
|
|
340
|
+
verdict="extracted",
|
|
341
|
+
range_clamped=bool(
|
|
342
|
+
count is not None and config["pages"] and config["pages"][1] > count
|
|
343
|
+
),
|
|
344
|
+
files=names,
|
|
345
|
+
)
|
|
346
|
+
except Refused as exc:
|
|
347
|
+
metadata["reason"] = str(exc)[:MAX_DIAGNOSTIC]
|
|
348
|
+
return metadata
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def main() -> int:
|
|
352
|
+
"""Keep parser errors guest-derived and reserve exit 2 for a definitive refusal."""
|
|
353
|
+
config = json.loads(Path("request.json").read_text("utf-8"))
|
|
354
|
+
metadata: dict[str, Any]
|
|
355
|
+
try:
|
|
356
|
+
data = Path("input.bin").read_bytes()
|
|
357
|
+
metadata = convert(data, config)
|
|
358
|
+
except ImportError:
|
|
359
|
+
print("document converter dependencies are unavailable", file=sys.stderr)
|
|
360
|
+
return 3
|
|
361
|
+
except (
|
|
362
|
+
ValueError,
|
|
363
|
+
RuntimeError,
|
|
364
|
+
KeyError,
|
|
365
|
+
IndexError,
|
|
366
|
+
zipfile.BadZipFile,
|
|
367
|
+
zlib.error,
|
|
368
|
+
ET.ParseError,
|
|
369
|
+
xml.parsers.expat.ExpatError,
|
|
370
|
+
) as exc:
|
|
371
|
+
metadata = {
|
|
372
|
+
"schema_version": 1,
|
|
373
|
+
"verdict": "refused",
|
|
374
|
+
"reason": f"invalid document: {exc}"[:2000],
|
|
375
|
+
"pages": None,
|
|
376
|
+
"selected_pages": [],
|
|
377
|
+
"characters": 0,
|
|
378
|
+
"truncated": False,
|
|
379
|
+
"range_clamped": False,
|
|
380
|
+
"supported_formats": [],
|
|
381
|
+
"format": config["format"],
|
|
382
|
+
"media_type": MEDIA_TYPES.get(config["kind"]),
|
|
383
|
+
"files": [],
|
|
384
|
+
}
|
|
385
|
+
Path("document.json").write_text(json.dumps(metadata, ensure_ascii=True), encoding="utf-8")
|
|
386
|
+
if metadata["verdict"] == "refused":
|
|
387
|
+
print(metadata["reason"], file=sys.stderr)
|
|
388
|
+
return 2
|
|
389
|
+
return 0
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
if __name__ == "__main__":
|
|
393
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Bound supported PDF streams before PDFium sees the document.
|
|
2
|
+
|
|
3
|
+
Only a single classic cross-reference table and losslessly encoded streams are admitted.
|
|
4
|
+
Unsupported structures are refused rather than passed to a second, less bounded parser.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import re
|
|
11
|
+
import zlib
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
MAX_STREAM_BYTES = 32 * 1024 * 1024
|
|
15
|
+
MAX_STREAM_TOTAL = 64 * 1024 * 1024
|
|
16
|
+
MAX_OBJECTS = 100_000
|
|
17
|
+
_SPACE = b"\x00\t\n\f\r "
|
|
18
|
+
_DELIMITERS = _SPACE + b"()<>[]{}/%"
|
|
19
|
+
_LINE_END = re.compile(rb"[\r\n]")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Refused(ValueError):
|
|
23
|
+
"""The document exceeds a guard or uses an unsupported structure."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Tokens:
|
|
27
|
+
def __init__(self, data: bytes) -> None:
|
|
28
|
+
self.data = data
|
|
29
|
+
self.pos = 0
|
|
30
|
+
self.count = 0
|
|
31
|
+
|
|
32
|
+
def space(self) -> None:
|
|
33
|
+
while self.pos < len(self.data):
|
|
34
|
+
if self.data[self.pos] in _SPACE:
|
|
35
|
+
self.pos += 1
|
|
36
|
+
elif self.data[self.pos] == 37:
|
|
37
|
+
end = _LINE_END.search(self.data, self.pos)
|
|
38
|
+
self.pos = end.end() if end else len(self.data)
|
|
39
|
+
else:
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
def word(self) -> bytes:
|
|
43
|
+
self.space()
|
|
44
|
+
start = self.pos
|
|
45
|
+
while self.pos < len(self.data) and self.data[self.pos] not in _DELIMITERS:
|
|
46
|
+
self.pos += 1
|
|
47
|
+
if start == self.pos:
|
|
48
|
+
raise Refused("unsupported PDF syntax")
|
|
49
|
+
return self.data[start : self.pos]
|
|
50
|
+
|
|
51
|
+
def value(self, depth: int = 0) -> Any:
|
|
52
|
+
self.count += 1
|
|
53
|
+
if depth > 32 or self.count > 1_000_000:
|
|
54
|
+
raise Refused("max_pdf_structure exceeded")
|
|
55
|
+
self.space()
|
|
56
|
+
data, start = self.data, self.pos
|
|
57
|
+
if data[start : start + 2] == b"<<":
|
|
58
|
+
self.pos += 2
|
|
59
|
+
result: dict[bytes, Any] = {}
|
|
60
|
+
while True:
|
|
61
|
+
self.space()
|
|
62
|
+
if data[self.pos : self.pos + 2] == b">>":
|
|
63
|
+
self.pos += 2
|
|
64
|
+
return result
|
|
65
|
+
key = self.value(depth + 1)
|
|
66
|
+
if not isinstance(key, bytes) or not key.startswith(b"/") or key in result:
|
|
67
|
+
raise Refused("unsupported PDF dictionary")
|
|
68
|
+
result[key] = self.value(depth + 1)
|
|
69
|
+
if data[start : start + 1] == b"[":
|
|
70
|
+
self.pos += 1
|
|
71
|
+
values: list[Any] = []
|
|
72
|
+
while True:
|
|
73
|
+
self.space()
|
|
74
|
+
if data[self.pos : self.pos + 1] == b"]":
|
|
75
|
+
self.pos += 1
|
|
76
|
+
return values
|
|
77
|
+
values.append(self.value(depth + 1))
|
|
78
|
+
if data[start : start + 1] == b"/":
|
|
79
|
+
self.pos += 1
|
|
80
|
+
name_start = self.pos
|
|
81
|
+
while self.pos < len(data) and data[self.pos] not in _DELIMITERS:
|
|
82
|
+
self.pos += 1
|
|
83
|
+
name = data[name_start : self.pos]
|
|
84
|
+
if re.search(rb"#(?![0-9a-fA-F]{2})", name):
|
|
85
|
+
raise Refused("unsupported PDF name")
|
|
86
|
+
return b"/" + re.sub(rb"#([0-9a-fA-F]{2})", lambda m: bytes([int(m[1], 16)]), name)
|
|
87
|
+
if data[start : start + 1] == b"(":
|
|
88
|
+
self.pos += 1
|
|
89
|
+
nesting = 1
|
|
90
|
+
while self.pos < len(data) and nesting:
|
|
91
|
+
char = data[self.pos]
|
|
92
|
+
self.pos += 1
|
|
93
|
+
if char == 92:
|
|
94
|
+
self.pos += 1
|
|
95
|
+
elif char == 40:
|
|
96
|
+
nesting += 1
|
|
97
|
+
elif char == 41:
|
|
98
|
+
nesting -= 1
|
|
99
|
+
if nesting or self.pos > len(data):
|
|
100
|
+
raise Refused("invalid PDF string")
|
|
101
|
+
return None
|
|
102
|
+
if data[start : start + 1] == b"<":
|
|
103
|
+
end = data.find(b">", start + 1)
|
|
104
|
+
if end < 0 or re.search(
|
|
105
|
+
rb"[^0-9a-fA-F]", data[start + 1 : end].translate(None, _SPACE)
|
|
106
|
+
):
|
|
107
|
+
raise Refused("invalid PDF hex string")
|
|
108
|
+
self.pos = end + 1
|
|
109
|
+
return None
|
|
110
|
+
word = self.word()
|
|
111
|
+
if re.fullmatch(rb"[+-]?\d+", word):
|
|
112
|
+
number = int(word)
|
|
113
|
+
saved = self.pos
|
|
114
|
+
try:
|
|
115
|
+
generation = self.word()
|
|
116
|
+
if generation.isdigit() and self.word() == b"R":
|
|
117
|
+
return (number, int(generation))
|
|
118
|
+
except Refused:
|
|
119
|
+
# Failed lookahead leaves a plain integer, not an indirect reference.
|
|
120
|
+
pass
|
|
121
|
+
self.pos = saved
|
|
122
|
+
return number
|
|
123
|
+
if word in (b"true", b"false", b"null") or re.fullmatch(rb"[+-]?(?:\d+\.\d*|\.\d+)", word):
|
|
124
|
+
return None
|
|
125
|
+
raise Refused("unsupported PDF value")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _decode(data: bytes, filters: Any) -> bytes:
|
|
129
|
+
if filters is None:
|
|
130
|
+
filters = []
|
|
131
|
+
elif isinstance(filters, bytes):
|
|
132
|
+
filters = [filters]
|
|
133
|
+
if not isinstance(filters, list) or len(cast(list[Any], filters)) > 4:
|
|
134
|
+
raise Refused("unsupported PDF stream filter")
|
|
135
|
+
for filter_name in cast(list[Any], filters):
|
|
136
|
+
if filter_name in (b"/FlateDecode", b"/Fl"):
|
|
137
|
+
decoder = zlib.decompressobj()
|
|
138
|
+
data = decoder.decompress(data, MAX_STREAM_BYTES + 1)
|
|
139
|
+
if len(data) > MAX_STREAM_BYTES or decoder.unconsumed_tail:
|
|
140
|
+
raise Refused("max_pdf_stream_bytes exceeded")
|
|
141
|
+
if not decoder.eof or decoder.unused_data:
|
|
142
|
+
raise Refused("invalid PDF compressed stream")
|
|
143
|
+
elif filter_name in (b"/ASCII85Decode", b"/A85"):
|
|
144
|
+
# PDFium stops decoding at these otherwise legal PDF whitespace bytes.
|
|
145
|
+
if b"\x00" in data or b"\f" in data:
|
|
146
|
+
raise Refused("unsupported PDF ASCII85 whitespace")
|
|
147
|
+
raw = data.translate(None, _SPACE).removesuffix(b"~>")
|
|
148
|
+
zeros = raw.count(b"z")
|
|
149
|
+
groups, remainder = divmod(len(raw) - zeros, 5)
|
|
150
|
+
if 4 * (zeros + groups) + max(0, remainder - 1) > MAX_STREAM_BYTES:
|
|
151
|
+
raise Refused("max_pdf_stream_bytes exceeded")
|
|
152
|
+
data = base64.a85decode(raw, adobe=False, ignorechars=_SPACE)
|
|
153
|
+
elif filter_name in (b"/ASCIIHexDecode", b"/AHx"):
|
|
154
|
+
raw = data.translate(None, _SPACE)
|
|
155
|
+
if not raw.endswith(b">"):
|
|
156
|
+
raise Refused("invalid PDF hex stream")
|
|
157
|
+
raw = raw[:-1]
|
|
158
|
+
data = bytes.fromhex((raw + (b"0" if len(raw) % 2 else b"")).decode("ascii"))
|
|
159
|
+
else:
|
|
160
|
+
raise Refused("unsupported PDF stream filter")
|
|
161
|
+
if len(data) > MAX_STREAM_BYTES:
|
|
162
|
+
raise Refused("max_pdf_stream_bytes exceeded")
|
|
163
|
+
return data
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _has_inline_image(data: bytes) -> bool:
|
|
167
|
+
tokens = _Tokens(data)
|
|
168
|
+
while tokens.pos < len(data):
|
|
169
|
+
tokens.space()
|
|
170
|
+
start = tokens.pos
|
|
171
|
+
if data[start : start + 1] in (b"(", b"/", b"<", b"["):
|
|
172
|
+
tokens.value()
|
|
173
|
+
elif start < len(data) and data[start] not in _DELIMITERS:
|
|
174
|
+
word = tokens.word()
|
|
175
|
+
if re.fullmatch(rb"[+\-0-9.]*BI", word):
|
|
176
|
+
return True
|
|
177
|
+
else:
|
|
178
|
+
tokens.pos += 1
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def check_pdf(data: bytes) -> None:
|
|
183
|
+
"""Refuse unbounded, encrypted, incremental or ambiguous PDF input before native parsing."""
|
|
184
|
+
if not re.match(rb"%PDF-1\.[0-7][\r\n]", data):
|
|
185
|
+
raise Refused("unsupported PDF header")
|
|
186
|
+
end = re.search(rb"startxref\s+(\d+)\s+%%EOF\s*$", data)
|
|
187
|
+
if end is None:
|
|
188
|
+
raise Refused("invalid PDF cross-reference")
|
|
189
|
+
offset = int(end[1])
|
|
190
|
+
table = _Tokens(data[offset : end.start()])
|
|
191
|
+
if table.word() != b"xref":
|
|
192
|
+
raise Refused("unsupported PDF cross-reference stream")
|
|
193
|
+
entries: dict[int, tuple[int, int]] = {}
|
|
194
|
+
declared: set[int] = set()
|
|
195
|
+
while True:
|
|
196
|
+
first = table.word()
|
|
197
|
+
if first == b"trailer":
|
|
198
|
+
break
|
|
199
|
+
count = table.word()
|
|
200
|
+
if not first.isdigit() or not count.isdigit():
|
|
201
|
+
raise Refused("invalid PDF cross-reference")
|
|
202
|
+
start, size = int(first), int(count)
|
|
203
|
+
if size > MAX_OBJECTS or len(declared) + size > MAX_OBJECTS:
|
|
204
|
+
raise Refused("max_pdf_objects exceeded")
|
|
205
|
+
for number in range(start, start + size):
|
|
206
|
+
position, generation, state = table.word(), table.word(), table.word()
|
|
207
|
+
if number in declared or not position.isdigit() or not generation.isdigit():
|
|
208
|
+
raise Refused("invalid PDF cross-reference")
|
|
209
|
+
declared.add(number)
|
|
210
|
+
if state == b"n":
|
|
211
|
+
entries[number] = (int(position), int(generation))
|
|
212
|
+
elif state != b"f":
|
|
213
|
+
raise Refused("invalid PDF cross-reference")
|
|
214
|
+
trailer = table.value()
|
|
215
|
+
table.space()
|
|
216
|
+
if (
|
|
217
|
+
not isinstance(trailer, dict)
|
|
218
|
+
or any(key in trailer for key in (b"/Prev", b"/XRefStm", b"/Encrypt"))
|
|
219
|
+
or table.pos != len(table.data)
|
|
220
|
+
):
|
|
221
|
+
raise Refused("unsupported PDF trailer")
|
|
222
|
+
positions = sorted((pos, number, gen) for number, (pos, gen) in entries.items())
|
|
223
|
+
prefix = _Tokens(data[: positions[0][0]] if positions else data[:offset])
|
|
224
|
+
prefix.space()
|
|
225
|
+
if prefix.pos != len(prefix.data):
|
|
226
|
+
raise Refused("unindexed PDF object")
|
|
227
|
+
objects: dict[tuple[int, int], Any] = {}
|
|
228
|
+
streams: dict[tuple[int, int], tuple[dict[bytes, Any], bytes]] = {}
|
|
229
|
+
for index, (pos, number, generation) in enumerate(positions):
|
|
230
|
+
stop = positions[index + 1][0] if index + 1 < len(positions) else offset
|
|
231
|
+
if not 0 < pos < stop <= offset:
|
|
232
|
+
raise Refused("invalid PDF object offset")
|
|
233
|
+
tokens = _Tokens(data[pos:stop])
|
|
234
|
+
if tokens.word() != str(number).encode() or tokens.word() != str(generation).encode():
|
|
235
|
+
raise Refused("invalid PDF object identity")
|
|
236
|
+
if tokens.word() != b"obj":
|
|
237
|
+
raise Refused("invalid PDF object")
|
|
238
|
+
value = tokens.value()
|
|
239
|
+
objects[(number, generation)] = value
|
|
240
|
+
following = tokens.word()
|
|
241
|
+
if following == b"stream" and isinstance(value, dict):
|
|
242
|
+
value = cast(dict[bytes, Any], value)
|
|
243
|
+
if value.get(b"/Type") in (b"/ObjStm", b"/XRef"):
|
|
244
|
+
raise Refused("unsupported PDF object stream")
|
|
245
|
+
tail = tokens.data[tokens.pos :]
|
|
246
|
+
if not tail.startswith((b"\r\n", b"\n")):
|
|
247
|
+
raise Refused("invalid PDF stream delimiter")
|
|
248
|
+
tail = tail[2:] if tail.startswith(b"\r\n") else tail[1:]
|
|
249
|
+
streams[(number, generation)] = (value, tail)
|
|
250
|
+
else:
|
|
251
|
+
tokens.space()
|
|
252
|
+
if following != b"endobj" or tokens.pos != len(tokens.data):
|
|
253
|
+
raise Refused("invalid PDF object ending")
|
|
254
|
+
|
|
255
|
+
def check_references(value: Any) -> None:
|
|
256
|
+
if isinstance(value, tuple):
|
|
257
|
+
if cast(tuple[int, int], value) not in objects:
|
|
258
|
+
raise Refused("unresolved PDF reference")
|
|
259
|
+
elif isinstance(value, dict):
|
|
260
|
+
for nested in cast(dict[bytes, Any], value).values():
|
|
261
|
+
check_references(nested)
|
|
262
|
+
elif isinstance(value, list):
|
|
263
|
+
for nested in cast(list[Any], value):
|
|
264
|
+
check_references(nested)
|
|
265
|
+
|
|
266
|
+
for value in objects.values():
|
|
267
|
+
check_references(value)
|
|
268
|
+
check_references(trailer)
|
|
269
|
+
root_reference = cast(dict[bytes, Any], trailer).get(b"/Root")
|
|
270
|
+
if not isinstance(root_reference, tuple):
|
|
271
|
+
raise Refused("invalid PDF catalog reference")
|
|
272
|
+
root = objects.get(cast(tuple[int, int], root_reference))
|
|
273
|
+
if not isinstance(root, dict) or cast(dict[bytes, Any], root).get(b"/Type") != b"/Catalog":
|
|
274
|
+
raise Refused("invalid PDF catalog")
|
|
275
|
+
total = 0
|
|
276
|
+
decoded_streams: dict[tuple[int, int], bytes] = {}
|
|
277
|
+
for identity, (info, tail) in streams.items():
|
|
278
|
+
length = info.get(b"/Length")
|
|
279
|
+
if isinstance(length, tuple):
|
|
280
|
+
length = objects.get(cast(tuple[int, int], length))
|
|
281
|
+
if type(length) is not int or not 0 <= length <= MAX_STREAM_BYTES:
|
|
282
|
+
raise Refused("max_pdf_stream_bytes exceeded")
|
|
283
|
+
rest = _Tokens(tail[length:])
|
|
284
|
+
if rest.word() != b"endstream" or rest.word() != b"endobj":
|
|
285
|
+
raise Refused("invalid PDF stream length")
|
|
286
|
+
rest.space()
|
|
287
|
+
if rest.pos != len(rest.data) or info.get(b"/DecodeParms") is not None:
|
|
288
|
+
raise Refused("unsupported PDF stream parameters")
|
|
289
|
+
if any(key in info for key in (b"/F", b"/FFilter", b"/FDecodeParms")):
|
|
290
|
+
raise Refused("external PDF streams are refused")
|
|
291
|
+
decoded = _decode(tail[:length], info.get(b"/Filter"))
|
|
292
|
+
total += len(decoded)
|
|
293
|
+
if total > MAX_STREAM_TOTAL:
|
|
294
|
+
raise Refused("max_pdf_stream_total exceeded")
|
|
295
|
+
decoded_streams[identity] = decoded
|
|
296
|
+
|
|
297
|
+
lookups = 0
|
|
298
|
+
|
|
299
|
+
def resolve(value: Any, depth: int = 0) -> Any:
|
|
300
|
+
nonlocal lookups
|
|
301
|
+
lookups += 1
|
|
302
|
+
if depth > 32 or lookups > MAX_OBJECTS * 10:
|
|
303
|
+
raise Refused("max_pdf_structure exceeded")
|
|
304
|
+
if isinstance(value, tuple):
|
|
305
|
+
return resolve(objects[cast(tuple[int, int], value)], depth + 1)
|
|
306
|
+
return value
|
|
307
|
+
|
|
308
|
+
def content(value: Any, depth: int = 0) -> list[bytes]:
|
|
309
|
+
nonlocal lookups
|
|
310
|
+
lookups += 1
|
|
311
|
+
if depth > 32 or lookups > MAX_OBJECTS * 10:
|
|
312
|
+
raise Refused("max_pdf_structure exceeded")
|
|
313
|
+
if isinstance(value, tuple):
|
|
314
|
+
reference = cast(tuple[int, int], value)
|
|
315
|
+
if reference in decoded_streams:
|
|
316
|
+
return [decoded_streams[reference]]
|
|
317
|
+
return content(objects[reference], depth + 1)
|
|
318
|
+
if isinstance(value, list):
|
|
319
|
+
return [chunk for item in cast(list[Any], value) for chunk in content(item, depth + 1)]
|
|
320
|
+
if value is None:
|
|
321
|
+
return []
|
|
322
|
+
raise Refused("invalid PDF content reference")
|
|
323
|
+
|
|
324
|
+
groups: list[list[bytes]] = []
|
|
325
|
+
for identity, (info, _) in streams.items():
|
|
326
|
+
if resolve(info.get(b"/Subtype")) == b"/Form" or resolve(info.get(b"/PatternType")) == 1:
|
|
327
|
+
groups.append([decoded_streams[identity]])
|
|
328
|
+
pending = list(objects.values())
|
|
329
|
+
while pending:
|
|
330
|
+
value = pending.pop()
|
|
331
|
+
if isinstance(value, list):
|
|
332
|
+
pending.extend(cast(list[Any], value))
|
|
333
|
+
if not isinstance(value, dict):
|
|
334
|
+
continue
|
|
335
|
+
info = cast(dict[bytes, Any], value)
|
|
336
|
+
pending.extend(info.values())
|
|
337
|
+
if resolve(info.get(b"/Type")) == b"/Page":
|
|
338
|
+
groups.append(content(info.get(b"/Contents")))
|
|
339
|
+
procedures = resolve(info.get(b"/CharProcs"))
|
|
340
|
+
if isinstance(procedures, dict):
|
|
341
|
+
groups.extend(content(item) for item in cast(dict[bytes, Any], procedures).values())
|
|
342
|
+
appearances = resolve(info.get(b"/AP"))
|
|
343
|
+
if isinstance(appearances, dict):
|
|
344
|
+
for key in (b"/N", b"/R", b"/D"):
|
|
345
|
+
appearance = cast(dict[bytes, Any], appearances).get(key)
|
|
346
|
+
if isinstance(appearance, tuple) and appearance in decoded_streams:
|
|
347
|
+
groups.append(content(appearance))
|
|
348
|
+
else:
|
|
349
|
+
states = resolve(appearance)
|
|
350
|
+
if isinstance(states, dict):
|
|
351
|
+
groups.extend(
|
|
352
|
+
content(item) for item in cast(dict[bytes, Any], states).values()
|
|
353
|
+
)
|
|
354
|
+
elif states is not None:
|
|
355
|
+
raise Refused("invalid PDF appearance reference")
|
|
356
|
+
scanned = 0
|
|
357
|
+
for chunks in groups:
|
|
358
|
+
scanned += sum(map(len, chunks))
|
|
359
|
+
if scanned > MAX_STREAM_TOTAL:
|
|
360
|
+
raise Refused("max_pdf_content_total exceeded")
|
|
361
|
+
if _has_inline_image(b"".join(chunks)):
|
|
362
|
+
raise Refused("inline PDF images are refused")
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Document reads through core's labelled store, call isolation and bounded output delivery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
from importlib.resources import files
|
|
12
|
+
from pathlib import PurePosixPath
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from maf_sandbox import (
|
|
16
|
+
CallerContext,
|
|
17
|
+
Capability,
|
|
18
|
+
Cleanup,
|
|
19
|
+
DeclaredOutput,
|
|
20
|
+
Egress,
|
|
21
|
+
FileStoreProvenance,
|
|
22
|
+
Isolation,
|
|
23
|
+
IsolationScope,
|
|
24
|
+
OsFamily,
|
|
25
|
+
OutputSink,
|
|
26
|
+
SandboxRouter,
|
|
27
|
+
SandboxSpec,
|
|
28
|
+
SourceIntegrity,
|
|
29
|
+
TransferLimits,
|
|
30
|
+
collect_outputs,
|
|
31
|
+
)
|
|
32
|
+
from maf_sandbox.maf import SandboxResult, SandboxToolSession, sandboxed_tool
|
|
33
|
+
|
|
34
|
+
from ._guest import (
|
|
35
|
+
MAX_DIAGNOSTIC,
|
|
36
|
+
MAX_INPUT_BYTES,
|
|
37
|
+
MAX_METADATA_BYTES,
|
|
38
|
+
MAX_OUTPUT_BYTES,
|
|
39
|
+
MAX_OUTPUT_TOTAL,
|
|
40
|
+
selected_pages,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
DOCUMENT_KIND = "document"
|
|
44
|
+
DOCUMENT_READ_TOOL_NAME = "document_read"
|
|
45
|
+
_LOGGER = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def document_sandbox_spec(image: str | None = None) -> SandboxSpec:
|
|
49
|
+
"""Require a disposable, isolated POSIX guest with closed egress for every document."""
|
|
50
|
+
return SandboxSpec(
|
|
51
|
+
kind=DOCUMENT_KIND,
|
|
52
|
+
image=image,
|
|
53
|
+
work_dir="/maf-sandbox/work",
|
|
54
|
+
requires=frozenset({Capability.EXEC, Capability.FILES_IN, Capability.FILES_OUT}),
|
|
55
|
+
requires_os_family=OsFamily.POSIX,
|
|
56
|
+
egress=Egress.CLOSED,
|
|
57
|
+
min_isolation=Isolation.CONTAINER,
|
|
58
|
+
isolation_scope=IsolationScope.CALL,
|
|
59
|
+
min_cleanup=Cleanup.DISPOSE,
|
|
60
|
+
confined_to_guest_call_path=False,
|
|
61
|
+
outputs_named_at_call_time=True,
|
|
62
|
+
files_in=TransferLimits(
|
|
63
|
+
max_bytes_per_file=MAX_INPUT_BYTES,
|
|
64
|
+
max_total_bytes=MAX_INPUT_BYTES + 256 * 1024,
|
|
65
|
+
max_files=4,
|
|
66
|
+
),
|
|
67
|
+
files_out=TransferLimits(
|
|
68
|
+
max_bytes_per_file=MAX_OUTPUT_BYTES,
|
|
69
|
+
max_total_bytes=MAX_OUTPUT_TOTAL,
|
|
70
|
+
max_files=101,
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def make_document_tools(
|
|
76
|
+
router: SandboxRouter | None,
|
|
77
|
+
file_store: Any,
|
|
78
|
+
agent_id: str,
|
|
79
|
+
context: CallerContext,
|
|
80
|
+
sink: OutputSink,
|
|
81
|
+
*,
|
|
82
|
+
image: str | None = None,
|
|
83
|
+
exec_timeout_seconds: float = 60,
|
|
84
|
+
max_pages: int = 200,
|
|
85
|
+
max_chars: int = 1_000_000,
|
|
86
|
+
image_dpi: int = 96,
|
|
87
|
+
max_image_pixels: int = 16_000_000,
|
|
88
|
+
max_image_pages: int = 20,
|
|
89
|
+
formats: tuple[str, ...] = ("text", "markdown", "images"),
|
|
90
|
+
file_store_provenance: FileStoreProvenance | None = None,
|
|
91
|
+
requires_file_integrity: SourceIntegrity | None = None,
|
|
92
|
+
) -> list[Any]:
|
|
93
|
+
"""Attach document_read(file, format, pages) to a store whose read returns bytes.
|
|
94
|
+
|
|
95
|
+
The context supplies the authorized listing. The host selects all limits and the output
|
|
96
|
+
sink; the model selects one listed document and a 1-based inclusive page/slide range.
|
|
97
|
+
"""
|
|
98
|
+
if (
|
|
99
|
+
type(exec_timeout_seconds) not in (int, float)
|
|
100
|
+
or not math.isfinite(exec_timeout_seconds)
|
|
101
|
+
or not 0 < exec_timeout_seconds <= 300
|
|
102
|
+
):
|
|
103
|
+
raise ValueError("exec_timeout_seconds must be finite and in (0, 300]")
|
|
104
|
+
settings = {
|
|
105
|
+
"max_pages": max_pages,
|
|
106
|
+
"max_chars": max_chars,
|
|
107
|
+
"image_dpi": image_dpi,
|
|
108
|
+
"max_image_pixels": max_image_pixels,
|
|
109
|
+
"max_image_pages": max_image_pages,
|
|
110
|
+
}
|
|
111
|
+
ceilings = {
|
|
112
|
+
"max_pages": 10_000,
|
|
113
|
+
"max_chars": 8_000_000,
|
|
114
|
+
"image_dpi": 600,
|
|
115
|
+
"max_image_pixels": 32_000_000,
|
|
116
|
+
"max_image_pages": 100,
|
|
117
|
+
}
|
|
118
|
+
for name, value in settings.items():
|
|
119
|
+
if type(value) is not int or not 1 <= value <= ceilings[name]:
|
|
120
|
+
raise ValueError(f"{name} must be an integer in [1, {ceilings[name]}]")
|
|
121
|
+
if (
|
|
122
|
+
not isinstance(cast(object, formats), tuple)
|
|
123
|
+
or not formats
|
|
124
|
+
or any(value not in ("text", "markdown", "images") for value in formats)
|
|
125
|
+
or len(set(formats)) != len(formats)
|
|
126
|
+
):
|
|
127
|
+
raise ValueError("formats must be a nonempty tuple of distinct text, markdown or images")
|
|
128
|
+
return sandboxed_tool(
|
|
129
|
+
lambda session: _build_tool(
|
|
130
|
+
session, file_store, sink, settings, formats, exec_timeout_seconds
|
|
131
|
+
),
|
|
132
|
+
router=router,
|
|
133
|
+
agent_id=agent_id,
|
|
134
|
+
context=context,
|
|
135
|
+
spec=document_sandbox_spec(image),
|
|
136
|
+
name=DOCUMENT_READ_TOOL_NAME,
|
|
137
|
+
approval_mode="never_require",
|
|
138
|
+
source_integrity=SourceIntegrity.UNTRUSTED,
|
|
139
|
+
result_contract=True,
|
|
140
|
+
verdicts=("extracted", "refused"),
|
|
141
|
+
output_sink=sink,
|
|
142
|
+
file_store_provenance=file_store_provenance,
|
|
143
|
+
requires_file_integrity=requires_file_integrity,
|
|
144
|
+
admission_timeout=max(30, exec_timeout_seconds),
|
|
145
|
+
logger=_LOGGER,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _stopped(sentence: str) -> SandboxResult:
|
|
150
|
+
return SandboxResult(completed=False, trusted_output=(sentence,))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _outputs(
|
|
154
|
+
data: bytes, request: dict[str, Any], call_id: str, refused: bool
|
|
155
|
+
) -> tuple[DeclaredOutput, ...]:
|
|
156
|
+
manifest = json.loads(data)
|
|
157
|
+
if not isinstance(manifest, dict):
|
|
158
|
+
raise ValueError("invalid document metadata")
|
|
159
|
+
manifest = cast(dict[str, Any], manifest)
|
|
160
|
+
if (
|
|
161
|
+
set(manifest)
|
|
162
|
+
!= {
|
|
163
|
+
"schema_version",
|
|
164
|
+
"media_type",
|
|
165
|
+
"format",
|
|
166
|
+
"supported_formats",
|
|
167
|
+
"pages",
|
|
168
|
+
"selected_pages",
|
|
169
|
+
"characters",
|
|
170
|
+
"truncated",
|
|
171
|
+
"range_clamped",
|
|
172
|
+
"verdict",
|
|
173
|
+
"reason",
|
|
174
|
+
"files",
|
|
175
|
+
}
|
|
176
|
+
or type(manifest.get("schema_version")) is not int
|
|
177
|
+
or manifest.get("schema_version") != 1
|
|
178
|
+
or manifest.get("verdict") != ("refused" if refused else "extracted")
|
|
179
|
+
or manifest.get("truncated") is not False
|
|
180
|
+
or manifest.get("format") != request["format"]
|
|
181
|
+
or type(manifest.get("characters")) is not int
|
|
182
|
+
or not 0 <= manifest["characters"] <= request["max_chars"]
|
|
183
|
+
or type(manifest.get("range_clamped")) is not bool
|
|
184
|
+
or (not refused and manifest.get("reason") is not None)
|
|
185
|
+
or (refused and not isinstance(manifest.get("reason"), str))
|
|
186
|
+
):
|
|
187
|
+
raise ValueError("invalid document metadata")
|
|
188
|
+
names: list[str] = []
|
|
189
|
+
if not refused:
|
|
190
|
+
count = manifest.get("pages")
|
|
191
|
+
if request["kind"] in ("pdf", "pptx"):
|
|
192
|
+
if type(count) is not int or not 1 <= count <= request["max_pages"]:
|
|
193
|
+
raise ValueError("invalid page count")
|
|
194
|
+
chosen = selected_pages(count, request["pages"])
|
|
195
|
+
else:
|
|
196
|
+
if count is not None:
|
|
197
|
+
raise ValueError("invalid pagination")
|
|
198
|
+
chosen = []
|
|
199
|
+
if manifest.get("selected_pages") != chosen:
|
|
200
|
+
raise ValueError("invalid selected pages")
|
|
201
|
+
if request["format"] == "images":
|
|
202
|
+
if request["kind"] != "pdf" or len(chosen) > request["max_image_pages"]:
|
|
203
|
+
raise ValueError("invalid image output")
|
|
204
|
+
names = [f"page-{number}.png" for number in chosen]
|
|
205
|
+
else:
|
|
206
|
+
names = ["document.md" if request["format"] == "markdown" else "document.txt"]
|
|
207
|
+
if manifest.get("files") != names:
|
|
208
|
+
raise ValueError("invalid output manifest")
|
|
209
|
+
media = {
|
|
210
|
+
".png": "image/png",
|
|
211
|
+
".txt": "text/plain",
|
|
212
|
+
".md": "text/markdown",
|
|
213
|
+
".json": "application/json",
|
|
214
|
+
}
|
|
215
|
+
return tuple(
|
|
216
|
+
DeclaredOutput(
|
|
217
|
+
path=f"{call_id}/{name}", name=name, media_type=media[PurePosixPath(name).suffix]
|
|
218
|
+
)
|
|
219
|
+
for name in [*names, "document.json"]
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _build_tool(
|
|
224
|
+
session: SandboxToolSession,
|
|
225
|
+
store: Any,
|
|
226
|
+
sink: OutputSink,
|
|
227
|
+
settings: dict[str, int],
|
|
228
|
+
formats: tuple[str, ...],
|
|
229
|
+
timeout: float,
|
|
230
|
+
) -> Callable[..., Awaitable[SandboxResult]]:
|
|
231
|
+
programs = {
|
|
232
|
+
name: files("maf_sandbox_document").joinpath(name).read_text("utf-8")
|
|
233
|
+
for name in ("_guest.py", "_pdf_guard.py")
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async def document_read(
|
|
237
|
+
file: str, format: str = "text", pages: str | None = None
|
|
238
|
+
) -> SandboxResult:
|
|
239
|
+
"""Read one listed PDF, DOCX, PPTX or RTF as text, Markdown or PDF page PNGs.
|
|
240
|
+
|
|
241
|
+
pages is a 1-based inclusive range such as "2-5" or "3", clamped to the available
|
|
242
|
+
pages or slides. DOCX ranges are refused; RTF ignores pages. Unsupported formats
|
|
243
|
+
and documents exceeding a cap are refused. Output includes document.json metadata.
|
|
244
|
+
"""
|
|
245
|
+
if not isinstance(cast(object, file), str) or not file or format not in formats:
|
|
246
|
+
return _stopped("Error: choose one listed file and an enabled output format")
|
|
247
|
+
page_range = None
|
|
248
|
+
if pages is not None:
|
|
249
|
+
if not isinstance(cast(object, pages), str) or not re.fullmatch(
|
|
250
|
+
r"[1-9][0-9]{0,8}(-[1-9][0-9]{0,8})?", pages
|
|
251
|
+
):
|
|
252
|
+
return _stopped("Error: pages must be a positive 1-based inclusive range")
|
|
253
|
+
ends = [int(part) for part in pages.split("-")]
|
|
254
|
+
page_range = [ends[0], ends[-1]]
|
|
255
|
+
if page_range[0] > page_range[1]:
|
|
256
|
+
return _stopped("Error: pages must be an ascending range")
|
|
257
|
+
listing = await session.list_files(store)
|
|
258
|
+
if isinstance(listing, str):
|
|
259
|
+
return _stopped(listing)
|
|
260
|
+
matches = [item for item in listing if item.name == file]
|
|
261
|
+
if len(matches) != 1:
|
|
262
|
+
return _stopped("Error: file must name exactly one visible stored document")
|
|
263
|
+
item = await session.read_file(store, matches[0], at="file", hidden=True)
|
|
264
|
+
if isinstance(item, str):
|
|
265
|
+
return _stopped(item)
|
|
266
|
+
if item is None or item.uri is None or not item.uri.startswith("data:"):
|
|
267
|
+
return _stopped("Error: the document store must return binary bytes")
|
|
268
|
+
try:
|
|
269
|
+
encoded = item.uri.split(",", 1)[1]
|
|
270
|
+
if len(encoded) > (MAX_INPUT_BYTES + 2) // 3 * 4:
|
|
271
|
+
return _stopped("Error: max_input_bytes exceeded")
|
|
272
|
+
data = base64.b64decode(encoded, validate=True)
|
|
273
|
+
except (ValueError, IndexError):
|
|
274
|
+
return _stopped("Error: the document store returned invalid binary content")
|
|
275
|
+
if len(data) > MAX_INPUT_BYTES:
|
|
276
|
+
return _stopped("Error: max_input_bytes exceeded")
|
|
277
|
+
key = session.key()
|
|
278
|
+
if isinstance(key, str):
|
|
279
|
+
return _stopped(key)
|
|
280
|
+
sandbox = await session.acquire(key)
|
|
281
|
+
if isinstance(sandbox, str):
|
|
282
|
+
return _stopped(sandbox)
|
|
283
|
+
guest_directory = session.guest_call_path()
|
|
284
|
+
call_id = guest_directory.rsplit("/", 1)[-1]
|
|
285
|
+
request = dict(
|
|
286
|
+
settings,
|
|
287
|
+
kind=PurePosixPath(file).suffix.lower().removeprefix("."),
|
|
288
|
+
format=format,
|
|
289
|
+
pages=page_range,
|
|
290
|
+
)
|
|
291
|
+
try:
|
|
292
|
+
await sandbox.write_file("input.bin", data, working_directory=guest_directory)
|
|
293
|
+
await sandbox.write_file(
|
|
294
|
+
"request.json", json.dumps(request), working_directory=guest_directory
|
|
295
|
+
)
|
|
296
|
+
for name, program in programs.items():
|
|
297
|
+
await sandbox.write_file(name, program, working_directory=guest_directory)
|
|
298
|
+
result = await sandbox.exec(
|
|
299
|
+
["python3", "-I", "_guest.py"], working_directory=guest_directory, timeout=timeout
|
|
300
|
+
)
|
|
301
|
+
except TimeoutError:
|
|
302
|
+
return _stopped(f"Error: document conversion timed out after {timeout:g}s")
|
|
303
|
+
except Exception:
|
|
304
|
+
_LOGGER.warning("document_read: staging or execution failed", exc_info=True)
|
|
305
|
+
return _stopped("Error: could not run the document converter")
|
|
306
|
+
if result.exit_code not in (0, 2):
|
|
307
|
+
diagnostic = result.stdout if result.producer_owns_stderr else result.stderr
|
|
308
|
+
return SandboxResult(
|
|
309
|
+
completed=False,
|
|
310
|
+
trusted_output=("Error: document conversion did not complete",),
|
|
311
|
+
output=(diagnostic[:MAX_DIAGNOSTIC],),
|
|
312
|
+
)
|
|
313
|
+
refused = result.exit_code == 2
|
|
314
|
+
try:
|
|
315
|
+
metadata = await sandbox.read_file(
|
|
316
|
+
"document.json", working_directory=guest_directory, max_bytes=MAX_METADATA_BYTES
|
|
317
|
+
)
|
|
318
|
+
outputs = _outputs(metadata, request, call_id, refused)
|
|
319
|
+
landed = await collect_outputs(
|
|
320
|
+
sandbox,
|
|
321
|
+
session.spec,
|
|
322
|
+
sink=sink,
|
|
323
|
+
outputs=outputs,
|
|
324
|
+
call_id=call_id,
|
|
325
|
+
observer=session.observer,
|
|
326
|
+
key=key,
|
|
327
|
+
)
|
|
328
|
+
if len(landed) != len(outputs):
|
|
329
|
+
return _stopped("Error: not every document output was delivered")
|
|
330
|
+
except Exception:
|
|
331
|
+
_LOGGER.warning("document_read: output delivery failed", exc_info=True)
|
|
332
|
+
return _stopped("Error: document output verification or delivery failed")
|
|
333
|
+
diagnostic = result.stdout if result.producer_owns_stderr else result.stderr
|
|
334
|
+
return SandboxResult(
|
|
335
|
+
completed=True,
|
|
336
|
+
verdict="refused" if refused else "extracted",
|
|
337
|
+
output=tuple(item.display for item in landed)
|
|
338
|
+
+ ((diagnostic[:MAX_DIAGNOSTIC],) if refused else ()),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
document_read.__doc__ = (
|
|
342
|
+
f"{document_read.__doc__}\nEnabled output formats: {', '.join(formats)}."
|
|
343
|
+
)
|
|
344
|
+
return document_read
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: maf-sandbox-document
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Guarded PDF, DOCX, PPTX and RTF extraction in disposable sandboxes.
|
|
5
|
+
Keywords: document,pdf,sandbox,agent-framework
|
|
6
|
+
Author: SOKOLAI BV
|
|
7
|
+
Author-email: SOKOLAI BV <info@sokolai.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Requires-Dist: maf-sandbox>=0.52.0,<0.53
|
|
19
|
+
Requires-Dist: agent-framework-core>=1.23.0,<1.24
|
|
20
|
+
Requires-Python: >=3.12, <3.15
|
|
21
|
+
Project-URL: Homepage, https://www.sokol.ai
|
|
22
|
+
Project-URL: Source, https://github.com/sokolaidev/maf-extensions
|
|
23
|
+
Project-URL: Issues, https://github.com/sokolaidev/maf-extensions/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# maf-sandbox-document
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/maf-sandbox-document/) [](https://pypi.org/project/maf-sandbox-document/) [](https://github.com/sokolaidev/maf-extensions/blob/main/packages/maf-sandbox-document/LICENSE)
|
|
29
|
+
|
|
30
|
+
> **Experimental.** Releases before 1.0 may change or remove APIs. Importing this package emits `MafSandboxDocumentExperimentalWarning`.
|
|
31
|
+
|
|
32
|
+
Read PDF, DOCX, PPTX and RTF files through a fixed converter in a disposable sandbox. The model chooses a listed file, output format and optional page range; it supplies no converter code. Parsers and archive/XML guards run in the guest.
|
|
33
|
+
|
|
34
|
+
This package is experimental and not yet published. Its first release requires the core release containing binary file-store reads; release core before this package. The image must also have a completed, currently eligible `document` release before the kind publishes.
|
|
35
|
+
|
|
36
|
+
## Attach the tool
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from maf_sandbox_document import make_document_tools
|
|
40
|
+
|
|
41
|
+
[document_read] = make_document_tools(
|
|
42
|
+
router,
|
|
43
|
+
file_store,
|
|
44
|
+
"document-reader",
|
|
45
|
+
context,
|
|
46
|
+
sink,
|
|
47
|
+
image=verified_document_image,
|
|
48
|
+
formats=("text", "markdown"), # Enable images only for a host that can consume them.
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The host supplies a store with `async read(name) -> bytes | None`, and `context.list_files(store)` returns authorized `ListedFile` entries. Text-only AgentFileStore implementations need a binary adapter. Names are matched exactly, never interpreted as host paths, guest paths or URLs. The host must bound ingress bytes before the store returns them; the kind independently checks its 16 MiB input limit.
|
|
53
|
+
|
|
54
|
+
Use `file_store_provenance` for stores that may change during a call and `requires_file_integrity` when admission requires an established source label. Completion and the `extracted`/`refused` verdict are trusted. Document text, metadata, images, delivery references and converter diagnostics are guest-derived output. Core derives their effective label from the file read, model arguments and conversation.
|
|
55
|
+
|
|
56
|
+
The router must allow at least container isolation and supply POSIX `EXEC`, `FILES_IN` and `FILES_OUT` with closed egress. Every call requires its own sandbox and disposal, including refusal, failure and cancellation.
|
|
57
|
+
|
|
58
|
+
## Tool contract
|
|
59
|
+
|
|
60
|
+
`document_read(file, format="text", pages=None)` accepts `text`, `markdown` or `images`. `pages` is a positive, 1-based inclusive range such as `"2-5"` or `"3"`. PDF ranges select pages; PPTX ranges select slides. Both endpoints clamp to the available last page. DOCX has no reliable pagination without a layout engine, so an explicit range is refused. RTF ignores ranges.
|
|
61
|
+
|
|
62
|
+
| Input | Text / Markdown | Images | Limits |
|
|
63
|
+
|---|---|---|---|
|
|
64
|
+
| PDF | PDFium text in reading order; Markdown retains that text | PNG pages at the host's fixed DPI | Supported PDF subset below; no OCR |
|
|
65
|
+
| DOCX | Mammoth text or Markdown conversion | Refused | No pagination or faithful layout |
|
|
66
|
+
| PPTX | Slide text, table cells and notes; Markdown slide headings | Refused | No office renderer |
|
|
67
|
+
| RTF (`application/rtf`, `text/rtf`) | striprtf text, also usable as plain Markdown | Refused | Page range ignored |
|
|
68
|
+
|
|
69
|
+
Outputs are `document.txt` (`text/plain`), `document.md` (`text/markdown`) or `page-N.png` (`image/png`), plus `document.json` (`application/json`). Image names preserve original page numbers. An extracted verdict requires delivery of every requested output. Guard refusals deliver only the metadata and diagnostic. Invalid arguments, missing files, acquisition failures, timeouts, operational exits and incomplete delivery return `completed=False` with no verdict.
|
|
70
|
+
|
|
71
|
+
Metadata schema version 1 contains `media_type`, `format`, `supported_formats`, `pages` (a page/slide count, or null), `selected_pages`, `characters`, `truncated` (always false), `range_clamped`, `verdict`, `reason` and `files`. A refusal never represents capped content as a successful extraction.
|
|
72
|
+
|
|
73
|
+
## Host options and guards
|
|
74
|
+
|
|
75
|
+
| Option | Default | Allowed |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `exec_timeout_seconds` | 60 | Finite number greater than 0, at most 300 |
|
|
78
|
+
| `max_pages` | 200 | 1–10,000 |
|
|
79
|
+
| `max_chars` | 1,000,000 | 1–8,000,000 |
|
|
80
|
+
| `image_dpi` | 96 | 1–600 |
|
|
81
|
+
| `max_image_pixels` | 16,000,000 | 1–32,000,000 per page |
|
|
82
|
+
| `max_image_pages` | 20 | 1–100 per call |
|
|
83
|
+
| `formats` | text, markdown, images | Nonempty tuple of distinct enabled formats |
|
|
84
|
+
|
|
85
|
+
OOXML is checked before Mammoth or python-pptx opens it: at most 2,048 ZIP entries, 16 MiB per expanded member, 64 MiB total, and a declared expansion ratio at most 200. Absolute/traversing/duplicate names, symlinks, encryption, unsupported compression and nested archives are refused. Bounded decompression verifies stream completion, actual lengths and CRCs independently of declared member sizes; converters receive a rebuilt, stored-only ZIP. XML preflight refuses DTDs and entity declarations, including UTF-16 declarations and XML in nonstandard member names. python-pptx uses a parser with entity resolution, DTD loading and network access disabled. Mammoth's pinned minidom path receives only the preflighted XML; external file access and embedded style maps are disabled.
|
|
86
|
+
|
|
87
|
+
PDF preflight admits a single classic cross-reference table, bounded objects and streams with no compression, Flate, ASCII85 or ASCIIHex. Each decoded stream is at most 32 MiB, with 64 MiB total. Drawing syntax is checked in page content, forms, annotation appearances, tiling patterns and Type 3 glyph programs, with at most 64 MiB of content including repeated uses; binary image and font bytes are not interpreted as drawing commands. Object/cross-reference streams, incremental or encrypted PDFs, external streams, inline images, other filters (including JPEG/JPEG2000) and decoding parameters are refused. ASCII85 streams containing NUL or form-feed whitespace are also refused because the pinned PDFium decoder stops at those bytes. This deliberately restricts compatibility: PDFium has no public per-stream allocation limit, so unsupported structures are not passed to it. A host can prepare an accepted PDF before ingress; this kind never silently falls back to an unbounded parser. Text is capped at 100,000 characters per PDF page or PPTX slide as well as the configured total.
|
|
88
|
+
|
|
89
|
+
Outputs are capped at 32 MiB per file and 64 MiB total including metadata, independently of backend transfer limits. The execution deadline covers preflight and conversion together. Backend resource limits remain necessary for native parser allocations, CPU and temporary storage; these guards do not establish immunity to parser exploits.
|
|
90
|
+
|
|
91
|
+
See the [document contract](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/kinds/document.md) and [image guide](https://github.com/sokolaidev/maf-extensions/blob/main/images/document-sandbox/README.md) for verification and release evidence.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
maf_sandbox_document/__init__.py,sha256=WZf0ySMb3Oa4dk-xBs7_FWUUWbISJ-phvLOosiJtREc,829
|
|
2
|
+
maf_sandbox_document/_guest.py,sha256=LjEEns9fprgSLsSWQCIEKO2-Gm_hNXjcut3kYdGT3hc,16626
|
|
3
|
+
maf_sandbox_document/_pdf_guard.py,sha256=EQ7eptdB82_eLVeycU02i-vvLNbqFGKrmdth-TNNbqs,15447
|
|
4
|
+
maf_sandbox_document/_tool.py,sha256=TVAusS3F6mNgCMi66ZurwsEJbWwsUAQvWSLuSVNq-Yw,13190
|
|
5
|
+
maf_sandbox_document/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
6
|
+
maf_sandbox_document-0.2.0.dist-info/licenses/LICENSE,sha256=CuJYlGnKM4A7I0_kdsEipKMk2vPYG1YyyZZMUDqYbOI,1067
|
|
7
|
+
maf_sandbox_document-0.2.0.dist-info/WHEEL,sha256=3NO1oYsF7mANFUWVdyEQLM-zmecczWWEmmF5rEbXrWk,80
|
|
8
|
+
maf_sandbox_document-0.2.0.dist-info/METADATA,sha256=j7bCYijY0aQYXtYfeL2oZJRelSm0os2kq5miRTOwAJc,8027
|
|
9
|
+
maf_sandbox_document-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SOKOLAI BV
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|