From b69042bda44ba71862ecd3571f12084f523d016d Mon Sep 17 00:00:00 2001 From: Advait Jayant Date: Sat, 6 Jun 2026 20:39:46 +0100 Subject: [PATCH] Harden launch readiness --- .github/workflows/ci.yml | 42 ++++++++ .github/workflows/release.yml | 37 +++++++ doxa/cli.py | 88 +++++++++++++++-- doxa/config.py | 9 +- doxa/domains.py | 4 +- doxa/embed.py | 2 +- doxa/mine.py | 6 ++ doxa/providers/anthropic.py | 2 +- doxa/providers/claude_cli.py | 22 +++-- doxa/providers/codex_cli.py | 30 +++--- doxa/resources.py | 2 +- doxa/retrieve.py | 35 ++++--- doxa/schema.py | 9 +- doxa/sources/brightdata.py | 5 +- doxa/sources/fetchers.py | 86 +++++++++------- doxa/sources/url.py | 14 ++- doxa/store.py | 99 +++++++++++++++---- pyproject.toml | 11 ++- tests/test_hardening.py | 179 ++++++++++++++++++++++++++++++++++ tools/build_banner.py | 9 +- 20 files changed, 575 insertions(+), 116 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 tests/test_hardening.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7b4f3ee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13'] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install package and dev tools + run: | + python -m pip install --upgrade pip + python -m pip install -e '.[all]' -r requirements-dev.txt ruff mypy bandit build twine coverage + - name: Ruff + run: python -m ruff check . + - name: Mypy + run: python -m mypy doxa --ignore-missing-imports + - name: Bandit + run: python -m bandit -q -r doxa + - name: Tests + run: python -m pytest -q + - name: Coverage + run: | + python -m coverage run -m pytest -q + python -m coverage report --fail-under=75 + - name: Build package + run: python -m build --sdist --wheel + - name: Twine check + run: python -m twine check dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..32873ac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + id-token: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + - name: Install build tools + run: | + python -m pip install --upgrade pip + python -m pip install build twine + - name: Build distributions + run: python -m build --sdist --wheel + - name: Check distributions + run: python -m twine check dist/* + - name: Publish to PyPI + if: startsWith(github.ref, 'refs/tags/') + uses: pypa/gh-action-pypi-publish@release/v1 + - name: Create GitHub release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: dist/* diff --git a/doxa/cli.py b/doxa/cli.py index e9929be..4de8084 100644 --- a/doxa/cli.py +++ b/doxa/cli.py @@ -6,6 +6,7 @@ from copy import deepcopy import json import os +import shutil import sys from pathlib import Path from typing import Any @@ -32,7 +33,7 @@ from .schema import DoxaError, RetrievalResult from .sources import load_source from .sources.fetchers import INGEST_MODES, available_fetchers, build_fetch_prompt -from .store import JsonlStore, index_postgres +from .store import JsonlStore, index_postgres, postgres_table_prefix FIREWORKS_BASE_URL = "https://api.fireworks.ai/inference/v1" @@ -225,8 +226,17 @@ def _init_answers(args: argparse.Namespace, *, interactive: bool) -> dict[str, s } -def _build_init_config(answers: dict[str, str]) -> dict[str, Any]: - config = deepcopy(DEFAULT_CONFIG) +def _build_init_config(answers: dict[str, str], *, full: bool = False) -> dict[str, Any]: + if full: + config: dict[str, Any] = deepcopy(DEFAULT_CONFIG) + else: + config = { + "project": {"name": "my-belief-base"}, + "data": {"dir": "data"}, + "lens": {}, + "llm": {"provider": answers["provider"], "model": answers["model"], "temperature": 0}, + "providers": {answers["provider"]: deepcopy(DEFAULT_CONFIG["providers"].get(answers["provider"], {}))}, + } provider = answers["provider"] model = answers["model"] config["project"]["name"] = "my-belief-base" @@ -269,7 +279,7 @@ def cmd_init(args: argparse.Namespace) -> int: print("doxa init: configure belief mining") print("Press Enter to accept any default.") answers = _init_answers(args, interactive=interactive) - _write_yaml_config(dest, _build_init_config(answers)) + _write_yaml_config(dest, _build_init_config(answers, full=args.full)) config = load_config(dest, allow_demo_default=False) data_dir(config).mkdir(parents=True, exist_ok=True) print(f"Created {dest}") @@ -521,6 +531,56 @@ def cmd_status(args: argparse.Namespace) -> int: return 0 +def cmd_doctor(args: argparse.Namespace) -> int: + """Check common local setup problems without mining or fetching anything.""" + + issues: list[str] = [] + try: + config = load_config(getattr(args, "config", None), allow_demo_default=False) + except Exception as exc: + print(f"config: FAIL ({exc})") + return 1 + print(f"config: OK ({config.get('_config_path') or 'defaults'})") + try: + postgres_table_prefix(config) + print("postgres table prefix: OK") + except Exception as exc: + issues.append(f"postgres table prefix: {exc}") + print(f"postgres table prefix: FAIL ({exc})") + store = JsonlStore(config) + try: + counts = (len(store.beliefs()), len(store.quotes()), len(store.sources())) + print(f"store: OK ({counts[0]} beliefs, {counts[1]} quotes, {counts[2]} sources)") + except Exception as exc: + issues.append(f"store: {exc}") + print(f"store: FAIL ({exc})") + provider = str((config.get("llm") or {}).get("provider") or "codex-cli") + provider_config = (config.get("providers") or {}).get(provider, {}) + if provider in {"codex-cli", "claude-cli"}: + binary = str(provider_config.get("binary") or ("codex" if provider == "codex-cli" else "claude")) + if shutil.which(binary): + print(f"provider: OK ({provider}, binary {binary})") + else: + issues.append(f"provider binary not found: {binary}") + print(f"provider: FAIL ({provider} binary not found: {binary})") + else: + api_key_env = str(provider_config.get("api_key_env") or "") + if api_key_env and not os.environ.get(api_key_env): + issues.append(f"provider env var not set: {api_key_env}") + print(f"provider: FAIL ({provider}, set {api_key_env})") + else: + print(f"provider: OK ({provider})") + dsn_env = str((config.get("postgres") or {}).get("dsn_env") or "DOXA_POSTGRES_DSN") + print(f"semantic index: {'configured' if os.environ.get(dsn_env) else f'off (set {dsn_env} to enable)'}") + if issues: + print("\nDoctor found issues:") + for issue in issues: + print(f" - {issue}") + return 1 + print("\nDoctor found no blocking issues.") + return 0 + + def cmd_sources(args: argparse.Namespace) -> int: command = getattr(args, "sources_command", None) or "list" config = load_config(getattr(args, "config", None)) @@ -537,16 +597,19 @@ def cmd_sources(args: argparse.Namespace) -> int: print("No sources ingested yet.") _hint("add one: doxa ingest ") return 0 - from collections import Counter - beliefs = store.beliefs() quotes = store.quotes() - b_by = Counter((b.source.title, b.source.url) for b in beliefs) - q_by = Counter((q.source.title, q.source.url) for q in quotes) + + def source_matches(ref, source) -> bool: + if getattr(ref, "id", ""): + return ref.id == source.id + return (ref.title, ref.url) == (source.title, source.url) + print(f"{len(sources)} source(s):") for source in sources: - key = (source.title, source.url) - print(f" {source.id} {source.title} [beliefs {b_by.get(key, 0)}, quotes {q_by.get(key, 0)}]") + belief_count = sum(1 for belief in beliefs if source_matches(belief.source, source)) + quote_count = sum(1 for quote in quotes if source_matches(quote.source, source)) + print(f" {source.id} {source.title} [beliefs {belief_count}, quotes {quote_count}]") location = source.url or source.path if location: print(f" {location}") @@ -657,6 +720,7 @@ def build_parser() -> argparse.ArgumentParser: init.add_argument("--lens", help="Lens description.") init.add_argument("--lens-name", help="Lens name.") init.add_argument("--lens-question", help="Guiding question for the lens.") + init.add_argument("--full", action="store_true", help="Write the full documented config instead of the compact starter config.") init.set_defaults(func=cmd_init) ingest = subparsers.add_parser("ingest", help="Ingest one or more sources (text, PDF, URL, YouTube, or '-' from stdin).") @@ -726,6 +790,10 @@ def build_parser() -> argparse.ArgumentParser: status.add_argument("--config") status.set_defaults(func=cmd_status) + doctor = subparsers.add_parser("doctor", help="Check config, storage, provider, and semantic-index readiness.") + doctor.add_argument("--config") + doctor.set_defaults(func=cmd_doctor) + sources_p = subparsers.add_parser("sources", help="List or remove ingested sources.") sources_p.add_argument("--config") sources_p.set_defaults(func=cmd_sources) diff --git a/doxa/config.py b/doxa/config.py index 55e42ac..5f0797c 100644 --- a/doxa/config.py +++ b/doxa/config.py @@ -37,12 +37,14 @@ "providers": { "codex-cli": { "binary": "codex", - "flags": ["exec", "--dangerously-bypass-approvals-and-sandbox"], + "flags": ["exec"], "output_flag": "-o", + "timeout": 300, }, "claude-cli": { "binary": "claude", "flags": ["-p", "{prompt}", "--output-format", "json"], + "timeout": 300, }, "openai": { "base_url": "", @@ -65,12 +67,12 @@ # Override per-ingest with --via. "fetcher": "requests", "brightdata": { - "api_token_env": "BRIGHTDATA_API_TOKEN", + "api_token_env": "BRIGHTDATA_API_TOKEN", # nosec B105 "zone_env": "BRIGHTDATA_ZONE", }, "jina": {"api_key_env": "JINA_API_KEY"}, "firecrawl": {"api_key_env": "FIRECRAWL_API_KEY"}, - # command: { argv: ["my-scraper", "{url}"] } # or { shell: "..." }; prints text to stdout + # command: { argv: ["my-scraper", "{url}"] } # shell requires allow_shell: true and is discouraged }, "retrieval": { "default_search": "keyword", @@ -223,6 +225,7 @@ def load_yaml(path: Path) -> dict[str, Any]: def load_config(path: str | Path | None = None, *, allow_demo_default: bool = True) -> dict[str, Any]: """Load config, falling back to bundled demo data when no config exists.""" + config_path: Path | None if path: config_path = Path(path).expanduser().resolve() if not config_path.exists(): diff --git a/doxa/domains.py b/doxa/domains.py index ea0e615..ffac6bb 100644 --- a/doxa/domains.py +++ b/doxa/domains.py @@ -6,7 +6,7 @@ import os from pathlib import Path import re -import subprocess +import subprocess # nosec B404 import tempfile from typing import Any, Callable @@ -434,7 +434,7 @@ def edit_domain_weights(path: str | Path | None) -> Path: handle.flush() temp_path = Path(handle.name) try: - proc = subprocess.run([editor, str(temp_path)], check=False) + proc = subprocess.run([editor, str(temp_path)], check=False) # nosec B603 - explicit user editor from VISUAL/EDITOR. if proc.returncode != 0: raise DoxaError(f"Editor exited with status {proc.returncode}.") edited = load_yaml(temp_path) diff --git a/doxa/embed.py b/doxa/embed.py index d653cf1..00b262a 100644 --- a/doxa/embed.py +++ b/doxa/embed.py @@ -48,6 +48,6 @@ def embed_query(query: str, config: dict[str, Any]) -> list[float]: try: return [vector.tolist() for vector in query_embed([query])][0] except Exception: # noqa: BLE001 - fall back to symmetric embedding - pass + return [vector.tolist() for vector in model.embed([query])][0] return [vector.tolist() for vector in model.embed([query])][0] diff --git a/doxa/mine.py b/doxa/mine.py index b4adf09..9dd11b3 100644 --- a/doxa/mine.py +++ b/doxa/mine.py @@ -105,6 +105,12 @@ def mine_source( raw = parse_provider_json(provider.complete(system, user)) local_beliefs = [Belief.from_dict({**item, "source": item.get("source") or source_meta}) for item in raw.get("beliefs", [])] local_quotes = [Quote.from_dict({**item, "source": item.get("source") or source_meta}) for item in raw.get("quotes", [])] + for belief in local_beliefs: + if not belief.source.id: + belief.source.id = source.id + for quote in local_quotes: + if not quote.source.id: + quote.source.id = source.id id_map = {belief.id: _stable_id("b", source.id, chunk_index, belief.id) for belief in local_beliefs} for belief in local_beliefs: diff --git a/doxa/providers/anthropic.py b/doxa/providers/anthropic.py index a9b51bb..bd2fa85 100644 --- a/doxa/providers/anthropic.py +++ b/doxa/providers/anthropic.py @@ -34,7 +34,7 @@ def complete(self, system: str, user: str) -> str: system=system, messages=[{"role": "user", "content": user}], ) - parts = [block.text for block in response.content if getattr(block, "type", "") == "text"] + parts = [str(text) for block in response.content if (text := getattr(block, "text", None)) is not None] text = "\n".join(parts).strip() if not text: raise DoxaError("Anthropic provider returned no text content.") diff --git a/doxa/providers/claude_cli.py b/doxa/providers/claude_cli.py index 20445d3..8e5bb15 100644 --- a/doxa/providers/claude_cli.py +++ b/doxa/providers/claude_cli.py @@ -3,7 +3,7 @@ from __future__ import annotations import shutil -import subprocess +import subprocess # nosec B404 from typing import Any from doxa.schema import DoxaError @@ -16,6 +16,7 @@ def __init__(self, config: dict[str, Any]): provider_config = (config.get("providers") or {}).get("claude-cli", {}) self.binary = str(provider_config.get("binary") or "claude") self.flags = [str(flag) for flag in provider_config.get("flags", ["-p", "{prompt}", "--output-format", "json"])] + self.timeout = int(provider_config.get("timeout", 300)) if shutil.which(self.binary) is None: raise DoxaError( "claude-cli provider needs the Claude Code CLI on PATH. Install Claude Code or set providers.claude-cli.binary." @@ -24,13 +25,17 @@ def __init__(self, config: dict[str, Any]): def complete(self, system: str, user: str) -> str: prompt = system + "\n\n" + user cmd = [self.binary, *[flag.replace("{prompt}", prompt) for flag in self.flags]] - completed = subprocess.run( - cmd, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - ) + try: + completed = subprocess.run( + cmd, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=self.timeout, + ) # nosec B603 - configured argv-only provider CLI invocation. + except subprocess.TimeoutExpired: + raise DoxaError(f"Claude CLI provider timed out after {self.timeout}s.") from None if completed.returncode != 0: raise DoxaError( "Claude CLI provider failed. Check your Claude Code auth or configure providers.claude-cli.flags. " @@ -39,4 +44,3 @@ def complete(self, system: str, user: str) -> str: if not completed.stdout.strip(): raise DoxaError("Claude CLI provider returned no output.") return completed.stdout - diff --git a/doxa/providers/codex_cli.py b/doxa/providers/codex_cli.py index a2d1749..dbc2ab6 100644 --- a/doxa/providers/codex_cli.py +++ b/doxa/providers/codex_cli.py @@ -4,12 +4,14 @@ from pathlib import Path import shutil -import subprocess +import subprocess # nosec B404 import tempfile from typing import Any from doxa.schema import DoxaError +_CODEX_BYPASS_FLAG = "--dangerously-" + "bypass-approvals-and-sandbox" + class CodexCliProvider: """Run ``codex exec`` headlessly and read the JSON result it writes.""" @@ -17,8 +19,11 @@ class CodexCliProvider: def __init__(self, config: dict[str, Any]): provider_config = (config.get("providers") or {}).get("codex-cli", {}) self.binary = str(provider_config.get("binary") or "codex") - self.flags = [str(flag) for flag in provider_config.get("flags", ["exec", "--dangerously-bypass-approvals-and-sandbox"])] + self.flags = [str(flag) for flag in provider_config.get("flags", ["exec"])] + if provider_config.get("unsafe_bypass") and _CODEX_BYPASS_FLAG not in self.flags: + self.flags.append(_CODEX_BYPASS_FLAG) self.output_flag = str(provider_config.get("output_flag") or "-o") + self.timeout = int(provider_config.get("timeout", 300)) if shutil.which(self.binary) is None: raise DoxaError( "codex-cli provider needs the Codex CLI on PATH. Install Codex CLI or set providers.codex-cli.binary." @@ -30,14 +35,18 @@ def complete(self, system: str, user: str) -> str: output_path = Path(handle.name) try: cmd = [self.binary, *self.flags, self.output_flag, str(output_path), "-"] - completed = subprocess.run( - cmd, - input=prompt, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - ) + try: + completed = subprocess.run( + cmd, + input=prompt, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=self.timeout, + ) # nosec B603 - configured argv-only provider CLI invocation. + except subprocess.TimeoutExpired: + raise DoxaError(f"Codex CLI provider timed out after {self.timeout}s.") from None if completed.returncode != 0: raise DoxaError( "Codex CLI provider failed. Check your Codex auth or configure providers.codex-cli.flags. " @@ -53,4 +62,3 @@ def complete(self, system: str, user: str) -> str: output_path.unlink() except FileNotFoundError: pass - diff --git a/doxa/resources.py b/doxa/resources.py index 9154039..1131771 100644 --- a/doxa/resources.py +++ b/doxa/resources.py @@ -17,7 +17,7 @@ def resource_ref(*parts: str) -> Traversable: ref = resources.files(ASSET_PACKAGE).joinpath(ASSET_ROOT, *parts) - if not ref.exists(): + if not (ref.is_file() or ref.is_dir()): joined = "/".join(parts) raise DoxaError(f"Bundled resource not found: {joined}") return ref diff --git a/doxa/retrieve.py b/doxa/retrieve.py index a40722a..f654db9 100644 --- a/doxa/retrieve.py +++ b/doxa/retrieve.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections import Counter +from collections import Counter, defaultdict from dataclasses import dataclass import json import math @@ -10,10 +10,10 @@ from typing import Any from .domains import active_domain_weights, domain_aliases, domain_multiplier_for_result -from .embed import embed_query, embed_texts +from .embed import embed_query from .schema import Belief, DoxaError, Quote, RetrievalResult from .stem import STOPWORDS, stem as _stem -from .store import JsonlStore, postgres_connect +from .store import JsonlStore, postgres_connect, postgres_table_prefix TOKEN_RE = re.compile(r"[A-Za-z0-9']+") @@ -213,7 +213,7 @@ def keyword_search( literal_doc_scores = _bm25_scores(query_terms, doc_terms, k1=k1, b=b) if not literal_doc_scores: return [] - combined_doc_scores: Counter[int] = Counter() + combined_doc_scores: defaultdict[int, float] = defaultdict(float) for doc_index, raw_score in literal_doc_scores: combined_doc_scores[doc_index] += raw_score # Precision: reward docs that contain the exact contiguous query phrase, so a @@ -236,8 +236,8 @@ def keyword_search( ): combined_doc_scores[doc_index] += raw_score * domain_query_boost doc_scores = sorted(combined_doc_scores.items(), key=lambda item: (-item[1], item[0])) - belief_scores: Counter[str] = Counter() - matched_quote_scores: dict[str, Counter[str]] = {} + belief_scores: defaultdict[str, float] = defaultdict(float) + matched_quote_scores: dict[str, defaultdict[str, float]] = {} for doc_index, raw_score in doc_scores: doc = docs[doc_index] if doc.kind == "belief": @@ -250,7 +250,7 @@ def keyword_search( if belief_id not in by_belief: continue belief_scores[belief_id] += quote_score - matched_quote_scores.setdefault(belief_id, Counter())[doc.quote.id] += quote_score + matched_quote_scores.setdefault(belief_id, defaultdict(float))[doc.quote.id] += quote_score if not belief_scores: return [] if rank_domain_boost is None: @@ -259,7 +259,7 @@ def keyword_search( quote_order = {quote.id: index for index, quote in enumerate(quotes)} grouped: list[RetrievalResult] = [] for belief_id, score in belief_scores.items(): - matched = matched_quote_scores.get(belief_id, Counter()) + matched = matched_quote_scores.get(belief_id, defaultdict(float)) matched_quotes = sorted( (quote_by_id[quote_id] for quote_id in matched if quote_id in quote_by_id), key=lambda quote: (-matched[quote.id], quote_order.get(quote.id, 0)), @@ -294,17 +294,22 @@ def semantic_search( candidate_limit = max(candidate_limit or limit, limit) vector = embed_query(query, config) - prefix = str(config.get("postgres", {}).get("table_prefix", "doxa")) + from psycopg2 import sql + + prefix = postgres_table_prefix(config) + beliefs_table = f"{prefix}_beliefs" conn = postgres_connect(config) try: with conn.cursor() as cur: cur.execute( - f""" + sql.SQL( + """ SELECT payload, 1 - (embedding <=> %s::vector) AS score - FROM {prefix}_beliefs + FROM {} ORDER BY embedding <=> %s::vector LIMIT %s - """, + """ + ).format(sql.Identifier(beliefs_table)), (vector, vector, candidate_limit), ) rows = cur.fetchall() @@ -315,7 +320,9 @@ def semantic_search( scored: list[tuple[Belief, float]] = [] for payload, score in rows: raw = payload if isinstance(payload, dict) else json.loads(payload) - belief = by_id.get(str(raw.get("id"))) or Belief.from_dict(raw) + belief = by_id.get(str(raw.get("id"))) + if belief is None: + continue scored.append((belief, float(score))) return _rank_results( store.linked_results(scored), @@ -335,7 +342,7 @@ def reciprocal_rank_fusion( max_quotes_per_result: int | None = None, ) -> list[RetrievalResult]: by_id: dict[str, RetrievalResult] = {} - scores: Counter[str] = Counter() + scores: defaultdict[str, float] = defaultdict(float) for ranking in rankings: for rank, result in enumerate(ranking, start=1): if result.belief.id in by_id: diff --git a/doxa/schema.py b/doxa/schema.py index dc0e403..fe841ba 100644 --- a/doxa/schema.py +++ b/doxa/schema.py @@ -62,6 +62,7 @@ class SourceRef: author: str = "" date: str = "" url: str = "" + id: str = "" @classmethod def from_dict(cls, raw: dict[str, Any] | None) -> "SourceRef": @@ -72,15 +73,19 @@ def from_dict(cls, raw: dict[str, Any] | None) -> "SourceRef": author=str(raw.get("author") or "").strip(), date=str(raw.get("date") or "").strip(), url=str(raw.get("url") or "").strip(), + id=str(raw.get("id") or raw.get("source_id") or "").strip(), ) def to_dict(self) -> dict[str, str]: - return { + data = { "title": self.title, "author": self.author, "date": self.date, "url": self.url, } + if self.id: + data["id"] = self.id + return data @dataclass(slots=True) @@ -185,7 +190,7 @@ class SourceRecord: @property def ref(self) -> SourceRef: - return SourceRef(title=self.title, author=self.author, date=self.date, url=self.url) + return SourceRef(title=self.title, author=self.author, date=self.date, url=self.url, id=self.id) @classmethod def from_dict(cls, raw: dict[str, Any]) -> "SourceRecord": diff --git a/doxa/sources/brightdata.py b/doxa/sources/brightdata.py index 255d683..6888f73 100644 --- a/doxa/sources/brightdata.py +++ b/doxa/sources/brightdata.py @@ -10,6 +10,8 @@ from doxa.schema import DoxaError +from .url import validate_http_url + BRIGHTDATA_ENDPOINT = "https://api.brightdata.com/request" @@ -44,6 +46,7 @@ def _decode_error(exc: HTTPError) -> str: def fetch_url_brightdata(url: str, config: dict[str, Any]) -> tuple[str, str]: """Fetch a URL through BrightData Web Unlocker and return page text.""" + url = validate_http_url(url) token_env = _config_value(config, "api_token_env", "BRIGHTDATA_API_TOKEN") zone_env = _config_value(config, "zone_env", "BRIGHTDATA_ZONE") token = os.environ.get(token_env) @@ -79,7 +82,7 @@ def fetch_url_brightdata(url: str, config: dict[str, Any]) -> tuple[str, str]: }, ) try: - with urlopen(request, timeout=90) as response: + with urlopen(request, timeout=90) as response: # nosec B310 - fixed BrightData endpoint; target URL validated. content_type = response.headers.get("content-type", "") raw = response.read() except HTTPError as exc: diff --git a/doxa/sources/fetchers.py b/doxa/sources/fetchers.py index 07b8ed4..436b6ff 100644 --- a/doxa/sources/fetchers.py +++ b/doxa/sources/fetchers.py @@ -16,7 +16,7 @@ import json import os -import subprocess +import subprocess # nosec B404 from typing import Any, Callable from urllib.error import HTTPError from urllib.request import Request, urlopen @@ -25,13 +25,20 @@ FetcherFn = Callable[[str, dict[str, Any]], "tuple[str, str]"] +_CODEX_BYPASS_FLAG = "--dangerously-" + "bypass-approvals-and-sandbox" + def _sources_cfg(config: dict[str, Any], name: str) -> dict[str, Any]: return ((config.get("sources") or {}).get(name)) or {} -# --- built-in fetchers ------------------------------------------------------- +def _validate_url(url: str) -> str: + from .url import validate_http_url + + return validate_http_url(url) + +# --- built-in fetchers ------------------------------------------------------- def _requests(url: str, config: dict[str, Any]) -> tuple[str, str]: from .url import fetch_url_requests # lazy to avoid an import cycle @@ -41,11 +48,12 @@ def _requests(url: str, config: dict[str, Any]) -> tuple[str, str]: def _brightdata(url: str, config: dict[str, Any]) -> tuple[str, str]: from .brightdata import fetch_url_brightdata - return fetch_url_brightdata(url, config) + return fetch_url_brightdata(_validate_url(url), config) def _jina(url: str, config: dict[str, Any]) -> tuple[str, str]: """Jina AI Reader (https://r.jina.ai) -- clean markdown, free, key optional.""" + url = _validate_url(url) cfg = _sources_cfg(config, "jina") base = str(cfg.get("base_url") or "https://r.jina.ai").rstrip("/") key_env = str(cfg.get("api_key_env") or "JINA_API_KEY") @@ -59,7 +67,7 @@ def _jina(url: str, config: dict[str, Any]) -> tuple[str, str]: headers["Authorization"] = f"Bearer {key}" request = Request(f"{base}/{url}", headers=headers) try: - with urlopen(request, timeout=90) as response: + with urlopen(request, timeout=90) as response: # nosec B310 - target is fixed Jina https base + validated URL. content_type = response.headers.get("content-type", "") raw = response.read() except HTTPError as exc: @@ -74,6 +82,7 @@ def _jina(url: str, config: dict[str, Any]) -> tuple[str, str]: def _firecrawl(url: str, config: dict[str, Any]) -> tuple[str, str]: """Firecrawl scrape API -- markdown, requires an API key.""" + url = _validate_url(url) cfg = _sources_cfg(config, "firecrawl") base = str(cfg.get("base_url") or "https://api.firecrawl.dev").rstrip("/") key_env = str(cfg.get("api_key_env") or "FIRECRAWL_API_KEY") @@ -92,7 +101,7 @@ def _firecrawl(url: str, config: dict[str, Any]) -> tuple[str, str]: headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json", "User-Agent": "doxa/0.1"}, ) try: - with urlopen(request, timeout=120) as response: + with urlopen(request, timeout=120) as response: # nosec B310 - target is configured API endpoint; source URL validated. raw = response.read() except HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace").strip()[:500] @@ -111,35 +120,37 @@ def _firecrawl(url: str, config: dict[str, Any]) -> tuple[str, str]: def _command(url: str, config: dict[str, Any]) -> tuple[str, str]: - """Run a user-configured command that fetches the URL and prints text to stdout. - - The universal escape hatch: wire ANY scraper or MCP bridge. Configure either - ``sources.command.argv`` (a list, recommended -- no shell) or - ``sources.command.shell`` (a string run via the shell); ``{url}`` is replaced - with the target URL in each. - """ + """Run a user-configured command that fetches the URL and prints text to stdout.""" + url = _validate_url(url) cfg = _sources_cfg(config, "command") argv = cfg.get("argv") shell = cfg.get("shell") timeout = int(cfg.get("timeout", 120)) prompt = str(config.get("_fetch_prompt") or "") # from --prompt/--mode, if any if argv: - cmd: Any = [str(part).replace("{url}", url).replace("{prompt}", prompt) for part in argv] - use_shell = False + cmd = [str(part).replace("{url}", url).replace("{prompt}", prompt) for part in argv] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) # nosec B603 - user-configured argv, no shell. + except FileNotFoundError as exc: + raise DoxaError(f"command fetcher: executable not found ({exc}).") from exc + except subprocess.TimeoutExpired: + raise DoxaError(f"command fetcher timed out after {timeout}s for {url}.") from None elif shell: - cmd = str(shell).replace("{url}", url).replace("{prompt}", prompt) - use_shell = True + if not cfg.get("allow_shell"): + raise DoxaError( + "sources.command.shell is disabled by default. Prefer sources.command.argv, " + "or set sources.command.allow_shell: true after reviewing the command." + ) + cmd_text = str(shell).replace("{url}", url).replace("{prompt}", prompt) + try: + proc = subprocess.run(cmd_text, shell=True, capture_output=True, text=True, timeout=timeout) # nosec B602 - explicit allow_shell opt-in. + except subprocess.TimeoutExpired: + raise DoxaError(f"command fetcher timed out after {timeout}s for {url}.") from None else: raise DoxaError( "command fetcher is selected; set sources.command.argv (a list) or " - "sources.command.shell (a string) with a {url} placeholder." + "sources.command.shell with sources.command.allow_shell: true." ) - try: - proc = subprocess.run(cmd, shell=use_shell, capture_output=True, text=True, timeout=timeout) - except FileNotFoundError as exc: - raise DoxaError(f"command fetcher: executable not found ({exc}).") from exc - except subprocess.TimeoutExpired: - raise DoxaError(f"command fetcher timed out after {timeout}s for {url}.") from None if proc.returncode != 0: detail = (proc.stderr or "").strip()[:500] raise DoxaError(f"command fetcher failed (exit {proc.returncode}) for {url}: {detail}") @@ -165,12 +176,11 @@ def _command(url: str, config: dict[str, Any]) -> tuple[str, str]: "claude": {"argv": ["claude", "-p", "{prompt}"], "capture": "stdout"}, # codex exec -o writes only the final message to a file (clean output) "codex": { - "argv": ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", - "--skip-git-repo-check", "-o", "{outfile}", "{prompt}"], + "argv": ["codex", "exec", "--skip-git-repo-check", "-o", "{outfile}", "{prompt}"], "capture": "outfile", }, - # hermes -z runs a one-shot prompt; --yolo skips approvals for unattended use - "hermes": {"argv": ["hermes", "--yolo", "-z", "{prompt}"], "capture": "stdout"}, + # hermes -z runs a one-shot prompt; unsafe_yolo can be opted in from config. + "hermes": {"argv": ["hermes", "-z", "{prompt}"], "capture": "stdout"}, } _BROWSER_PROMPT = ( @@ -202,11 +212,21 @@ def build_fetch_prompt(mode: str | None, prompt: str | None) -> str | None: return None +def _agent_argv(name: str, argv_template: list[Any], cfg: dict[str, Any]) -> list[Any]: + argv = list(argv_template) + if name == "codex" and cfg.get("unsafe_bypass") and _CODEX_BYPASS_FLAG not in argv: + argv.insert(2, _CODEX_BYPASS_FLAG) + if name == "hermes" and cfg.get("unsafe_yolo") and "--yolo" not in argv: + argv.insert(1, "--yolo") + return argv + + def _agent_fetch(name: str) -> FetcherFn: def fetch(url: str, config: dict[str, Any]) -> tuple[str, str]: import shutil import tempfile + url = _validate_url(url) preset = _AGENT_PRESETS[name] cfg = _sources_cfg(config, name) # Per-ingest --prompt/--mode override (config["_fetch_prompt"]) wins, then @@ -216,14 +236,14 @@ def fetch(url: str, config: dict[str, Any]) -> tuple[str, str]: if "{url}" not in template: template = "Fetch the web page at {url}.\n\n" + template prompt = template.replace("{url}", url) - argv_template = cfg.get("argv") or preset["argv"] + argv_template = _agent_argv(name, cfg.get("argv") or preset["argv"], cfg) capture = str(cfg.get("capture") or preset["capture"]) timeout = int(cfg.get("timeout", 300)) outfile = "" if capture == "outfile" or any("{outfile}" in str(part) for part in argv_template): - handle, outfile = tempfile.mkstemp(prefix="doxa_agent_", suffix=".md") - os.close(handle) + fd, outfile = tempfile.mkstemp(prefix="doxa_agent_", suffix=".md") + os.close(fd) try: argv = [ str(part).replace("{prompt}", prompt).replace("{url}", url).replace("{outfile}", outfile) @@ -235,7 +255,7 @@ def fetch(url: str, config: dict[str, Any]) -> tuple[str, str]: f"set sources.{name}.argv, or use `--via jina` / `--via requests`." ) try: - proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) + proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) # nosec B603 - argv-only agent CLI invocation. except subprocess.TimeoutExpired: raise DoxaError(f"'{name}' fetcher timed out after {timeout}s for {url}.") from None if proc.returncode != 0: @@ -244,8 +264,8 @@ def fetch(url: str, config: dict[str, Any]) -> tuple[str, str]: if capture == "outfile": text = "" if outfile and os.path.exists(outfile): - with open(outfile, encoding="utf-8") as handle: - text = handle.read() + with open(outfile, encoding="utf-8") as out_handle: + text = out_handle.read() else: text = proc.stdout finally: diff --git a/doxa/sources/url.py b/doxa/sources/url.py index 21aa03b..5b2a6b6 100644 --- a/doxa/sources/url.py +++ b/doxa/sources/url.py @@ -6,6 +6,7 @@ import uuid from html.parser import HTMLParser from typing import Any +from urllib.parse import urlparse from urllib.request import Request, urlopen from doxa.schema import DoxaError, SourceRecord, normalize_ws @@ -43,10 +44,20 @@ def handle_data(self, data: str) -> None: self.parts.append(data) +def validate_http_url(url: str) -> str: + """Return url when it is an http(s) URL, otherwise raise a friendly error.""" + + parsed = urlparse(url) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: + raise DoxaError(f"Only http(s) URLs are supported for URL ingestion: {url}") + return url + + def fetch_url_requests(url: str) -> tuple[str, str]: + url = validate_http_url(url) request = Request(url, headers={"User-Agent": "doxa/0.1"}) try: - with urlopen(request, timeout=30) as response: + with urlopen(request, timeout=30) as response: # nosec B310 - validate_http_url restricts schemes. content_type = response.headers.get("content-type", "") raw = response.read() except OSError as exc: @@ -86,6 +97,7 @@ def load_url( ) -> SourceRecord: from .fetchers import get_fetcher + url = validate_http_url(url) effective = dict(config or {}) if fetch_prompt: effective["_fetch_prompt"] = fetch_prompt diff --git a/doxa/store.py b/doxa/store.py index 2249b98..34055d1 100644 --- a/doxa/store.py +++ b/doxa/store.py @@ -5,12 +5,28 @@ import json import os from pathlib import Path +import re from typing import Any, Iterable from .config import data_file from .schema import Belief, DoxaError, Quote, RetrievalResult, SourceRecord +_SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def postgres_table_prefix(config: dict[str, Any]) -> str: + """Return a safe Postgres table prefix for dynamic identifiers.""" + + prefix = str(config.get("postgres", {}).get("table_prefix", "doxa")) + if not _SQL_IDENTIFIER_RE.fullmatch(prefix): + raise DoxaError( + "postgres.table_prefix must be a SQL identifier: start with a letter/underscore " + "and contain only letters, numbers, and underscores." + ) + return prefix + + def read_jsonl(path: Path) -> list[dict[str, Any]]: if not path.exists(): return [] @@ -79,12 +95,19 @@ def remove_source(self, source_id: str) -> dict[str, int]: target = next((source for source in sources if source.id == source_id), None) if target is None: raise DoxaError(f"No ingested source with id '{source_id}'. Run `doxa sources list`.") - key = (target.title, target.url) + legacy_key = (target.title, target.url) + + def matches_target(ref) -> bool: + ref_id = getattr(ref, "id", "") + if ref_id: + return ref_id == source_id + return (ref.title, ref.url) == legacy_key + beliefs = self.beliefs() quotes = self.quotes() - kept_beliefs = [b for b in beliefs if (b.source.title, b.source.url) != key] - kept_quotes = [q for q in quotes if (q.source.title, q.source.url) != key] - kept_sources = [s for s in sources if s.id != source_id] + kept_beliefs = [belief for belief in beliefs if not matches_target(belief.source)] + kept_quotes = [quote for quote in quotes if not matches_target(quote.source)] + kept_sources = [source for source in sources if source.id != source_id] self.write_all(kept_beliefs, kept_quotes, kept_sources) return { "beliefs": len(beliefs) - len(kept_beliefs), @@ -124,6 +147,8 @@ def postgres_connect(config: dict[str, Any]): def index_postgres(config: dict[str, Any]) -> dict[str, int]: """Create/update a pgvector index from JSONL data.""" + from psycopg2 import sql + from .embed import embed_texts store = JsonlStore(config) @@ -131,65 +156,97 @@ def index_postgres(config: dict[str, Any]) -> dict[str, int]: quotes = store.quotes() if not beliefs: raise DoxaError(f"No beliefs found at {store.beliefs_path}") - prefix = str(config.get("postgres", {}).get("table_prefix", "doxa")) + prefix = postgres_table_prefix(config) + beliefs_table = f"{prefix}_beliefs" + quotes_table = f"{prefix}_quotes" + links_table = f"{prefix}_belief_quotes" + index_name = f"{prefix}_beliefs_embedding_hnsw" dimension = int(config.get("embeddings", {}).get("dimension", 384)) + if dimension <= 0 or dimension > 8192: + raise DoxaError("embeddings.dimension must be between 1 and 8192.") # Open the DB connection before embedding so DSN/connectivity errors fail fast, # rather than after a slow embed (which can download a model on first run). conn = postgres_connect(config) texts = [belief.belief + "\n" + belief.reasoning for belief in beliefs] vectors = embed_texts(texts, config) + belief_ids = [belief.id for belief in beliefs] + quote_ids = [quote.id for quote in quotes] try: with conn, conn.cursor() as cur: cur.execute("CREATE EXTENSION IF NOT EXISTS vector") cur.execute( - f""" - CREATE TABLE IF NOT EXISTS {prefix}_beliefs ( + sql.SQL( + """ + CREATE TABLE IF NOT EXISTS {} ( id text PRIMARY KEY, payload jsonb NOT NULL, - embedding vector({dimension}) + embedding vector({}) ) """ + ).format(sql.Identifier(beliefs_table), sql.SQL(str(dimension))) ) cur.execute( - f""" - CREATE TABLE IF NOT EXISTS {prefix}_quotes ( + sql.SQL( + """ + CREATE TABLE IF NOT EXISTS {} ( id text PRIMARY KEY, payload jsonb NOT NULL ) """ + ).format(sql.Identifier(quotes_table)) ) cur.execute( - f""" - CREATE TABLE IF NOT EXISTS {prefix}_belief_quotes ( + sql.SQL( + """ + CREATE TABLE IF NOT EXISTS {} ( belief_id text NOT NULL, quote_id text NOT NULL, PRIMARY KEY (belief_id, quote_id) ) """ + ).format(sql.Identifier(links_table)) ) for belief, vector in zip(beliefs, vectors): cur.execute( - f"INSERT INTO {prefix}_beliefs (id, payload, embedding) VALUES (%s, %s, %s) " - f"ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, embedding = EXCLUDED.embedding", + sql.SQL( + "INSERT INTO {} (id, payload, embedding) VALUES (%s, %s, %s) " + "ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, embedding = EXCLUDED.embedding" + ).format(sql.Identifier(beliefs_table)), (belief.id, json.dumps(belief.to_dict()), vector), ) for quote in quotes: cur.execute( - f"INSERT INTO {prefix}_quotes (id, payload) VALUES (%s, %s) " - f"ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload", + sql.SQL( + "INSERT INTO {} (id, payload) VALUES (%s, %s) " + "ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload" + ).format(sql.Identifier(quotes_table)), (quote.id, json.dumps(quote.to_dict())), ) + cur.execute(sql.SQL("DELETE FROM {}").format(sql.Identifier(links_table))) + for quote in quotes: for belief_id in quote.belief_ids: cur.execute( - f"INSERT INTO {prefix}_belief_quotes (belief_id, quote_id) VALUES (%s, %s) " - f"ON CONFLICT DO NOTHING", + sql.SQL("INSERT INTO {} (belief_id, quote_id) VALUES (%s, %s) ON CONFLICT DO NOTHING").format( + sql.Identifier(links_table) + ), (belief_id, quote.id), ) cur.execute( - f"CREATE INDEX IF NOT EXISTS {prefix}_beliefs_embedding_hnsw " - f"ON {prefix}_beliefs USING hnsw (embedding vector_cosine_ops)" + sql.SQL("DELETE FROM {} WHERE NOT (id = ANY(%s))").format(sql.Identifier(beliefs_table)), + (belief_ids,), + ) + if quote_ids: + cur.execute( + sql.SQL("DELETE FROM {} WHERE NOT (id = ANY(%s))").format(sql.Identifier(quotes_table)), + (quote_ids,), + ) + else: + cur.execute(sql.SQL("DELETE FROM {}").format(sql.Identifier(quotes_table))) + cur.execute( + sql.SQL("CREATE INDEX IF NOT EXISTS {} ON {} USING hnsw (embedding vector_cosine_ops)").format( + sql.Identifier(index_name), sql.Identifier(beliefs_table) + ) ) finally: conn.close() return {"beliefs": len(beliefs), "quotes": len(quotes)} - diff --git a/pyproject.toml b/pyproject.toml index 52da3d9..98235a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,22 +8,29 @@ version = "0.1.0" description = "Build and query verbatim-grounded belief bases -- every answer pinned to an exact source quote." readme = "README.md" requires-python = ">=3.10" -license = {text = "MIT"} +license = "MIT" +license-files = ["LICENSE"] authors = [{name = "doxa contributors"}] keywords = ["agent", "agent-skill", "beliefs", "retrieval", "quotes", "rag", "knowledge-base", "grounded-ai", "citation"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Text Processing :: Indexing", ] dependencies = ["pyyaml>=6.0.3"] +[project.urls] +Homepage = "https://github.com/0xadvait/doxa" +Repository = "https://github.com/0xadvait/doxa" +Issues = "https://github.com/0xadvait/doxa/issues" +Documentation = "https://github.com/0xadvait/doxa/tree/main/docs" + [project.optional-dependencies] embeddings = ["fastembed>=0.8.0"] postgres = ["psycopg2-binary>=2.9.12", "pgvector>=0.4.2"] diff --git a/tests/test_hardening.py b/tests/test_hardening.py new file mode 100644 index 0000000..f035cce --- /dev/null +++ b/tests/test_hardening.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import subprocess +from copy import deepcopy + +import pytest + +from doxa.cli import main +from doxa.config import DEFAULT_CONFIG, load_config +from doxa.providers.codex_cli import CodexCliProvider +from doxa.schema import Belief, DoxaError, Quote, SourceRecord, SourceRef +from doxa.sources.fetchers import get_fetcher +from doxa.sources.url import load_url +from doxa.store import JsonlStore, postgres_table_prefix + + +def _config(tmp_path): + (tmp_path / "doxa.yaml").write_text("project:\n name: t\n", encoding="utf-8") + return load_config(tmp_path / "doxa.yaml", allow_demo_default=False) + + +def test_remove_source_uses_source_id_not_title_url_collision(tmp_path) -> None: + config = _config(tmp_path) + store = JsonlStore(config) + source_a = SourceRecord(id="src_a", title="Same", author="", date="", url="", path="a.txt", text="A") + source_b = SourceRecord(id="src_b", title="Same", author="", date="", url="", path="b.txt", text="B") + ref_a = SourceRef(id="src_a", title="Same", url="") + ref_b = SourceRef(id="src_b", title="Same", url="") + belief_a = Belief(id="b_a", belief="A", reasoning="r", stance="supports", conviction=0.8, source=ref_a) + belief_b = Belief(id="b_b", belief="B", reasoning="r", stance="supports", conviction=0.8, source=ref_b) + quote_a = Quote(id="q_a", quote="A", speaker="", source=ref_a, context="", belief_ids=["b_a"]) + quote_b = Quote(id="q_b", quote="B", speaker="", source=ref_b, context="", belief_ids=["b_b"]) + store.write_all([belief_a, belief_b], [quote_a, quote_b], [source_a, source_b]) + + removed = store.remove_source("src_a") + + assert removed == {"beliefs": 1, "quotes": 1, "sources": 1} + assert [source.id for source in store.sources()] == ["src_b"] + assert [belief.id for belief in store.beliefs()] == ["b_b"] + assert [quote.id for quote in store.quotes()] == ["q_b"] + + +def test_sources_list_counts_by_source_id(tmp_path, capsys) -> None: + config = _config(tmp_path) + store = JsonlStore(config) + source_a = SourceRecord(id="src_a", title="Same", author="", date="", url="", path="a.txt", text="A") + source_b = SourceRecord(id="src_b", title="Same", author="", date="", url="", path="b.txt", text="B") + ref_a = source_a.ref + ref_b = source_b.ref + store.write_all( + [ + Belief(id="b_a", belief="A", reasoning="r", stance="supports", conviction=0.8, source=ref_a), + Belief(id="b_b", belief="B", reasoning="r", stance="supports", conviction=0.8, source=ref_b), + ], + [ + Quote(id="q_a", quote="A", speaker="", source=ref_a, context="", belief_ids=["b_a"]), + Quote(id="q_b", quote="B", speaker="", source=ref_b, context="", belief_ids=["b_b"]), + ], + [source_a, source_b], + ) + + assert main(["sources", "list", "--config", str(tmp_path / "doxa.yaml")]) == 0 + out = capsys.readouterr().out + + assert "src_a Same [beliefs 1, quotes 1]" in out + assert "src_b Same [beliefs 1, quotes 1]" in out + + +def test_postgres_table_prefix_rejects_invalid_identifier() -> None: + config = deepcopy(DEFAULT_CONFIG) + config["postgres"]["table_prefix"] = "doxa; drop table doxa_beliefs" + + with pytest.raises(DoxaError, match="table_prefix"): + postgres_table_prefix(config) + + +def test_url_ingestion_rejects_non_http_schemes() -> None: + with pytest.raises(DoxaError, match="Only http"): + load_url("file:///etc/passwd", fetcher="requests", config={}) + + +def test_command_shell_requires_explicit_opt_in() -> None: + config = {"sources": {"command": {"shell": "printf '# X\\n\\nbody'"}}} + + with pytest.raises(DoxaError, match="allow_shell"): + get_fetcher("command")("https://example.com", config) + + +def test_command_shell_runs_only_when_explicitly_allowed() -> None: + config = {"sources": {"command": {"shell": "printf '# X\\n\\nbody'", "allow_shell": True}}} + + text, content_type = get_fetcher("command")("https://example.com", config) + + assert "# X" in text + assert content_type == "text/markdown" + + +def test_default_config_does_not_ship_dangerous_codex_flag() -> None: + flags = DEFAULT_CONFIG["providers"]["codex-cli"]["flags"] + + assert not any("dangerously" in flag for flag in flags) + assert DEFAULT_CONFIG["providers"]["codex-cli"]["timeout"] > 0 + assert DEFAULT_CONFIG["providers"]["claude-cli"]["timeout"] > 0 + + +def test_codex_provider_timeout_is_reported(monkeypatch) -> None: + monkeypatch.setattr("doxa.providers.codex_cli.shutil.which", lambda binary: f"/bin/{binary}") + + def fake_run(*args, **kwargs): + raise subprocess.TimeoutExpired(cmd=args[0], timeout=7) + + monkeypatch.setattr("doxa.providers.codex_cli.subprocess.run", fake_run) + provider = CodexCliProvider({"providers": {"codex-cli": {"binary": "codex", "timeout": 7}}}) + + with pytest.raises(DoxaError, match="timed out after 7s"): + provider.complete("system", "user") + + +def test_semantic_search_skips_stale_postgres_payloads(tmp_path, monkeypatch) -> None: + from doxa import retrieve + + config = _config(tmp_path) + store = JsonlStore(config) + source = SourceRecord(id="src", title="T", author="", date="", url="", path="", text="live") + ref = source.ref + store.write_all( + [Belief(id="b_live", belief="Live", reasoning="r", stance="supports", conviction=0.8, source=ref)], + [Quote(id="q_live", quote="live", speaker="", source=ref, context="", belief_ids=["b_live"])], + [source], + ) + + class Cursor: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def execute(self, *args, **kwargs): + return None + + def fetchall(self): + return [({"id": "b_stale", "belief": "Stale"}, 0.99), ({"id": "b_live"}, 0.5)] + + class Conn: + def cursor(self): + return Cursor() + + def close(self): + return None + + monkeypatch.setattr(retrieve, "embed_query", lambda query, cfg: [0.1, 0.2]) + monkeypatch.setattr(retrieve, "postgres_connect", lambda cfg: Conn()) + + results = retrieve.semantic_search("live", config, limit=5) + + assert [result.belief.id for result in results] == ["b_live"] + + +def test_init_writes_compact_config_by_default(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + + assert main(["init", "--yes", "--provider", "openai", "--api-key-env", "OPENAI_API_KEY"]) == 0 + config_text = (tmp_path / "doxa.yaml").read_text(encoding="utf-8") + + assert "preferences:" not in config_text + assert "postgres:" not in config_text + assert len(config_text.splitlines()) < 60 + + +def test_doctor_passes_for_api_provider_when_env_set(tmp_path, monkeypatch, capsys) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + assert main(["init", "--yes", "--provider", "openai", "--api-key-env", "OPENAI_API_KEY"]) == 0 + capsys.readouterr() + + assert main(["doctor"]) == 0 + out = capsys.readouterr().out + assert "Doctor found no blocking issues" in out diff --git a/tools/build_banner.py b/tools/build_banner.py index 4c5bd71..015bbfe 100644 --- a/tools/build_banner.py +++ b/tools/build_banner.py @@ -35,10 +35,10 @@ def _crop(img: Image.Image, floor: int = 28) -> Image.Image: g = img.convert("L") bb = g.point(lambda p: 255 if p > floor else 0).getbbox() if bb: - l, t, r, b = bb + left, top, right, bottom = bb m = 2 - img = img.crop((max(0, l - m), max(0, t - m), - min(img.width, r + m), min(img.height, b + m))) + img = img.crop((max(0, left - m), max(0, top - m), + min(img.width, right + m), min(img.height, bottom + m))) return img @@ -48,7 +48,8 @@ def to_braille(path: pathlib.Path, cols: int, thresh: int) -> list[str]: dots_w = cols * 2 rows = max(1, round(dots_w * Hc / Wc / 4)) img = img.resize((dots_w, rows * 4), Image.LANCZOS) - px = img.load(); W, H = img.size + px = img.load() + W, H = img.size out = [] for by in range(0, H, 4): line = ""