brainiac-cli 0.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. brain/__init__.py +39 -0
  2. brain/__main__.py +14 -0
  3. brain/_assets/AGENTS.md +564 -0
  4. brain/_assets/overlay/template/brand/brand-guide.md +24 -0
  5. brain/_assets/overlay/template/keywords/glossary.md +15 -0
  6. brain/_assets/overlay/template/people/roster.md +14 -0
  7. brain/_assets/overlay/template/voice/voice-profile.md +34 -0
  8. brain/_assets/routines/manifest.json +257 -0
  9. brain/_assets/scripts/brain-brief-mac.plist +59 -0
  10. brain/_assets/scripts/brain-brief.sh +74 -0
  11. brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
  12. brain/_assets/scripts/brain-synthesis.sh +214 -0
  13. brain/_assets/scripts/install-brief-mac.sh +152 -0
  14. brain/_assets/scripts/install-brief-windows.ps1 +97 -0
  15. brain/_assets/scripts/register_tasks.py +386 -0
  16. brain/_assets/templates/company.md +21 -0
  17. brain/_assets/templates/concept.md +27 -0
  18. brain/_assets/templates/daily.md +20 -0
  19. brain/_assets/templates/decision.md +42 -0
  20. brain/_assets/templates/meeting.md +33 -0
  21. brain/_assets/templates/person.md +20 -0
  22. brain/_assets/templates/project.md +23 -0
  23. brain/_assets/templates/state-moc.md +40 -0
  24. brain/_version.py +12 -0
  25. brain/anchor.py +121 -0
  26. brain/audit.py +422 -0
  27. brain/backup.py +210 -0
  28. brain/brief.py +417 -0
  29. brain/capture.py +117 -0
  30. brain/chunk.py +249 -0
  31. brain/classification.py +134 -0
  32. brain/cli.py +1906 -0
  33. brain/config.py +368 -0
  34. brain/connect.py +362 -0
  35. brain/context.py +108 -0
  36. brain/core.py +3018 -0
  37. brain/doctor.py +1161 -0
  38. brain/egress.py +148 -0
  39. brain/embed.py +857 -0
  40. brain/encryption.py +217 -0
  41. brain/frontmatter.py +102 -0
  42. brain/golden_probe.py +678 -0
  43. brain/graph.py +369 -0
  44. brain/graphify.py +352 -0
  45. brain/index.py +1576 -0
  46. brain/ingest/__init__.py +19 -0
  47. brain/ingest/handlers/__init__.py +43 -0
  48. brain/ingest/handlers/base.py +95 -0
  49. brain/ingest/handlers/docx.py +78 -0
  50. brain/ingest/handlers/email.py +228 -0
  51. brain/ingest/handlers/html.py +142 -0
  52. brain/ingest/handlers/image.py +91 -0
  53. brain/ingest/handlers/pdf.py +99 -0
  54. brain/ingest/handlers/pptx.py +69 -0
  55. brain/ingest/handlers/tables.py +41 -0
  56. brain/ingest/handlers/text.py +43 -0
  57. brain/ingest/handlers/xlsx.py +100 -0
  58. brain/ingest/handlers/zip.py +163 -0
  59. brain/ingest/pipeline.py +839 -0
  60. brain/ingest/transcript.py +158 -0
  61. brain/init.py +870 -0
  62. brain/maintenance.py +2266 -0
  63. brain/mcp_adapter.py +217 -0
  64. brain/multihop.py +232 -0
  65. brain/notes.py +195 -0
  66. brain/overlay.py +183 -0
  67. brain/projection.py +79 -0
  68. brain/rerank.py +425 -0
  69. brain/snapshot.py +231 -0
  70. brain/update.py +743 -0
  71. brain/vectors.py +225 -0
  72. brainiac_cli-0.16.0.dist-info/METADATA +306 -0
  73. brainiac_cli-0.16.0.dist-info/RECORD +77 -0
  74. brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
  75. brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
  76. brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
  77. brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,91 @@
