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.
- brain/__init__.py +39 -0
- brain/__main__.py +14 -0
- brain/_assets/AGENTS.md +564 -0
- brain/_assets/overlay/template/brand/brand-guide.md +24 -0
- brain/_assets/overlay/template/keywords/glossary.md +15 -0
- brain/_assets/overlay/template/people/roster.md +14 -0
- brain/_assets/overlay/template/voice/voice-profile.md +34 -0
- brain/_assets/routines/manifest.json +257 -0
- brain/_assets/scripts/brain-brief-mac.plist +59 -0
- brain/_assets/scripts/brain-brief.sh +74 -0
- brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
- brain/_assets/scripts/brain-synthesis.sh +214 -0
- brain/_assets/scripts/install-brief-mac.sh +152 -0
- brain/_assets/scripts/install-brief-windows.ps1 +97 -0
- brain/_assets/scripts/register_tasks.py +386 -0
- brain/_assets/templates/company.md +21 -0
- brain/_assets/templates/concept.md +27 -0
- brain/_assets/templates/daily.md +20 -0
- brain/_assets/templates/decision.md +42 -0
- brain/_assets/templates/meeting.md +33 -0
- brain/_assets/templates/person.md +20 -0
- brain/_assets/templates/project.md +23 -0
- brain/_assets/templates/state-moc.md +40 -0
- brain/_version.py +12 -0
- brain/anchor.py +121 -0
- brain/audit.py +422 -0
- brain/backup.py +210 -0
- brain/brief.py +417 -0
- brain/capture.py +117 -0
- brain/chunk.py +249 -0
- brain/classification.py +134 -0
- brain/cli.py +1906 -0
- brain/config.py +368 -0
- brain/connect.py +362 -0
- brain/context.py +108 -0
- brain/core.py +3018 -0
- brain/doctor.py +1161 -0
- brain/egress.py +148 -0
- brain/embed.py +857 -0
- brain/encryption.py +217 -0
- brain/frontmatter.py +102 -0
- brain/golden_probe.py +678 -0
- brain/graph.py +369 -0
- brain/graphify.py +352 -0
- brain/index.py +1576 -0
- brain/ingest/__init__.py +19 -0
- brain/ingest/handlers/__init__.py +43 -0
- brain/ingest/handlers/base.py +95 -0
- brain/ingest/handlers/docx.py +78 -0
- brain/ingest/handlers/email.py +228 -0
- brain/ingest/handlers/html.py +142 -0
- brain/ingest/handlers/image.py +91 -0
- brain/ingest/handlers/pdf.py +99 -0
- brain/ingest/handlers/pptx.py +69 -0
- brain/ingest/handlers/tables.py +41 -0
- brain/ingest/handlers/text.py +43 -0
- brain/ingest/handlers/xlsx.py +100 -0
- brain/ingest/handlers/zip.py +163 -0
- brain/ingest/pipeline.py +839 -0
- brain/ingest/transcript.py +158 -0
- brain/init.py +870 -0
- brain/maintenance.py +2266 -0
- brain/mcp_adapter.py +217 -0
- brain/multihop.py +232 -0
- brain/notes.py +195 -0
- brain/overlay.py +183 -0
- brain/projection.py +79 -0
- brain/rerank.py +425 -0
- brain/snapshot.py +231 -0
- brain/update.py +743 -0
- brain/vectors.py +225 -0
- brainiac_cli-0.16.0.dist-info/METADATA +306 -0
- brainiac_cli-0.16.0.dist-info/RECORD +77 -0
- brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
- brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
- brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- 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)})
|