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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/maf-sandbox-document)](https://pypi.org/project/maf-sandbox-document/) [![Python](https://img.shields.io/pypi/pyversions/maf-sandbox-document)](https://pypi.org/project/maf-sandbox-document/) [![License](https://img.shields.io/badge/license-MIT-green)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.6
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.