1
+ """Image handler — Pillow for metadata, optional LOCAL-only OCR (pytesseract
2
+ + the system tesseract binary). ADR-0003 Ruling 1(g): image OCR degrades
3
+ gracefully when the local engine is absent; there is NO cloud OCR code path
4
+ at all in this kernel (never optional, never a fallback) — a screenshot with
5
+ no local OCR available simply becomes a metadata-only note, never a quarantine.
6
+
7
+ ponytail: no HEIC/HEIF (would need the extra pillow-heif optional dep) — add
8
+ if a real HEIC drop shows up; Pillow alone already covers the common formats.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from .base import ExtractResult, Handler, density_gate
15
+
16
+ try:
17
+ from PIL import Image
18
+ _HAS_PIL = True
19
+ except ImportError: # pragma: no cover - exercised via degraded-deps test
20
+ _HAS_PIL = False
21
+
22
+ try:
23
+ import pytesseract
24
+ _HAS_PYTESSERACT = True
25
+ except ImportError: # pragma: no cover
26
+ _HAS_PYTESSERACT = False
27
+
28
+ MAX_IMAGE_BYTES = 100 * 1024 * 1024
29
+
30
+
31
+ def _ocr(img: "Image.Image") -> tuple[str, list[str]]:
32
+ """LOCAL-only OCR. Never raises: missing binding, missing tesseract
33
+ binary (pytesseract.TesseractNotFoundError), or any other engine failure
34
+ all degrade to a metadata-only body — there is no cloud fallback to
35
+ reach for, so failure here is never fatal to the ingest."""
36
+ if not _HAS_PYTESSERACT:
37
+ return "", ["ocr_unavailable: pytesseract not installed (metadata-only)"]
38
+ try:
39
+ text = pytesseract.image_to_string(img)
40
+ return text.strip(), []
41
+ except Exception as exc:
42
+ return "", [f"ocr_unavailable: {type(exc).__name__}: {exc}"]
43
+
44
+
45
+ class ImageHandler(Handler):
46
+ extensions = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff")
47
+ dependency_name = "Pillow"
48
+
49
+ @classmethod
50
+ def available(cls) -> bool:
51
+ return _HAS_PIL
52
+
53
+ @classmethod
54
+ def extract(cls, path: Path) -> ExtractResult:
55
+ if not _HAS_PIL:
56
+ return ExtractResult.quarantine("missing_dependency:Pillow")
57
+ try:
58
+ size = path.stat().st_size
59
+ except OSError:
60
+ size = 0
61
+ if size > MAX_IMAGE_BYTES:
62
+ return ExtractResult.quarantine(
63
+ "file_too_large", warnings=[f"{size} bytes exceeds cap {MAX_IMAGE_BYTES}"]
64
+ )
65
+ try:
66
+ with Image.open(path) as img:
67
+ img.load() # force decode now, inside the try
68
+ fmt = img.format or path.suffix.lstrip(".").upper() or "image"
69
+ width, height = img.size
70
+ mode = img.mode
71
+ ocr_text, warnings = _ocr(img)
72
+ except Exception as exc:
73
+ return ExtractResult.quarantine(
74
+ "image_open_error", warnings=[f"{type(exc).__name__}: {exc}"]
75
+ )
76
+
77
+ body = (
78
+ "## OCR (verbatim)\n\n"
79
+ f"{ocr_text if ocr_text else '[no text detected]'}\n\n"
80
+ "## Image metadata\n\n"
81
+ f"- **Format:** {fmt}\n"
82
+ f"- **Dimensions:** {width} x {height} px\n"
83
+ f"- **Mode:** {mode}\n"
84
+ )
85
+ reason = density_gate(body)
86
+ if reason:
87
+ return ExtractResult.quarantine(reason, warnings=warnings)
88
+ return ExtractResult(
89
+ markdown=body, warnings=warnings,
90
+ metadata={"format": fmt, "width": width, "height": height, "ocr": bool(ocr_text)},
91
+ )
@@ -0,0 +1,99 @@
1
+ """PDF handler — pypdf only (ADR-0003 Ruling 1(g): pure-ish Python, no system
2
+ binaries; no poppler/pdfplumber acceleration — out of scope for this session,
3
+ add if a size/latency ceiling is ever measured to need it).
4
+
5
+ ponytail: no image extraction, no poppler fast-lane for huge PDFs (the
6
+ reference vault's biggest complexity driver) — this session's deliverable is
7
+ faithful TEXT extraction with a quality gate, not image/asset pipelines. Add
8
+ poppler-accelerated large-PDF lane if a real corpus ever needs it.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from .base import ExtractResult, Handler, density_gate
15
+
16
+ try:
17
+ from pypdf import PdfReader
18
+ from pypdf.errors import PdfReadError
19
+ _HAS_PYPDF = True
20
+ except ImportError: # pragma: no cover - exercised via degraded-deps test
21
+ _HAS_PYPDF = False
22
+
23
+ _MIN_PAGE_CHARS = 5
24
+ # HARDENED:codex — max-size cap so a pathological file can't hang extraction
25
+ # or blow memory before it ever reaches the signed write path.
26
+ MAX_PDF_BYTES = 200 * 1024 * 1024 # 200 MB
27
+
28
+
29
+ class PdfHandler(Handler):
30
+ extensions = (".pdf",)
31
+ dependency_name = "pypdf"
32
+
33
+ @classmethod
34
+ def available(cls) -> bool:
35
+ return _HAS_PYPDF
36
+
37
+ @classmethod
38
+ def extract(cls, path: Path) -> ExtractResult:
39
+ if not _HAS_PYPDF:
40
+ return ExtractResult.quarantine("missing_dependency:pypdf")
41
+ try:
42
+ size = path.stat().st_size
43
+ except OSError:
44
+ size = 0
45
+ if size > MAX_PDF_BYTES:
46
+ return ExtractResult.quarantine(
47
+ "file_too_large",
48
+ warnings=[f"pdf {size} bytes exceeds cap {MAX_PDF_BYTES}"],
49
+ )
50
+ try:
51
+ reader = PdfReader(str(path))
52
+ except Exception as exc:
53
+ return ExtractResult.quarantine(
54
+ "pdf_extraction_error", warnings=[f"{type(exc).__name__}: {exc}"]
55
+ )
56
+ if reader.is_encrypted:
57
+ # Pre-sign guard (HARDENED:grill) — never sign a garbage/opaque
58
+ # extraction of a password-protected file.
59
+ return ExtractResult.quarantine("pdf_encrypted")
60
+
61
+ scanned_pages: list[int] = []
62
+ sections: list[str] = []
63
+ try:
64
+ total = len(reader.pages)
65
+ for i, page in enumerate(reader.pages, start=1):
66
+ text = (page.extract_text() or "").strip()
67
+ if len(text) < _MIN_PAGE_CHARS:
68
+ scanned_pages.append(i)
69
+ sections.append(f"## Page {i} (scanned — no text extracted)\n")
70
+ else:
71
+ sections.append(f"## Page {i}\n\n{text}\n")
72
+ except PdfReadError as exc:
73
+ return ExtractResult.quarantine(
74
+ "pdf_extraction_error", warnings=[f"{type(exc).__name__}: {exc}"]
75
+ )
76
+ except Exception as exc:
77
+ return ExtractResult.quarantine(
78
+ "pdf_extraction_error", warnings=[f"{type(exc).__name__}: {exc}"]
79
+ )
80
+
81
+ if total == 0 or len(scanned_pages) == total:
82
+ return ExtractResult.quarantine(
83
+ "pdf_no_text_layer",
84
+ warnings=[f"{len(scanned_pages)}/{total} pages had no extractable text"],
85
+ )
86
+
87
+ body = "\n".join(sections)
88
+ reason = density_gate(body)
89
+ if reason:
90
+ return ExtractResult.quarantine(reason)
91
+
92
+ warnings = []
93
+ if scanned_pages:
94
+ warnings.append(f"scanned_pages: {scanned_pages}")
95
+ return ExtractResult(
96
+ markdown=body,
97
+ warnings=warnings,
98
+ metadata={"page_count": total, "scanned_pages": scanned_pages},
99
+ )
@@ -0,0 +1,69 @@
1
+ """PPTX handler — python-pptx. One `## Slide N` section per slide; text frames
2
+ + tables (Markdown tables, headers retained)."""
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .base import ExtractResult, Handler, density_gate
8
+ from .tables import rows_to_markdown
9
+
10
+ try:
11
+ from pptx import Presentation
12
+ _HAS_PPTX = True
13
+ except ImportError: # pragma: no cover
14
+ _HAS_PPTX = False
15
+
16
+ MAX_PPTX_BYTES = 150 * 1024 * 1024
17
+
18
+
19
+ class PptxHandler(Handler):
20
+ extensions = (".pptx",)
21
+ dependency_name = "python-pptx"
22
+
23
+ @classmethod
24
+ def available(cls) -> bool:
25
+ return _HAS_PPTX
26
+
27
+ @classmethod
28
+ def extract(cls, path: Path) -> ExtractResult:
29
+ if not _HAS_PPTX:
30
+ return ExtractResult.quarantine("missing_dependency:python-pptx")
31
+ try:
32
+ size = path.stat().st_size
33
+ except OSError:
34
+ size = 0
35
+ if size > MAX_PPTX_BYTES:
36
+ return ExtractResult.quarantine("file_too_large")
37
+ try:
38
+ prs = Presentation(str(path))
39
+ except Exception as exc:
40
+ return ExtractResult.quarantine(
41
+ "pptx_extraction_error", warnings=[f"{type(exc).__name__}: {exc}"]
42
+ )
43
+
44
+ sections: list[str] = []
45
+ slide_count = 0
46
+ try:
47
+ for i, slide in enumerate(prs.slides, start=1):
48
+ slide_count = i
49
+ lines = [f"## Slide {i}\n"]
50
+ for shape in slide.shapes:
51
+ if shape.has_table:
52
+ tbl = shape.table
53
+ rows = [[c.text for c in row.cells] for row in tbl.rows]
54
+ lines.append(rows_to_markdown(rows))
55
+ elif shape.has_text_frame:
56
+ text = shape.text_frame.text.strip()
57
+ if text:
58
+ lines.append(text + "\n")
59
+ sections.append("\n".join(lines))
60
+ except Exception as exc:
61
+ return ExtractResult.quarantine(
62
+ "pptx_extraction_error", warnings=[f"{type(exc).__name__}: {exc}"]
63
+ )
64
+
65
+ body = "\n".join(sections)
66
+ reason = density_gate(body)
67
+ if reason:
68
+ return ExtractResult.quarantine(reason)
69
+ return ExtractResult(markdown=body, metadata={"slide_count": slide_count})
@@ -0,0 +1,41 @@
1
+ """Shared table-to-Markdown reconstruction (HARDENED:grill).
2
+
3
+ Header loss silently corrupts facts at low character-error rates (a table
4
+ flattened to prose loses the row/column association entirely). Rule: preserve
5
+ structure as a Markdown table with headers retained; a table that cannot be
6
+ reconstructed with headers is emitted as a fenced block flagged
7
+ ``table-unparsed`` instead of being flattened to prose.
8
+ """
9
+ from __future__ import annotations
10
+
11
+
12
+ def _cell(v: object) -> str:
13
+ s = "" if v is None else str(v)
14
+ return s.replace("|", "\\|").replace("\n", " ").strip()
15
+
16
+
17
+ def rows_to_markdown(rows: list[list[object]], *, label: str = "table") -> str:
18
+ """Render ``rows`` (first row = header) as a Markdown table, or a fenced
19
+ ``table-unparsed`` block if headers can't be trusted (empty header row,
20
+ ragged column counts, or zero data rows)."""
21
+ rows = [r for r in rows if any(_cell(c) for c in r)] # drop fully-blank rows
22
+ if not rows:
23
+ return ""
24
+ header = [_cell(c) for c in rows[0]]
25
+ ncols = len(header)
26
+ unparsed = (
27
+ ncols == 0
28
+ or not any(header)
29
+ or len(rows) < 2
30
+ or any(len(r) != ncols for r in rows[1:])
31
+ )
32
+ if unparsed:
33
+ raw = "\n".join(" | ".join(_cell(c) for c in r) for r in rows)
34
+ return f"```table-unparsed\n{raw}\n```\n"
35
+ lines = [
36
+ "| " + " | ".join(header) + " |",
37
+ "| " + " | ".join(["---"] * ncols) + " |",
38
+ ]
39
+ for r in rows[1:]:
40
+ lines.append("| " + " | ".join(_cell(c) for c in r) + " |")
41
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,43 @@
1
+ """Plain-text / Markdown pass-through handler."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from .base import ExtractResult, Handler, density_gate
7
+
8
+ _ENCODINGS = ("utf-8", "utf-8-sig", "latin-1")
9
+
10
+
11
+ class TextHandler(Handler):
12
+ extensions = (".txt", ".md", ".markdown", ".csv")
13
+ dependency_name = "stdlib"
14
+
15
+ @classmethod
16
+ def available(cls) -> bool:
17
+ return True
18
+
19
+ @classmethod
20
+ def extract(cls, path: Path) -> ExtractResult:
21
+ # C2(b): read_bytes() used to be unguarded — an OSError (permission
22
+ # denied, disk error, vanished file) propagated as a raw exception out
23
+ # of a handler, which the contract (see handlers/base.py Handler.extract
24
+ # docstring) forbids: a crash here must never abort the whole drain.
25
+ try:
26
+ raw = path.read_bytes()
27
+ except OSError as exc:
28
+ return ExtractResult.quarantine(
29
+ "text_read_error", warnings=[f"{type(exc).__name__}: {exc}"]
30
+ )
31
+ text: str | None = None
32
+ for enc in _ENCODINGS:
33
+ try:
34
+ text = raw.decode(enc)
35
+ break
36
+ except UnicodeDecodeError:
37
+ continue
38
+ if text is None:
39
+ return ExtractResult.quarantine("text_decode_error")
40
+ reason = density_gate(text)
41
+ if reason:
42
+ return ExtractResult.quarantine(reason)
43
+ return ExtractResult(markdown=text, metadata={"encoding": "utf-8"})
@@ -0,0 +1,100 @@
1
+ """XLSX handler — openpyxl. One `## Sheet: <name>` section per sheet, rendered
2
+ as a Markdown table (headers retained). Formula collapse (HARDENED, per the
3
+ session brief): prefer the last-computed CACHED value (``data_only=True``); a
4
+ formula with no cached value (never opened in Excel) falls back to the raw
5
+ formula text tagged `(formula, uncomputed)` rather than crashing or silently
6
+ dropping the cell."""
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from .base import ExtractResult, Handler, density_gate
12
+ from .tables import rows_to_markdown
13
+
14
+ try:
15
+ import openpyxl
16
+ _HAS_OPENPYXL = True
17
+ except ImportError: # pragma: no cover
18
+ _HAS_OPENPYXL = False
19
+
20
+ MAX_XLSX_BYTES = 100 * 1024 * 1024
21
+ MAX_ROWS_PER_SHEET = 20_000 # cap runaway sheets rather than hang
22
+
23
+
24
+ class XlsxHandler(Handler):
25
+ extensions = (".xlsx",)
26
+ dependency_name = "openpyxl"
27
+
28
+ @classmethod
29
+ def available(cls) -> bool:
30
+ return _HAS_OPENPYXL
31
+
32
+ @classmethod
33
+ def extract(cls, path: Path) -> ExtractResult:
34
+ if not _HAS_OPENPYXL:
35
+ return ExtractResult.quarantine("missing_dependency:openpyxl")
36
+ try:
37
+ size = path.stat().st_size
38
+ except OSError:
39
+ size = 0
40
+ if size > MAX_XLSX_BYTES:
41
+ return ExtractResult.quarantine("file_too_large")
42
+
43
+ try:
44
+ wb_values = openpyxl.load_workbook(str(path), data_only=True, read_only=True)
45
+ except Exception as exc:
46
+ return ExtractResult.quarantine(
47
+ "xlsx_extraction_error", warnings=[f"{type(exc).__name__}: {exc}"]
48
+ )
49
+ try:
50
+ wb_formulas = openpyxl.load_workbook(str(path), data_only=False, read_only=True)
51
+ except Exception:
52
+ wb_formulas = None
53
+
54
+ sections: list[str] = []
55
+ warnings: list[str] = []
56
+ try:
57
+ for name in wb_values.sheetnames:
58
+ ws_v = wb_values[name]
59
+ ws_f = wb_formulas[name] if wb_formulas is not None else None
60
+ rows: list[list[object]] = []
61
+ # C9: ws_f.cell(row=, column=) is RANDOM ACCESS on a
62
+ # read_only workbook — each call silently re-parses the
63
+ # whole sheet from the start, and it fired for every single
64
+ # empty cell (quadratic). Iterate both workbooks' rows in
65
+ # lockstep instead (each row read exactly once), only
66
+ # consulting the formula row when the value cell is empty.
67
+ if ws_f is not None:
68
+ row_pairs = zip(ws_v.iter_rows(), ws_f.iter_rows())
69
+ else:
70
+ row_pairs = ((row, None) for row in ws_v.iter_rows())
71
+ for r_idx, (row, f_row) in enumerate(row_pairs, start=1):
72
+ if r_idx > MAX_ROWS_PER_SHEET:
73
+ warnings.append(f"sheet {name!r} truncated at {MAX_ROWS_PER_SHEET} rows")
74
+ break
75
+ out_row = []
76
+ for c_idx, cell in enumerate(row):
77
+ val = cell.value
78
+ if val is None and f_row is not None and c_idx < len(f_row):
79
+ f_val = f_row[c_idx].value
80
+ if isinstance(f_val, str) and f_val.startswith("="):
81
+ val = f"{f_val} (formula, uncomputed)"
82
+ out_row.append(val)
83
+ rows.append(out_row)
84
+ sections.append(f"## Sheet: {name}\n\n" + rows_to_markdown(rows))
85
+ except Exception as exc:
86
+ return ExtractResult.quarantine(
87
+ "xlsx_extraction_error", warnings=[f"{type(exc).__name__}: {exc}"]
88
+ )
89
+ finally:
90
+ wb_values.close()
91
+ if wb_formulas is not None:
92
+ wb_formulas.close()
93
+
94
+ body = "\n".join(sections)
95
+ reason = density_gate(body)
96
+ if reason:
97
+ return ExtractResult.quarantine(reason)
98
+ return ExtractResult(
99
+ markdown=body, warnings=warnings, metadata={"sheet_count": len(sections)}
100
+ )
@@ -0,0 +1,163 @@
1
+ """ZIP handler — bounded, Zip-Slip-hardened member expansion. No file in a
2
+ ZIP is EVER extracted to a path derived from its own name: every member's
3
+ bytes are read in-memory here and handed to the pipeline as
4
+ ``metadata["nested"]`` entries (name, data); the pipeline
5
+ (``pipeline._process_nested``) is the only thing that ever writes them to
6
+ disk, and it does so under a SYNTHETIC generated name, never the archive's
7
+ own path (S06 HARDENED:codex-verify-r2).
8
+
9
+ Caps are checked from the central directory (``ZipInfo.file_size`` /
10
+ ``.external_attr``) BEFORE any member is decompressed, and each member's
11
+ actual decompressed byte count is also counted DURING extraction (a
12
+ malformed/lying declared size must not buy a bigger decompression than the
13
+ cap allows) — "before/during, never after" per the S06 brief.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import posixpath
18
+ import re
19
+ import zipfile
20
+ from pathlib import Path
21
+
22
+ from .base import ExtractResult, Handler, density_gate, strip_control_chars
23
+
24
+ MAX_MEMBERS = 500
25
+ MAX_MEMBER_BYTES = 200 * 1024 * 1024 # per-member cap (matches pipeline.MAX_INGEST_BYTES)
26
+ MAX_TOTAL_DECLARED_BYTES = 500 * 1024 * 1024 # sum of DECLARED (pre-decompress) sizes
27
+
28
+ _WINDOWS_DRIVE = re.compile(r"^[A-Za-z]:")
29
+ _S_IFLNK = 0xA000
30
+ _S_IFREG = 0x8000
31
+
32
+
33
+ def _unsafe_zip_member_reason(info: "zipfile.ZipInfo") -> str | None:
34
+ """Zip-Slip hardening: reject absolute paths, ``..`` traversal, a
35
+ Windows-drive-rooted name, and symlink/hardlink/non-regular members —
36
+ BEFORE any member is opened for reading. A zip's own path separator is
37
+ always ``/`` per the spec; a member trying to look like a Windows
38
+ absolute path (drive letter, or a literal backslash root) is just as
39
+ hostile as a POSIX one."""
40
+ name = info.filename
41
+ if not name or not name.strip():
42
+ return "empty_member_name"
43
+ normalized = name.replace("\\", "/")
44
+ if normalized.startswith("/") or posixpath.isabs(normalized):
45
+ return "absolute_path"
46
+ if _WINDOWS_DRIVE.match(normalized):
47
+ return "windows_drive_path"
48
+ if any(part == ".." for part in normalized.split("/")):
49
+ return "path_traversal"
50
+ # external_attr's high 16 bits carry unix st_mode ONLY when the member was
51
+ # created on a unix host (create_system == 3); on other systems (e.g. 0 =
52
+ # FAT/Windows) those bits are meaningless and must not be interpreted.
53
+ if info.create_system == 3:
54
+ mode = (info.external_attr >> 16) & 0xF000
55
+ if mode == _S_IFLNK:
56
+ return "symlink_member"
57
+ if mode not in (0, _S_IFREG):
58
+ return "non_regular_member"
59
+ return None
60
+
61
+
62
+ def _read_member_bounded(zf: "zipfile.ZipFile", info: "zipfile.ZipInfo", cap: int) -> bytes | None:
63
+ """Stream-decompress ``info`` in chunks, counting REAL output bytes as
64
+ they arrive. Returns ``None`` if the actual decompressed size exceeds
65
+ ``cap`` (defends against a declared ``file_size`` that lies) instead of
66
+ ever fully materializing an over-cap member."""
67
+ chunks: list[bytes] = []
68
+ total = 0
69
+ with zf.open(info) as fh:
70
+ while True:
71
+ chunk = fh.read(1024 * 1024)
72
+ if not chunk:
73
+ break
74
+ total += len(chunk)
75
+ if total > cap:
76
+ return None
77
+ chunks.append(chunk)
78
+ return b"".join(chunks)
79
+
80
+
81
+ class ZipHandler(Handler):
82
+ extensions = (".zip",)
83
+ dependency_name = "stdlib"
84
+
85
+ @classmethod
86
+ def available(cls) -> bool:
87
+ return True
88
+
89
+ @classmethod
90
+ def extract(cls, path: Path) -> ExtractResult:
91
+ try:
92
+ zf = zipfile.ZipFile(path)
93
+ except zipfile.BadZipFile as exc:
94
+ return ExtractResult.quarantine("zip_corrupt", warnings=[f"{type(exc).__name__}: {exc}"])
95
+ except OSError as exc:
96
+ return ExtractResult.quarantine("zip_read_error", warnings=[f"{type(exc).__name__}: {exc}"])
97
+
98
+ with zf:
99
+ infos = [i for i in zf.infolist() if not i.is_dir()]
100
+ if not infos:
101
+ return ExtractResult.quarantine("zip_empty")
102
+ if len(infos) > MAX_MEMBERS:
103
+ return ExtractResult.quarantine(
104
+ "zip_too_many_members",
105
+ warnings=[f"{len(infos)} members exceeds cap {MAX_MEMBERS}"],
106
+ )
107
+
108
+ # Pre-scan BEFORE any decompression: Zip-Slip safety + declared-size caps.
109
+ total_declared = 0
110
+ for info in infos:
111
+ reason = _unsafe_zip_member_reason(info)
112
+ if reason:
113
+ return ExtractResult.quarantine(
114
+ "zip_unsafe_member",
115
+ warnings=[f"{strip_control_chars(info.filename)}: {reason}"],
116
+ )
117
+ if info.file_size > MAX_MEMBER_BYTES:
118
+ return ExtractResult.quarantine(
119
+ "zip_member_too_large",
120
+ warnings=[f"{strip_control_chars(info.filename)}: declared {info.file_size} bytes"],
121
+ )
122
+ total_declared += info.file_size
123
+ if total_declared > MAX_TOTAL_DECLARED_BYTES:
124
+ return ExtractResult.quarantine(
125
+ "zip_bomb_suspected",
126
+ warnings=[f"declared total {total_declared} bytes exceeds cap {MAX_TOTAL_DECLARED_BYTES}"],
127
+ )
128
+
129
+ nested: list[dict] = []
130
+ listing: list[str] = []
131
+ for info in infos:
132
+ try:
133
+ data = _read_member_bounded(zf, info, MAX_MEMBER_BYTES)
134
+ except Exception as exc:
135
+ return ExtractResult.quarantine(
136
+ "zip_extraction_error",
137
+ warnings=[f"{strip_control_chars(info.filename)}: {type(exc).__name__}: {exc}"],
138
+ )
139
+ if data is None:
140
+ return ExtractResult.quarantine(
141
+ "zip_bomb_suspected",
142
+ warnings=[f"{strip_control_chars(info.filename)}: decompressed beyond declared size"],
143
+ )
144
+ # Member names NEVER become filesystem paths directly — only
145
+ # the sanitized BASENAME (no directory components) is even
146
+ # offered to the pipeline, which further wraps it in a
147
+ # synthetic generated filename before it ever touches disk.
148
+ basename = strip_control_chars(Path(info.filename).name) or "member"
149
+ nested.append({"name": basename, "data": data})
150
+ listing.append(f"- `{basename}` ({len(data)} bytes)")
151
+
152
+ body = "## Archive contents\n\n" + "\n".join(listing) + "\n"
153
+ # ponytail: min_chars=1, not the shared 40-char default — this body is
154
+ # a member LISTING (quantity of entries, not extracted prose), so the
155
+ # prose-density threshold doesn't fit; a non-empty listing already
156
+ # proves real content. Still routed through the same shared gate
157
+ # function per the "same gate applies to every handler" rule, just
158
+ # tuned to this content's shape — this only guards a truly-empty
159
+ # listing that somehow slipped past the `zip_empty` check above.
160
+ reason = density_gate(body, min_chars=1)
161
+ if reason:
162
+ return ExtractResult.quarantine(reason)
163
+ return ExtractResult(markdown=body, metadata={"nested": nested, "member_count": len(nested)})