merge: feat/phase1-knowledge-specialist-s1 — the knowledge specialist's governed ToolUniverse core (ADR-0001): 28-tool allow-list with exactly 12 loaded, no-model-key and load_workspace=False startup guards, 10-field provenance, pinned output schemas, retrieved_at-preserving cache, per-source rate limits, identifier validator, annotation payloads; 117 passed / 1 strict xfail; ADR-0014 scan clean
Browse files- .gitattributes +4 -0
- .github/workflows/tests.yml +69 -0
- .gitleaks.toml +31 -0
- .pre-commit-config.yaml +23 -0
- Makefile +36 -0
- README.md +71 -0
- access.py +69 -0
- audit.py +66 -0
- bandit.yaml +15 -0
- config/sources.yaml +133 -0
- config/tools_allowlist.yaml +147 -0
- docs/adr/ADR-0001-knowledge-specialist-contract.md +116 -0
- docs/adr/RESERVED.md +21 -0
- docs/plans/PLAN-phase1-knowledge-specialist.md +201 -0
- logging_sink.py +123 -0
- pytest.ini +6 -0
- requirements-dev.txt +12 -0
- requirements.in +30 -0
- requirements.txt +397 -0
- ruff.toml +23 -0
- scripts/hooks/pre-push +188 -0
- scripts/hooks/pre-push-shim +38 -0
- scripts/security_scan.sh +204 -0
- security/.gitignore +10 -0
- security/ACCEPTED-FINDINGS.md +22 -0
- src/__init__.py +0 -0
- src/cache.py +80 -0
- src/config.py +142 -0
- src/identifiers.py +154 -0
- src/payloads.py +151 -0
- src/provenance.py +74 -0
- src/ratelimit.py +78 -0
- src/schemas/OpenTargets_get_associated_drugs_by_target_ensemblID.schema.json +79 -0
- src/schemas/__init__.py +65 -0
- src/tu_client.py +335 -0
- tests/__init__.py +0 -0
- tests/conftest.py +103 -0
- tests/fixtures/phase0/opentargets_drugs_TNFRSF10B.json +162 -0
- tests/test_allowlist.py +166 -0
- tests/test_audit.py +58 -0
- tests/test_cache.py +74 -0
- tests/test_identifiers.py +109 -0
- tests/test_no_llm_keys.py +61 -0
- tests/test_output_schemas.py +114 -0
- tests/test_provenance.py +145 -0
- tests/test_ratelimit.py +143 -0
- tests/test_sources_hosts.py +73 -0
.gitattributes
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Bookkeeping files are union-merged so concurrent lanes' entries both survive
|
| 2 |
+
# (fleet convention). NEVER set merge=union on code or lockfiles.
|
| 3 |
+
memory.md merge=union
|
| 4 |
+
TODO.md merge=union
|
.github/workflows/tests.yml
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# The test suite and the formatting check, run on the GitHub mirror (copied from pdac-spatial-agent,
|
| 2 |
+
# where a repo with no workflow let an unformatted file sit for three days).
|
| 3 |
+
#
|
| 4 |
+
# WHERE IT RUNS. Once the Spaces exist, `origin` IS the HuggingFace prod Space and `hf-dev` the dev
|
| 5 |
+
# Space; HuggingFace does not execute GitHub Actions, so this only ever runs via the `github` mirror,
|
| 6 |
+
# which deploys nothing.
|
| 7 |
+
#
|
| 8 |
+
# WHY IT NEEDS NO SECRETS. `tests/conftest.py` blocks outbound sockets before collection and removes
|
| 9 |
+
# every model-provider variable, so the suite is hermetic by enforcement. Install-time network is
|
| 10 |
+
# still required (PyPI). If this job ever starts needing a token, that is a regression in the guard,
|
| 11 |
+
# not a missing secret.
|
| 12 |
+
name: tests
|
| 13 |
+
|
| 14 |
+
on:
|
| 15 |
+
pull_request:
|
| 16 |
+
push:
|
| 17 |
+
branches: [main, dev]
|
| 18 |
+
workflow_dispatch:
|
| 19 |
+
|
| 20 |
+
permissions:
|
| 21 |
+
contents: read
|
| 22 |
+
|
| 23 |
+
concurrency:
|
| 24 |
+
group: tests-${{ github.ref }}
|
| 25 |
+
cancel-in-progress: true
|
| 26 |
+
|
| 27 |
+
jobs:
|
| 28 |
+
# Separate from pytest on purpose: a style failure and a test failure are different news, and
|
| 29 |
+
# folding them together hides one behind the other. Safe to gate only because the repo is
|
| 30 |
+
# formatted from its first commit — a check that fails on day one is one people learn to ignore.
|
| 31 |
+
format:
|
| 32 |
+
runs-on: ubuntu-latest
|
| 33 |
+
steps:
|
| 34 |
+
- uses: actions/checkout@v7
|
| 35 |
+
- uses: actions/setup-python@v6
|
| 36 |
+
with:
|
| 37 |
+
python-version: "3.12"
|
| 38 |
+
- name: Install ruff (pinned — see requirements-dev.txt)
|
| 39 |
+
run: pip install "$(grep '^ruff==' requirements-dev.txt)"
|
| 40 |
+
- name: Check formatting
|
| 41 |
+
# `format --check` only. `ruff check` (lint) is not gated: it still has pre-existing
|
| 42 |
+
# findings across files no single lane owns, and gating that would fail on day one.
|
| 43 |
+
run: ruff format --check .
|
| 44 |
+
|
| 45 |
+
pytest:
|
| 46 |
+
runs-on: ubuntu-latest
|
| 47 |
+
steps:
|
| 48 |
+
- uses: actions/checkout@v7
|
| 49 |
+
|
| 50 |
+
- uses: actions/setup-python@v6
|
| 51 |
+
with:
|
| 52 |
+
# Matches `python_version` in the README front-matter — CI should run what the Space
|
| 53 |
+
# runs. Note this is 3.12 where most of the fleet is 3.11; that is this Space's setting,
|
| 54 |
+
# not a copy-paste slip.
|
| 55 |
+
python-version: "3.12"
|
| 56 |
+
cache: pip
|
| 57 |
+
cache-dependency-path: |
|
| 58 |
+
requirements.txt
|
| 59 |
+
requirements-dev.txt
|
| 60 |
+
|
| 61 |
+
- name: Install deps
|
| 62 |
+
# The whole pinned set plus pytest. ToolUniverse (~100 packages) makes this the slow step; the
|
| 63 |
+
# pip cache above is what keeps it from being slow every time.
|
| 64 |
+
run: |
|
| 65 |
+
python -m pip install --upgrade pip
|
| 66 |
+
pip install -r requirements-dev.txt
|
| 67 |
+
|
| 68 |
+
- name: Run the suite
|
| 69 |
+
run: python -m pytest tests/ -q
|
.gitleaks.toml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Gitleaks config — ADR-0014 secret scan (this repo's copy of the shared definition
|
| 2 |
+
# authored in DecoupleRpy_Agent, ported via pdac-subtype-agent).
|
| 3 |
+
#
|
| 4 |
+
# Extends the upstream default ruleset (do not replace it). Real secrets (HF_TOKEN,
|
| 5 |
+
# `research_agent_token`, `ANTHROPIC_API_KEY`) must NEVER be committed — they live in HF
|
| 6 |
+
# Space secrets. Runs over the working tree AND full git history, because tokens have
|
| 7 |
+
# flowed through these repos.
|
| 8 |
+
#
|
| 9 |
+
# Run: gitleaks detect --config .gitleaks.toml --redact --no-banner
|
| 10 |
+
|
| 11 |
+
[extend]
|
| 12 |
+
useDefault = true
|
| 13 |
+
|
| 14 |
+
[[allowlists]]
|
| 15 |
+
description = "known-safe paths and placeholder tokens"
|
| 16 |
+
paths = [
|
| 17 |
+
'''\.venv/''',
|
| 18 |
+
'''(^|/)node_modules/''',
|
| 19 |
+
'''security/sbom\.json''',
|
| 20 |
+
'''security/pip-audit-.*\.(json|txt)''',
|
| 21 |
+
'''\.gitleaks\.toml''',
|
| 22 |
+
]
|
| 23 |
+
# Docs and ADRs reference token *names* as identifiers, never their values.
|
| 24 |
+
regexes = [
|
| 25 |
+
'''research_agent_token''',
|
| 26 |
+
'''ANTHROPIC_API_KEY''',
|
| 27 |
+
'''(?i)your[-_]?(hf|api|anthropic)[-_]?(token|key)[-_]?here''',
|
| 28 |
+
'''(?i)example[-_]?(token|key|secret)''',
|
| 29 |
+
'''xxx+|placeholder|dummy|fake[-_]?(token|key|secret)''',
|
| 30 |
+
]
|
| 31 |
+
stopwords = ["example", "placeholder", "changeme"]
|
.pre-commit-config.yaml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pre-commit hooks — shared lint/format baseline across the PDAC-system repos.
|
| 2 |
+
# Install once per clone: pip install pre-commit && pre-commit install
|
| 3 |
+
# Run on all files: pre-commit run --all-files
|
| 4 |
+
#
|
| 5 |
+
# NOTE: this is separate from the ADR-0014 *security* pre-push hook
|
| 6 |
+
# (scripts/hooks/pre-push, installed via `make install-hooks`). This one runs
|
| 7 |
+
# ruff at commit time for style/correctness; that one runs the security scan at
|
| 8 |
+
# push time.
|
| 9 |
+
repos:
|
| 10 |
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
| 11 |
+
rev: v0.16.9
|
| 12 |
+
hooks:
|
| 13 |
+
- id: ruff # lint (uses ruff.toml / [tool.ruff]); --fix applies safe fixes
|
| 14 |
+
args: [--fix]
|
| 15 |
+
- id: ruff-format # formatter (line-length + quote style from config)
|
| 16 |
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
| 17 |
+
rev: v5.0.0
|
| 18 |
+
hooks:
|
| 19 |
+
- id: end-of-file-fixer
|
| 20 |
+
- id: trailing-whitespace
|
| 21 |
+
- id: check-yaml
|
| 22 |
+
- id: check-added-large-files
|
| 23 |
+
args: [--maxkb=2048]
|
Makefile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pdac-knowledge-agent — developer entry points. The security targets are this repo's copy
|
| 2 |
+
# of the shared surface ADR-0014 standardizes across the fleet.
|
| 3 |
+
.PHONY: help security-scan security-baseline install-hooks test
|
| 4 |
+
|
| 5 |
+
help:
|
| 6 |
+
@echo "make install-hooks Install the pre-push hook (security gate + GitHub mirror sync on prod deploys)."
|
| 7 |
+
@echo "make security-scan Run the security scan (pip-audit, bandit, gitleaks; trivy N/A)."
|
| 8 |
+
@echo "make security-baseline Regenerate the accepted-findings bandit baseline."
|
| 9 |
+
@echo "make test Run the test suite."
|
| 10 |
+
|
| 11 |
+
# One scan definition; the pre-push hook and a manual run both call it, so the check
|
| 12 |
+
# cannot drift between entry points.
|
| 13 |
+
security-scan:
|
| 14 |
+
@bash scripts/security_scan.sh
|
| 15 |
+
|
| 16 |
+
# Only after intentionally accepting a new finding. Review the diff before committing —
|
| 17 |
+
# a new entry is a new accepted suppression and must be justified in
|
| 18 |
+
# security/ACCEPTED-FINDINGS.md. Targets match the scan: src/ + root-level modules.
|
| 19 |
+
security-baseline:
|
| 20 |
+
@if command -v bandit >/dev/null 2>&1; then B="bandit"; else B="uvx bandit"; fi; \
|
| 21 |
+
$$B -r src $$(git ls-files '*.py' | grep -v /) -ll -c bandit.yaml \
|
| 22 |
+
-f json -o security/bandit-baseline.json -q; \
|
| 23 |
+
echo "Wrote security/bandit-baseline.json — review the diff and document any new suppression."
|
| 24 |
+
|
| 25 |
+
# Git never commits .git/hooks, so this installs one (idempotent).
|
| 26 |
+
# `--git-path hooks` resolves correctly from a linked worktree, where .git is a file.
|
| 27 |
+
# It installs a SHIM, not a copy: scripts/hooks/pre-push-shim runs the tracked hook as it is
|
| 28 |
+
# on `dev`, so an edited gate never needs re-installing (a stale copy silently disabled a gate
|
| 29 |
+
# in pdac-analysis-orchestrator for six days). Run once per clone.
|
| 30 |
+
install-hooks:
|
| 31 |
+
@hooks_dir="$$(git rev-parse --git-path hooks)"; mkdir -p "$$hooks_dir"; \
|
| 32 |
+
cp scripts/hooks/pre-push-shim "$$hooks_dir/pre-push"; chmod +x "$$hooks_dir/pre-push"; \
|
| 33 |
+
echo "Installed $$hooks_dir/pre-push — every push now runs dev's security gate on the pushed commit."
|
| 34 |
+
|
| 35 |
+
test:
|
| 36 |
+
@python -m pytest tests/ -q
|
README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: PDAC Knowledge Agent
|
| 3 |
+
emoji: 📚
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.28.0
|
| 8 |
+
python_version: "3.12"
|
| 9 |
+
app_file: gradio_ui.py
|
| 10 |
+
hf_oauth: true
|
| 11 |
+
pinned: false
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# PDAC Knowledge Agent
|
| 15 |
+
|
| 16 |
+
The knowledge specialist of the PDAC Research Agent System. It looks up what public knowledge
|
| 17 |
+
bases say about named genes, drugs, diseases and trials, and returns **annotation, never
|
| 18 |
+
analysis**: it reads no dataset and computes no statistic. Built per
|
| 19 |
+
[ADR-0001](docs/adr/ADR-0001-knowledge-specialist-contract.md) on a pinned, allow-listed subset
|
| 20 |
+
of [ToolUniverse](https://github.com/mims-harvard/ToolUniverse) (Apache-2.0).
|
| 21 |
+
|
| 22 |
+
> **Status: Phase 1, slice s1 — the governed core only.** The endpoints, the Gradio app and the
|
| 23 |
+
> deploy descriptor arrive in s2; there is no Space yet. See
|
| 24 |
+
> [the plan](docs/plans/PLAN-phase1-knowledge-specialist.md).
|
| 25 |
+
|
| 26 |
+
## What it will answer (s2)
|
| 27 |
+
|
| 28 |
+
| Endpoint | What | Sources |
|
| 29 |
+
|---|---|---|
|
| 30 |
+
| `drugs_for_targets` | Drugs and clinical candidates per target, with mechanism and maximum clinical stage | HGNC → Open Targets; ChEMBL |
|
| 31 |
+
| `trials_for_genes` | ClinicalTrials.gov studies whose intervention matches the gene | ClinicalTrials.gov |
|
| 32 |
+
| `annotate_genes` | Approved name, UniProt accession, protein name, function, subcellular location | HGNC, UniProt |
|
| 33 |
+
| `pathways_for_genes` | Reactome pathways each gene belongs to (membership, not enrichment) | UniProt → Reactome |
|
| 34 |
+
| `targets_for_disease` | Top associated targets, with Open Targets' own association score | Open Targets |
|
| 35 |
+
|
| 36 |
+
Which TFs or pathways are active, which genes are differentially expressed and which gene sets
|
| 37 |
+
are enriched in a dataset are DecoupleRpy's questions; the literature corpus is lit-agent's.
|
| 38 |
+
|
| 39 |
+
## The rules, enforced in code
|
| 40 |
+
|
| 41 |
+
- **Provenance on every row:** source, ToolUniverse tool and version, the query sent, the
|
| 42 |
+
source's own id and URL, when the source answered, cache hit or miss, licence and licence URL
|
| 43 |
+
(`src/provenance.py`). A row missing any of them cannot reach a payload.
|
| 44 |
+
- **Annotation, not analysis:** every ok payload is `kind: "annotation"` and carries no
|
| 45 |
+
`n_for_inference`; a row count is never a sample size (`src/payloads.py`).
|
| 46 |
+
- **Allow-list by construction:** only the tools the endpoints use are loaded, from the approved
|
| 47 |
+
28 in `config/tools_allowlist.yaml`. Agentic/LLM-backed tools, tool finders, remote MCP
|
| 48 |
+
servers, web search and key-gated tools can never load, ToolUniverse's workspace and profile
|
| 49 |
+
are never read, and startup refuses if any model-provider key is set (`src/tu_client.py`).
|
| 50 |
+
- **Pinned output schemas:** a source response that drifts from its schema becomes
|
| 51 |
+
`upstream_schema_drift`, never a guessed row (`src/schemas/`).
|
| 52 |
+
- **Identifiers only leave the Space:** gene symbols, Ensembl/UniProt/EFO/MONDO/NCT ids and a
|
| 53 |
+
bounded disease name. Free text, sample ids and numbers are refused before any call
|
| 54 |
+
(`src/identifiers.py`).
|
| 55 |
+
- **Declared hosts:** every host the loaded tools can reach is in `config/sources.yaml`, checked
|
| 56 |
+
offline against the installed package. This is a behaviour contract, not an egress firewall:
|
| 57 |
+
Hugging Face Spaces have no egress allow-list.
|
| 58 |
+
- **Cache and limits:** ephemeral SQLite cache with a TTL per source that keeps the original
|
| 59 |
+
`retrieved_at`; a token bucket per source; timeouts and limits become structured errors.
|
| 60 |
+
|
| 61 |
+
## Development
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
uv venv --python 3.12 .venv && uv pip install -r requirements-dev.txt
|
| 65 |
+
python -m pytest # hermetic: outbound sockets are blocked
|
| 66 |
+
make security-scan # ADR-0014: pip-audit, bandit, gitleaks (trivy N/A)
|
| 67 |
+
make install-hooks # the pre-push security gate, once per clone
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
`dev` is the integration branch and `main` is production (once the Spaces exist, `main` IS the
|
| 71 |
+
prod Space). Lanes merge to `dev` only.
|
access.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""App-layer identity & authorization — the family allow-list gate.
|
| 2 |
+
|
| 3 |
+
Deliberately mirrors the sibling specialists' ``access.py`` env contract
|
| 4 |
+
(pdac-genomics-agent, pdac-subtype-agent ADR-0012, DecoupleRpy_Agent
|
| 5 |
+
``access_control``) so all Spaces share ONE allow-list model:
|
| 6 |
+
|
| 7 |
+
- ``ADMIN_IDS`` — comma-separated admin identities (``UPLOAD_ADMIN_IDS`` honored
|
| 8 |
+
for back-compat with the family contract).
|
| 9 |
+
- ``ALLOWED_IDS`` — comma-separated regular-user identities. Admins are allowed too.
|
| 10 |
+
- ``ACCESS_CONTROL`` — dark-launch switch. OFF unless one of ``1/on/true/enforce/yes``.
|
| 11 |
+
It is set to enforce on both Spaces before either is made public (plan D2-C). When
|
| 12 |
+
ON, fail-closed: an empty list denies everyone; unknown/anonymous identities are refused.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
|
| 19 |
+
ADMIN_ROLE = "admin"
|
| 20 |
+
USER_ROLE = "user"
|
| 21 |
+
|
| 22 |
+
_ADMIN_ENV_VARS = ("ADMIN_IDS", "UPLOAD_ADMIN_IDS")
|
| 23 |
+
_ALLOWED_ENV_VAR = "ALLOWED_IDS"
|
| 24 |
+
_ENFORCE_VALUES = {"1", "on", "true", "enforce", "yes"}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _ids_from_env(*env_vars: str) -> set[str]:
|
| 28 |
+
ids: set[str] = set()
|
| 29 |
+
for var in env_vars:
|
| 30 |
+
raw = os.environ.get(var, "")
|
| 31 |
+
ids.update(part.strip() for part in raw.split(",") if part.strip())
|
| 32 |
+
return ids
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def access_enforced() -> bool:
|
| 36 |
+
return os.environ.get("ACCESS_CONTROL", "").strip().lower() in _ENFORCE_VALUES
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def role_for(identity: str | None) -> str | None:
|
| 40 |
+
if not identity:
|
| 41 |
+
return None
|
| 42 |
+
identity = identity.strip()
|
| 43 |
+
if not identity:
|
| 44 |
+
return None
|
| 45 |
+
if identity in _ids_from_env(*_ADMIN_ENV_VARS):
|
| 46 |
+
return ADMIN_ROLE
|
| 47 |
+
if identity in _ids_from_env(_ALLOWED_ENV_VAR):
|
| 48 |
+
return USER_ROLE
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def is_authorized(identity: str | None) -> bool:
|
| 53 |
+
return role_for(identity) is not None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def check_access(username: str | None) -> tuple[bool, str | None]:
|
| 57 |
+
"""(allowed, denial_message). Enforcement off ⇒ everyone allowed."""
|
| 58 |
+
if not access_enforced():
|
| 59 |
+
return True, None
|
| 60 |
+
if not username:
|
| 61 |
+
return False, (
|
| 62 |
+
"🔒 Please sign in with your HuggingFace account (button above) to use this tool."
|
| 63 |
+
)
|
| 64 |
+
if not is_authorized(username):
|
| 65 |
+
return False, (
|
| 66 |
+
f"🔒 Access denied — '{username}' is not on the authorized-users list "
|
| 67 |
+
"for this deployment. Contact the study admin to request access."
|
| 68 |
+
)
|
| 69 |
+
return True, None
|
audit.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal run audit — one record per call, results NEVER stored.
|
| 2 |
+
|
| 3 |
+
Records who called which endpoint, with how many identifiers and what status. Neither the
|
| 4 |
+
identifiers nor the payload are written (the ADR-0008 posture of the sibling
|
| 5 |
+
specialists — orchestrator persistence stores results in ITS private index; the
|
| 6 |
+
specialist's sink stays result-free). Failure to write never fails a call.
|
| 7 |
+
|
| 8 |
+
Destination is the configured sink (``LOG_SINK``, see :mod:`logging_sink`): ``local`` by
|
| 9 |
+
default, ``hf`` on the deployed Spaces so the trail survives a rebuild (2026-10-13; the
|
| 10 |
+
temp-dir JSONL this replaced was erased by every rebuild).
|
| 11 |
+
|
| 12 |
+
The ``hf`` upload runs on a daemon thread: the answer never waits on HuggingFace. A slow or
|
| 13 |
+
failing hub (HF git returned 500s for minutes on 2026-10-13) must not push a call past the
|
| 14 |
+
orchestrator's 120 s budget. The cost is that a record still uploading when the container is
|
| 15 |
+
killed is lost — seconds, against the whole trail before.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import datetime
|
| 21 |
+
import secrets
|
| 22 |
+
import threading
|
| 23 |
+
|
| 24 |
+
from access import access_enforced, role_for
|
| 25 |
+
from logging_sink import get_log_sink, persist_trace_safe
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def record_run(
|
| 29 |
+
tool: str,
|
| 30 |
+
*,
|
| 31 |
+
username: str | None,
|
| 32 |
+
n_identifiers: int | None,
|
| 33 |
+
status: str,
|
| 34 |
+
run_id: str | None = None,
|
| 35 |
+
) -> threading.Thread | None:
|
| 36 |
+
"""Build and persist one audit record. Returns the upload thread for the ``hf`` sink.
|
| 37 |
+
|
| 38 |
+
``run_id`` is the payload's own id (None for denials and input errors). The record's
|
| 39 |
+
storage key is that id, or a fresh one when there is none, so no record overwrites
|
| 40 |
+
another. Fail-open: any error building or writing the record is swallowed.
|
| 41 |
+
"""
|
| 42 |
+
try:
|
| 43 |
+
now = datetime.datetime.now(datetime.UTC)
|
| 44 |
+
key = run_id or f"{now:%Y%m%d_%H%M%S}_{secrets.token_hex(4)}_{status}"
|
| 45 |
+
record = {
|
| 46 |
+
"run_id": run_id,
|
| 47 |
+
"timestamp": now.isoformat(),
|
| 48 |
+
"tool": tool,
|
| 49 |
+
"status": status,
|
| 50 |
+
"n_identifiers": n_identifiers,
|
| 51 |
+
"principal": username or "anonymous",
|
| 52 |
+
"role": role_for(username),
|
| 53 |
+
"enforced": access_enforced(),
|
| 54 |
+
}
|
| 55 |
+
sink = get_log_sink()
|
| 56 |
+
if sink.name != "hf":
|
| 57 |
+
persist_trace_safe(sink, key, record)
|
| 58 |
+
return None
|
| 59 |
+
upload = threading.Thread(
|
| 60 |
+
target=persist_trace_safe, args=(sink, key, record), name="audit-upload", daemon=True
|
| 61 |
+
)
|
| 62 |
+
upload.start()
|
| 63 |
+
return upload
|
| 64 |
+
except Exception as e: # noqa: BLE001 — auditing must never crash a request
|
| 65 |
+
print(f"[audit] record_run failed: {e}")
|
| 66 |
+
return None
|
bandit.yaml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Bandit config — ADR-0014 static-analysis scan (this repo's copy of the shared
|
| 2 |
+
# definition authored in DecoupleRpy_Agent, ported via pdac-subtype-agent).
|
| 3 |
+
#
|
| 4 |
+
# Scope: the app code — `src/` plus every root-level module (scripts/security_scan.sh
|
| 5 |
+
# discovers them). Tests use synthetic data and assert on error paths, so scanning them
|
| 6 |
+
# produces noise without signal. Accepted findings live in security/bandit-baseline.json
|
| 7 |
+
# (the scan fails only on NEW ones) and each is justified in
|
| 8 |
+
# security/ACCEPTED-FINDINGS.md. `-ll` = medium severity and above.
|
| 9 |
+
|
| 10 |
+
exclude_dirs:
|
| 11 |
+
- tests
|
| 12 |
+
- .venv
|
| 13 |
+
- scripts
|
| 14 |
+
- docker
|
| 15 |
+
- .claude
|
config/sources.yaml
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Every external source this Space may call, with the hosts its loaded tools reach (ADR-0001 §6).
|
| 2 |
+
#
|
| 3 |
+
# This file is the honest egress statement, not an egress firewall: Hugging Face Spaces have no
|
| 4 |
+
# egress allow-list, so what this lists is "the hosts this code is built to call", enforced by
|
| 5 |
+
# tests/test_sources_hosts.py (every loaded tool's host must appear here) and by the allow-list
|
| 6 |
+
# loader. The security-review addendum's host table is generated from it (s4), and on any future
|
| 7 |
+
# AWS footprint this list becomes the VPC egress allow-list.
|
| 8 |
+
#
|
| 9 |
+
# Per source:
|
| 10 |
+
# hosts hosts the loaded tools call (found in ToolUniverse 1.5.6's tool code/config)
|
| 11 |
+
# licence the licence string stamped on every row from this source
|
| 12 |
+
# licence_url where that licence is stated
|
| 13 |
+
# licence_checked date + how it was checked; "partial" says what could not be confirmed
|
| 14 |
+
# ttl_seconds cache lifetime (ADR-0001 §8): 7 days; ClinicalTrials.gov 24 h (recruitment moves)
|
| 15 |
+
# rate_per_second token-bucket rate (plan default: at most 3 requests/s per source)
|
| 16 |
+
# burst bucket size
|
| 17 |
+
# timeout_seconds per-call limit; a timeout becomes a `source_unavailable` entry
|
| 18 |
+
# coverage_notes deterministic caveats every payload from this source carries
|
| 19 |
+
# citation the attribution line for `attribution[]`
|
| 20 |
+
#
|
| 21 |
+
# PubChem and DGIdb have no Phase 1 endpoint, so none of their tools is loaded; they are listed
|
| 22 |
+
# because they are on the approved allow-list, and their hosts are declared ahead of use.
|
| 23 |
+
|
| 24 |
+
sources:
|
| 25 |
+
opentargets:
|
| 26 |
+
name: "Open Targets Platform"
|
| 27 |
+
hosts: [api.platform.opentargets.org]
|
| 28 |
+
licence: "CC0 1.0"
|
| 29 |
+
licence_url: "https://platform-docs.opentargets.org/licence"
|
| 30 |
+
licence_checked: "2026-10-20: the licence page states the Platform is marked with CC0 1.0"
|
| 31 |
+
ttl_seconds: 604800
|
| 32 |
+
rate_per_second: 3
|
| 33 |
+
burst: 3
|
| 34 |
+
timeout_seconds: 30
|
| 35 |
+
coverage_notes:
|
| 36 |
+
- "Open Targets lists drugs and clinical candidates with trial or approval evidence; an investigational compound with no trial record (for example MRTX1133 against KRAS G12D) may be absent."
|
| 37 |
+
- "Open Targets association scores are Open Targets' own scores, relayed as such; they are not a statistic computed here."
|
| 38 |
+
citation: "Open Targets Platform (platform.opentargets.org)"
|
| 39 |
+
|
| 40 |
+
chembl:
|
| 41 |
+
name: "ChEMBL"
|
| 42 |
+
hosts: [www.ebi.ac.uk]
|
| 43 |
+
licence: "CC BY-SA 3.0"
|
| 44 |
+
licence_url: "https://chembl.gitbook.io/chembl-interface-documentation/about"
|
| 45 |
+
licence_checked: "2026-10-20: 'made available on a Creative Commons Attribution-Share Alike 3.0 Unported License'"
|
| 46 |
+
ttl_seconds: 604800
|
| 47 |
+
rate_per_second: 3
|
| 48 |
+
burst: 3
|
| 49 |
+
timeout_seconds: 30
|
| 50 |
+
coverage_notes:
|
| 51 |
+
- "ChEMBL data are share-alike (CC BY-SA 3.0): a redistributed table built from these rows must carry the same licence."
|
| 52 |
+
citation: "ChEMBL, EMBL-EBI (www.ebi.ac.uk/chembl)"
|
| 53 |
+
|
| 54 |
+
uniprot:
|
| 55 |
+
name: "UniProt"
|
| 56 |
+
hosts: [rest.uniprot.org]
|
| 57 |
+
licence: "CC BY 4.0"
|
| 58 |
+
licence_url: "https://www.uniprot.org/help/license"
|
| 59 |
+
licence_checked: "2026-10-20 partial: the licence page is script-rendered and did not load; every UniProtKB record's copyright line states CC BY 4.0 and points to uniprot.org/terms"
|
| 60 |
+
ttl_seconds: 604800
|
| 61 |
+
rate_per_second: 3
|
| 62 |
+
burst: 3
|
| 63 |
+
timeout_seconds: 30
|
| 64 |
+
coverage_notes:
|
| 65 |
+
- "Only reviewed (Swiss-Prot) human entries are used; a symbol with no reviewed entry (for example a pseudogene such as SORD2P) is reported as unresolved, not guessed."
|
| 66 |
+
citation: "The UniProt Consortium, UniProtKB (www.uniprot.org)"
|
| 67 |
+
|
| 68 |
+
clinicaltrials:
|
| 69 |
+
name: "ClinicalTrials.gov"
|
| 70 |
+
hosts: [clinicaltrials.gov]
|
| 71 |
+
licence: "Public (U.S. National Library of Medicine); NLM terms and conditions apply"
|
| 72 |
+
licence_url: "https://clinicaltrials.gov/about-site/terms-conditions"
|
| 73 |
+
licence_checked: "2026-10-20 partial: the terms page did not render; NLM policy (ncbi.nlm.nih.gov/home/about/policies) puts government-created information in the public domain and asks for acknowledgment"
|
| 74 |
+
ttl_seconds: 86400
|
| 75 |
+
rate_per_second: 3
|
| 76 |
+
burst: 3
|
| 77 |
+
timeout_seconds: 30
|
| 78 |
+
coverage_notes:
|
| 79 |
+
- "ToolUniverse narrows an intervention search to trials whose intervention matches the gene (Phase 0 finding 5: 1 pancreas-specific CD40 trial, where a broad API query also returns 2 solid-tumour basket trials). The payload's query_semantics states this."
|
| 80 |
+
citation: "ClinicalTrials.gov, U.S. National Library of Medicine"
|
| 81 |
+
|
| 82 |
+
reactome:
|
| 83 |
+
name: "Reactome"
|
| 84 |
+
hosts: [reactome.org]
|
| 85 |
+
licence: "CC0 1.0"
|
| 86 |
+
licence_url: "https://reactome.org/license"
|
| 87 |
+
licence_checked: "2026-10-20: the License Agreement §1(c) releases data under the Creative Commons Public Domain Dedication (CC0). The plan had assumed CC BY 4.0; corrected here."
|
| 88 |
+
ttl_seconds: 604800
|
| 89 |
+
rate_per_second: 3
|
| 90 |
+
burst: 3
|
| 91 |
+
timeout_seconds: 30
|
| 92 |
+
coverage_notes:
|
| 93 |
+
- "Pathway rows are membership (this gene is annotated to this pathway), never enrichment of a gene list."
|
| 94 |
+
citation: "Reactome Knowledgebase (reactome.org)"
|
| 95 |
+
|
| 96 |
+
hgnc:
|
| 97 |
+
name: "HGNC"
|
| 98 |
+
hosts: [rest.genenames.org]
|
| 99 |
+
licence: "No restrictions on access or use (HGNC data release policy); citation requested"
|
| 100 |
+
licence_url: "https://www.genenames.org/about/"
|
| 101 |
+
licence_checked: "2026-10-20: 'No restrictions are imposed on access to, or use of, the data provided by the HGNC'; asks users to cite the HUGO Gene Nomenclature Committee (RRID:SCR_002827)"
|
| 102 |
+
ttl_seconds: 604800
|
| 103 |
+
rate_per_second: 3
|
| 104 |
+
burst: 3
|
| 105 |
+
timeout_seconds: 30
|
| 106 |
+
coverage_notes: []
|
| 107 |
+
citation: "HUGO Gene Nomenclature Committee (HGNC), www.genenames.org"
|
| 108 |
+
|
| 109 |
+
pubchem:
|
| 110 |
+
name: "PubChem"
|
| 111 |
+
hosts: [pubchem.ncbi.nlm.nih.gov]
|
| 112 |
+
licence: "NCBI places no restrictions on the data; some depositor content may carry its own rights"
|
| 113 |
+
licence_url: "https://www.ncbi.nlm.nih.gov/home/about/policies/"
|
| 114 |
+
licence_checked: "2026-10-20: NCBI policy page; PubChem is named among resources that may include third-party material"
|
| 115 |
+
ttl_seconds: 604800
|
| 116 |
+
rate_per_second: 3
|
| 117 |
+
burst: 3
|
| 118 |
+
timeout_seconds: 30
|
| 119 |
+
coverage_notes: []
|
| 120 |
+
citation: "PubChem, U.S. National Library of Medicine"
|
| 121 |
+
|
| 122 |
+
dgidb:
|
| 123 |
+
name: "DGIdb"
|
| 124 |
+
hosts: [dgidb.org]
|
| 125 |
+
licence: "Unconfirmed: DGIdb aggregates sources that carry their own terms; rows must name the interaction's source"
|
| 126 |
+
licence_url: "https://dgidb.org/about"
|
| 127 |
+
licence_checked: "2026-10-20 partial: no data licence found on dgidb.org; must be confirmed before any DGIdb endpoint ships"
|
| 128 |
+
ttl_seconds: 604800
|
| 129 |
+
rate_per_second: 3
|
| 130 |
+
burst: 3
|
| 131 |
+
timeout_seconds: 30
|
| 132 |
+
coverage_notes: []
|
| 133 |
+
citation: "DGIdb, the Drug Gene Interaction Database (dgidb.org)"
|
config/tools_allowlist.yaml
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# The ToolUniverse allow-list — the ONLY tools this Space may ever load.
|
| 2 |
+
#
|
| 3 |
+
# Source of truth: the Phase 0 evaluation memo's 28-tool allow-list across 8 keyless public
|
| 4 |
+
# sources, approved by the project lead 2026-10-13 (memo decision 2: "additions go by config
|
| 5 |
+
# with a review note"). The memo described the tools by function; the exact names below were
|
| 6 |
+
# resolved against ToolUniverse 1.5.6's own tool configs on 2026-10-20 (s1), and every name
|
| 7 |
+
# exists in that version (tests/test_allowlist.py asserts it).
|
| 8 |
+
#
|
| 9 |
+
# `endpoints` names which BCC endpoints use a tool (ADR-0001 §4). Only tools with at least one
|
| 10 |
+
# endpoint are LOADED (ADR-0001 §7); the rest are approved but wait for an endpoint that needs
|
| 11 |
+
# them. Adding a tool here is a reviewed change: give it a `review` note saying who approved it
|
| 12 |
+
# and why. Never add an agentic/LLM-backed tool, the tool finder, a remote MCP server, a
|
| 13 |
+
# web-search backend or a key-gated tool — tests/test_allowlist.py refuses each of those.
|
| 14 |
+
#
|
| 15 |
+
# The five endpoints are built in s2; their tool sets are fixed here so the loaded surface is
|
| 16 |
+
# reviewable before any endpoint code exists.
|
| 17 |
+
|
| 18 |
+
tooluniverse_version: "1.5.6"
|
| 19 |
+
|
| 20 |
+
tools:
|
| 21 |
+
# --- Open Targets (6) ---
|
| 22 |
+
- name: OpenTargets_get_disease_id_description_by_name
|
| 23 |
+
source: opentargets
|
| 24 |
+
endpoints: [targets_for_disease]
|
| 25 |
+
review: "memo 2026-10-12, approved 2026-10-13 — disease name to EFO/MONDO id"
|
| 26 |
+
- name: OpenTargets_get_associated_targets_by_disease_efoId
|
| 27 |
+
source: opentargets
|
| 28 |
+
endpoints: [targets_for_disease]
|
| 29 |
+
review: "memo 2026-10-12, approved 2026-10-13 — targets by disease (Phase 0 Q8)"
|
| 30 |
+
- name: OpenTargets_get_associated_drugs_by_target_ensemblID
|
| 31 |
+
source: opentargets
|
| 32 |
+
endpoints: [drugs_for_targets]
|
| 33 |
+
review: "memo 2026-10-12, approved 2026-10-13 — drugs by target (Phase 0 Q1, Q2, Q7)"
|
| 34 |
+
- name: OpenTargets_get_associated_drugs_by_disease_efoId
|
| 35 |
+
source: opentargets
|
| 36 |
+
endpoints: []
|
| 37 |
+
review: "memo 2026-10-12, approved 2026-10-13 — drugs by disease"
|
| 38 |
+
- name: OpenTargets_get_drug_mechanisms_of_action_by_chemblId
|
| 39 |
+
source: opentargets
|
| 40 |
+
endpoints: []
|
| 41 |
+
review: "memo 2026-10-12, approved 2026-10-13 — drug mechanisms"
|
| 42 |
+
- name: OpenTargets_get_drug_description_by_chemblId
|
| 43 |
+
source: opentargets
|
| 44 |
+
endpoints: []
|
| 45 |
+
review: "memo 2026-10-12, approved 2026-10-13 — drug description"
|
| 46 |
+
|
| 47 |
+
# --- ChEMBL (4) ---
|
| 48 |
+
- name: ChEMBL_search_molecules
|
| 49 |
+
source: chembl
|
| 50 |
+
endpoints: []
|
| 51 |
+
review: "memo 2026-10-12, approved 2026-10-13 — molecule search (Phase 0 found MRTX-1133 only here)"
|
| 52 |
+
- name: ChEMBL_get_drug
|
| 53 |
+
source: chembl
|
| 54 |
+
endpoints: []
|
| 55 |
+
review: "memo 2026-10-12, approved 2026-10-13 — drug record"
|
| 56 |
+
- name: ChEMBL_get_drug_mechanisms
|
| 57 |
+
source: chembl
|
| 58 |
+
endpoints: [drugs_for_targets]
|
| 59 |
+
review: "memo 2026-10-12, approved 2026-10-13 — mechanism of action per drug (ADR-0001 §4)"
|
| 60 |
+
- name: ChEMBL_search_targets
|
| 61 |
+
source: chembl
|
| 62 |
+
endpoints: []
|
| 63 |
+
review: "memo 2026-10-12, approved 2026-10-13 — target search"
|
| 64 |
+
|
| 65 |
+
# --- UniProt (4) ---
|
| 66 |
+
- name: UniProt_search
|
| 67 |
+
source: uniprot
|
| 68 |
+
endpoints: [annotate_genes, pathways_for_genes]
|
| 69 |
+
review: "memo 2026-10-12, approved 2026-10-13 — symbol to reviewed human accession (Phase 0 Q4)"
|
| 70 |
+
- name: UniProt_get_function_by_accession
|
| 71 |
+
source: uniprot
|
| 72 |
+
endpoints: [annotate_genes]
|
| 73 |
+
review: "memo 2026-10-12, approved 2026-10-13 — function (Phase 0 Q3)"
|
| 74 |
+
- name: UniProt_get_recommended_name_by_accession
|
| 75 |
+
source: uniprot
|
| 76 |
+
endpoints: [annotate_genes]
|
| 77 |
+
review: "memo 2026-10-12, approved 2026-10-13 — recommended protein name"
|
| 78 |
+
- name: UniProt_get_subcellular_location_by_accession
|
| 79 |
+
source: uniprot
|
| 80 |
+
endpoints: [annotate_genes]
|
| 81 |
+
review: "memo 2026-10-12, approved 2026-10-13 — subcellular location"
|
| 82 |
+
|
| 83 |
+
# --- ClinicalTrials.gov (3) ---
|
| 84 |
+
- name: ClinicalTrials_search_studies
|
| 85 |
+
source: clinicaltrials
|
| 86 |
+
endpoints: [trials_for_genes]
|
| 87 |
+
review: "memo 2026-10-12, approved 2026-10-13 — interactive trials lookups (memo decision 3; Phase 0 Q5)"
|
| 88 |
+
- name: ClinicalTrials_get_study
|
| 89 |
+
source: clinicaltrials
|
| 90 |
+
endpoints: []
|
| 91 |
+
review: "memo 2026-10-12, approved 2026-10-13 — one study by NCT id"
|
| 92 |
+
- name: ClinicalTrials_search_by_intervention
|
| 93 |
+
source: clinicaltrials
|
| 94 |
+
endpoints: []
|
| 95 |
+
review: "memo 2026-10-12, approved 2026-10-13 — search by intervention"
|
| 96 |
+
|
| 97 |
+
# --- PubChem (3) ---
|
| 98 |
+
- name: PubChem_get_CID_by_compound_name
|
| 99 |
+
source: pubchem
|
| 100 |
+
endpoints: []
|
| 101 |
+
review: "memo 2026-10-12, approved 2026-10-13 — compound name to CID (Phase 0 Q10)"
|
| 102 |
+
- name: PubChem_get_compound_properties_by_CID
|
| 103 |
+
source: pubchem
|
| 104 |
+
endpoints: []
|
| 105 |
+
review: "memo 2026-10-12, approved 2026-10-13 — compound properties (Phase 0 Q10)"
|
| 106 |
+
- name: PubChem_get_compound_xrefs_by_CID
|
| 107 |
+
source: pubchem
|
| 108 |
+
endpoints: []
|
| 109 |
+
review: "memo 2026-10-12, approved 2026-10-13 — cross-references"
|
| 110 |
+
|
| 111 |
+
# --- Reactome (3) ---
|
| 112 |
+
- name: Reactome_map_uniprot_to_pathways
|
| 113 |
+
source: reactome
|
| 114 |
+
endpoints: [pathways_for_genes]
|
| 115 |
+
review: "memo 2026-10-12, approved 2026-10-13 — protein to pathways (Phase 0 Q9)"
|
| 116 |
+
- name: Reactome_get_pathway
|
| 117 |
+
source: reactome
|
| 118 |
+
endpoints: []
|
| 119 |
+
review: "memo 2026-10-12, approved 2026-10-13 — one pathway"
|
| 120 |
+
- name: Reactome_get_pathways_low_entity
|
| 121 |
+
source: reactome
|
| 122 |
+
endpoints: [pathways_for_genes]
|
| 123 |
+
review: "memo 2026-10-12, approved 2026-10-13 — lowest-level pathways for an entity (ADR-0001 §4)"
|
| 124 |
+
|
| 125 |
+
# --- DGIdb (3) ---
|
| 126 |
+
- name: DGIdb_get_drug_gene_interactions
|
| 127 |
+
source: dgidb
|
| 128 |
+
endpoints: []
|
| 129 |
+
review: "memo 2026-10-12, approved 2026-10-13 — drug–gene interactions (Phase 0 Q6)"
|
| 130 |
+
- name: DGIdb_get_gene_druggability
|
| 131 |
+
source: dgidb
|
| 132 |
+
endpoints: []
|
| 133 |
+
review: "memo 2026-10-12, approved 2026-10-13 — druggability categories"
|
| 134 |
+
- name: DGIdb_get_drug_info
|
| 135 |
+
source: dgidb
|
| 136 |
+
endpoints: []
|
| 137 |
+
review: "memo 2026-10-12, approved 2026-10-13 — drug info"
|
| 138 |
+
|
| 139 |
+
# --- HGNC (2) ---
|
| 140 |
+
- name: HGNC_fetch_gene_by_symbol
|
| 141 |
+
source: hgnc
|
| 142 |
+
endpoints: [drugs_for_targets, annotate_genes]
|
| 143 |
+
review: "memo 2026-10-12, approved 2026-10-13 — approved symbol, name and Ensembl id"
|
| 144 |
+
- name: HGNC_search_genes
|
| 145 |
+
source: hgnc
|
| 146 |
+
endpoints: []
|
| 147 |
+
review: "memo 2026-10-12, approved 2026-10-13 — gene search"
|
docs/adr/ADR-0001-knowledge-specialist-contract.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR-0001 — The knowledge specialist: contract, boundary, and what a lookup may claim
|
| 2 |
+
|
| 3 |
+
**Status:** Accepted 2026-10-20 (project lead: D1-A of `docs/plans/PLAN-phase1-knowledge-specialist.md`, five endpoints
|
| 4 |
+
as drafted).
|
| 5 |
+
Access is set by D2-C: public Spaces behind the allow-list gate.
|
| 6 |
+
**Numbering:** drafted beside the roadmap's planning ADRs and moved into this repo at s1 (2026-10-20); this repo's
|
| 7 |
+
`docs/adr/RESERVED.md` owns the number. No orchestrator ADR number is claimed for Phase 1.
|
| 8 |
+
**Rests on:** the Phase 0 evaluation memo (kept with the roadmap, outside this repo), all four of whose decisions were approved
|
| 9 |
+
2026-10-13: borrow, wrapped; the 28-tool allow-list; ToolUniverse for interactive trials lookups (lit-agent's
|
| 10 |
+
harvester keeps the digest); knowledge lookups joined by the orchestrator, never called from inside DecoupleRpy's
|
| 11 |
+
code agent. It also rests on `pdac-analysis-orchestrator` ADR-0001 (tool_server dispatch), ADR-0002 (machine auth)
|
| 12 |
+
and ADR-0006 (a run card before every tool-server run), and on `pdac-spatial-agent` ADR-0001, the template.
|
| 13 |
+
|
| 14 |
+
## Context
|
| 15 |
+
|
| 16 |
+
After any result, scientists ask the same next question: what is known about these genes, and is anything
|
| 17 |
+
targeting them? Today the model answers that from memory, and memory is the one source the rest of the architecture
|
| 18 |
+
refuses to rely on. The Phase 0 test showed that ToolUniverse 1.5.6 answers the ten PDAC lookups as well as direct
|
| 19 |
+
API calls do (9 of 10 equal), keeps up with upstream schema changes we would otherwise maintain, and returns
|
| 20 |
+
structured errors. But it stamps no provenance or licence, it silently rewrites some queries (ClinicalTrials.gov
|
| 21 |
+
"CD40"), it carries LLM-backed and hosted-service tools that must never load, and it changes often (six releases in
|
| 22 |
+
two weeks). The fleet's contract has to sit between ToolUniverse and the orchestrator.
|
| 23 |
+
|
| 24 |
+
## Decisions
|
| 25 |
+
|
| 26 |
+
1. **Repo `pdac-knowledge-agent`, agent id `pdac_knowledge_agent`.** Spaces are `anne-voigt/pdac-knowledge-agent`
|
| 27 |
+
(prod; `main` is prod) and `anne-voigt/pdac-knowledge-agent-dev`, with a GitHub mirror at
|
| 28 |
+
`Anne-Voigt/pdac-knowledge-agent`. It uses the standard remotes: `origin`, `hf-dev`, `github`.
|
| 29 |
+
2. **Dispatch kind `tool_server`.** Deterministic `gr.api` endpoints, plain strings in and JSON strings out.
|
| 30 |
+
`mcp_server=True` and the `x-orchestrator-token` machine gate work as they do in the siblings. It is not a
|
| 31 |
+
CodeAgent, and **it makes no model call.** No LLM-provider key may be set; startup refuses if one is.
|
| 32 |
+
3. **The boundary sentence**, reused verbatim in the descriptor and the routing prompt: *"Looks up what public
|
| 33 |
+
knowledge bases say about named genes, drugs, diseases and trials (Open Targets, ChEMBL, UniProt,
|
| 34 |
+
ClinicalTrials.gov, Reactome, HGNC). It returns annotation, never analysis: it reads no dataset and computes no
|
| 35 |
+
statistic. Which genes are differentially expressed, which TFs or pathways are active, and which gene sets are
|
| 36 |
+
enriched in a dataset are DecoupleRpy's questions. The literature corpus belongs to lit-agent."*
|
| 37 |
+
4. **Five endpoints, frozen once the descriptor exists:**
|
| 38 |
+
|
| 39 |
+
| Endpoint | Input | What it returns | Sources |
|
| 40 |
+
|---|---|---|---|
|
| 41 |
+
| `drugs_for_targets` | gene symbols or Ensembl ids | drugs and clinical candidates per target, with mechanism and maximum clinical stage | HGNC → Open Targets; ChEMBL for mechanism |
|
| 42 |
+
| `trials_for_genes` | gene symbols, optional condition and status | ClinicalTrials.gov studies whose intervention matches the gene, with NCT id, phase, status | ClinicalTrials.gov |
|
| 43 |
+
| `annotate_genes` | gene symbols | approved name, UniProt accession, recommended protein name, function, subcellular location; unresolved symbols listed | HGNC, UniProt |
|
| 44 |
+
| `pathways_for_genes` | gene symbols or UniProt accessions | Reactome lowest-level pathways each gene belongs to (membership, not enrichment) | UniProt → Reactome |
|
| 45 |
+
| `targets_for_disease` | disease name or EFO/MONDO id | top associated targets, with Open Targets' association score labelled as its score | Open Targets |
|
| 46 |
+
|
| 47 |
+
No endpoint passes through a raw ToolUniverse tool name. A ToolUniverse version bump may change the code behind
|
| 48 |
+
an endpoint, but never the endpoint's name or its payload shape.
|
| 49 |
+
5. **The payload contract.** The family invariants apply: `status` is one of `ok`, `refused`, `error` or `denied`,
|
| 50 |
+
with a `reason` on anything but `ok`. Refusals are data. `run_id` is on everything except denials. The gate runs
|
| 51 |
+
first, then argument validation. Beyond those:
|
| 52 |
+
- **`kind: "annotation"` is on every ok payload.** Ok payloads carry no `hierarchy` and no `n_for_inference`. A row
|
| 53 |
+
count is not a sample size and must never be rendered as one.
|
| 54 |
+
- **Every row carries `provenance`:** `source`, `source_tool` (the ToolUniverse tool name), `tooluniverse_version`,
|
| 55 |
+
`query` (the arguments actually sent), `source_ref` (the source's own id: ChEMBL, NCT, UniProt, Reactome or
|
| 56 |
+
Ensembl), `source_url`, `retrieved_at` (when the source answered, which is not when the cache served it),
|
| 57 |
+
`cache` (`hit` or `miss`), `licence` and `licence_url`.
|
| 58 |
+
- **Payload-level fields:** `unresolved[]` (each input not found, with its reason) and `source_errors[]` (each with
|
| 59 |
+
`source` and one of `source_unavailable`, `rate_limited` or `upstream_schema_drift`). `coverage_notes[]` come
|
| 60 |
+
from config (for example, that Open Targets may omit investigational compounds that have no trial record, such
|
| 61 |
+
as MRTX1133). `attribution[]` holds one entry per source used. A deterministic `narrative` is generated from the
|
| 62 |
+
rows and always says "according to <source> (queried <date>)".
|
| 63 |
+
- **Output schema check.** Each source's raw response is validated against a pinned schema before any row is
|
| 64 |
+
built. A mismatch gives `upstream_schema_drift` for that source. Rows are never guessed from a partial match.
|
| 65 |
+
Phase 0 saw Open Targets rename `knownDrugs` while the test ran; this check is what catches that.
|
| 66 |
+
6. **What may leave the Space: identifiers only.** Allowed inputs are HGNC symbols, Ensembl gene ids, UniProt
|
| 67 |
+
accessions, EFO/MONDO ids, NCT ids, and disease names as a bounded string (80 characters; letters, digits,
|
| 68 |
+
spaces, hyphens, commas and apostrophes). At most 100 identifiers per call. Free text, sample ids, expression
|
| 69 |
+
values and file contents are refused as `invalid_identifier` before any outbound call. Every outbound host is
|
| 70 |
+
named in `config/sources.yaml`. The Space calls those hosts and nothing else that is ours to configure; this is a
|
| 71 |
+
behaviour contract, not an egress firewall, and Hugging Face Spaces have no egress allow-list. The security-review
|
| 72 |
+
wording must say exactly that.
|
| 73 |
+
7. **Allow-list by construction.** `config/tools_allowlist.yaml` holds the approved 28. Only the tools the five
|
| 74 |
+
endpoints use are loaded. Agentic or LLM-backed tools, the tool finder, remote MCP servers, web-search backends
|
| 75 |
+
and any key-gated tool can never load. ToolUniverse's own result cache and any download of an embedding model
|
| 76 |
+
or tool index are switched off. `tooluniverse==1.5.6` is pinned. A version bump is its own lane:
|
| 77 |
+
`tooluniverse-doctor`, re-recorded fixtures with the diff reviewed, the schema tests, and the security scan.
|
| 78 |
+
Adding a tool is a config change with a review note.
|
| 79 |
+
8. **Cache and rate limits.** The cache is SQLite inside the container and ephemeral, with a TTL per source from
|
| 80 |
+
`config/sources.yaml`. A per-source token-bucket limit and a per-call timeout also come from that file. A timeout
|
| 81 |
+
or limit becomes a structured `source_errors` entry, never a retry loop that outlasts the orchestrator's call
|
| 82 |
+
limit.
|
| 83 |
+
|
| 84 |
+
## Knowledge is annotation, never analysis
|
| 85 |
+
|
| 86 |
+
- The specialist computes nothing across sources: no combined scores, no rankings of its own, no p-values. When a
|
| 87 |
+
source has its own score (Open Targets' association score), it is relayed as that source's score.
|
| 88 |
+
- Knowledge payloads never enter `biodata-registry`, any statistical step, or the orchestrator's significance
|
| 89 |
+
chain. In Phase 1 the orchestrator registration declares no `cross_modality_join` partner, and a test asserts
|
| 90 |
+
that. The Phase 2 recipes may join a knowledge payload to a data result **by identifier only**, and must label the
|
| 91 |
+
join. That join rule gets its own orchestrator ADR when Phase 2 is shaped.
|
| 92 |
+
- Summaries say "according to <source> (queried <date>)" and never state an external fact flat. A deterministic
|
| 93 |
+
backstop in the orchestrator flags any drug name, NCT id or ChEMBL id in a summary that is absent from the
|
| 94 |
+
payload.
|
| 95 |
+
|
| 96 |
+
## Consequences
|
| 97 |
+
|
| 98 |
+
- The fleet gets a fifth Space with a 266 MB, 106-package dependency. The memo accepted that under the security scan
|
| 99 |
+
(ADR-0014 standard), with mandatory pinning and recorded fixtures.
|
| 100 |
+
- **The Space adds runtime calls to eight public hosts.** The OHSU security review's "Where data comes from" lists
|
| 101 |
+
only GEO, GDC, ICGC, CPTAC and cBioPortal, so the review needs an addendum naming the component and its hosts.
|
| 102 |
+
Plan slice s4 drafts it in Phase 1. Filing it is the project lead's call, and lab access does not wait for it (plan D2-C).
|
| 103 |
+
- Recorded-fixture tests block outbound sockets, so drift in a source shows up only at a deliberate re-record or as
|
| 104 |
+
`upstream_schema_drift` in production, never as a silently different answer.
|
| 105 |
+
- DecoupleRpy's `mcp_config.yaml` remote-server path stays unused for knowledge tools (memo decision 4).
|
| 106 |
+
|
| 107 |
+
## Alternatives rejected
|
| 108 |
+
|
| 109 |
+
- **Expose `tooluniverse-smcp` directly with category filters.** It has no contract of our own, no provenance, and
|
| 110 |
+
a version bump would change what the orchestrator sees. Rejected by memo decision 1.
|
| 111 |
+
- **Build thin clients for five sources.** That was the desk review's lean. The hands-on test showed our own Open
|
| 112 |
+
Targets client breaking on an upstream schema change that ToolUniverse had already absorbed.
|
| 113 |
+
- **Add the endpoints to an existing specialist.** That puts external calls and a large dependency inside a Space
|
| 114 |
+
that holds data and computes on it, and it blurs the line this ADR exists to draw.
|
| 115 |
+
- **Let the DecoupleRpy code agent call knowledge tools.** Rejected by memo decision 4, for the same provenance
|
| 116 |
+
reasons as re-mining's chain-first design.
|
docs/adr/RESERVED.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR number ledger — pdac-knowledge-agent
|
| 2 |
+
|
| 3 |
+
Claim-first discipline (the fleet pattern): to claim a number, add a CLAIMED row and commit it to
|
| 4 |
+
**`dev`** BEFORE writing the ADR. Here, as in pdac-spatial-agent, `main` IS production once the
|
| 5 |
+
Spaces exist, so a claim on `main` would either strand unpushed or ship a docs-only prod deploy to
|
| 6 |
+
announce an intention; lanes branch from `dev`, which is where a claim is visible to the collision it
|
| 7 |
+
exists to prevent. Statuses: `PUBLISHED`, `CLAIMED`, `EXTERNAL`, `DROPPED`. Family-wide standards
|
| 8 |
+
owned elsewhere are EXTERNAL here, and cross-repo citations are always repo-qualified.
|
| 9 |
+
|
| 10 |
+
| ADR | Title | Status | Notes |
|
| 11 |
+
|---|---|---|---|
|
| 12 |
+
| 0001 | Knowledge specialist: contract, boundary, and what a lookup may claim | PUBLISHED | accepted 2026-10-20 (project lead, plan D1-A); drafted beside the roadmap's planning ADRs, moved in at s1 |
|
| 13 |
+
| — | Specialist dispatch contracts (tool_server) | EXTERNAL | pdac-analysis-orchestrator ADR-0001 |
|
| 14 |
+
| — | Machine auth (`x-orchestrator-token`) | EXTERNAL | pdac-analysis-orchestrator ADR-0002 |
|
| 15 |
+
| — | A run card before every tool-server run | EXTERNAL | pdac-analysis-orchestrator ADR-0006 |
|
| 16 |
+
| — | Family security scan | EXTERNAL | ADR-0014 (fleet-wide, authored in DecoupleRpy_Agent) |
|
| 17 |
+
|
| 18 |
+
**Next free number: 0002.**
|
| 19 |
+
|
| 20 |
+
The orchestrator-side ADR for joining annotation to data results by identifier (Phase 2's recipes)
|
| 21 |
+
belongs to pdac-analysis-orchestrator's ledger, not this one, and is claimed when Phase 2 is shaped.
|
docs/plans/PLAN-phase1-knowledge-specialist.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PLAN — A fifth specialist answers "what is known about these genes, and what targets them?" from named public sources, with provenance on every row
|
| 2 |
+
|
| 3 |
+
**Status:** Approved 2026-10-20 (project lead): D1–D4 decided · **Owner repo:** `pdac-knowledge-agent` (this repo; s1 created
|
| 4 |
+
it and moved this plan in) · **Touches:** pdac-analysis-orchestrator, the fleet status rollup and skills ·
|
| 5 |
+
**Asked:** project lead, 2026-10-20 (roadmap Plan 4 / index plan 3, Phase 1) · **Gate on starting:** none since D4-A
|
| 6 |
+
(2026-10-20): s1 starts now, in parallel with re-mining.
|
| 7 |
+
|
| 8 |
+
**Why it lives here.** The specialist owns the user-facing outcome, not the orchestrator, so a plan in the
|
| 9 |
+
orchestrator's `docs/design/` would be the copy that drifts. It was drafted beside the roadmap's planning ADRs while
|
| 10 |
+
this repo did not exist, and moved in at s1 with `docs/adr/ADR-0001-knowledge-specialist-contract.md`. This copy is
|
| 11 |
+
the canonical one; the planning folder keeps a pointer.
|
| 12 |
+
|
| 13 |
+
**Settled before this plan; cited, not re-asked.** All four memo decisions were approved 2026-10-13
|
| 14 |
+
(the Phase 0 evaluation memo, kept with the roadmap): borrow ToolUniverse, wrapped behind BCC endpoints; the 28-tool
|
| 15 |
+
allow-list, with additions by config and a review note; ToolUniverse for interactive trials lookups while
|
| 16 |
+
lit-agent's harvester keeps the digest; knowledge lookups joined by the orchestrator, never called from inside
|
| 17 |
+
DecoupleRpy's code agent. The memo's acceptance of the dependency footprint under the security scan is also settled.
|
| 18 |
+
|
| 19 |
+
## Brief
|
| 20 |
+
|
| 21 |
+
- **The ask:** build Phase 1 of the ToolUniverse plan, the skeleton of a knowledge specialist. It wraps the approved
|
| 22 |
+
allow-list behind the fleet's contract: provenance on every row, output schema checks, caching, rate limits, and
|
| 23 |
+
external knowledge as annotation, never analysis.
|
| 24 |
+
- **The problem behind it:** after any result, a scientist's next question is what is known about these genes and
|
| 25 |
+
whether anything targets them. Today the model answers from memory, which is the one source the architecture
|
| 26 |
+
otherwise refuses to rely on. Phase 2's three recipes (activity → targets, DE genes → trials,
|
| 27 |
+
signature → annotation) need a governed source to chain to. This phase builds that source and proves it on its
|
| 28 |
+
own.
|
| 29 |
+
- **Who meets it, and where:** lab members, in orchestrator chat, asking a direct lookup ("which drugs target JAK1
|
| 30 |
+
and JAK2?"). They get a table with a source, a query date and a licence on each row, after the run card
|
| 31 |
+
(orchestrator ADR-0006). Later callers are Phase 2's recipes, the knowledge graph (index plan 5's `targets` and
|
| 32 |
+
`tested_in` edges) and re-mining's optional enrichment.
|
| 33 |
+
- **The boundary:** the sentence in ADR-0001 §3, reused verbatim. The specialist reads no dataset and computes no
|
| 34 |
+
statistic. Activity, DE and enrichment belong to DecoupleRpy, the literature corpus to lit-agent, and the
|
| 35 |
+
chained recipes to Phase 2.
|
| 36 |
+
|
| 37 |
+
## Done when
|
| 38 |
+
|
| 39 |
+
| # | Check | How |
|
| 40 |
+
|---|---|---|
|
| 41 |
+
| C1 | **The project lead's click-through on the dev orchestrator.** (a) "Which drugs target JAK1, JAK2 or STAT1, and what is their maximum clinical stage?" gives a table with ruxolitinib among the rows. Every row shows its source, query date and licence, and the narrative reads "According to Open Targets (queried …)". (b) "Are there recruiting pancreatic cancer trials with a CD40 intervention?" gives NCT ids plus the sentence stating the query semantics. (c) "Annotate KRAS, TP53 and SORD2P" lists SORD2P as unresolved, not guessed. (d) "Which TFs are most active in basal-like TCGA-PAAD samples?" goes to DecoupleRpy, not here | ticked with screenshots |
|
| 42 |
+
| C2 | **Allow-list by construction.** The loaded tool set equals the five endpoints' set, which is a subset of the 28 in `config/tools_allowlist.yaml`. No agentic, tool-finder, remote-MCP, web-search or key-gated tool can load. Startup refuses if any LLM-provider key is set. `tooluniverse.__version__ == "1.5.6"` at runtime. Loading tools opens no socket | `tests/test_allowlist.py`, `tests/test_no_llm_keys.py` |
|
| 43 |
+
| C3 | **Provenance on every row.** For every endpoint and every fixture, each row carries all ten provenance fields (ADR-0001 §5), and every ok payload has `kind: "annotation"` and no `n_for_inference` | `tests/test_provenance.py` (property test over all fixtures) |
|
| 44 |
+
| C4 | **Output schema checks.** Every recorded response validates against its pinned schema. A fixture mutated the way Open Targets changed during Phase 0 (`knownDrugs` → `drugAndClinicalCandidates`) returns `upstream_schema_drift` for that source, with no rows built from it | `tests/test_output_schemas.py` |
|
| 45 |
+
| C5 | **Cache and rate limits.** A repeated call is a `hit` that keeps the original `retrieved_at`, and the TTL expires per source. A 55-gene burst (the pORG list) never exceeds a source's configured rate. A source timeout becomes a `source_unavailable` entry within the per-call limit | `tests/test_cache.py`, `tests/test_ratelimit.py` |
|
| 46 |
+
| C6 | **Hermetic Phase 0 replay.** Tests block outbound sockets. The ten Phase 0 lookups, recorded once, replay to their expected entities: KRAS G12C gives sotorasib and adagrasib; DR5 gives all 7 agents; pORG gives 54 of 55 with SORD2P unresolved; the CD40 trial semantics are pinned; ISG15, RSAD2 and OAS1 are in Interferon alpha/beta signaling; KRAS G12D has no MRTX1133 from Open Targets, with the coverage note present | `tests/conftest.py` socket block, `tests/test_phase0_replay.py` |
|
| 47 |
+
| C7 | **Refusals are data.** Free text, sample ids, numbers or more than 100 identifiers are refused as `invalid_identifier` or `too_many_identifiers` before any outbound call. A call without the token is `denied`. The gate runs before validation on every handler | `tests/test_refusals_and_payloads.py`, `tests/test_access_gate.py` |
|
| 48 |
+
| C8 | **Hosts are declared.** Each loaded tool's configured endpoint URL has its host in `config/sources.yaml`, and every source there has a licence string and licence URL, checked against the source's own page | `tests/test_sources_hosts.py` |
|
| 49 |
+
| C9 | **Deploy surface.** Descriptor endpoint names equal the live `gr.api` surface on the dev Space. An unauthenticated call to the dev Space is `denied` (the gate fails closed). The prod Space is RUNNING at the verified sha (runtime API) | `tests/test_deploy_surface.py`; runtime sha in the s3 packet |
|
| 50 |
+
| C10 | **Orchestrator registration.** The `agents.yaml` block mirrors the descriptor. It is a tool_server pointing at the prod Space, with the dev override `HF_SPACE_PDAC_KNOWLEDGE_AGENT`. There is no `cross_modality_join` or significance-chain partner, so knowledge never enters analysis. The routing bank has boundary cases going to DecoupleRpy ("which pathways are enriched in…") and to the direct route. `make eval-cheap` is green, and `scripts/build_tool_catalog.py --check` exits 0 with the five rows | `tests/test_knowledge_registration.py`, `eval/routing_cases.json`, `tests/test_tool_catalog.py` |
|
| 51 |
+
| C11 | **Summary backstop.** The rows table and the narrative are rendered from the payload, not from model prose. A summary that names a drug, NCT id or ChEMBL id absent from the payload is flagged. On the recorded KRAS G12D payload, a summary that adds MRTX1133 is caught | `tests/test_knowledge_backstop.py` |
|
| 52 |
+
| C12 | **End to end.** One judged eval case, dev orchestrator to dev knowledge Space, judged PASS, with the trace checked for the payload itself (rows plus provenance). That is one live question, per the credit rule | `eval/` judged case `KNOW-001`; packet cites the run id |
|
| 53 |
+
| C13 | **Security-review addendum drafted** in the review's own language. The component, its eight hosts (generated from `config/sources.yaml`), keyless access, no model calls, the identifiers-only boundary, and the egress sentence from ADR-0001 §6. Filing it with OHSU is the project lead's act, and it does not gate lab access (D2-C) | `scripts/render_host_table.py --check`; the project lead ticks "filed" |
|
| 54 |
+
|
| 55 |
+
## Options and recommendation
|
| 56 |
+
|
| 57 |
+
The memo settled the approach. The only real choice left is where the endpoints live:
|
| 58 |
+
|
| 59 |
+
- **A. A new tool_server specialist from the spatial template (recommended).** Six slices, each S or M. It keeps
|
| 60 |
+
the 266 MB dependency and the outbound calls out of every Space that holds data, and it is the contract the Phase 2
|
| 61 |
+
joins and the knowledge graph will consume.
|
| 62 |
+
- **B. Expose `tooluniverse-smcp` with category filters.** It is quick, but it has no contract of our own, and a
|
| 63 |
+
version bump changes what the router sees. Memo decision 1 rejects it.
|
| 64 |
+
- **C. Put the endpoints in DecoupleRpy or the orchestrator.** It adds no Space, but it puts external calls inside a
|
| 65 |
+
Space that computes on data, and memo decision 4 rejects calls from inside the code agent.
|
| 66 |
+
|
| 67 |
+
## Decisions
|
| 68 |
+
|
| 69 |
+
### Decision D1: accept the knowledge-specialist contract (ADR-0001) as drafted?
|
| 70 |
+
- **Door:** one-way. It freezes the repo, the Space names, five endpoint names and the payload contract that Phase 2,
|
| 71 |
+
the knowledge graph and re-mining will consume.
|
| 72 |
+
- **Options:**
|
| 73 |
+
- A) accept as drafted: `pdac-knowledge-agent` / `pdac_knowledge_agent`; the five endpoints from the memo
|
| 74 |
+
(`drugs_for_targets`, `trials_for_genes`, `annotate_genes`, `pathways_for_genes`, `targets_for_disease`);
|
| 75 |
+
`kind: annotation` with ten provenance fields per row;
|
| 76 |
+
- B) only the three endpoints the Phase 2 recipes need (`drugs_for_targets`, `trials_for_genes`,
|
| 77 |
+
`annotate_genes`), with pathways and disease targets deferred. This removes the boundary risk that
|
| 78 |
+
`pathways_for_genes` gets mistaken for enrichment;
|
| 79 |
+
- C) accept with changes she names.
|
| 80 |
+
- **Recommend:** A. The memo named all five. The pathways boundary is held by the routing sentence and a C10 boundary
|
| 81 |
+
case, and `targets_for_disease` is the lookup Phase 0 found most useful as a sanity check.
|
| 82 |
+
- **Blocks:** s1, s2.
|
| 83 |
+
- **Status:** DECIDED 2026-10-20 (project lead): A — the five endpoints as drafted. ADR-0001 is accepted.
|
| 84 |
+
|
| 85 |
+
### Decision D2: who may use the new Spaces, and does lab access wait for the security-review addendum?
|
| 86 |
+
- **Door:** one-way. It sets access and visibility. The Space adds runtime calls to eight hosts that OHSU's security
|
| 87 |
+
review does not list (the review names GEO, GDC, ICGC, CPTAC and cBioPortal), and the review is what a reviewer sees.
|
| 88 |
+
- **Options:**
|
| 89 |
+
- A) both Spaces private with `ACCESS_CONTROL=enforce` from the first deploy, and admin only until the addendum
|
| 90 |
+
(C13) is filed with OHSU. After that, the lab allow-list goes on prod, followed by the orchestrator's prod
|
| 91 |
+
registration (orchestrator-forwarded principals must be on the specialist's `ALLOWED_IDS`);
|
| 92 |
+
- B) both private and gated, with the lab allow-list on prod as soon as the specialist is verified there, and the
|
| 93 |
+
addendum in Phase 3 as the roadmap first planned;
|
| 94 |
+
- C) public Spaces behind the allow-list gate, the older sibling pattern.
|
| 95 |
+
- **Recommend:** A. The addendum is an S slice that s4 drafts in parallel, so the wait costs little. It also keeps
|
| 96 |
+
the review's "where data comes from" true on the day lab members can reach the new hosts.
|
| 97 |
+
- **Blocks:** s3 (Space settings), and the prod half of s5.
|
| 98 |
+
- **Status:** DECIDED 2026-10-20 (project lead): C — public Spaces behind the allow-list gate, the sibling pattern.
|
| 99 |
+
- What follows for s3: `ACCESS_CONTROL=enforce` with `ADMIN_IDS` and `ALLOWED_IDS` set before either Space is made
|
| 100 |
+
public. The gate is verified to fail closed on the dev Space while it is still private (`new-specialist-agent`
|
| 101 |
+
§5). Every handler calls the access check itself, so an anonymous `gr.api` call is `denied`.
|
| 102 |
+
- **Lab access is not tied to filing the addendum.** Only option A tied the two, so that is how this answer is
|
| 103 |
+
read; if the project lead meant otherwise, this line changes and nothing else does.
|
| 104 |
+
- s4 still drafts the addendum in Phase 1, and when to file it is her call.
|
| 105 |
+
|
| 106 |
+
### Decision D3: what does the router advertise in Phase 1?
|
| 107 |
+
- **Door:** one-way. It sets what the router offers lab users.
|
| 108 |
+
- **Options:**
|
| 109 |
+
- A) the five direct lookups go to orchestrator prod once the specialist is verified on its own prod, C11's
|
| 110 |
+
backstop and C12's judged case pass, and D2's access condition is met;
|
| 111 |
+
- B) registration on orchestrator dev only, with prod waiting for Phase 2's recipes;
|
| 112 |
+
- C) no orchestrator work in Phase 1; the specialist is proven on its own Spaces only.
|
| 113 |
+
- **Recommend:** A. Direct lookups are useful on their own. The deterministic table plus the backstop makes the
|
| 114 |
+
"memory blended with tool results" risk testable now. And Phase 2 then only adds joins on a route that is already
|
| 115 |
+
proven.
|
| 116 |
+
- **Blocks:** s5.
|
| 117 |
+
- **Status:** DECIDED 2026-10-20 (project lead): A. Under D2-C, "D2's condition" means the lab allow-list is set on the
|
| 118 |
+
knowledge prod Space (orchestrator-forwarded principals must be on it).
|
| 119 |
+
|
| 120 |
+
### Decision D4: what gates the first slice, given that re-mining has not shown anything visible yet?
|
| 121 |
+
- **Door:** one-way, in schedule. The roadmap index says the build "waits until Plan 1 has produced something
|
| 122 |
+
visible", and re-mining Phase 1 had not started. On 2026-10-20 the project lead chose to start this plan in
|
| 123 |
+
parallel with two others.
|
| 124 |
+
- **Options:**
|
| 125 |
+
- A) start s1 now. Her 2026-10-20 call supersedes the README order. s1, s2 and s4 are offline, cost zero credits
|
| 126 |
+
and show nothing to users, and D2 and D3 already hold anything user-facing back;
|
| 127 |
+
- B) s1, s2 and s4 now, but no Space (s3) until re-mining has something visible (re-mining's start, or Plan 1's
|
| 128 |
+
first dry-run digest);
|
| 129 |
+
- C) nothing until re-mining Phase 1 shows its dry-run digest (the README's original order).
|
| 130 |
+
- **Recommend:** A. The README's reason was not to put a new capability in front of the lab ahead of re-mining. D2 and
|
| 131 |
+
D3 already make every user-facing step the project lead's call, so holding the offline slices back only spends calendar
|
| 132 |
+
time.
|
| 133 |
+
- **Blocks:** s1 (and through it every slice).
|
| 134 |
+
- **Status:** DECIDED 2026-10-20 (project lead): A — start s1 now.
|
| 135 |
+
|
| 136 |
+
**Two-way defaults** (DECIDED by default; overrule in one line):
|
| 137 |
+
- The plan and ADR-0001 stay here until s1 moves them into the new repo.
|
| 138 |
+
- **No orchestrator ADR number is claimed in Phase 1.** The knowledge-join ADR (annotation inside chains, the recipe
|
| 139 |
+
backstop, BYOD-derived gene lists) is claimed when Phase 2 is shaped. That leaves `0008` free for the audit-trail
|
| 140 |
+
plan.
|
| 141 |
+
- The repo mirrors `pdac-spatial-agent`: Python 3.12, gradio 6.28.0, `access.py`/`audit.py` copied from the newest
|
| 142 |
+
sibling, the keep-local `.gitignore` from the first commit, the ADR-0014 security scan, and a `dev` branch.
|
| 143 |
+
It has **no `biodata-registry` dependency**, because it reads no dataset. `src/registry_gate.py` and `src/fetch.py`
|
| 144 |
+
are replaced by `src/tu_client.py` (the only module that imports `tooluniverse`), `src/provenance.py`,
|
| 145 |
+
`src/schemas/`, `src/cache.py`, `src/ratelimit.py` and `src/identifiers.py`.
|
| 146 |
+
- Only the tools the five endpoints use are loaded; PubChem and DGIdb wait for an endpoint that needs them.
|
| 147 |
+
ToolUniverse's own cache is off. Our SQLite cache lives in `/tmp` and is ephemeral, with a 7-day TTL (24 h for
|
| 148 |
+
ClinicalTrials.gov).
|
| 149 |
+
- Limits per source: at most 3 requests/s, 30 s per call, an endpoint budget of 240 s, and 100 identifiers per call.
|
| 150 |
+
The orchestrator sets `timeout_seconds: 300` on `annotate_genes`, since the 55 pORG genes took 75 s unbatched in
|
| 151 |
+
Phase 0. UniProt lookups are batched where its query syntax allows.
|
| 152 |
+
- Trials semantics follow ToolUniverse's intervention-narrowed query, stated in `query_semantics` and pinned by a
|
| 153 |
+
fixture (memo finding 5). The defaults are condition "pancreatic cancer" and status RECRUITING.
|
| 154 |
+
- Fixtures are recorded once from the ten Phase 0 lookups (live, keyless, from the Mac) in s2. They are re-recorded
|
| 155 |
+
only in a version-bump lane.
|
| 156 |
+
- Licences per source, as checked in s1 (2026-10-20, `config/sources.yaml`): Open Targets CC0 1.0, ChEMBL CC BY-SA 3.0,
|
| 157 |
+
UniProt CC BY 4.0, **Reactome CC0 1.0** (not CC BY 4.0 as first assumed), HGNC unrestricted with citation requested,
|
| 158 |
+
ClinicalTrials.gov public under NLM terms (terms page did not render: partial check). DGIdb's data licence could not
|
| 159 |
+
be confirmed, so no DGIdb endpoint ships until it is.
|
| 160 |
+
- The specialist writes the narrative. The orchestrator relays it and renders the rows table from the payload. The
|
| 161 |
+
model adds framing only, and C11's backstop checks it.
|
| 162 |
+
- On the orchestrator side: `own_dataset_universe: true`, with a clarification saying it takes gene, drug, disease
|
| 163 |
+
or trial names rather than datasets. The run card shows "lookup, not analysis". Catalog rows come through the
|
| 164 |
+
existing `tool_catalog` annotations.
|
| 165 |
+
- Slice ids `phase1-knowledge-specialist-s<n>` go in branch names, with a `Slice:` trailer on commits.
|
| 166 |
+
|
| 167 |
+
## Slices
|
| 168 |
+
|
| 169 |
+
| id | repo | goal (one sentence) | depends on | gates | size | status |
|
| 170 |
+
|---|---|---|---|---|---|---|
|
| 171 |
+
| s1 | pdac-knowledge-agent (new, local only) | Create the repo from the spatial template and build the governed core: the allow-list loader, the no-LLM-key guard, provenance stamping, schema validation, cache, rate limiter, identifier validator and hermetic test harness, behind the security gate; move ADR-0001 and this plan in | D1, D4 (decided) | C2, C5, C8, C3/C4 (core, on synthetic responses) | M | READY |
|
| 172 |
+
| s2 | pdac-knowledge-agent | The five endpoints on recorded Phase 0 fixtures, with deterministic narratives, refusals, `gradio_ui.py` (gate, `mcp_server=True`) and the frozen `deploy/orchestrator_registration.yaml` | s1 | C3, C4, C6, C7 | M | BLOCKED |
|
| 173 |
+
| s4 | pdac-knowledge-agent | Security-review addendum, with its host table generated from `config/sources.yaml`, written in the review's own language | s1 | C13 (drafted) | S | BLOCKED |
|
| 174 |
+
| s3 | pdac-knowledge-agent + HF | Dev and prod Spaces and the GitHub mirror (the project lead creates or approves), each created private. Set the secrets and gate variables, verify the gate fails closed on dev, then make the Spaces public (D2-C). Check the deploy surface on dev, then go to prod through `promote-to-prod` | s2 | C9 | S | BLOCKED |
|
| 175 |
+
| s5 | pdac-analysis-orchestrator | Registration: the `agents.yaml` block, the `prompts.yaml` boundary, routing cases, the registration test, catalog rows, the deterministic table and narrative relay with the entity backstop, and one judged case on dev | s3 on **prod**; prod half also needs the lab allow-list on the knowledge prod Space | C10, C11, C12, C1 | M | BLOCKED |
|
| 176 |
+
| s6 | fleet | Fleet wiring: a status fragment and `build-status.sh` row, plus the repo added to the skills and checks that list repos by name (`promote-to-prod`, `pre-showing-repo-cleanup`, `hf-allowlist`, the weekday health check, the Friday report, the hook fleet path list) | s3 | `grep -l pdac-knowledge-agent` hits each listed file | S | BLOCKED |
|
| 177 |
+
|
| 178 |
+
- **Order and parallelism:** s1, then s2. s4 can run beside s2 once s1 lands (it needs only `config/sources.yaml`).
|
| 179 |
+
s3 needs s2 and D2. s5 and s6 can run in parallel once the specialist is on its own prod. Orchestrator prod is
|
| 180 |
+
last, through `promote-to-prod`, once D2's condition holds.
|
| 181 |
+
- **Fleet rule** (`new-specialist-agent` §8): the specialist reaches its dev Space, then its prod at a verified
|
| 182 |
+
commit, then the orchestrator's dev registration and a real question end to end, and only then orchestrator prod.
|
| 183 |
+
- **Credits:** s1–s4 and s6 spend none. s5 spends one judged question on dev (C12) and, at promotion, at most one
|
| 184 |
+
live prod question.
|
| 185 |
+
|
| 186 |
+
## Out of scope (Phase 2 and later)
|
| 187 |
+
|
| 188 |
+
- The three chained recipes and their joins. These include the orchestrator ADR on joining annotation to data by
|
| 189 |
+
identifier, and the 20-question knowledge bank (roadmap §7, Phase 2).
|
| 190 |
+
- **Whether gene lists derived from BYOD or unpublished uploads may be sent to external sources.** Phase 1 has no
|
| 191 |
+
chain, so its only inputs are identifiers a user types. This becomes a Phase 2 one-way decision.
|
| 192 |
+
- Scheduled smoke runs, the ≥ 95% reliability week, the p95 latency report and the cost report (Phase 3).
|
| 193 |
+
- PubChem and DGIdb endpoints, compound identifiers, and any tool beyond the five endpoints' set.
|
| 194 |
+
- Outbound registration of BCC methods in ToolUniverse (Phase 4, after Plan 5).
|
| 195 |
+
- lit-agent's ClinicalTrials.gov harvester, which stays as it is (memo decision 3).
|
| 196 |
+
|
| 197 |
+
## Kickoffs and pointers
|
| 198 |
+
|
| 199 |
+
- Kickoffs are keep-local (`docs/tasks/`, gitignored). s1's was written beside the roadmap's planning ADRs; s2's and
|
| 200 |
+
s4's are written into this repo's `docs/tasks/` when s1 merges. s5's goes in the orchestrator's `docs/tasks/`.
|
| 201 |
+
- The roadmap's Plan 4 file points at this plan. DecoupleRpy is not touched by Phase 1 (memo decision 4).
|
logging_sink.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configurable sink for audit-record persistence.
|
| 2 |
+
|
| 3 |
+
Copied from `pdac-spatial-agent/logging_sink.py` (genomics → subtype → spatial) so every
|
| 4 |
+
Space in the family shares one audit-sink contract. The destination is a CONFIG value (the
|
| 5 |
+
`LOG_SINK` env var), not a hardcoded path — prep for a future migration to OHSU-managed AWS.
|
| 6 |
+
|
| 7 |
+
Why it exists in the family (2026-10-13): a sink that wrote JSONL to the container's temp dir
|
| 8 |
+
lost the whole trail on every rebuild, because the Spaces have no persistent storage.
|
| 9 |
+
|
| 10 |
+
**No redaction layer, deliberately.** Runs are structured tool calls and `audit.py` builds a
|
| 11 |
+
record that by construction holds no results and no query identifiers, only a count. The
|
| 12 |
+
record is safe *by shape*.
|
| 13 |
+
|
| 14 |
+
Every sink implements one method::
|
| 15 |
+
|
| 16 |
+
persist_trace(run_id: str, trace: dict) -> str | None
|
| 17 |
+
|
| 18 |
+
returning a location string on success, or None on failure/no-op. Callers use
|
| 19 |
+
:func:`persist_trace_safe` so a logging failure NEVER crashes a user request.
|
| 20 |
+
|
| 21 |
+
Env vars (all optional):
|
| 22 |
+
|
| 23 |
+
LOG_SINK local | hf (default: local)
|
| 24 |
+
LOG_SINK_LOCAL_DIR output directory (default: AUDIT_DIR, else <tmp>/pdac-knowledge-agent-audit)
|
| 25 |
+
LOG_SINK_HF_DATASET dataset repo id (default: anne-voigt/pdac-knowledge-results)
|
| 26 |
+
LOG_SINK_HF_TOKEN HF write token (falls back to research_agent_token;
|
| 27 |
+
no token => no-op)
|
| 28 |
+
|
| 29 |
+
**Never co-mingle with a sibling's results dataset** — this Space writes to its own
|
| 30 |
+
`pdac-knowledge-results` repo.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import json
|
| 36 |
+
import os
|
| 37 |
+
import tempfile
|
| 38 |
+
from pathlib import Path
|
| 39 |
+
|
| 40 |
+
DEFAULT_HF_DATASET = "anne-voigt/pdac-knowledge-results"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class LogSink:
|
| 44 |
+
"""Common interface for audit-record persistence."""
|
| 45 |
+
|
| 46 |
+
name = "base"
|
| 47 |
+
|
| 48 |
+
def persist_trace(self, run_id: str, trace: dict) -> str | None:
|
| 49 |
+
raise NotImplementedError
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class LocalLogSink(LogSink):
|
| 53 |
+
"""Write the record JSON to a directory on local disk (the default).
|
| 54 |
+
|
| 55 |
+
On a Space this directory is ephemeral: it is for local runs and tests, not an audit trail.
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
name = "local"
|
| 59 |
+
|
| 60 |
+
def __init__(self, directory: str | None = None):
|
| 61 |
+
self.directory = (
|
| 62 |
+
directory
|
| 63 |
+
or os.environ.get("LOG_SINK_LOCAL_DIR")
|
| 64 |
+
or os.environ.get("AUDIT_DIR") # the pre-2026-10-13 variable, still honoured
|
| 65 |
+
or str(Path(tempfile.gettempdir()) / "pdac-knowledge-agent-audit")
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
def persist_trace(self, run_id: str, trace: dict) -> str | None:
|
| 69 |
+
os.makedirs(self.directory, exist_ok=True)
|
| 70 |
+
path = os.path.join(self.directory, f"{run_id}.json")
|
| 71 |
+
with open(path, "w", encoding="utf-8") as fh:
|
| 72 |
+
json.dump(trace, fh, indent=2)
|
| 73 |
+
return path
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class HFDatasetLogSink(LogSink):
|
| 77 |
+
"""Write the record to a private HuggingFace dataset repo (the deployed-Space posture).
|
| 78 |
+
|
| 79 |
+
One file per record (``runs/<run_id>.json``), so concurrent calls never contend for a
|
| 80 |
+
shared file. No-ops (returns None) when no write token is configured, so a Space without
|
| 81 |
+
the secret degrades to "not logged" rather than erroring on every request.
|
| 82 |
+
"""
|
| 83 |
+
|
| 84 |
+
name = "hf"
|
| 85 |
+
|
| 86 |
+
def __init__(self, repo_id: str | None = None, token: str | None = None):
|
| 87 |
+
self.repo_id = repo_id or os.environ.get("LOG_SINK_HF_DATASET", DEFAULT_HF_DATASET)
|
| 88 |
+
self.token = (
|
| 89 |
+
token or os.environ.get("LOG_SINK_HF_TOKEN") or os.environ.get("research_agent_token")
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
def persist_trace(self, run_id: str, trace: dict) -> str | None:
|
| 93 |
+
if not self.token:
|
| 94 |
+
return None
|
| 95 |
+
from huggingface_hub import HfApi
|
| 96 |
+
|
| 97 |
+
path_in_repo = f"runs/{run_id}.json"
|
| 98 |
+
HfApi(token=self.token).upload_file(
|
| 99 |
+
path_or_fileobj=json.dumps(trace, indent=2).encode("utf-8"),
|
| 100 |
+
path_in_repo=path_in_repo,
|
| 101 |
+
repo_id=self.repo_id,
|
| 102 |
+
repo_type="dataset",
|
| 103 |
+
commit_message=f"audit: {trace.get('tool', '?')} {trace.get('status', '?')}",
|
| 104 |
+
)
|
| 105 |
+
return f"{self.repo_id}/{path_in_repo}"
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
_SINKS = {"local": LocalLogSink, "hf": HFDatasetLogSink}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def get_log_sink() -> LogSink:
|
| 112 |
+
"""The sink selected by the `LOG_SINK` env var (default `local`)."""
|
| 113 |
+
choice = os.environ.get("LOG_SINK", "local").strip().lower()
|
| 114 |
+
return _SINKS.get(choice, LocalLogSink)()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def persist_trace_safe(sink: LogSink, run_id: str, trace: dict) -> str | None:
|
| 118 |
+
"""Persist via `sink`, swallowing any failure (auditing must never break a request)."""
|
| 119 |
+
try:
|
| 120 |
+
return sink.persist_trace(run_id, trace)
|
| 121 |
+
except Exception as e: # noqa: BLE001
|
| 122 |
+
print(f"[logging_sink] persist_trace failed ({sink.name}): {e}")
|
| 123 |
+
return None
|
pytest.ini
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
# `tests/` only, so a hand-run script under scripts/ can never be collected (the DecoupleRpy
|
| 3 |
+
# 2026-09 lesson), and the repo root on sys.path so `src` and the root modules import.
|
| 4 |
+
testpaths = tests
|
| 5 |
+
pythonpath = .
|
| 6 |
+
addopts = -q
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Test-only dependencies. Deliberately NOT in requirements.txt: that file IS the Space's build
|
| 2 |
+
# input, so anything added there is installed into production.
|
| 3 |
+
#
|
| 4 |
+
# It layers on requirements.txt rather than restating it — one pinned runtime set, extended, so
|
| 5 |
+
# CI resolves the same versions the Space builds from.
|
| 6 |
+
-r requirements.txt
|
| 7 |
+
|
| 8 |
+
pytest==9.1.1
|
| 9 |
+
|
| 10 |
+
# Pinned, not floating: the formatter's output may change between ruff releases. Same version
|
| 11 |
+
# the rest of the fleet pins.
|
| 12 |
+
ruff==0.16.9
|
requirements.in
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Curated top-level dependencies for pdac-knowledge-agent (HF gradio Space, py3.12).
|
| 2 |
+
# Same convention as the sibling specialists: THIS file is edited by hand; requirements.txt is
|
| 3 |
+
# the full lock HF actually installs, regenerated via:
|
| 4 |
+
#
|
| 5 |
+
# uv pip compile requirements.in -o requirements.txt \
|
| 6 |
+
# --python-version 3.12 --python-platform linux
|
| 7 |
+
#
|
| 8 |
+
# uv prefers the versions already in requirements.txt, so a recompile only moves what a changed
|
| 9 |
+
# line forces. Check `git diff requirements.txt` shows only what you meant to move. Because
|
| 10 |
+
# requirements.txt is a compiled lock, scripts/security_scan.sh audits it as-is
|
| 11 |
+
# (pip-audit --no-deps), transitive dependencies included.
|
| 12 |
+
#
|
| 13 |
+
# There is NO biodata-registry line: this specialist reads no dataset (ADR-0001 §3).
|
| 14 |
+
|
| 15 |
+
# --- UI / MCP ---
|
| 16 |
+
# Pinned to the Space's sdk_version (README front matter). HF's generated Dockerfile installs
|
| 17 |
+
# `gradio[oauth,mcp]==<sdk_version>` in the same pip command as this lock, so the extras are named
|
| 18 |
+
# here too and the audit sees exactly what the Space installs.
|
| 19 |
+
gradio[oauth,mcp]==6.28.0
|
| 20 |
+
|
| 21 |
+
# --- ToolUniverse (ADR-0001 §7) ---
|
| 22 |
+
# Pinned EXACTLY. config/tools_allowlist.yaml carries the same version and src/tu_client.py
|
| 23 |
+
# refuses to start on a mismatch. A bump is its own lane: tooluniverse-doctor, re-recorded
|
| 24 |
+
# fixtures with the diff reviewed, the schema tests, and this repo's security scan. The memo
|
| 25 |
+
# accepted its footprint (~100 packages) under that scan, 2026-10-13.
|
| 26 |
+
tooluniverse==1.5.6
|
| 27 |
+
|
| 28 |
+
# --- Config and output-schema checks ---
|
| 29 |
+
PyYAML==6.0.3
|
| 30 |
+
jsonschema==4.26.0
|
requirements.txt
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file was autogenerated by uv via the following command:
|
| 2 |
+
# uv pip compile requirements.in -o requirements.txt --python-version 3.12 --python-platform linux
|
| 3 |
+
aiofile==3.12.3
|
| 4 |
+
# via py-key-value-aio
|
| 5 |
+
annotated-doc==0.0.5
|
| 6 |
+
# via
|
| 7 |
+
# fastapi
|
| 8 |
+
# typer
|
| 9 |
+
annotated-types==0.8.0
|
| 10 |
+
# via pydantic
|
| 11 |
+
anyio==4.15.1
|
| 12 |
+
# via
|
| 13 |
+
# boltz-api
|
| 14 |
+
# gradio
|
| 15 |
+
# httpx
|
| 16 |
+
# mcp
|
| 17 |
+
# py-key-value-aio
|
| 18 |
+
# sse-starlette
|
| 19 |
+
# starlette
|
| 20 |
+
# watchfiles
|
| 21 |
+
attrs==26.1.0
|
| 22 |
+
# via
|
| 23 |
+
# cyclopts
|
| 24 |
+
# jsonschema
|
| 25 |
+
# jsonschema-path
|
| 26 |
+
# referencing
|
| 27 |
+
authlib==1.9.1
|
| 28 |
+
# via
|
| 29 |
+
# fastmcp-slim
|
| 30 |
+
# gradio
|
| 31 |
+
beartype==0.23.1
|
| 32 |
+
# via py-key-value-aio
|
| 33 |
+
beautifulsoup4==4.15.0
|
| 34 |
+
# via tooluniverse
|
| 35 |
+
boltz-api==0.46.0
|
| 36 |
+
# via tooluniverse
|
| 37 |
+
brotli==1.2.0
|
| 38 |
+
# via gradio
|
| 39 |
+
cachetools==7.2.4
|
| 40 |
+
# via py-key-value-aio
|
| 41 |
+
caio==0.12.9
|
| 42 |
+
# via aiofile
|
| 43 |
+
certifi==2026.10.19
|
| 44 |
+
# via
|
| 45 |
+
# httpcore
|
| 46 |
+
# httpx
|
| 47 |
+
# requests
|
| 48 |
+
cffi==2.1.1
|
| 49 |
+
# via cryptography
|
| 50 |
+
charset-normalizer==3.5.2
|
| 51 |
+
# via
|
| 52 |
+
# pdfminer-six
|
| 53 |
+
# requests
|
| 54 |
+
click==8.5.0
|
| 55 |
+
# via
|
| 56 |
+
# huggingface-hub
|
| 57 |
+
# uvicorn
|
| 58 |
+
cryptography==50.0.2
|
| 59 |
+
# via
|
| 60 |
+
# authlib
|
| 61 |
+
# joserfc
|
| 62 |
+
# pdfminer-six
|
| 63 |
+
# pyjwt
|
| 64 |
+
# secretstorage
|
| 65 |
+
cyclopts==5.2.0
|
| 66 |
+
# via fastmcp-slim
|
| 67 |
+
distro==1.9.0
|
| 68 |
+
# via boltz-api
|
| 69 |
+
dnspython==2.9.0
|
| 70 |
+
# via email-validator
|
| 71 |
+
docstring-parser==0.18.0
|
| 72 |
+
# via cyclopts
|
| 73 |
+
email-validator==2.3.0
|
| 74 |
+
# via pydantic
|
| 75 |
+
et-xmlfile==2.0.0
|
| 76 |
+
# via openpyxl
|
| 77 |
+
exceptiongroup==1.3.1
|
| 78 |
+
# via fastmcp-slim
|
| 79 |
+
faiss-cpu==1.12.0
|
| 80 |
+
# via tooluniverse
|
| 81 |
+
fastapi==0.156.2
|
| 82 |
+
# via
|
| 83 |
+
# gradio
|
| 84 |
+
# tooluniverse
|
| 85 |
+
fastmcp==3.4.10
|
| 86 |
+
# via tooluniverse
|
| 87 |
+
fastmcp-slim==3.4.10
|
| 88 |
+
# via fastmcp
|
| 89 |
+
filelock==4.2.4
|
| 90 |
+
# via huggingface-hub
|
| 91 |
+
fsspec==2026.9.0
|
| 92 |
+
# via
|
| 93 |
+
# gradio-client
|
| 94 |
+
# huggingface-hub
|
| 95 |
+
gradio==6.28.0
|
| 96 |
+
# via -r requirements.in
|
| 97 |
+
gradio-client==2.7.1
|
| 98 |
+
# via
|
| 99 |
+
# gradio
|
| 100 |
+
# hf-gradio
|
| 101 |
+
graphql-core==3.3.0
|
| 102 |
+
# via
|
| 103 |
+
# rcsb-api
|
| 104 |
+
# tooluniverse
|
| 105 |
+
griffelib==2.4.0
|
| 106 |
+
# via fastmcp-slim
|
| 107 |
+
groovy==0.1.2
|
| 108 |
+
# via gradio
|
| 109 |
+
h11==0.16.0
|
| 110 |
+
# via
|
| 111 |
+
# httpcore
|
| 112 |
+
# uvicorn
|
| 113 |
+
hf-gradio==0.4.1
|
| 114 |
+
# via gradio
|
| 115 |
+
hf-xet==1.7.0
|
| 116 |
+
# via huggingface-hub
|
| 117 |
+
httpcore==1.0.9
|
| 118 |
+
# via httpx
|
| 119 |
+
httpx==0.28.1
|
| 120 |
+
# via
|
| 121 |
+
# boltz-api
|
| 122 |
+
# fastmcp-slim
|
| 123 |
+
# gradio
|
| 124 |
+
# gradio-client
|
| 125 |
+
# huggingface-hub
|
| 126 |
+
# mcp
|
| 127 |
+
# rcsb-api
|
| 128 |
+
# safehttpx
|
| 129 |
+
httpx-sse==0.4.3
|
| 130 |
+
# via mcp
|
| 131 |
+
huggingface-hub==1.33.0
|
| 132 |
+
# via
|
| 133 |
+
# gradio
|
| 134 |
+
# gradio-client
|
| 135 |
+
# tooluniverse
|
| 136 |
+
idna==3.20
|
| 137 |
+
# via
|
| 138 |
+
# anyio
|
| 139 |
+
# email-validator
|
| 140 |
+
# httpx
|
| 141 |
+
# requests
|
| 142 |
+
itsdangerous==2.2.0
|
| 143 |
+
# via gradio
|
| 144 |
+
jaraco-classes==3.4.0
|
| 145 |
+
# via keyring
|
| 146 |
+
jaraco-context==6.1.2
|
| 147 |
+
# via keyring
|
| 148 |
+
jaraco-functools==4.6.0
|
| 149 |
+
# via keyring
|
| 150 |
+
jeepney==0.9.0
|
| 151 |
+
# via
|
| 152 |
+
# keyring
|
| 153 |
+
# secretstorage
|
| 154 |
+
jinja2==3.1.6
|
| 155 |
+
# via gradio
|
| 156 |
+
joserfc==1.7.7
|
| 157 |
+
# via
|
| 158 |
+
# authlib
|
| 159 |
+
# fastmcp-slim
|
| 160 |
+
jsonpath-ng==1.11.0
|
| 161 |
+
# via tooluniverse
|
| 162 |
+
jsonref==1.1.0
|
| 163 |
+
# via fastmcp-slim
|
| 164 |
+
jsonschema==4.26.0
|
| 165 |
+
# via
|
| 166 |
+
# -r requirements.in
|
| 167 |
+
# mcp
|
| 168 |
+
# tooluniverse
|
| 169 |
+
jsonschema-path==0.5.0
|
| 170 |
+
# via fastmcp-slim
|
| 171 |
+
jsonschema-specifications==2025.9.1
|
| 172 |
+
# via jsonschema
|
| 173 |
+
keyring==25.7.0
|
| 174 |
+
# via py-key-value-aio
|
| 175 |
+
lxml==6.1.3
|
| 176 |
+
# via tooluniverse
|
| 177 |
+
markdown-it-py==4.2.0
|
| 178 |
+
# via rich
|
| 179 |
+
markupsafe==3.0.4
|
| 180 |
+
# via
|
| 181 |
+
# gradio
|
| 182 |
+
# jinja2
|
| 183 |
+
mcp==1.30.0
|
| 184 |
+
# via
|
| 185 |
+
# fastmcp-slim
|
| 186 |
+
# gradio
|
| 187 |
+
# tooluniverse
|
| 188 |
+
mdurl==0.1.2
|
| 189 |
+
# via markdown-it-py
|
| 190 |
+
more-itertools==11.2.1
|
| 191 |
+
# via
|
| 192 |
+
# jaraco-classes
|
| 193 |
+
# jaraco-functools
|
| 194 |
+
nest-asyncio==1.6.0
|
| 195 |
+
# via rcsb-api
|
| 196 |
+
numpy==2.5.4
|
| 197 |
+
# via
|
| 198 |
+
# faiss-cpu
|
| 199 |
+
# gradio
|
| 200 |
+
# pandas
|
| 201 |
+
# rustworkx
|
| 202 |
+
# tooluniverse
|
| 203 |
+
openapi-pydantic==0.6.0
|
| 204 |
+
# via fastmcp-slim
|
| 205 |
+
openpyxl==3.1.5
|
| 206 |
+
# via tooluniverse
|
| 207 |
+
opentelemetry-api==1.45.1
|
| 208 |
+
# via
|
| 209 |
+
# fastapi
|
| 210 |
+
# fastmcp-slim
|
| 211 |
+
orjson==3.13.0
|
| 212 |
+
# via gradio
|
| 213 |
+
packaging==26.3
|
| 214 |
+
# via
|
| 215 |
+
# faiss-cpu
|
| 216 |
+
# fastmcp-slim
|
| 217 |
+
# gradio
|
| 218 |
+
# gradio-client
|
| 219 |
+
# huggingface-hub
|
| 220 |
+
pandas==3.0.6
|
| 221 |
+
# via
|
| 222 |
+
# gradio
|
| 223 |
+
# tooluniverse
|
| 224 |
+
pathable==0.6.0
|
| 225 |
+
# via jsonschema-path
|
| 226 |
+
pdfminer-six==20260107
|
| 227 |
+
# via pdfplumber
|
| 228 |
+
pdfplumber==0.11.10
|
| 229 |
+
# via tooluniverse
|
| 230 |
+
pillow==12.3.0
|
| 231 |
+
# via
|
| 232 |
+
# gradio
|
| 233 |
+
# pdfplumber
|
| 234 |
+
pip==26.2.2
|
| 235 |
+
# via tooluniverse
|
| 236 |
+
platformdirs==4.13.5
|
| 237 |
+
# via fastmcp-slim
|
| 238 |
+
py-key-value-aio==0.4.6
|
| 239 |
+
# via fastmcp-slim
|
| 240 |
+
pycparser==3.11
|
| 241 |
+
# via cffi
|
| 242 |
+
pydantic==2.12.5
|
| 243 |
+
# via
|
| 244 |
+
# boltz-api
|
| 245 |
+
# fastapi
|
| 246 |
+
# fastmcp-slim
|
| 247 |
+
# gradio
|
| 248 |
+
# mcp
|
| 249 |
+
# openapi-pydantic
|
| 250 |
+
# pydantic-settings
|
| 251 |
+
# tooluniverse
|
| 252 |
+
pydantic-core==2.41.5
|
| 253 |
+
# via pydantic
|
| 254 |
+
pydantic-settings==2.16.1
|
| 255 |
+
# via
|
| 256 |
+
# fastmcp-slim
|
| 257 |
+
# mcp
|
| 258 |
+
pydub==0.25.1
|
| 259 |
+
# via gradio
|
| 260 |
+
pygments==2.21.0
|
| 261 |
+
# via
|
| 262 |
+
# rich
|
| 263 |
+
# rich-rst
|
| 264 |
+
pyjwt==2.15.1
|
| 265 |
+
# via mcp
|
| 266 |
+
pypdfium2==5.14.0
|
| 267 |
+
# via pdfplumber
|
| 268 |
+
pyperclip==1.11.0
|
| 269 |
+
# via fastmcp-slim
|
| 270 |
+
python-dateutil==2.9.0.post0
|
| 271 |
+
# via pandas
|
| 272 |
+
python-dotenv==1.2.4
|
| 273 |
+
# via
|
| 274 |
+
# fastmcp-slim
|
| 275 |
+
# mcp
|
| 276 |
+
# pydantic-settings
|
| 277 |
+
# tooluniverse
|
| 278 |
+
python-multipart==0.0.32
|
| 279 |
+
# via
|
| 280 |
+
# fastmcp-slim
|
| 281 |
+
# gradio
|
| 282 |
+
# mcp
|
| 283 |
+
pytz==2026.5
|
| 284 |
+
# via gradio
|
| 285 |
+
pyyaml==6.0.3
|
| 286 |
+
# via
|
| 287 |
+
# -r requirements.in
|
| 288 |
+
# fastmcp-slim
|
| 289 |
+
# gradio
|
| 290 |
+
# huggingface-hub
|
| 291 |
+
# jsonschema-path
|
| 292 |
+
# tooluniverse
|
| 293 |
+
rcsb-api==1.7.3
|
| 294 |
+
# via tooluniverse
|
| 295 |
+
referencing==0.37.0
|
| 296 |
+
# via
|
| 297 |
+
# jsonschema
|
| 298 |
+
# jsonschema-path
|
| 299 |
+
# jsonschema-specifications
|
| 300 |
+
requests==2.34.2
|
| 301 |
+
# via tooluniverse
|
| 302 |
+
rich==15.0.0
|
| 303 |
+
# via
|
| 304 |
+
# cyclopts
|
| 305 |
+
# fastmcp-slim
|
| 306 |
+
# rich-rst
|
| 307 |
+
# typer
|
| 308 |
+
rich-rst==2.2.0
|
| 309 |
+
# via cyclopts
|
| 310 |
+
rpds-py==2026.9.1
|
| 311 |
+
# via
|
| 312 |
+
# jsonschema
|
| 313 |
+
# referencing
|
| 314 |
+
rustworkx==0.18.1
|
| 315 |
+
# via rcsb-api
|
| 316 |
+
safehttpx==0.1.7
|
| 317 |
+
# via gradio
|
| 318 |
+
secretstorage==3.5.0
|
| 319 |
+
# via keyring
|
| 320 |
+
semantic-version==2.10.0
|
| 321 |
+
# via gradio
|
| 322 |
+
shellingham==1.5.4
|
| 323 |
+
# via typer
|
| 324 |
+
six==1.17.0
|
| 325 |
+
# via python-dateutil
|
| 326 |
+
sniffio==1.3.1
|
| 327 |
+
# via boltz-api
|
| 328 |
+
soupsieve==3.0.3
|
| 329 |
+
# via beautifulsoup4
|
| 330 |
+
sse-starlette==3.5.0
|
| 331 |
+
# via mcp
|
| 332 |
+
starlette==1.9.0
|
| 333 |
+
# via
|
| 334 |
+
# fastapi
|
| 335 |
+
# fastmcp-slim
|
| 336 |
+
# gradio
|
| 337 |
+
# mcp
|
| 338 |
+
# sse-starlette
|
| 339 |
+
tomlkit==0.14.0
|
| 340 |
+
# via gradio
|
| 341 |
+
tooluniverse==1.5.6
|
| 342 |
+
# via -r requirements.in
|
| 343 |
+
tqdm==4.70.1
|
| 344 |
+
# via
|
| 345 |
+
# huggingface-hub
|
| 346 |
+
# rcsb-api
|
| 347 |
+
typer==0.27.3
|
| 348 |
+
# via
|
| 349 |
+
# gradio
|
| 350 |
+
# hf-gradio
|
| 351 |
+
# mcp
|
| 352 |
+
typing-extensions==4.16.0
|
| 353 |
+
# via
|
| 354 |
+
# anyio
|
| 355 |
+
# beautifulsoup4
|
| 356 |
+
# boltz-api
|
| 357 |
+
# exceptiongroup
|
| 358 |
+
# fastapi
|
| 359 |
+
# fastmcp-slim
|
| 360 |
+
# gradio
|
| 361 |
+
# gradio-client
|
| 362 |
+
# huggingface-hub
|
| 363 |
+
# mcp
|
| 364 |
+
# opentelemetry-api
|
| 365 |
+
# py-key-value-aio
|
| 366 |
+
# pydantic
|
| 367 |
+
# pydantic-core
|
| 368 |
+
# pydantic-settings
|
| 369 |
+
# referencing
|
| 370 |
+
# starlette
|
| 371 |
+
# typing-inspection
|
| 372 |
+
typing-inspection==0.4.4
|
| 373 |
+
# via
|
| 374 |
+
# fastapi
|
| 375 |
+
# mcp
|
| 376 |
+
# pydantic
|
| 377 |
+
# pydantic-settings
|
| 378 |
+
uncalled-for==0.4.1
|
| 379 |
+
# via fastmcp-slim
|
| 380 |
+
urllib3==2.8.0
|
| 381 |
+
# via
|
| 382 |
+
# requests
|
| 383 |
+
# tooluniverse
|
| 384 |
+
uvicorn==0.54.0
|
| 385 |
+
# via
|
| 386 |
+
# fastmcp-slim
|
| 387 |
+
# gradio
|
| 388 |
+
# mcp
|
| 389 |
+
# tooluniverse
|
| 390 |
+
watchfiles==1.3.0
|
| 391 |
+
# via fastmcp-slim
|
| 392 |
+
websockets==17.3
|
| 393 |
+
# via
|
| 394 |
+
# fastmcp-slim
|
| 395 |
+
# tooluniverse
|
| 396 |
+
xmltodict==1.0.4
|
| 397 |
+
# via tooluniverse
|
ruff.toml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ruff configuration. Its job today is to pin the line length, because without a config file every
|
| 2 |
+
# contributor and every CI runner silently uses ruff's default of 88 while this fleet writes at 100.
|
| 3 |
+
#
|
| 4 |
+
# That is not theoretical here. On 2026-09-25, porting `memory.py` into this repo, a reflexive
|
| 5 |
+
# `ruff format` reformatted the shared builder to 88 — breaking the byte-identity with the other
|
| 6 |
+
# five copies that is the entire point of sharing it. Caught and reverted, but the next person gets
|
| 7 |
+
# no warning either. This file is the warning.
|
| 8 |
+
|
| 9 |
+
line-length = 100
|
| 10 |
+
|
| 11 |
+
# **py312, unlike the siblings' py311** — deliberately, and not a copy-paste slip: this Space runs
|
| 12 |
+
# `python_version: "3.12"` (README front-matter). target-version tells ruff which syntax is valid,
|
| 13 |
+
# so it should describe the interpreter the code actually runs on, not the one next door.
|
| 14 |
+
target-version = "py312"
|
| 15 |
+
|
| 16 |
+
exclude = [".venv", "run_logs", "security"]
|
| 17 |
+
|
| 18 |
+
[format]
|
| 19 |
+
quote-style = "double"
|
| 20 |
+
|
| 21 |
+
[lint]
|
| 22 |
+
# Ruff's defaults. Turning on more rules is a separate decision from writing down a line length,
|
| 23 |
+
# and would arrive as findings across files this lane does not own.
|
scripts/hooks/pre-push
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Two jobs, in this order (the genomics-agent pattern):
|
| 3 |
+
# 1. ADR-0014 security gate — runs on EVERY push. Blocks on findings.
|
| 4 |
+
# 2. GitHub mirror sync — only on a PROD deploy (`main` to `origin`). Warns, never blocks.
|
| 5 |
+
# The gate runs first, so a blocked deploy can never have already updated the mirror.
|
| 6 |
+
#
|
| 7 |
+
# --- 1. ADR-0014 pre-push security gate ----------------------------------------------------
|
| 8 |
+
#
|
| 9 |
+
# `origin` and `hf-dev` ARE HuggingFace Spaces — a push builds a live Space — so there is
|
| 10 |
+
# no CI merge gate in front of them. This hook is the enforced check: it runs the shared
|
| 11 |
+
# scan (scripts/security_scan.sh) before anything leaves the machine.
|
| 12 |
+
#
|
| 13 |
+
# It scans the commit being PUSHED, not the working tree it happens to run from. The
|
| 14 |
+
# sibling hooks scan the working tree, and on 2026-09-21 that went wrong in the
|
| 15 |
+
# orchestrator: pushed from a checkout parked on another lane's branch, the scan audited
|
| 16 |
+
# THAT branch's requirements.txt instead of the commit being shipped. Here each pushed sha
|
| 17 |
+
# is checked out into a throwaway worktree and scanned there; reports (and the cached
|
| 18 |
+
# audit venv) are written to this checkout's security/ so they outlive it.
|
| 19 |
+
#
|
| 20 |
+
# Install with: make install-hooks
|
| 21 |
+
# Bypass (e.g. a docs-only push, knowingly): git push --no-verify
|
| 22 |
+
# Set SECURITY_SCAN_STRICT=1 to fail on skipped stages.
|
| 23 |
+
set -euo pipefail
|
| 24 |
+
|
| 25 |
+
# The mirror push below sets this, so that nested push is neither rescanned nor re-mirrored.
|
| 26 |
+
[[ -n "${SECURITY_SCAN_DONE:-}" ]] && exit 0
|
| 27 |
+
|
| 28 |
+
remote_name="${1:-}"
|
| 29 |
+
remote_url="${2:-}"
|
| 30 |
+
mirror_remote="${MIRROR_REMOTE:-github}"
|
| 31 |
+
dev_remote="${DEV_REMOTE:-hf-dev}"
|
| 32 |
+
repo_root="$(git rev-parse --show-toplevel)"
|
| 33 |
+
|
| 34 |
+
# stdin: "<local ref> <local sha> <remote ref> <remote sha>" per ref being pushed. Git sends it
|
| 35 |
+
# once and both jobs need it, so it is read here and replayed from the variable.
|
| 36 |
+
push_lines="$(cat)"
|
| 37 |
+
shas=()
|
| 38 |
+
while read -r _lref lsha _rref _rsha; do
|
| 39 |
+
[[ -n "${lsha:-}" ]] || continue
|
| 40 |
+
case "$lsha" in *[!0]*) ;; *) continue ;; esac # all-zero = a delete; nothing to scan
|
| 41 |
+
[[ " ${shas[*]-} " == *" $lsha "* ]] || shas+=("$lsha")
|
| 42 |
+
done <<< "$push_lines"
|
| 43 |
+
[[ ${#shas[@]} -gt 0 ]] || exit 0
|
| 44 |
+
|
| 45 |
+
# Sweep leaks from earlier interrupted pushes. Named (not `mktemp -d`) so a leak is
|
| 46 |
+
# identifiable, and keyed on LIVENESS rather than age: each scan dir records its hook's PID
|
| 47 |
+
# and its scan child's, so a dir whose hook is gone is swept at once while a CONCURRENT
|
| 48 |
+
# push's dir is never touched. A dir with no .hookpid predates this and is by definition a
|
| 49 |
+
# leak. (2026-09-23: a leaked worktree was found with `git worktree add`'s inner
|
| 50 |
+
# `git reset --hard` still sleeping after 2 days 7 hours, in a directory macOS had reaped.)
|
| 51 |
+
for stale in "${TMPDIR:-/tmp}"/pre-push-scan.*; do
|
| 52 |
+
[[ -d "$stale" ]] || continue
|
| 53 |
+
stale_pid="$(cat "$stale/.hookpid" 2>/dev/null || true)"
|
| 54 |
+
if [[ -z "$stale_pid" ]] || ! kill -0 "$stale_pid" 2>/dev/null; then
|
| 55 |
+
stale_scan="$(cat "$stale/.scanpid" 2>/dev/null || true)"
|
| 56 |
+
if [[ -n "$stale_scan" ]] && kill -0 "$stale_scan" 2>/dev/null; then
|
| 57 |
+
kill -TERM "-$stale_scan" 2>/dev/null || kill -TERM "$stale_scan" 2>/dev/null || true
|
| 58 |
+
sleep 1
|
| 59 |
+
if kill -0 "$stale_scan" 2>/dev/null; then
|
| 60 |
+
kill -KILL "-$stale_scan" 2>/dev/null || kill -KILL "$stale_scan" 2>/dev/null || true
|
| 61 |
+
fi
|
| 62 |
+
fi
|
| 63 |
+
for wt in "$stale"/wt-*; do
|
| 64 |
+
[[ -d "$wt" ]] && git worktree remove --force "$wt" >/dev/null 2>&1
|
| 65 |
+
done
|
| 66 |
+
rm -rf "$stale"
|
| 67 |
+
echo "[pre-push] swept a leaked scan directory from an interrupted push: $(basename "$stale")"
|
| 68 |
+
fi
|
| 69 |
+
done
|
| 70 |
+
git worktree prune >/dev/null 2>&1 || true
|
| 71 |
+
|
| 72 |
+
tmp="$(mktemp -d "${TMPDIR:-/tmp}/pre-push-scan.XXXXXX")"
|
| 73 |
+
echo "$$" > "$tmp/.hookpid"
|
| 74 |
+
scan_pid=""
|
| 75 |
+
cleanup() {
|
| 76 |
+
# Kill the scan BEFORE its directory disappears: deleting the tree under a live child is
|
| 77 |
+
# how a gitleaks or `git reset --hard` ends up running in a path that no longer exists.
|
| 78 |
+
if [[ -n "$scan_pid" ]] && kill -0 "$scan_pid" 2>/dev/null; then
|
| 79 |
+
# Negative pid = the whole process group. run_scan starts the job with `set -m` so it
|
| 80 |
+
# gets its own group; signalling the group takes gitleaks / pip-audit / bandit down with
|
| 81 |
+
# the shell that launched them, instead of reparenting them to init.
|
| 82 |
+
kill -TERM "-$scan_pid" 2>/dev/null || kill -TERM "$scan_pid" 2>/dev/null || true
|
| 83 |
+
wait "$scan_pid" 2>/dev/null || true
|
| 84 |
+
fi
|
| 85 |
+
for wt in "$tmp"/wt-*; do [[ -d "$wt" ]] && git worktree remove --force "$wt" >/dev/null 2>&1; done
|
| 86 |
+
git worktree prune >/dev/null 2>&1 || true
|
| 87 |
+
rm -rf "$tmp"
|
| 88 |
+
}
|
| 89 |
+
# EXIT alone is not enough: a non-interactive bash killed by an UNTRAPPED signal dies
|
| 90 |
+
# without running its EXIT trap, so Ctrl-C, a closed terminal or a terminated agent session
|
| 91 |
+
# left the worktree registered and its directory behind. `trap - EXIT` stops a double run.
|
| 92 |
+
# Nothing in-process survives SIGKILL — that case is what the sweep above is for.
|
| 93 |
+
on_signal() {
|
| 94 |
+
cleanup
|
| 95 |
+
trap - EXIT
|
| 96 |
+
echo "[pre-push] interrupted — scan worktree cleaned up; push aborted." >&2
|
| 97 |
+
exit 130
|
| 98 |
+
}
|
| 99 |
+
trap cleanup EXIT
|
| 100 |
+
trap on_signal INT TERM HUP
|
| 101 |
+
|
| 102 |
+
# Run the scan as a background job and `wait`: bash defers a trap handler until the running
|
| 103 |
+
# FOREGROUND command returns, so a Ctrl-C during a multi-minute scan would not be acted on
|
| 104 |
+
# until the scan finished. `wait` IS interruptible, so cleanup runs at once.
|
| 105 |
+
run_scan() {
|
| 106 |
+
set -m # give the job its own process group, so cleanup can signal the tree
|
| 107 |
+
"$@" &
|
| 108 |
+
scan_pid=$!
|
| 109 |
+
set +m
|
| 110 |
+
echo "$scan_pid" > "$tmp/.scanpid"
|
| 111 |
+
local rc=0
|
| 112 |
+
wait "$scan_pid" || rc=$?
|
| 113 |
+
scan_pid=""
|
| 114 |
+
rm -f "$tmp/.scanpid"
|
| 115 |
+
return "$rc"
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
for sha in "${shas[@]}"; do
|
| 119 |
+
short="${sha:0:7}"
|
| 120 |
+
# Branches that predate the scan have no script — skip them cleanly rather than block.
|
| 121 |
+
if ! git cat-file -e "$sha:scripts/security_scan.sh" 2>/dev/null; then
|
| 122 |
+
echo "[pre-push] $short has no scripts/security_scan.sh — skipping scan for it."
|
| 123 |
+
continue
|
| 124 |
+
fi
|
| 125 |
+
echo "[pre-push] running ADR-0014 security scan on the pushed commit $short (git push --no-verify to skip)…"
|
| 126 |
+
wt="$tmp/wt-$short"
|
| 127 |
+
git worktree add --detach --quiet "$wt" "$sha"
|
| 128 |
+
# Git exports GIT_DIR & co. into hooks; left set, the scan's own git calls (bandit's
|
| 129 |
+
# target discovery, gitleaks) would read the wrong checkout.
|
| 130 |
+
scan_job() {
|
| 131 |
+
env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX \
|
| 132 |
+
SECURITY_SCAN_OUT="$repo_root/security" bash "$wt/scripts/security_scan.sh"
|
| 133 |
+
}
|
| 134 |
+
if run_scan scan_job; then
|
| 135 |
+
echo "[pre-push] $short: security scan clean."
|
| 136 |
+
else
|
| 137 |
+
echo "[pre-push] $short: security scan reported findings — push blocked." >&2
|
| 138 |
+
echo "[pre-push] triage the reports in security/, or 'git push --no-verify' to override." >&2
|
| 139 |
+
exit 1
|
| 140 |
+
fi
|
| 141 |
+
done
|
| 142 |
+
echo "[pre-push] security scan clean — proceeding."
|
| 143 |
+
|
| 144 |
+
# --- 2. GitHub mirror sync -----------------------------------------------------------------
|
| 145 |
+
# `origin` IS the prod Space and `github` is a source mirror that only runs CI; nothing links
|
| 146 |
+
# them, so the mirror fell behind prod by hand-sync on 2026-10-01 more than once. A stale mirror
|
| 147 |
+
# means CI runs a SUBSET of production, which reads as a pass. This syncs the mirror's `main`
|
| 148 |
+
# at the only moment git offers: when a prod deploy is about to go out.
|
| 149 |
+
#
|
| 150 |
+
# Two honest limitations (as in pdac-genomics-agent):
|
| 151 |
+
# 1. The mirror is pushed FIRST: git has no post-push hook. If the `origin` push then fails,
|
| 152 |
+
# `github` is briefly AHEAD of prod. That self-corrects on the next deploy, and CI running a
|
| 153 |
+
# superset of production is the milder error.
|
| 154 |
+
# 2. A mirror failure WARNS; it does not block. The mirror is CI; `origin` is production. It is
|
| 155 |
+
# pushed without force, so a mirror that has moved on (another lane synced it) is left alone.
|
| 156 |
+
#
|
| 157 |
+
# Only `main` on `origin` is mirrored. `git push hf-dev dev:main` also targets refs/heads/main on
|
| 158 |
+
# a huggingface.co URL, so the dev Space is excluded by name AND by URL first — mirroring it
|
| 159 |
+
# would put dev code on the mirror's `main`, where CI reads it as production.
|
| 160 |
+
case "$remote_name$remote_url" in
|
| 161 |
+
"$dev_remote"*|*-dev|*-dev.git|*-dev/) exit 0 ;;
|
| 162 |
+
esac
|
| 163 |
+
case "$remote_name$remote_url" in
|
| 164 |
+
origin*|*huggingface.co/spaces/anne-voigt/pdac-knowledge-agent) ;;
|
| 165 |
+
*) exit 0 ;;
|
| 166 |
+
esac
|
| 167 |
+
|
| 168 |
+
main_sha=""
|
| 169 |
+
while read -r _lref lsha rref _rsha; do
|
| 170 |
+
[[ "${rref:-}" == "refs/heads/main" ]] || continue
|
| 171 |
+
case "${lsha:-}" in *[!0]*) main_sha="$lsha" ;; esac # all-zero = a DELETE; never mirror it
|
| 172 |
+
done <<< "$push_lines"
|
| 173 |
+
[[ -n "$main_sha" ]] || exit 0 # nothing headed for main: not a deploy
|
| 174 |
+
|
| 175 |
+
if ! git remote get-url "$mirror_remote" >/dev/null 2>&1; then
|
| 176 |
+
echo "[pre-push] no '$mirror_remote' remote — the GitHub mirror will fall behind this deploy." >&2
|
| 177 |
+
echo "[pre-push] git remote add $mirror_remote https://github.com/Anne-Voigt/pdac-knowledge-agent.git" >&2
|
| 178 |
+
exit 0
|
| 179 |
+
fi
|
| 180 |
+
|
| 181 |
+
echo "[pre-push] mirroring ${main_sha:0:7} to '$mirror_remote' main so the mirror matches prod…"
|
| 182 |
+
if SECURITY_SCAN_DONE=1 git push --quiet "$mirror_remote" "$main_sha:refs/heads/main"; then
|
| 183 |
+
echo "[pre-push] mirror in sync — proceeding with the deploy."
|
| 184 |
+
else
|
| 185 |
+
echo "[pre-push] WARNING: mirror push failed (often: it already moved on). The deploy proceeds." >&2
|
| 186 |
+
echo "[pre-push] Check it after the deploy: git fetch $mirror_remote && git log --oneline -1 $mirror_remote/main" >&2
|
| 187 |
+
fi
|
| 188 |
+
exit 0
|
scripts/hooks/pre-push-shim
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Installed as .git/hooks/pre-push by `make install-hooks`. It never needs re-installing.
|
| 3 |
+
#
|
| 4 |
+
# The real gate is the TRACKED scripts/hooks/pre-push. Installing a COPY freezes whatever
|
| 5 |
+
# version was current on install day — in pdac-analysis-orchestrator that silently disabled a
|
| 6 |
+
# gate for six days (the installed copy predated it). This shim instead runs the tracked hook
|
| 7 |
+
# as it is on `dev` (the integration branch), so an edited gate is live on the next push from
|
| 8 |
+
# any checkout or worktree, including one parked on an older branch. The gate LOGIC comes from
|
| 9 |
+
# `dev`; the CODE it judges is still the pushed commit (the hook scans each pushed sha).
|
| 10 |
+
# Falls back to this checkout's copy if `dev` has none.
|
| 11 |
+
set -euo pipefail
|
| 12 |
+
|
| 13 |
+
hook="$(mktemp)"
|
| 14 |
+
refs="$(mktemp)"
|
| 15 |
+
trap 'rm -f "$hook" "$refs"' EXIT
|
| 16 |
+
if ! git show dev:scripts/hooks/pre-push > "$hook" 2>/dev/null; then
|
| 17 |
+
cp "$(git rev-parse --show-toplevel)/scripts/hooks/pre-push" "$hook"
|
| 18 |
+
fi
|
| 19 |
+
|
| 20 |
+
# Capture git's ref lines BEFORE backgrounding the gate. Not optional plumbing: POSIX gives an
|
| 21 |
+
# asynchronous command's stdin /dev/null in a non-interactive shell, so a backgrounded gate reads
|
| 22 |
+
# NO ref lines, concludes "no commits to scan" and exits 0 — a gate that passes every push while
|
| 23 |
+
# checking nothing. Found in DecoupleRpy_Agent on 2026-09-23 while adding the forwarding below,
|
| 24 |
+
# and only because the test asserted the gate had SCANNED rather than that the hook exited 0.
|
| 25 |
+
cat > "$refs"
|
| 26 |
+
|
| 27 |
+
# Run the gate as a background job and FORWARD signals to it. The shim is the process git starts,
|
| 28 |
+
# so it is the one a terminating session signals — and the gate's own cleanup traps cannot help if
|
| 29 |
+
# the shim dies and leaves the gate reparented to init with a scan worktree open, which is the
|
| 30 |
+
# 2026-09-23 leak one layer up. (Ctrl-C alone is fine: the terminal signals the whole foreground
|
| 31 |
+
# group. A plain TERM to the hook is not.) `wait` is interruptible, so the forward is immediate.
|
| 32 |
+
bash "$hook" "$@" < "$refs" & # "$@" is git's remote name + URL
|
| 33 |
+
gate=$!
|
| 34 |
+
forward() { kill -TERM "$gate" 2>/dev/null || true; }
|
| 35 |
+
trap forward INT TERM HUP
|
| 36 |
+
rc=0
|
| 37 |
+
wait "$gate" || rc=$?
|
| 38 |
+
exit "$rc"
|
scripts/security_scan.sh
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# ADR-0014 security scan — this repo's copy of the shared definition (authored in
|
| 3 |
+
# DecoupleRpy_Agent; ported from pdac-subtype-agent). Same stages everywhere, run by
|
| 4 |
+
# the pre-push hook and `make security-scan`:
|
| 5 |
+
#
|
| 6 |
+
# 1. Dependencies — pip-audit over the FULL resolved dependency tree (+ SBOM)
|
| 7 |
+
# 2. Static — bandit over the app code (baseline-suppressed accepted findings)
|
| 8 |
+
# 3. Secrets — gitleaks over the working tree AND full git history
|
| 9 |
+
# 4. Sandbox image — trivy over docker/sandbox.Dockerfile (skips where absent)
|
| 10 |
+
#
|
| 11 |
+
# Differences from the sibling copies, both deliberate:
|
| 12 |
+
#
|
| 13 |
+
# - Stage 1 audits the RESOLVED tree, not the lines in requirements.txt. The
|
| 14 |
+
# siblings run `pip-audit --no-deps`, which is right for them because their
|
| 15 |
+
# requirements.txt is a compiled lockfile pinning every transitive dependency.
|
| 16 |
+
# Here it is a short hand-written list, so --no-deps would audit only the top-level
|
| 17 |
+
# pins and never see a transitive CVE — exactly the class that hit anyio on
|
| 18 |
+
# 2026-09-21. So a hand-written file is first resolved with uv for the Space's own
|
| 19 |
+
# python_version, plus the gradio HF installs from README `sdk_version`; a file that
|
| 20 |
+
# is already a uv lock is audited as-is.
|
| 21 |
+
# - bandit targets are discovered, not listed: `src/` plus every root-level module,
|
| 22 |
+
# so a new top-level file cannot silently fall outside the scan.
|
| 23 |
+
#
|
| 24 |
+
# Not audited: HF's generic SDK base layer (datasets, huggingface-hub, …), which HF
|
| 25 |
+
# installs and caches outside this repo. Anything we depend on through it that
|
| 26 |
+
# matters must be floored in requirements.in (see its security-floors block).
|
| 27 |
+
#
|
| 28 |
+
# Design goals (unchanged from the shared definition):
|
| 29 |
+
# - No global installs, no repo mutation beyond security/ artifacts.
|
| 30 |
+
# - Graceful skip (not failure) when an optional scanner is absent; loud about it.
|
| 31 |
+
# SECURITY_SCAN_STRICT=1 turns skips into failures.
|
| 32 |
+
# - Artifacts land in $SECURITY_SCAN_OUT (default: security/). The pre-push hook
|
| 33 |
+
# scans a throwaway checkout of the PUSHED commit and points this at the real
|
| 34 |
+
# repo, so reports and the cached audit venv survive the checkout's removal.
|
| 35 |
+
#
|
| 36 |
+
# Exit code: non-zero if ANY enabled stage reports findings.
|
| 37 |
+
# Usage: scripts/security_scan.sh [requirements.txt]
|
| 38 |
+
set -uo pipefail
|
| 39 |
+
|
| 40 |
+
cd "$(dirname "$0")/.."
|
| 41 |
+
REQ="${1:-requirements.txt}"
|
| 42 |
+
OUT="${SECURITY_SCAN_OUT:-security}"
|
| 43 |
+
mkdir -p "$OUT"
|
| 44 |
+
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
| 45 |
+
STRICT="${SECURITY_SCAN_STRICT:-0}"
|
| 46 |
+
|
| 47 |
+
RC=0
|
| 48 |
+
SKIPS=()
|
| 49 |
+
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
| 50 |
+
|
| 51 |
+
note() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
| 52 |
+
fail() { printf '\033[31mFINDINGS:\033[0m %s\n' "$*"; RC=1; }
|
| 53 |
+
skip() { printf '\033[33mSKIP:\033[0m %s\n' "$*"; SKIPS+=("$*"); [[ "$STRICT" == "1" ]] && RC=1; return 0; }
|
| 54 |
+
ok() { printf '\033[32mOK:\033[0m %s\n' "$*"; }
|
| 55 |
+
|
| 56 |
+
[[ -f "$REQ" ]] || { echo "error: '$REQ' not found" >&2; exit 2; }
|
| 57 |
+
|
| 58 |
+
# README front matter is what HF builds from — read it rather than restating it.
|
| 59 |
+
front() { awk '/^---$/{n++; next} n==1' README.md 2>/dev/null | sed -n "s/^$1:[[:space:]]*//p" | tr -d '"' | head -1; }
|
| 60 |
+
PYV="$(front python_version)"; PYV="${PYV:-3.10}" # HF's default when unset
|
| 61 |
+
SDKV="$(front sdk_version)"
|
| 62 |
+
|
| 63 |
+
# --------------------------------------------------------------------------- #
|
| 64 |
+
# Stage 1a — the full dependency lock that stage 1 audits
|
| 65 |
+
# --------------------------------------------------------------------------- #
|
| 66 |
+
note "dependency tree (python $PYV, linux${SDKV:+, gradio $SDKV from sdk_version})"
|
| 67 |
+
LOCK="$TMP/lock.txt"
|
| 68 |
+
if head -3 "$REQ" | grep -q 'uv pip compile'; then
|
| 69 |
+
cp "$REQ" "$LOCK"; ok "$REQ is already a compiled lock — auditing it as-is"
|
| 70 |
+
elif command -v uv >/dev/null 2>&1; then
|
| 71 |
+
{ cat "$REQ"; [[ -n "$SDKV" ]] && echo "gradio[oauth,mcp]==$SDKV"; } > "$TMP/in.txt"
|
| 72 |
+
if uv pip compile "$TMP/in.txt" -o "$LOCK" --python-version "$PYV" \
|
| 73 |
+
--python-platform linux --no-header --quiet 2>"$TMP/uv.err"; then
|
| 74 |
+
ok "resolved $(grep -cE '^[A-Za-z0-9_.-]+==' "$LOCK") packages (incl. transitive)"
|
| 75 |
+
else
|
| 76 |
+
fail "could not resolve $REQ for python $PYV:"; sed 's/^/ /' "$TMP/uv.err"; LOCK=""
|
| 77 |
+
fi
|
| 78 |
+
else
|
| 79 |
+
skip "dependency resolution — uv not found; transitive deps will NOT be audited"
|
| 80 |
+
cp "$REQ" "$LOCK"
|
| 81 |
+
fi
|
| 82 |
+
|
| 83 |
+
# --------------------------------------------------------------------------- #
|
| 84 |
+
# Stage 1b — CycloneDX SBOM (of the resolved tree)
|
| 85 |
+
# --------------------------------------------------------------------------- #
|
| 86 |
+
note "SBOM (CycloneDX) -> $OUT/sbom.json"
|
| 87 |
+
if [[ -z "$LOCK" ]]; then
|
| 88 |
+
skip "SBOM — no resolved dependency tree"
|
| 89 |
+
elif command -v uvx >/dev/null 2>&1; then
|
| 90 |
+
uvx --from cyclonedx-bom cyclonedx-py requirements "$LOCK" --of JSON -o "$OUT/sbom.json" \
|
| 91 |
+
>/dev/null 2>&1 && ok "SBOM written" || fail "SBOM generation errored"
|
| 92 |
+
else
|
| 93 |
+
skip "SBOM — uvx not found"
|
| 94 |
+
fi
|
| 95 |
+
|
| 96 |
+
# --------------------------------------------------------------------------- #
|
| 97 |
+
# Stage 1c — pip-audit (known CVEs)
|
| 98 |
+
# --------------------------------------------------------------------------- #
|
| 99 |
+
# biodata-registry is a first-party wheel hosted on HF, not a PyPI package, so it has
|
| 100 |
+
# no advisories to look up; strip it (and any URL/git line) before auditing.
|
| 101 |
+
note "pip-audit (known CVEs; first-party HF-wheel dep excluded)"
|
| 102 |
+
PYBIN=""
|
| 103 |
+
for cand in python3.13 python3.12 python3.11 python3; do
|
| 104 |
+
command -v "$cand" >/dev/null 2>&1 || continue
|
| 105 |
+
if "$cand" -m venv "$TMP/probe-$cand" >/dev/null 2>&1; then PYBIN="$cand"; break; fi
|
| 106 |
+
done
|
| 107 |
+
if [[ -z "$LOCK" ]]; then
|
| 108 |
+
: # already reported as a finding above
|
| 109 |
+
elif [[ -z "$PYBIN" ]]; then
|
| 110 |
+
skip "pip-audit — no Python >=3.11 with a working venv found"
|
| 111 |
+
else
|
| 112 |
+
grep -vE 'git\+|biodata-registry|https?://' "$LOCK" > "$TMP/audit.txt"
|
| 113 |
+
VENV="$OUT/.audit-venv"
|
| 114 |
+
if [[ ! -x "$VENV/bin/pip-audit" ]]; then
|
| 115 |
+
echo " setting up audit venv ($PYBIN)"
|
| 116 |
+
"$PYBIN" -m venv "$VENV" && "$VENV/bin/python" -m pip install -q --upgrade pip pip-audit
|
| 117 |
+
fi
|
| 118 |
+
"$VENV/bin/python" -m pip_audit --requirement "$TMP/audit.txt" --no-deps --disable-pip \
|
| 119 |
+
--format columns 2>/dev/null | tee "$OUT/pip-audit-$STAMP.txt"
|
| 120 |
+
AUDIT_RC=${PIPESTATUS[0]}
|
| 121 |
+
if [[ "$AUDIT_RC" -eq 0 ]]; then
|
| 122 |
+
ok "pip-audit — no known vulnerabilities in the resolved tree"
|
| 123 |
+
else
|
| 124 |
+
fail "pip-audit reported vulnerabilities (see $OUT/pip-audit-$STAMP.txt)."
|
| 125 |
+
echo " Fix by pinning or flooring the package in requirements.txt, or record a"
|
| 126 |
+
echo " justified acceptance in security/ACCEPTED-FINDINGS.md."
|
| 127 |
+
fi
|
| 128 |
+
fi
|
| 129 |
+
|
| 130 |
+
# --------------------------------------------------------------------------- #
|
| 131 |
+
# Stage 2 — bandit (static analysis, baseline-suppressed)
|
| 132 |
+
# --------------------------------------------------------------------------- #
|
| 133 |
+
TARGETS=(); [[ -d src ]] && TARGETS+=(src)
|
| 134 |
+
while IFS= read -r f; do TARGETS+=("$f"); done < <(git ls-files '*.py' 2>/dev/null | grep -v /)
|
| 135 |
+
note "bandit (static analysis over: ${TARGETS[*]})"
|
| 136 |
+
BASELINE="security/bandit-baseline.json"
|
| 137 |
+
BANDIT=(); if command -v bandit >/dev/null 2>&1; then BANDIT=(bandit); \
|
| 138 |
+
elif command -v uvx >/dev/null 2>&1; then BANDIT=(uvx bandit); fi
|
| 139 |
+
if [[ ${#BANDIT[@]} -eq 0 ]]; then
|
| 140 |
+
skip "bandit — not installed and uvx unavailable"
|
| 141 |
+
elif [[ ${#TARGETS[@]} -eq 0 ]]; then
|
| 142 |
+
skip "bandit — no Python code found"
|
| 143 |
+
else
|
| 144 |
+
BARGS=(-r "${TARGETS[@]}" -ll -c bandit.yaml)
|
| 145 |
+
[[ -f "$BASELINE" ]] && BARGS+=(-b "$BASELINE")
|
| 146 |
+
"${BANDIT[@]}" "${BARGS[@]}" -f json -o "$OUT/bandit-$STAMP.json" -q 2>/dev/null
|
| 147 |
+
BRC=$?
|
| 148 |
+
"${BANDIT[@]}" "${BARGS[@]}" -q 2>/dev/null | tail -n 25 || true
|
| 149 |
+
if [[ "$BRC" -eq 0 ]]; then
|
| 150 |
+
ok "bandit — no new findings beyond the accepted baseline"
|
| 151 |
+
else
|
| 152 |
+
fail "bandit reported NEW findings (see $OUT/bandit-$STAMP.json). Fix, or if"
|
| 153 |
+
echo " intended, annotate inline (# nosec Bxxx), document it in"
|
| 154 |
+
echo " security/ACCEPTED-FINDINGS.md, and run: make security-baseline"
|
| 155 |
+
fi
|
| 156 |
+
fi
|
| 157 |
+
|
| 158 |
+
# --------------------------------------------------------------------------- #
|
| 159 |
+
# Stage 3 — gitleaks (secrets: working tree AND full history)
|
| 160 |
+
# --------------------------------------------------------------------------- #
|
| 161 |
+
note "gitleaks (secrets — working tree + full git history)"
|
| 162 |
+
if command -v gitleaks >/dev/null 2>&1; then
|
| 163 |
+
gitleaks detect --config .gitleaks.toml --redact --no-banner --log-level warn \
|
| 164 |
+
--report-format json --report-path "$OUT/gitleaks-$STAMP.json"
|
| 165 |
+
if [[ $? -eq 0 ]]; then
|
| 166 |
+
ok "gitleaks — no secrets detected in tree or history"
|
| 167 |
+
else
|
| 168 |
+
fail "gitleaks detected potential secrets (see $OUT/gitleaks-$STAMP.json)."
|
| 169 |
+
echo " If a real secret leaked, ROTATE it (HF_TOKEN / research_agent_token /"
|
| 170 |
+
echo " ANTHROPIC_API_KEY are HF Space secrets) — removing the commit is not enough."
|
| 171 |
+
fi
|
| 172 |
+
else
|
| 173 |
+
skip "gitleaks — not installed (brew install gitleaks)"
|
| 174 |
+
fi
|
| 175 |
+
|
| 176 |
+
# --------------------------------------------------------------------------- #
|
| 177 |
+
# Stage 4 — trivy (sandbox image) — N/A unless the repo has one
|
| 178 |
+
# --------------------------------------------------------------------------- #
|
| 179 |
+
note "trivy (sandbox Dockerfile)"
|
| 180 |
+
DOCKERFILE="docker/sandbox.Dockerfile"
|
| 181 |
+
if [[ ! -f "$DOCKERFILE" ]]; then
|
| 182 |
+
skip "trivy — $DOCKERFILE not found (no sandboxed-exec image in this repo)"
|
| 183 |
+
elif command -v trivy >/dev/null 2>&1; then
|
| 184 |
+
trivy config --severity HIGH,CRITICAL --exit-code 1 \
|
| 185 |
+
--format json --output "$OUT/trivy-$STAMP.json" "$DOCKERFILE"
|
| 186 |
+
[[ $? -eq 0 ]] && ok "trivy — no HIGH/CRITICAL Dockerfile misconfigurations" \
|
| 187 |
+
|| fail "trivy flagged the sandbox Dockerfile (see $OUT/trivy-$STAMP.json)."
|
| 188 |
+
else
|
| 189 |
+
skip "trivy — not installed (brew install trivy)"
|
| 190 |
+
fi
|
| 191 |
+
|
| 192 |
+
# --------------------------------------------------------------------------- #
|
| 193 |
+
# Summary
|
| 194 |
+
# --------------------------------------------------------------------------- #
|
| 195 |
+
note "summary"
|
| 196 |
+
if [[ ${#SKIPS[@]} -gt 0 ]]; then
|
| 197 |
+
echo "Skipped stages:"; for s in "${SKIPS[@]}"; do echo " - $s"; done
|
| 198 |
+
fi
|
| 199 |
+
if [[ "$RC" -eq 0 ]]; then
|
| 200 |
+
ok "security scan passed (all enabled stages clean)."
|
| 201 |
+
else
|
| 202 |
+
echo "Security scan FAILED (rc=$RC) — triage above. Artifacts in $OUT/."
|
| 203 |
+
fi
|
| 204 |
+
exit "$RC"
|
security/.gitignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ephemeral scan outputs — reproducible via `make security-scan`, so not tracked.
|
| 2 |
+
.audit-venv/
|
| 3 |
+
bandit-2*.json
|
| 4 |
+
pip-audit-*.json
|
| 5 |
+
pip-audit-*.txt
|
| 6 |
+
gitleaks-*.json
|
| 7 |
+
trivy-*.json
|
| 8 |
+
|
| 9 |
+
# CycloneDX SBOM — regenerable, carries a per-run timestamp+uuid, so not tracked.
|
| 10 |
+
sbom.json
|
security/ACCEPTED-FINDINGS.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Accepted security-scan findings — pdac-knowledge-agent
|
| 2 |
+
|
| 3 |
+
Triage record for the ADR-0014 security scan (`make security-scan`, and the pre-push hook installed by
|
| 4 |
+
`make install-hooks`). Every suppression would be a deliberate, justified acceptance, not an ignored finding.
|
| 5 |
+
There are none.
|
| 6 |
+
|
| 7 |
+
## §0 — First full run (2026-10-20, slice s1)
|
| 8 |
+
|
| 9 |
+
| Stage | Result |
|
| 10 |
+
|-------|--------|
|
| 11 |
+
| **pip-audit** (compiled lock vs OSV/PyPI advisories) | **clean** — `requirements.txt` is a uv lock of `requirements.in` for python 3.12 / linux: 119 pinned packages, including gradio 6.28.0 from `sdk_version` and ToolUniverse 1.5.6 with every transitive dependency. The memo accepted that footprint under this scan (2026-10-13). |
|
| 12 |
+
| **bandit** (static, medium+, `src/` + `access.py`, `audit.py`, `logging_sink.py`) | **clean** — 0 findings, so there is no baseline file. |
|
| 13 |
+
| **gitleaks** (working tree + full git history) | **clean**. The first run flagged `src/identifiers.py:4` under `generic-api-key`: the docstring phrase "UniProt accessions, EFO/MONDO/Orphanet" matched the rule's keyword-plus-token shape. Not a secret; fixed by rewording the docstring and amending the unpushed commit, **not** by an allowlist entry. |
|
| 14 |
+
| **trivy** (sandbox image) | **N/A** — no sandboxed-exec Dockerfile in this repo; the stage skips. |
|
| 15 |
+
| **SBOM** (CycloneDX, of the lock) | written to `security/sbom.json` (not tracked — regenerable, as in pdac-spatial-agent). |
|
| 16 |
+
|
| 17 |
+
### What a ToolUniverse bump must re-run
|
| 18 |
+
|
| 19 |
+
A version bump is its own lane (ADR-0001 §7): `tooluniverse-doctor`, re-recorded fixtures with the diff reviewed,
|
| 20 |
+
the schema tests, and this scan on the recompiled lock. `tests/test_no_llm_keys.py` also fails if the lock ever
|
| 21 |
+
gains an LLM client library (openai, anthropic, google-genai, smolagents, litellm, langchain, vllm), so a new
|
| 22 |
+
transitive of that kind is a reviewed change, not a silent one.
|
src/__init__.py
ADDED
|
File without changes
|
src/cache.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Response cache — SQLite inside the container, ephemeral, TTL per source (ADR-0001 §8).
|
| 2 |
+
|
| 3 |
+
ToolUniverse's own result cache is switched off (``src.tu_client``) so this is the only one, and
|
| 4 |
+
it keeps the one fact a cache usually hides: ``retrieved_at`` is when the SOURCE answered, and a
|
| 5 |
+
hit returns that original time, never the time the cache served it. Only responses that passed
|
| 6 |
+
their schema check are stored, so a drifted response is never replayed.
|
| 7 |
+
|
| 8 |
+
The file lives in the temp dir by default (``KNOWLEDGE_CACHE_PATH`` overrides; ``:memory:`` for
|
| 9 |
+
tests). Nothing here is persistent on a Space, by design: a rebuild empties it.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import hashlib
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import sqlite3
|
| 18 |
+
import tempfile
|
| 19 |
+
import threading
|
| 20 |
+
import time
|
| 21 |
+
from collections.abc import Callable
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Any
|
| 24 |
+
|
| 25 |
+
from src import config
|
| 26 |
+
|
| 27 |
+
DEFAULT_PATH = str(Path(tempfile.gettempdir()) / "pdac-knowledge-cache.sqlite3")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def cache_key(tool: str, args: dict[str, Any]) -> str:
|
| 31 |
+
canonical = json.dumps({"tool": tool, "args": args}, sort_keys=True, separators=(",", ":"))
|
| 32 |
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ResponseCache:
|
| 36 |
+
def __init__(self, path: str | None = None, *, clock: Callable[[], float] = time.time):
|
| 37 |
+
self.path = path or os.environ.get("KNOWLEDGE_CACHE_PATH") or DEFAULT_PATH
|
| 38 |
+
self._clock = clock
|
| 39 |
+
self._lock = threading.Lock()
|
| 40 |
+
self._db = sqlite3.connect(self.path, check_same_thread=False)
|
| 41 |
+
self._db.execute(
|
| 42 |
+
"CREATE TABLE IF NOT EXISTS responses ("
|
| 43 |
+
" key TEXT PRIMARY KEY, source TEXT NOT NULL, tool TEXT NOT NULL,"
|
| 44 |
+
" data TEXT NOT NULL, retrieved_at REAL NOT NULL)"
|
| 45 |
+
)
|
| 46 |
+
self._db.commit()
|
| 47 |
+
|
| 48 |
+
def get(self, source: str, tool: str, args: dict[str, Any]) -> tuple[Any, float] | None:
|
| 49 |
+
"""``(data, retrieved_at)`` for a live entry, else None. Expired entries are dropped."""
|
| 50 |
+
key = cache_key(tool, args)
|
| 51 |
+
ttl = config.source(source)["ttl_seconds"]
|
| 52 |
+
with self._lock:
|
| 53 |
+
row = self._db.execute(
|
| 54 |
+
"SELECT data, retrieved_at FROM responses WHERE key = ?", (key,)
|
| 55 |
+
).fetchone()
|
| 56 |
+
if row is None:
|
| 57 |
+
return None
|
| 58 |
+
data, retrieved_at = row
|
| 59 |
+
if self._clock() - retrieved_at >= ttl:
|
| 60 |
+
self._db.execute("DELETE FROM responses WHERE key = ?", (key,))
|
| 61 |
+
self._db.commit()
|
| 62 |
+
return None
|
| 63 |
+
return json.loads(data), retrieved_at
|
| 64 |
+
|
| 65 |
+
def put(
|
| 66 |
+
self, source: str, tool: str, args: dict[str, Any], data: Any, retrieved_at: float
|
| 67 |
+
) -> None:
|
| 68 |
+
key = cache_key(tool, args)
|
| 69 |
+
with self._lock:
|
| 70 |
+
self._db.execute(
|
| 71 |
+
"INSERT OR REPLACE INTO responses (key, source, tool, data, retrieved_at)"
|
| 72 |
+
" VALUES (?, ?, ?, ?, ?)",
|
| 73 |
+
(key, source, tool, json.dumps(data), retrieved_at),
|
| 74 |
+
)
|
| 75 |
+
self._db.commit()
|
| 76 |
+
|
| 77 |
+
def clear(self) -> None:
|
| 78 |
+
with self._lock:
|
| 79 |
+
self._db.execute("DELETE FROM responses")
|
| 80 |
+
self._db.commit()
|
src/config.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The two config files every other module reads: the tool allow-list and the source list.
|
| 2 |
+
|
| 3 |
+
`config/tools_allowlist.yaml` is the approved surface (ADR-0001 §7): which ToolUniverse tools may
|
| 4 |
+
ever load, and which BCC endpoint uses each. `config/sources.yaml` is the honest egress statement
|
| 5 |
+
(ADR-0001 §6): the hosts each source's tools reach, the licence stamped on its rows, and its cache
|
| 6 |
+
and rate settings. Both are validated here once, so a malformed file stops the Space at import
|
| 7 |
+
rather than surfacing as a half-stamped row later.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import functools
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
import yaml
|
| 17 |
+
|
| 18 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 19 |
+
CONFIG_DIR = ROOT / "config"
|
| 20 |
+
|
| 21 |
+
# Frozen by ADR-0001 §4 (accepted 2026-10-20). s2 builds them; the names are fixed now so the
|
| 22 |
+
# loaded tool set can be checked before any endpoint code exists.
|
| 23 |
+
ENDPOINTS = (
|
| 24 |
+
"drugs_for_targets",
|
| 25 |
+
"trials_for_genes",
|
| 26 |
+
"annotate_genes",
|
| 27 |
+
"pathways_for_genes",
|
| 28 |
+
"targets_for_disease",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
_TOOL_FIELDS = ("name", "source", "endpoints", "review")
|
| 32 |
+
_SOURCE_FIELDS = {
|
| 33 |
+
"name": str,
|
| 34 |
+
"hosts": list,
|
| 35 |
+
"licence": str,
|
| 36 |
+
"licence_url": str,
|
| 37 |
+
"licence_checked": str,
|
| 38 |
+
"ttl_seconds": int,
|
| 39 |
+
"rate_per_second": (int, float),
|
| 40 |
+
"burst": int,
|
| 41 |
+
"timeout_seconds": (int, float),
|
| 42 |
+
"coverage_notes": list,
|
| 43 |
+
"citation": str,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class ConfigError(ValueError):
|
| 48 |
+
"""A config file is malformed. Raised at import, never at request time."""
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _read(name: str) -> dict[str, Any]:
|
| 52 |
+
with open(CONFIG_DIR / name, encoding="utf-8") as fh:
|
| 53 |
+
data = yaml.safe_load(fh)
|
| 54 |
+
if not isinstance(data, dict):
|
| 55 |
+
raise ConfigError(f"{name}: expected a mapping at the top level")
|
| 56 |
+
return data
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@functools.cache
|
| 60 |
+
def sources() -> dict[str, dict[str, Any]]:
|
| 61 |
+
"""Every declared source, keyed by its short id (`opentargets`, `chembl`, …)."""
|
| 62 |
+
data = _read("sources.yaml").get("sources")
|
| 63 |
+
if not isinstance(data, dict) or not data:
|
| 64 |
+
raise ConfigError("sources.yaml: `sources` must be a non-empty mapping")
|
| 65 |
+
for sid, entry in data.items():
|
| 66 |
+
for field, kind in _SOURCE_FIELDS.items():
|
| 67 |
+
if not isinstance(entry.get(field), kind):
|
| 68 |
+
raise ConfigError(f"sources.yaml: {sid}.{field} missing or not {kind}")
|
| 69 |
+
if not entry["hosts"] or not all(isinstance(h, str) and h for h in entry["hosts"]):
|
| 70 |
+
raise ConfigError(f"sources.yaml: {sid}.hosts must list at least one host")
|
| 71 |
+
if not entry["licence"].strip() or not entry["licence_url"].startswith("https://"):
|
| 72 |
+
raise ConfigError(f"sources.yaml: {sid} needs a licence and an https licence_url")
|
| 73 |
+
if entry["rate_per_second"] <= 0 or entry["burst"] < 1 or entry["ttl_seconds"] <= 0:
|
| 74 |
+
raise ConfigError(f"sources.yaml: {sid} has a non-positive rate, burst or TTL")
|
| 75 |
+
return data
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def source(source_id: str) -> dict[str, Any]:
|
| 79 |
+
try:
|
| 80 |
+
return sources()[source_id]
|
| 81 |
+
except KeyError:
|
| 82 |
+
raise ConfigError(f"unknown source {source_id!r}") from None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@functools.cache
|
| 86 |
+
def allowlist() -> dict[str, Any]:
|
| 87 |
+
"""The parsed allow-list: `tooluniverse_version` plus `tools` keyed by tool name."""
|
| 88 |
+
data = _read("tools_allowlist.yaml")
|
| 89 |
+
version = data.get("tooluniverse_version")
|
| 90 |
+
if not isinstance(version, str) or not version:
|
| 91 |
+
raise ConfigError("tools_allowlist.yaml: `tooluniverse_version` must be a string")
|
| 92 |
+
tools: dict[str, dict[str, Any]] = {}
|
| 93 |
+
for entry in data.get("tools") or []:
|
| 94 |
+
if not all(field in entry for field in _TOOL_FIELDS):
|
| 95 |
+
raise ConfigError(f"tools_allowlist.yaml: entry missing {_TOOL_FIELDS}: {entry!r}")
|
| 96 |
+
name = entry["name"]
|
| 97 |
+
if name in tools:
|
| 98 |
+
raise ConfigError(f"tools_allowlist.yaml: {name} is listed twice")
|
| 99 |
+
if entry["source"] not in sources():
|
| 100 |
+
raise ConfigError(
|
| 101 |
+
f"tools_allowlist.yaml: {name} names undeclared source {entry['source']!r}"
|
| 102 |
+
)
|
| 103 |
+
unknown = set(entry["endpoints"]) - set(ENDPOINTS)
|
| 104 |
+
if unknown:
|
| 105 |
+
raise ConfigError(
|
| 106 |
+
f"tools_allowlist.yaml: {name} names unknown endpoints {sorted(unknown)}"
|
| 107 |
+
)
|
| 108 |
+
if not str(entry["review"]).strip():
|
| 109 |
+
raise ConfigError(f"tools_allowlist.yaml: {name} has no review note")
|
| 110 |
+
tools[name] = entry
|
| 111 |
+
if not tools:
|
| 112 |
+
raise ConfigError("tools_allowlist.yaml: no tools")
|
| 113 |
+
return {"tooluniverse_version": version, "tools": tools}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def pinned_tooluniverse_version() -> str:
|
| 117 |
+
return allowlist()["tooluniverse_version"]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def allowed_tool_names() -> frozenset[str]:
|
| 121 |
+
"""Every approved tool (the memo's 28), loaded or not."""
|
| 122 |
+
return frozenset(allowlist()["tools"])
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def endpoint_tool_names() -> frozenset[str]:
|
| 126 |
+
"""The tools at least one endpoint uses: exactly the set that is loaded (ADR-0001 §7)."""
|
| 127 |
+
return frozenset(name for name, t in allowlist()["tools"].items() if t["endpoints"])
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def tools_for_endpoint(endpoint: str) -> tuple[str, ...]:
|
| 131 |
+
if endpoint not in ENDPOINTS:
|
| 132 |
+
raise ConfigError(f"unknown endpoint {endpoint!r}")
|
| 133 |
+
return tuple(
|
| 134 |
+
sorted(name for name, t in allowlist()["tools"].items() if endpoint in t["endpoints"])
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def source_of(tool: str) -> str:
|
| 139 |
+
try:
|
| 140 |
+
return allowlist()["tools"][tool]["source"]
|
| 141 |
+
except KeyError:
|
| 142 |
+
raise ConfigError(f"{tool!r} is not on the allow-list") from None
|
src/identifiers.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""What may leave the Space: identifiers only (ADR-0001 §6).
|
| 2 |
+
|
| 3 |
+
Every endpoint argument passes through here BEFORE any outbound call. Allowed are HGNC symbols,
|
| 4 |
+
Ensembl gene ids, UniProt accession numbers, disease ids from EFO, MONDO or Orphanet, NCT ids, and
|
| 5 |
+
a disease name as a bounded string. Free text, sample ids, numbers (expression values) and file contents are
|
| 6 |
+
refused as data — ``invalid_identifier`` — and more than 100 identifiers as
|
| 7 |
+
``too_many_identifiers``. A refusal never echoes more than a short, truncated token back.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import re
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
|
| 15 |
+
MAX_IDENTIFIERS = 100
|
| 16 |
+
MAX_DISEASE_NAME = 80
|
| 17 |
+
_ECHO = 24 # longest token a refusal message repeats back
|
| 18 |
+
|
| 19 |
+
# HGNC approved symbols start with a letter and use letters, digits and hyphens (C1orf112,
|
| 20 |
+
# HLA-DRB1, NKX2-1; immunoglobulin segments such as IGHV1-69-2 carry two hyphens).
|
| 21 |
+
SYMBOL = re.compile(r"^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+){0,2}$")
|
| 22 |
+
ENSEMBL_GENE = re.compile(r"^ENSG\d{11}(?:\.\d+)?$")
|
| 23 |
+
# UniProt's published accession pattern.
|
| 24 |
+
UNIPROT = re.compile(
|
| 25 |
+
r"^(?:[OPQ][0-9][A-Z0-9]{3}[0-9]|[A-NR-Z][0-9](?:[A-Z][A-Z0-9]{2}[0-9]){1,2})$"
|
| 26 |
+
)
|
| 27 |
+
DISEASE_ID = re.compile(r"^(?:EFO|MONDO|Orphanet)_\d{1,10}$")
|
| 28 |
+
NCT = re.compile(r"^NCT\d{8}$")
|
| 29 |
+
DISEASE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ,'\-]*$")
|
| 30 |
+
|
| 31 |
+
# Shapes that would pass as a symbol but are sample or run identifiers (TCGA barcodes, GEO and
|
| 32 |
+
# SRA accessions, CPTAC case ids, ICGC donor/sample ids). They never leave the Space: a sample id
|
| 33 |
+
# is study data, not a public identifier. Every prefix here is checked against real symbols that
|
| 34 |
+
# share its letters (CPT1A, ERRFI1, DOK1, SAA1 stay valid; tests/test_identifiers.py).
|
| 35 |
+
_SAMPLE_ID = re.compile(
|
| 36 |
+
r"^(?:TCGA-|GSM\d|GSE\d|SRR\d|ERR\d|DRR\d|SRS\d|SAMN\d|C3[LN]-|DO\d|SA\d)",
|
| 37 |
+
re.IGNORECASE,
|
| 38 |
+
)
|
| 39 |
+
_SPLIT = re.compile(r"[\s,;]+")
|
| 40 |
+
|
| 41 |
+
GENE_KINDS = frozenset({"symbol", "ensembl"})
|
| 42 |
+
PROTEIN_OR_GENE_KINDS = frozenset({"symbol", "ensembl", "uniprot"})
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass(frozen=True)
|
| 46 |
+
class Identifier:
|
| 47 |
+
value: str
|
| 48 |
+
kind: str # symbol | ensembl | uniprot | disease_id | disease_name | nct
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass(frozen=True)
|
| 52 |
+
class Parsed:
|
| 53 |
+
"""Either a list of identifiers or a refusal (``reason`` + ``message``), never both."""
|
| 54 |
+
|
| 55 |
+
identifiers: tuple[Identifier, ...] = ()
|
| 56 |
+
reason: str | None = None
|
| 57 |
+
message: str | None = None
|
| 58 |
+
duplicates_dropped: int = field(default=0)
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def ok(self) -> bool:
|
| 62 |
+
return self.reason is None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _echo(token: str) -> str:
|
| 66 |
+
return token if len(token) <= _ECHO else token[:_ECHO] + "…"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _refuse(reason: str, message: str) -> Parsed:
|
| 70 |
+
return Parsed(reason=reason, message=message)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _dedupe_key(token: str) -> str:
|
| 74 |
+
"""Symbols compare case-insensitively (kras == KRAS); Ensembl ids exactly."""
|
| 75 |
+
return token if token.startswith("ENSG") else token.upper()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def classify_gene_token(token: str, *, allow_uniprot: bool = False) -> str | None:
|
| 79 |
+
"""The identifier kind for one gene/protein token, or None if it is not one."""
|
| 80 |
+
if _SAMPLE_ID.match(token):
|
| 81 |
+
return None
|
| 82 |
+
if ENSEMBL_GENE.match(token):
|
| 83 |
+
return "ensembl"
|
| 84 |
+
if allow_uniprot and UNIPROT.match(token):
|
| 85 |
+
return "uniprot"
|
| 86 |
+
if len(token) <= 25 and SYMBOL.match(token):
|
| 87 |
+
return "symbol"
|
| 88 |
+
return None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def parse_gene_list(raw: str | list[str] | None, *, allow_uniprot: bool = False) -> Parsed:
|
| 92 |
+
"""Split, de-duplicate (order kept) and validate a gene list."""
|
| 93 |
+
if isinstance(raw, list):
|
| 94 |
+
tokens = [str(t).strip() for t in raw]
|
| 95 |
+
else:
|
| 96 |
+
tokens = _SPLIT.split((raw or "").strip())
|
| 97 |
+
tokens = [t for t in tokens if t]
|
| 98 |
+
if not tokens:
|
| 99 |
+
return _refuse("invalid_identifier", "No gene identifiers were given.")
|
| 100 |
+
unique = {_dedupe_key(t) for t in tokens}
|
| 101 |
+
if len(unique) > MAX_IDENTIFIERS:
|
| 102 |
+
return _refuse(
|
| 103 |
+
"too_many_identifiers",
|
| 104 |
+
f"{len(unique)} identifiers given; at most {MAX_IDENTIFIERS} per call.",
|
| 105 |
+
)
|
| 106 |
+
bad = [t for t in tokens if classify_gene_token(t, allow_uniprot=allow_uniprot) is None]
|
| 107 |
+
if bad:
|
| 108 |
+
shown = ", ".join(_echo(t) for t in bad[:5])
|
| 109 |
+
more = f" and {len(bad) - 5} more" if len(bad) > 5 else ""
|
| 110 |
+
accepted = "HGNC symbols or Ensembl gene ids" + (
|
| 111 |
+
" or UniProt accessions" if allow_uniprot else ""
|
| 112 |
+
)
|
| 113 |
+
return _refuse(
|
| 114 |
+
"invalid_identifier",
|
| 115 |
+
f"Not a gene identifier: {shown}{more}. Only {accepted} are sent to external sources; "
|
| 116 |
+
"free text, sample ids and numbers are not.",
|
| 117 |
+
)
|
| 118 |
+
out: list[Identifier] = []
|
| 119 |
+
emitted: set[str] = set()
|
| 120 |
+
for t in tokens:
|
| 121 |
+
key = _dedupe_key(t)
|
| 122 |
+
if key in emitted:
|
| 123 |
+
continue
|
| 124 |
+
emitted.add(key)
|
| 125 |
+
out.append(Identifier(t, classify_gene_token(t, allow_uniprot=allow_uniprot) or "symbol"))
|
| 126 |
+
return Parsed(identifiers=tuple(out), duplicates_dropped=len(tokens) - len(out))
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def parse_disease(raw: str | None) -> Parsed:
|
| 130 |
+
"""One disease: an EFO/MONDO/Orphanet id, or a bounded name."""
|
| 131 |
+
text = (raw or "").strip()
|
| 132 |
+
if not text:
|
| 133 |
+
return _refuse("invalid_identifier", "No disease was given.")
|
| 134 |
+
if DISEASE_ID.match(text):
|
| 135 |
+
return Parsed(identifiers=(Identifier(text, "disease_id"),))
|
| 136 |
+
if (
|
| 137 |
+
len(text) <= MAX_DISEASE_NAME
|
| 138 |
+
and DISEASE_NAME.match(text)
|
| 139 |
+
and any(c.isalpha() for c in text)
|
| 140 |
+
and not _SAMPLE_ID.match(text)
|
| 141 |
+
):
|
| 142 |
+
return Parsed(identifiers=(Identifier(" ".join(text.split()), "disease_name"),))
|
| 143 |
+
return _refuse(
|
| 144 |
+
"invalid_identifier",
|
| 145 |
+
f"Not a disease name or id: {_echo(text)}. Give an EFO/MONDO id or a name of at most "
|
| 146 |
+
f"{MAX_DISEASE_NAME} characters (letters, digits, spaces, hyphens, commas, apostrophes).",
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def parse_nct(raw: str | None) -> Parsed:
|
| 151 |
+
text = (raw or "").strip().upper()
|
| 152 |
+
if NCT.match(text):
|
| 153 |
+
return Parsed(identifiers=(Identifier(text, "nct"),))
|
| 154 |
+
return _refuse("invalid_identifier", f"Not an NCT id: {_echo(text)}.")
|
src/payloads.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Payload assembly — every answer this Space returns goes through here.
|
| 2 |
+
|
| 3 |
+
The contract (ADR-0001 §5, accepted 2026-10-20), on top of the family invariants:
|
| 4 |
+
|
| 5 |
+
- Every payload is a dict with ``status`` in {"ok", "refused", "error", "denied"} plus a
|
| 6 |
+
human-readable ``reason`` on every non-ok status. Refusals are answers, never exceptions.
|
| 7 |
+
- Every payload except a denial carries a ``run_id`` (the gate mints none for a caller it
|
| 8 |
+
turned away).
|
| 9 |
+
- Every ``ok`` payload carries ``kind: "annotation"`` and **no** ``hierarchy`` or
|
| 10 |
+
``n_for_inference``. This is the deliberate break from the data specialists: a row count here
|
| 11 |
+
is not a sample size, and nothing downstream may render it as one.
|
| 12 |
+
- Every row carries a complete ``provenance`` block (:mod:`src.provenance`); :func:`ok` refuses to
|
| 13 |
+
build a payload around a row that does not.
|
| 14 |
+
- ``coverage_notes`` and ``attribution`` come from config for every source the payload used, and
|
| 15 |
+
the deterministic ``narrative`` says "according to <source> (queried <date>)".
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import datetime
|
| 21 |
+
import json
|
| 22 |
+
import secrets
|
| 23 |
+
from collections.abc import Iterable
|
| 24 |
+
from typing import Any
|
| 25 |
+
|
| 26 |
+
from src import config
|
| 27 |
+
from src.provenance import missing_fields
|
| 28 |
+
|
| 29 |
+
STATUSES = ("ok", "refused", "error", "denied")
|
| 30 |
+
SOURCE_ERROR_REASONS = ("source_unavailable", "rate_limited", "upstream_schema_drift")
|
| 31 |
+
KIND = "annotation"
|
| 32 |
+
DISCLAIMER = (
|
| 33 |
+
"External annotation, not analysis: these rows are what the named public sources say, "
|
| 34 |
+
"as of the query date on each row. Nothing here was computed from a dataset, and no "
|
| 35 |
+
"statistic was run."
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def mint_run_id() -> str:
|
| 40 |
+
"""A sortable, collision-resistant run id (sibling-agent format)."""
|
| 41 |
+
stamp = datetime.datetime.now(datetime.UTC).strftime("%Y%m%d_%H%M%S")
|
| 42 |
+
return f"{stamp}_{secrets.randbelow(10**6):06d}"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def source_error(source: str, reason: str, detail: str) -> dict[str, str]:
|
| 46 |
+
if reason not in SOURCE_ERROR_REASONS:
|
| 47 |
+
raise ValueError(f"source error reason must be one of {SOURCE_ERROR_REASONS}")
|
| 48 |
+
return {"source": source, "reason": reason, "detail": detail}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _attribution(source_ids: Iterable[str]) -> list[dict[str, str]]:
|
| 52 |
+
out = []
|
| 53 |
+
for sid in sorted(set(source_ids)):
|
| 54 |
+
src = config.source(sid)
|
| 55 |
+
out.append(
|
| 56 |
+
{
|
| 57 |
+
"source": sid,
|
| 58 |
+
"name": src["name"],
|
| 59 |
+
"citation": src["citation"],
|
| 60 |
+
"licence": src["licence"],
|
| 61 |
+
"licence_url": src["licence_url"],
|
| 62 |
+
}
|
| 63 |
+
)
|
| 64 |
+
return out
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _coverage_notes(source_ids: Iterable[str]) -> list[dict[str, str]]:
|
| 68 |
+
return [
|
| 69 |
+
{"source": sid, "note": note}
|
| 70 |
+
for sid in sorted(set(source_ids))
|
| 71 |
+
for note in config.source(sid)["coverage_notes"]
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def ok(
|
| 76 |
+
endpoint: str,
|
| 77 |
+
*,
|
| 78 |
+
query: dict[str, Any],
|
| 79 |
+
rows: list[dict[str, Any]],
|
| 80 |
+
sources_used: Iterable[str],
|
| 81 |
+
narrative: str,
|
| 82 |
+
unresolved: Iterable[dict[str, str]] = (),
|
| 83 |
+
source_errors: Iterable[dict[str, str]] = (),
|
| 84 |
+
run_id: str | None = None,
|
| 85 |
+
**extra: Any,
|
| 86 |
+
) -> dict[str, Any]:
|
| 87 |
+
"""An ok payload. Raises ValueError (a programming error) on an unstamped row."""
|
| 88 |
+
for i, row in enumerate(rows):
|
| 89 |
+
gaps = missing_fields(row)
|
| 90 |
+
if gaps:
|
| 91 |
+
raise ValueError(f"row {i} is missing provenance fields {gaps}")
|
| 92 |
+
if not narrative.strip():
|
| 93 |
+
raise ValueError("an ok payload needs its deterministic narrative")
|
| 94 |
+
used = list(sources_used)
|
| 95 |
+
forbidden = {"hierarchy", "n_for_inference"} & set(extra)
|
| 96 |
+
if forbidden:
|
| 97 |
+
raise ValueError(f"annotation payloads never carry {sorted(forbidden)}")
|
| 98 |
+
payload: dict[str, Any] = {
|
| 99 |
+
"status": "ok",
|
| 100 |
+
"run_id": run_id or mint_run_id(),
|
| 101 |
+
"kind": KIND,
|
| 102 |
+
"endpoint": endpoint,
|
| 103 |
+
"query": query,
|
| 104 |
+
"rows": rows,
|
| 105 |
+
"unresolved": list(unresolved),
|
| 106 |
+
"source_errors": list(source_errors),
|
| 107 |
+
"coverage_notes": _coverage_notes(used),
|
| 108 |
+
"attribution": _attribution(used),
|
| 109 |
+
"narrative": narrative,
|
| 110 |
+
"disclaimer": DISCLAIMER,
|
| 111 |
+
}
|
| 112 |
+
payload.update(extra)
|
| 113 |
+
return payload
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def refused(endpoint: str, reason: str, message: str, *, run_id: str | None = None) -> dict:
|
| 117 |
+
return {
|
| 118 |
+
"status": "refused",
|
| 119 |
+
"run_id": run_id or mint_run_id(),
|
| 120 |
+
"endpoint": endpoint,
|
| 121 |
+
"reason": reason,
|
| 122 |
+
"message": message,
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def error(
|
| 127 |
+
endpoint: str,
|
| 128 |
+
reason: str,
|
| 129 |
+
message: str,
|
| 130 |
+
*,
|
| 131 |
+
source_errors: Iterable[dict[str, str]] = (),
|
| 132 |
+
run_id: str | None = None,
|
| 133 |
+
) -> dict:
|
| 134 |
+
return {
|
| 135 |
+
"status": "error",
|
| 136 |
+
"run_id": run_id or mint_run_id(),
|
| 137 |
+
"endpoint": endpoint,
|
| 138 |
+
"reason": reason,
|
| 139 |
+
"message": message,
|
| 140 |
+
"source_errors": list(source_errors),
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def denied(message: str) -> dict:
|
| 145 |
+
return {"status": "denied", "reason": "access_denied", "message": message}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def to_json(payload: dict[str, Any]) -> str:
|
| 149 |
+
if payload.get("status") not in STATUSES:
|
| 150 |
+
raise ValueError(f"unknown status {payload.get('status')!r}")
|
| 151 |
+
return json.dumps(payload, ensure_ascii=False)
|
src/provenance.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provenance on every row (ADR-0001 §5).
|
| 2 |
+
|
| 3 |
+
ToolUniverse returns each source's raw response with no provenance or licence (Phase 0 governance
|
| 4 |
+
finding), so every row this Space returns is stamped here with the ten fields below. A row that is
|
| 5 |
+
missing any of them is a bug, and :func:`src.payloads.ok` refuses to build a payload around it.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import datetime
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from src import config
|
| 14 |
+
|
| 15 |
+
PROVENANCE_FIELDS = (
|
| 16 |
+
"source", # short source id from config/sources.yaml (opentargets, chembl, …)
|
| 17 |
+
"source_tool", # the ToolUniverse tool name that produced the row
|
| 18 |
+
"tooluniverse_version", # the pinned version that ran it
|
| 19 |
+
"query", # the arguments actually sent
|
| 20 |
+
"source_ref", # the source's own id for the row (ChEMBL, NCT, UniProt, Reactome, Ensembl)
|
| 21 |
+
"source_url", # where a reader can look the row up
|
| 22 |
+
"retrieved_at", # when the SOURCE answered — survives a cache hit unchanged
|
| 23 |
+
"cache", # "hit" or "miss"
|
| 24 |
+
"licence",
|
| 25 |
+
"licence_url",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
CACHE_STATES = ("hit", "miss")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def iso_utc(epoch_seconds: float) -> str:
|
| 32 |
+
"""``2026-10-20T15:37:49Z`` — second resolution, always UTC."""
|
| 33 |
+
moment = datetime.datetime.fromtimestamp(epoch_seconds, tz=datetime.UTC)
|
| 34 |
+
return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def stamp(
|
| 38 |
+
row: dict[str, Any],
|
| 39 |
+
*,
|
| 40 |
+
source: str,
|
| 41 |
+
source_tool: str,
|
| 42 |
+
tooluniverse_version: str,
|
| 43 |
+
query: dict[str, Any],
|
| 44 |
+
source_ref: str,
|
| 45 |
+
source_url: str,
|
| 46 |
+
retrieved_at: float,
|
| 47 |
+
cache: str,
|
| 48 |
+
) -> dict[str, Any]:
|
| 49 |
+
"""A copy of ``row`` carrying a complete ``provenance`` block. Licence comes from config."""
|
| 50 |
+
if cache not in CACHE_STATES:
|
| 51 |
+
raise ValueError(f"cache must be one of {CACHE_STATES}, not {cache!r}")
|
| 52 |
+
src = config.source(source)
|
| 53 |
+
stamped = dict(row)
|
| 54 |
+
stamped["provenance"] = {
|
| 55 |
+
"source": source,
|
| 56 |
+
"source_tool": source_tool,
|
| 57 |
+
"tooluniverse_version": tooluniverse_version,
|
| 58 |
+
"query": dict(query),
|
| 59 |
+
"source_ref": source_ref,
|
| 60 |
+
"source_url": source_url,
|
| 61 |
+
"retrieved_at": iso_utc(retrieved_at),
|
| 62 |
+
"cache": cache,
|
| 63 |
+
"licence": src["licence"],
|
| 64 |
+
"licence_url": src["licence_url"],
|
| 65 |
+
}
|
| 66 |
+
return stamped
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def missing_fields(row: dict[str, Any]) -> list[str]:
|
| 70 |
+
"""Provenance fields absent or empty on ``row`` (empty list = complete)."""
|
| 71 |
+
prov = row.get("provenance")
|
| 72 |
+
if not isinstance(prov, dict):
|
| 73 |
+
return list(PROVENANCE_FIELDS)
|
| 74 |
+
return [f for f in PROVENANCE_FIELDS if prov.get(f) in (None, "", {}, [])]
|
src/ratelimit.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-source rate limits — a token bucket per source, settings from config/sources.yaml.
|
| 2 |
+
|
| 3 |
+
A caller that would have to wait longer than its budget does not wait: it gets ``False`` and the
|
| 4 |
+
client turns that into a ``rate_limited`` source error. A limit must never become a retry loop
|
| 5 |
+
that outlasts the orchestrator's call limit (ADR-0001 §8).
|
| 6 |
+
|
| 7 |
+
Tokens are reserved under the lock and slept for outside it, so concurrent callers queue in
|
| 8 |
+
order without holding the lock while they sleep.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import threading
|
| 14 |
+
import time
|
| 15 |
+
from collections.abc import Callable
|
| 16 |
+
|
| 17 |
+
from src import config
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TokenBucket:
|
| 21 |
+
def __init__(
|
| 22 |
+
self,
|
| 23 |
+
rate_per_second: float,
|
| 24 |
+
burst: int,
|
| 25 |
+
*,
|
| 26 |
+
clock: Callable[[], float] = time.monotonic,
|
| 27 |
+
sleep: Callable[[float], None] = time.sleep,
|
| 28 |
+
):
|
| 29 |
+
if rate_per_second <= 0 or burst < 1:
|
| 30 |
+
raise ValueError("rate must be positive and burst at least 1")
|
| 31 |
+
self.rate = float(rate_per_second)
|
| 32 |
+
self.burst = int(burst)
|
| 33 |
+
self._clock = clock
|
| 34 |
+
self._sleep = sleep
|
| 35 |
+
self._tokens = float(burst)
|
| 36 |
+
self._last = clock()
|
| 37 |
+
self._lock = threading.Lock()
|
| 38 |
+
|
| 39 |
+
def acquire(self, max_wait: float) -> bool:
|
| 40 |
+
"""Take one token, waiting at most ``max_wait`` seconds. False if that is not enough."""
|
| 41 |
+
with self._lock:
|
| 42 |
+
now = self._clock()
|
| 43 |
+
self._tokens = min(self.burst, self._tokens + (now - self._last) * self.rate)
|
| 44 |
+
self._last = now
|
| 45 |
+
wait = 0.0 if self._tokens >= 1 else (1 - self._tokens) / self.rate
|
| 46 |
+
if wait > max_wait:
|
| 47 |
+
return False
|
| 48 |
+
self._tokens -= 1 # reserve now; may go negative while the caller sleeps
|
| 49 |
+
if wait > 0:
|
| 50 |
+
self._sleep(wait)
|
| 51 |
+
return True
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class RateLimiters:
|
| 55 |
+
"""One bucket per declared source, built lazily from config."""
|
| 56 |
+
|
| 57 |
+
def __init__(
|
| 58 |
+
self,
|
| 59 |
+
*,
|
| 60 |
+
clock: Callable[[], float] = time.monotonic,
|
| 61 |
+
sleep: Callable[[float], None] = time.sleep,
|
| 62 |
+
):
|
| 63 |
+
self._clock = clock
|
| 64 |
+
self._sleep = sleep
|
| 65 |
+
self._buckets: dict[str, TokenBucket] = {}
|
| 66 |
+
self._lock = threading.Lock()
|
| 67 |
+
|
| 68 |
+
def bucket(self, source: str) -> TokenBucket:
|
| 69 |
+
with self._lock:
|
| 70 |
+
if source not in self._buckets:
|
| 71 |
+
cfg = config.source(source)
|
| 72 |
+
self._buckets[source] = TokenBucket(
|
| 73 |
+
cfg["rate_per_second"], cfg["burst"], clock=self._clock, sleep=self._sleep
|
| 74 |
+
)
|
| 75 |
+
return self._buckets[source]
|
| 76 |
+
|
| 77 |
+
def acquire(self, source: str, max_wait: float) -> bool:
|
| 78 |
+
return self.bucket(source).acquire(max_wait)
|
src/schemas/OpenTargets_get_associated_drugs_by_target_ensemblID.schema.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
| 3 |
+
"$comment": "Pinned 2026-10-20 (s1) from the Phase 0 response recorded through ToolUniverse 1.5.6 on 2026-10-12 (TNFRSF10B). Only the fields drugs_for_targets reads are required. A null target means the id is unknown to Open Targets: an unresolved input, not drift. `target` is object-or-null in one schema rather than a oneOf, so a drift error names the field that moved instead of 'not valid under any of the given schemas' (JSON Schema's required/properties only apply to objects).",
|
| 4 |
+
"type": "object",
|
| 5 |
+
"required": [
|
| 6 |
+
"target"
|
| 7 |
+
],
|
| 8 |
+
"properties": {
|
| 9 |
+
"target": {
|
| 10 |
+
"type": [
|
| 11 |
+
"object",
|
| 12 |
+
"null"
|
| 13 |
+
],
|
| 14 |
+
"required": [
|
| 15 |
+
"id",
|
| 16 |
+
"approvedSymbol",
|
| 17 |
+
"drugAndClinicalCandidates"
|
| 18 |
+
],
|
| 19 |
+
"properties": {
|
| 20 |
+
"id": {
|
| 21 |
+
"type": "string",
|
| 22 |
+
"pattern": "^ENSG\\d{11}$"
|
| 23 |
+
},
|
| 24 |
+
"approvedSymbol": {
|
| 25 |
+
"type": "string",
|
| 26 |
+
"minLength": 1
|
| 27 |
+
},
|
| 28 |
+
"drugAndClinicalCandidates": {
|
| 29 |
+
"type": "object",
|
| 30 |
+
"required": [
|
| 31 |
+
"count",
|
| 32 |
+
"rows"
|
| 33 |
+
],
|
| 34 |
+
"properties": {
|
| 35 |
+
"count": {
|
| 36 |
+
"type": "integer",
|
| 37 |
+
"minimum": 0
|
| 38 |
+
},
|
| 39 |
+
"rows": {
|
| 40 |
+
"type": "array",
|
| 41 |
+
"items": {
|
| 42 |
+
"type": "object",
|
| 43 |
+
"required": [
|
| 44 |
+
"drug",
|
| 45 |
+
"maxClinicalStage"
|
| 46 |
+
],
|
| 47 |
+
"properties": {
|
| 48 |
+
"drug": {
|
| 49 |
+
"type": "object",
|
| 50 |
+
"required": [
|
| 51 |
+
"id",
|
| 52 |
+
"name"
|
| 53 |
+
],
|
| 54 |
+
"properties": {
|
| 55 |
+
"id": {
|
| 56 |
+
"type": "string",
|
| 57 |
+
"pattern": "^CHEMBL\\d+$"
|
| 58 |
+
},
|
| 59 |
+
"name": {
|
| 60 |
+
"type": "string",
|
| 61 |
+
"minLength": 1
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
},
|
| 65 |
+
"maxClinicalStage": {
|
| 66 |
+
"type": [
|
| 67 |
+
"string",
|
| 68 |
+
"null"
|
| 69 |
+
]
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
}
|
src/schemas/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pinned output schemas — the check that stands between a source's response and a row.
|
| 2 |
+
|
| 3 |
+
ADR-0001 §5: each source's raw response is validated against a pinned schema BEFORE any row is
|
| 4 |
+
built, and a mismatch is ``upstream_schema_drift`` for that source. Rows are never guessed from a
|
| 5 |
+
partial match. Phase 0 is why: while the test ran, Open Targets renamed ``knownDrugs`` to
|
| 6 |
+
``drugAndClinicalCandidates`` and a hand-written client broke. A schema here pins the fields the
|
| 7 |
+
row builders READ, not ToolUniverse's whole declared ``return_schema``, so a harmless addition
|
| 8 |
+
upstream passes and a rename of something we rely on fails loudly.
|
| 9 |
+
|
| 10 |
+
One file per tool: ``<tool name>.schema.json`` (JSON Schema draft 2020-12), describing the tool's
|
| 11 |
+
``data`` after ToolUniverse's ``{"status": "success", "data": …}`` envelope is removed.
|
| 12 |
+
|
| 13 |
+
**No schema means no rows.** A loaded tool without a pinned schema is refused the same way as a
|
| 14 |
+
drifted one. That keeps the contract fail-closed while s2 is still recording fixtures and
|
| 15 |
+
pinning the remaining schemas.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import functools
|
| 21 |
+
import json
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Any
|
| 24 |
+
|
| 25 |
+
from jsonschema import Draft202012Validator
|
| 26 |
+
from jsonschema.exceptions import best_match
|
| 27 |
+
|
| 28 |
+
SCHEMA_DIR = Path(__file__).resolve().parent
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class SchemaDrift(ValueError):
|
| 32 |
+
"""The response does not match the pinned schema (or there is none to check against)."""
|
| 33 |
+
|
| 34 |
+
def __init__(self, tool: str, detail: str):
|
| 35 |
+
super().__init__(f"{tool}: {detail}")
|
| 36 |
+
self.tool = tool
|
| 37 |
+
self.detail = detail
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@functools.cache
|
| 41 |
+
def _validator(tool: str) -> Draft202012Validator | None:
|
| 42 |
+
path = SCHEMA_DIR / f"{tool}.schema.json"
|
| 43 |
+
if not path.is_file():
|
| 44 |
+
return None
|
| 45 |
+
schema = json.loads(path.read_text(encoding="utf-8"))
|
| 46 |
+
Draft202012Validator.check_schema(schema)
|
| 47 |
+
return Draft202012Validator(schema)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def pinned_tools() -> frozenset[str]:
|
| 51 |
+
"""Tools that have a pinned schema file."""
|
| 52 |
+
return frozenset(p.name.removesuffix(".schema.json") for p in SCHEMA_DIR.glob("*.schema.json"))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def validate(tool: str, data: Any) -> None:
|
| 56 |
+
"""Raise :class:`SchemaDrift` unless ``data`` matches the tool's pinned schema."""
|
| 57 |
+
validator = _validator(tool)
|
| 58 |
+
if validator is None:
|
| 59 |
+
raise SchemaDrift(tool, "no pinned schema for this tool, so no rows can be built from it")
|
| 60 |
+
error = best_match(validator.iter_errors(data))
|
| 61 |
+
if error is not None:
|
| 62 |
+
where = "/".join(str(p) for p in error.absolute_path) or "(root)"
|
| 63 |
+
raise SchemaDrift(
|
| 64 |
+
tool, f"response does not match the pinned schema at {where}: {error.message}"
|
| 65 |
+
)
|
src/tu_client.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The governed ToolUniverse client — the ONLY module that imports ``tooluniverse``.
|
| 2 |
+
|
| 3 |
+
ADR-0001 §7, enforced in code rather than by convention:
|
| 4 |
+
|
| 5 |
+
- **Allow-list by construction.** ToolUniverse is built with ``keep_default_tools=False`` and
|
| 6 |
+
``load_workspace=False``, then ``load_tools(include_tools=<the endpoints' set>)``, and startup
|
| 7 |
+
is REFUSED unless the loaded set equals that set exactly. With these arguments 1.5.6 loads the
|
| 8 |
+
named tools and nothing else: no special tools, no tool finder, no agentic tools, no remote MCP
|
| 9 |
+
servers (measured 2026-10-20, with outbound sockets blocked, so loading opens no connection).
|
| 10 |
+
- **No workspace.** ``load_workspace=False`` is not optional. With a workspace directory present
|
| 11 |
+
(``TOOLUNIVERSE_HOME``, or ``./.tooluniverse``), 1.5.6's constructor writes a default
|
| 12 |
+
``profile.yaml`` into it and applies it, which loads ALL 2,691 tools before ``load_tools`` runs;
|
| 13 |
+
it also reads that workspace's ``.env`` and ``~/.tooluniverse/.env`` into the environment, which
|
| 14 |
+
could bring a model key in after the check below. Found by this repo's own tests on 2026-10-20,
|
| 15 |
+
when an earlier draft pointed ``TOOLUNIVERSE_HOME`` at an empty temp dir.
|
| 16 |
+
- **No model provider keys.** ToolUniverse's agentic tools and tool finder can reach OpenAI, Azure
|
| 17 |
+
OpenAI, Gemini, OpenRouter or a vLLM server when keys are set. None of those tools can load
|
| 18 |
+
here, and startup still refuses if any key is present: the Space makes no model call
|
| 19 |
+
(ADR-0001 §2). ``HF_TOKEN`` is allowed — the machine gate needs it — and in 1.5.6 only
|
| 20 |
+
Hugging Face inference and embedding tools read it, none of which is on the allow-list.
|
| 21 |
+
- **Exactly one cache.** ToolUniverse's own result cache is disabled through its environment
|
| 22 |
+
switches and every call passes ``use_cache=False``; :mod:`src.cache` is the only cache, because
|
| 23 |
+
it is the one that keeps ``retrieved_at`` honest.
|
| 24 |
+
- **Pinned.** The installed version must equal ``config/tools_allowlist.yaml``'s
|
| 25 |
+
``tooluniverse_version``. A bump is its own lane (doctor, re-recorded fixtures, schema tests,
|
| 26 |
+
security scan) and never changes an endpoint's name or payload shape.
|
| 27 |
+
|
| 28 |
+
:class:`ToolClient` is what the endpoints call. It returns a :class:`SourceResult` or raises a
|
| 29 |
+
:class:`SourceError` whose ``reason`` is one of the three payload source-error reasons, so a
|
| 30 |
+
timeout, a limit or a drifted response is always structured, never a guess.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import ast
|
| 36 |
+
import concurrent.futures
|
| 37 |
+
import inspect
|
| 38 |
+
import os
|
| 39 |
+
import re
|
| 40 |
+
import threading
|
| 41 |
+
import time
|
| 42 |
+
from collections.abc import Callable, Mapping
|
| 43 |
+
from dataclasses import dataclass
|
| 44 |
+
from typing import Any
|
| 45 |
+
|
| 46 |
+
from src import config, schemas
|
| 47 |
+
from src.cache import ResponseCache
|
| 48 |
+
from src.ratelimit import RateLimiters
|
| 49 |
+
|
| 50 |
+
# Every variable through which ToolUniverse 1.5.6 (or a library it can load) reaches a model.
|
| 51 |
+
LLM_KEY_VARS = (
|
| 52 |
+
"OPENAI_API_KEY",
|
| 53 |
+
"OPENAI_BASE_URL",
|
| 54 |
+
"AZURE_OPENAI_API_KEY",
|
| 55 |
+
"AZURE_OPENAI_ENDPOINT",
|
| 56 |
+
"GEMINI_API_KEY",
|
| 57 |
+
"GOOGLE_API_KEY",
|
| 58 |
+
"OPENROUTER_API_KEY",
|
| 59 |
+
"ANTHROPIC_API_KEY",
|
| 60 |
+
"VLLM_SERVER_URL",
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# Tool types that must never load, whatever the allow-list says: LLM-backed agents, the tool
|
| 64 |
+
# finders (they can call a model or download an embedding index), remote MCP auto-loaders and
|
| 65 |
+
# web-search backends. A tool whose config names `required_api_keys` is key-gated and refused too.
|
| 66 |
+
FORBIDDEN_TOOL_TYPES = frozenset(
|
| 67 |
+
{
|
| 68 |
+
"AgenticTool",
|
| 69 |
+
"SmolAgentTool",
|
| 70 |
+
"MCPAutoLoaderTool",
|
| 71 |
+
"MCPClientTool",
|
| 72 |
+
"ToolFinderEmbedding",
|
| 73 |
+
"ToolFinderLLM",
|
| 74 |
+
"ToolFinderKeyword",
|
| 75 |
+
"ToolFinderJev",
|
| 76 |
+
"WebSearchTool",
|
| 77 |
+
"WebAPIDocumentationSearchTool",
|
| 78 |
+
}
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# ToolUniverse's own switches: its result cache off. The workspace and profile variables are
|
| 82 |
+
# REMOVED rather than set, because any workspace widens what loads (see the module docstring).
|
| 83 |
+
_TU_ENV = {
|
| 84 |
+
"TOOLUNIVERSE_CACHE_ENABLED": "false",
|
| 85 |
+
"TOOLUNIVERSE_CACHE_PERSIST": "false",
|
| 86 |
+
"TOOLUNIVERSE_QUIET": "1",
|
| 87 |
+
}
|
| 88 |
+
_TU_ENV_REMOVED = ("TOOLUNIVERSE_PROFILE", "TOOLUNIVERSE_HOME")
|
| 89 |
+
|
| 90 |
+
_HOST = re.compile(r"https?://([A-Za-z0-9.-]+)")
|
| 91 |
+
_build_lock = threading.Lock()
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class StartupRefused(RuntimeError):
|
| 95 |
+
"""The Space must not start: a key is set, the version drifted, or the tool set is wrong."""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class SourceError(Exception):
|
| 99 |
+
"""A structured failure from one source call (``reason`` ∈ payload SOURCE_ERROR_REASONS)."""
|
| 100 |
+
|
| 101 |
+
def __init__(self, source: str, reason: str, detail: str):
|
| 102 |
+
super().__init__(f"{source}: {reason}: {detail}")
|
| 103 |
+
self.source = source
|
| 104 |
+
self.reason = reason
|
| 105 |
+
self.detail = detail
|
| 106 |
+
|
| 107 |
+
def as_payload(self) -> dict[str, str]:
|
| 108 |
+
return {"source": self.source, "reason": self.reason, "detail": self.detail}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@dataclass(frozen=True)
|
| 112 |
+
class SourceResult:
|
| 113 |
+
tool: str
|
| 114 |
+
source: str
|
| 115 |
+
args: dict[str, Any]
|
| 116 |
+
data: Any
|
| 117 |
+
retrieved_at: float # epoch seconds when the SOURCE answered
|
| 118 |
+
cache: str # "hit" | "miss"
|
| 119 |
+
tooluniverse_version: str
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def assert_no_llm_keys(environ: Mapping[str, str] | None = None) -> None:
|
| 123 |
+
env = os.environ if environ is None else environ
|
| 124 |
+
present = [var for var in LLM_KEY_VARS if (env.get(var) or "").strip()]
|
| 125 |
+
if present:
|
| 126 |
+
raise StartupRefused(
|
| 127 |
+
f"Model-provider settings are present ({', '.join(present)}). This Space makes no "
|
| 128 |
+
"model call and must run without them (ADR-0001 §2); remove them from the Space's "
|
| 129 |
+
"secrets and variables."
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def configure_tooluniverse_env() -> None:
|
| 134 |
+
"""Set ToolUniverse's environment before an instance is built (idempotent)."""
|
| 135 |
+
for var, value in _TU_ENV.items():
|
| 136 |
+
os.environ[var] = value
|
| 137 |
+
for var in _TU_ENV_REMOVED:
|
| 138 |
+
os.environ.pop(var, None)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def installed_tooluniverse_version() -> str:
|
| 142 |
+
import tooluniverse
|
| 143 |
+
|
| 144 |
+
return str(getattr(tooluniverse, "__version__", ""))
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def build_tooluniverse(include: frozenset[str] | None = None):
|
| 148 |
+
"""A ToolUniverse holding exactly ``include`` (default: the endpoints' set), or refuse."""
|
| 149 |
+
include = config.endpoint_tool_names() if include is None else include
|
| 150 |
+
if not include <= config.allowed_tool_names():
|
| 151 |
+
raise StartupRefused(
|
| 152 |
+
f"not on the allow-list: {sorted(include - config.allowed_tool_names())}"
|
| 153 |
+
)
|
| 154 |
+
assert_no_llm_keys()
|
| 155 |
+
configure_tooluniverse_env()
|
| 156 |
+
version = installed_tooluniverse_version()
|
| 157 |
+
if version != config.pinned_tooluniverse_version():
|
| 158 |
+
raise StartupRefused(
|
| 159 |
+
f"ToolUniverse {version!r} is installed; config pins "
|
| 160 |
+
f"{config.pinned_tooluniverse_version()!r}. A version bump is its own reviewed lane."
|
| 161 |
+
)
|
| 162 |
+
from tooluniverse import ToolUniverse
|
| 163 |
+
|
| 164 |
+
tu = ToolUniverse(keep_default_tools=False, load_workspace=False)
|
| 165 |
+
if tu.all_tool_dict:
|
| 166 |
+
raise StartupRefused(
|
| 167 |
+
f"ToolUniverse loaded {len(tu.all_tool_dict)} tools before the allow-list was applied"
|
| 168 |
+
)
|
| 169 |
+
tu.load_tools(include_tools=sorted(include), quiet=True)
|
| 170 |
+
assert_no_llm_keys() # again: nothing during construction may have brought one in
|
| 171 |
+
loaded = frozenset(tu.all_tool_dict)
|
| 172 |
+
if loaded != include:
|
| 173 |
+
raise StartupRefused(
|
| 174 |
+
f"loaded tool set differs from the allow-listed set: extra {sorted(loaded - include)}, "
|
| 175 |
+
f"missing {sorted(include - loaded)}"
|
| 176 |
+
)
|
| 177 |
+
unsafe = sorted(
|
| 178 |
+
name
|
| 179 |
+
for name, cfg in tu.all_tool_dict.items()
|
| 180 |
+
if cfg.get("type") in FORBIDDEN_TOOL_TYPES or cfg.get("required_api_keys")
|
| 181 |
+
)
|
| 182 |
+
if unsafe:
|
| 183 |
+
raise StartupRefused(f"agentic, finder, remote-MCP, web-search or key-gated: {unsafe}")
|
| 184 |
+
cache_manager = getattr(tu, "cache_manager", None)
|
| 185 |
+
if cache_manager is not None and getattr(cache_manager, "enabled", False):
|
| 186 |
+
raise StartupRefused("ToolUniverse's own result cache is enabled; it must be off")
|
| 187 |
+
return tu
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def tool_hosts(tu, tool: str) -> set[str]:
|
| 191 |
+
"""Every host a loaded tool can reach: URLs in its config plus its class's code.
|
| 192 |
+
|
| 193 |
+
Read offline from the installed package: the tool's config dict, the source of its class and
|
| 194 |
+
ToolUniverse base classes, and any module-level URL constant those classes name (HGNC keeps
|
| 195 |
+
its base URL in one). So the egress statement in config/sources.yaml can be checked without a
|
| 196 |
+
network call (tests/test_sources_hosts.py).
|
| 197 |
+
"""
|
| 198 |
+
from tooluniverse.tool_registry import get_tool_class_lazy
|
| 199 |
+
|
| 200 |
+
cfg = tu.all_tool_dict[tool]
|
| 201 |
+
hosts = set(_HOST.findall(repr(cfg)))
|
| 202 |
+
# The lazy lookup imports the class's module (offline); the plain registry is empty until a
|
| 203 |
+
# tool instance has been created, which would make this check silently find nothing.
|
| 204 |
+
cls = get_tool_class_lazy(cfg["type"])
|
| 205 |
+
if cls is None:
|
| 206 |
+
raise StartupRefused(f"{tool}: tool type {cfg['type']!r} has no class to inspect")
|
| 207 |
+
for klass in inspect.getmro(cls):
|
| 208 |
+
if not klass.__module__.startswith("tooluniverse"):
|
| 209 |
+
continue
|
| 210 |
+
try:
|
| 211 |
+
class_source = inspect.getsource(klass)
|
| 212 |
+
module_source = inspect.getsource(inspect.getmodule(klass))
|
| 213 |
+
except (OSError, TypeError):
|
| 214 |
+
continue
|
| 215 |
+
hosts.update(_HOST.findall(class_source))
|
| 216 |
+
for name, value in _module_url_constants(module_source).items():
|
| 217 |
+
if re.search(rf"\b{re.escape(name)}\b", class_source):
|
| 218 |
+
hosts.update(_HOST.findall(value))
|
| 219 |
+
return hosts
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _module_url_constants(module_source: str) -> dict[str, str]:
|
| 223 |
+
"""Top-level ``NAME = "https://…"`` assignments in a module."""
|
| 224 |
+
found: dict[str, str] = {}
|
| 225 |
+
for node in ast.parse(module_source).body:
|
| 226 |
+
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant):
|
| 227 |
+
value = node.value.value
|
| 228 |
+
if isinstance(value, str) and _HOST.search(value):
|
| 229 |
+
for target in node.targets:
|
| 230 |
+
if isinstance(target, ast.Name):
|
| 231 |
+
found[target.id] = value
|
| 232 |
+
return found
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _unwrap(tool: str, source: str, raw: Any) -> Any:
|
| 236 |
+
"""ToolUniverse's envelope → the source's data, or a structured error."""
|
| 237 |
+
if raw is None:
|
| 238 |
+
raise SourceError(source, "source_unavailable", f"{tool} returned nothing")
|
| 239 |
+
if isinstance(raw, dict):
|
| 240 |
+
status = raw.get("status")
|
| 241 |
+
if status == "success" and "data" in raw:
|
| 242 |
+
return raw["data"]
|
| 243 |
+
if status == "error" or ("error" in raw and raw.get("error")):
|
| 244 |
+
detail = str(raw.get("error") or raw.get("message") or "error")[:200]
|
| 245 |
+
raise SourceError(source, "source_unavailable", f"{tool}: {detail}")
|
| 246 |
+
if isinstance(raw, str) and raw.lower().startswith("error"):
|
| 247 |
+
raise SourceError(source, "source_unavailable", f"{tool}: {raw[:200]}")
|
| 248 |
+
return raw
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
class ToolClient:
|
| 252 |
+
"""Cache → rate limit → call with a deadline → schema check → cache.
|
| 253 |
+
|
| 254 |
+
``runner(tool, args)`` performs the actual call. By default it is ToolUniverse's
|
| 255 |
+
``run_one_function`` on a lazily built, allow-listed instance; tests inject a fake runner
|
| 256 |
+
with synthetic or recorded responses, so the suite never reaches the network.
|
| 257 |
+
"""
|
| 258 |
+
|
| 259 |
+
def __init__(
|
| 260 |
+
self,
|
| 261 |
+
*,
|
| 262 |
+
runner: Callable[[str, dict[str, Any]], Any] | None = None,
|
| 263 |
+
cache: ResponseCache | None = None,
|
| 264 |
+
limiters: RateLimiters | None = None,
|
| 265 |
+
clock: Callable[[], float] = time.time,
|
| 266 |
+
tooluniverse_version: str | None = None,
|
| 267 |
+
):
|
| 268 |
+
self._runner = runner
|
| 269 |
+
self._tu = None
|
| 270 |
+
self.cache = cache if cache is not None else ResponseCache()
|
| 271 |
+
self.limiters = limiters if limiters is not None else RateLimiters()
|
| 272 |
+
self._clock = clock
|
| 273 |
+
self._version = tooluniverse_version
|
| 274 |
+
self._pool = concurrent.futures.ThreadPoolExecutor(
|
| 275 |
+
max_workers=8, thread_name_prefix="tu-call"
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
@property
|
| 279 |
+
def tooluniverse_version(self) -> str:
|
| 280 |
+
if self._version is None:
|
| 281 |
+
self._version = installed_tooluniverse_version()
|
| 282 |
+
return self._version
|
| 283 |
+
|
| 284 |
+
def _default_runner(self, tool: str, args: dict[str, Any]) -> Any:
|
| 285 |
+
with _build_lock:
|
| 286 |
+
if self._tu is None:
|
| 287 |
+
self._tu = build_tooluniverse()
|
| 288 |
+
return self._tu.run_one_function({"name": tool, "arguments": args}, use_cache=False)
|
| 289 |
+
|
| 290 |
+
def call(self, tool: str, args: dict[str, Any], *, max_wait: float = 10.0) -> SourceResult:
|
| 291 |
+
if tool not in config.endpoint_tool_names():
|
| 292 |
+
# A programming error, not a user input: endpoints only ever name loaded tools.
|
| 293 |
+
raise ValueError(f"{tool!r} is not an endpoint tool")
|
| 294 |
+
source = config.source_of(tool)
|
| 295 |
+
cfg = config.source(source)
|
| 296 |
+
|
| 297 |
+
hit = self.cache.get(source, tool, args)
|
| 298 |
+
if hit is not None:
|
| 299 |
+
data, retrieved_at = hit
|
| 300 |
+
return SourceResult(
|
| 301 |
+
tool, source, dict(args), data, retrieved_at, "hit", self.tooluniverse_version
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
if not self.limiters.acquire(source, max_wait):
|
| 305 |
+
raise SourceError(
|
| 306 |
+
source, "rate_limited", f"{cfg['name']} limit of {cfg['rate_per_second']}/s reached"
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
runner = self._runner or self._default_runner
|
| 310 |
+
future = self._pool.submit(runner, tool, dict(args))
|
| 311 |
+
try:
|
| 312 |
+
raw = future.result(timeout=cfg["timeout_seconds"])
|
| 313 |
+
except concurrent.futures.TimeoutError:
|
| 314 |
+
future.cancel()
|
| 315 |
+
raise SourceError(
|
| 316 |
+
source,
|
| 317 |
+
"source_unavailable",
|
| 318 |
+
f"{cfg['name']} did not answer within {cfg['timeout_seconds']} s",
|
| 319 |
+
) from None
|
| 320 |
+
except SourceError:
|
| 321 |
+
raise
|
| 322 |
+
except Exception as e:
|
| 323 |
+
raise SourceError(source, "source_unavailable", f"{tool}: {type(e).__name__}") from e
|
| 324 |
+
retrieved_at = self._clock()
|
| 325 |
+
|
| 326 |
+
data = _unwrap(tool, source, raw)
|
| 327 |
+
try:
|
| 328 |
+
schemas.validate(tool, data)
|
| 329 |
+
except schemas.SchemaDrift as drift:
|
| 330 |
+
raise SourceError(source, "upstream_schema_drift", drift.detail) from drift
|
| 331 |
+
|
| 332 |
+
self.cache.put(source, tool, args, data, retrieved_at)
|
| 333 |
+
return SourceResult(
|
| 334 |
+
tool, source, dict(args), data, retrieved_at, "miss", self.tooluniverse_version
|
| 335 |
+
)
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hermetic test setup — outbound network is BLOCKED before collection.
|
| 2 |
+
|
| 3 |
+
The block is enforced, not asserted (the genomics/spatial pattern): the socket patch is in place
|
| 4 |
+
at conftest import time, before any test module loads, so a test that would reach Open Targets,
|
| 5 |
+
UniProt or any other source fails loudly instead of quietly depending on the network. Responses
|
| 6 |
+
come from recorded Phase 0 fixtures (``tests/fixtures/``) or synthetic dicts, through
|
| 7 |
+
``ToolClient(runner=...)``.
|
| 8 |
+
|
| 9 |
+
Model-provider variables are removed for the session as well: this Space must start without them
|
| 10 |
+
(ADR-0001 §2), and a developer shell that happens to export ``ANTHROPIC_API_KEY`` must not turn
|
| 11 |
+
every startup test into a refusal. Tests that need one set it themselves.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import socket
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import pytest
|
| 22 |
+
|
| 23 |
+
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
| 24 |
+
os.environ.pop("ACCESS_CONTROL", None) # gate dark-launched off unless a test sets it
|
| 25 |
+
|
| 26 |
+
from src.tu_client import LLM_KEY_VARS
|
| 27 |
+
|
| 28 |
+
for _var in LLM_KEY_VARS:
|
| 29 |
+
os.environ.pop(_var, None)
|
| 30 |
+
|
| 31 |
+
_real_connect = socket.socket.connect
|
| 32 |
+
OUTBOUND_ATTEMPTS: list = []
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _blocked_connect(self, address):
|
| 36 |
+
host = address[0] if isinstance(address, tuple) else address
|
| 37 |
+
# AF_UNIX (a filesystem path) is local IPC.
|
| 38 |
+
if isinstance(address, (str, bytes)) or str(host) in ("127.0.0.1", "::1", "localhost"):
|
| 39 |
+
return _real_connect(self, address)
|
| 40 |
+
OUTBOUND_ATTEMPTS.append(address)
|
| 41 |
+
raise RuntimeError(f"Outbound network blocked in tests (attempted {address!r})")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
socket.socket.connect = _blocked_connect # type: ignore[method-assign]
|
| 45 |
+
|
| 46 |
+
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def load_fixture(relpath: str) -> dict:
|
| 50 |
+
return json.loads((FIXTURES / relpath).read_text(encoding="utf-8"))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class FakeClock:
|
| 54 |
+
"""A clock that only moves when told to; ``sleep`` advances it."""
|
| 55 |
+
|
| 56 |
+
def __init__(self, start: float = 1_760_000_000.0):
|
| 57 |
+
self.now = start
|
| 58 |
+
self.sleeps: list[float] = []
|
| 59 |
+
|
| 60 |
+
def __call__(self) -> float:
|
| 61 |
+
return self.now
|
| 62 |
+
|
| 63 |
+
def sleep(self, seconds: float) -> None:
|
| 64 |
+
self.sleeps.append(seconds)
|
| 65 |
+
self.now += seconds
|
| 66 |
+
|
| 67 |
+
def advance(self, seconds: float) -> None:
|
| 68 |
+
self.now += seconds
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class FakeRunner:
|
| 72 |
+
"""Stands in for ToolUniverse's ``run_one_function``: canned responses, every call recorded."""
|
| 73 |
+
|
| 74 |
+
def __init__(self, responses: dict[str, object] | None = None):
|
| 75 |
+
self.responses = dict(responses or {})
|
| 76 |
+
self.calls: list[tuple[str, dict]] = []
|
| 77 |
+
|
| 78 |
+
def __call__(self, tool: str, args: dict):
|
| 79 |
+
self.calls.append((tool, dict(args)))
|
| 80 |
+
response = self.responses[tool]
|
| 81 |
+
if isinstance(response, BaseException):
|
| 82 |
+
raise response
|
| 83 |
+
if callable(response):
|
| 84 |
+
return response(args)
|
| 85 |
+
return response
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@pytest.fixture()
|
| 89 |
+
def clock() -> FakeClock:
|
| 90 |
+
return FakeClock()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@pytest.fixture()
|
| 94 |
+
def memory_cache(clock):
|
| 95 |
+
from src.cache import ResponseCache
|
| 96 |
+
|
| 97 |
+
return ResponseCache(":memory:", clock=clock)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@pytest.fixture()
|
| 101 |
+
def tnfrsf10b_response() -> dict:
|
| 102 |
+
"""The real Open Targets response for DR5, recorded through ToolUniverse 1.5.6 in Phase 0."""
|
| 103 |
+
return load_fixture("phase0/opentargets_drugs_TNFRSF10B.json")["response"]
|
tests/fixtures/phase0/opentargets_drugs_TNFRSF10B.json
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_recorded": {
|
| 3 |
+
"tool": "OpenTargets_get_associated_drugs_by_target_ensemblID",
|
| 4 |
+
"arguments": {
|
| 5 |
+
"ensemblId": "ENSG00000120889",
|
| 6 |
+
"size": 100
|
| 7 |
+
},
|
| 8 |
+
"tooluniverse_version": "1.5.6",
|
| 9 |
+
"recorded_on": "2026-10-12",
|
| 10 |
+
"origin": "Phase 0 evaluation run (run2_results.json, Q7_tu); each row's diseases list trimmed to two entries",
|
| 11 |
+
"licence": "Open Targets Platform, CC0 1.0"
|
| 12 |
+
},
|
| 13 |
+
"response": {
|
| 14 |
+
"status": "success",
|
| 15 |
+
"data": {
|
| 16 |
+
"target": {
|
| 17 |
+
"id": "ENSG00000120889",
|
| 18 |
+
"approvedSymbol": "TNFRSF10B",
|
| 19 |
+
"drugAndClinicalCandidates": {
|
| 20 |
+
"count": 7,
|
| 21 |
+
"rows": [
|
| 22 |
+
{
|
| 23 |
+
"drug": {
|
| 24 |
+
"id": "CHEMBL2109247",
|
| 25 |
+
"name": "LBY-135",
|
| 26 |
+
"maximumClinicalStage": "PHASE_1"
|
| 27 |
+
},
|
| 28 |
+
"maxClinicalStage": "PHASE_1",
|
| 29 |
+
"diseases": []
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"drug": {
|
| 33 |
+
"id": "CHEMBL2107846",
|
| 34 |
+
"name": "DULANERMIN",
|
| 35 |
+
"maximumClinicalStage": "PHASE_2"
|
| 36 |
+
},
|
| 37 |
+
"maxClinicalStage": "PHASE_2",
|
| 38 |
+
"diseases": [
|
| 39 |
+
{
|
| 40 |
+
"disease": {
|
| 41 |
+
"id": "MONDO_0056806",
|
| 42 |
+
"name": "non-small cell squamous lung carcinoma"
|
| 43 |
+
}
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"disease": {
|
| 47 |
+
"id": "MONDO_0005233",
|
| 48 |
+
"name": "non-small cell lung carcinoma"
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
]
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"drug": {
|
| 55 |
+
"id": "CHEMBL1743008",
|
| 56 |
+
"name": "DROZITUMAB",
|
| 57 |
+
"maximumClinicalStage": "PHASE_2"
|
| 58 |
+
},
|
| 59 |
+
"maxClinicalStage": "PHASE_2",
|
| 60 |
+
"diseases": [
|
| 61 |
+
{
|
| 62 |
+
"disease": {
|
| 63 |
+
"id": "MONDO_0005233",
|
| 64 |
+
"name": "non-small cell lung carcinoma"
|
| 65 |
+
}
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"disease": {
|
| 69 |
+
"id": "MONDO_0018906",
|
| 70 |
+
"name": "follicular lymphoma"
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
]
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"drug": {
|
| 77 |
+
"id": "CHEMBL1743080",
|
| 78 |
+
"name": "TIGATUZUMAB",
|
| 79 |
+
"maximumClinicalStage": "PHASE_2"
|
| 80 |
+
},
|
| 81 |
+
"maxClinicalStage": "PHASE_2",
|
| 82 |
+
"diseases": [
|
| 83 |
+
{
|
| 84 |
+
"disease": {
|
| 85 |
+
"id": "MONDO_0007254",
|
| 86 |
+
"name": "breast cancer"
|
| 87 |
+
}
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"disease": {
|
| 91 |
+
"id": "MONDO_0005575",
|
| 92 |
+
"name": "colorectal cancer"
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
]
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"drug": {
|
| 99 |
+
"id": "CHEMBL2109248",
|
| 100 |
+
"name": "APOMAB",
|
| 101 |
+
"maximumClinicalStage": "PHASE_1"
|
| 102 |
+
},
|
| 103 |
+
"maxClinicalStage": "PHASE_1",
|
| 104 |
+
"diseases": [
|
| 105 |
+
{
|
| 106 |
+
"disease": {
|
| 107 |
+
"id": "MONDO_0005575",
|
| 108 |
+
"name": "colorectal cancer"
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
]
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"drug": {
|
| 115 |
+
"id": "CHEMBL1743036",
|
| 116 |
+
"name": "LEXATUMUMAB",
|
| 117 |
+
"maximumClinicalStage": "PHASE_2"
|
| 118 |
+
},
|
| 119 |
+
"maxClinicalStage": "PHASE_2",
|
| 120 |
+
"diseases": [
|
| 121 |
+
{
|
| 122 |
+
"disease": {
|
| 123 |
+
"id": "MONDO_0005072",
|
| 124 |
+
"name": "neuroblastoma"
|
| 125 |
+
}
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"disease": {
|
| 129 |
+
"id": "MONDO_0012817",
|
| 130 |
+
"name": "Ewing sarcoma"
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
]
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"drug": {
|
| 137 |
+
"id": "CHEMBL1743003",
|
| 138 |
+
"name": "CONATUMUMAB",
|
| 139 |
+
"maximumClinicalStage": "PHASE_2"
|
| 140 |
+
},
|
| 141 |
+
"maxClinicalStage": "PHASE_2",
|
| 142 |
+
"diseases": [
|
| 143 |
+
{
|
| 144 |
+
"disease": {
|
| 145 |
+
"id": "MONDO_0005575",
|
| 146 |
+
"name": "colorectal cancer"
|
| 147 |
+
}
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"disease": {
|
| 151 |
+
"id": "HP_0012531",
|
| 152 |
+
"name": "Pain"
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
]
|
| 156 |
+
}
|
| 157 |
+
]
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
}
|
tests/test_allowlist.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""C2 — the allow-list holds by construction (ADR-0001 §7).
|
| 2 |
+
|
| 3 |
+
What these tests pin, against the INSTALLED ToolUniverse (offline: loading reads the package's
|
| 4 |
+
own JSON configs and opens no socket, which the conftest block would turn into a failure):
|
| 5 |
+
|
| 6 |
+
- the config is the memo's 28 tools across 8 sources, and every name exists in 1.5.6;
|
| 7 |
+
- the Space loads exactly the five endpoints' set (12 tools), nothing more;
|
| 8 |
+
- no agentic, tool-finder, remote-MCP, web-search or key-gated tool is or can be loaded;
|
| 9 |
+
- the installed version is the pinned one, and a mismatch refuses startup;
|
| 10 |
+
- ToolUniverse's own result cache is off.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from collections import Counter
|
| 16 |
+
|
| 17 |
+
import pytest
|
| 18 |
+
|
| 19 |
+
from src import config, tu_client
|
| 20 |
+
from src.tu_client import FORBIDDEN_TOOL_TYPES, StartupRefused, build_tooluniverse
|
| 21 |
+
from tests.conftest import OUTBOUND_ATTEMPTS
|
| 22 |
+
|
| 23 |
+
MEMO_COUNTS = {
|
| 24 |
+
"opentargets": 6,
|
| 25 |
+
"chembl": 4,
|
| 26 |
+
"uniprot": 4,
|
| 27 |
+
"clinicaltrials": 3,
|
| 28 |
+
"pubchem": 3,
|
| 29 |
+
"reactome": 3,
|
| 30 |
+
"dgidb": 3,
|
| 31 |
+
"hgnc": 2,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@pytest.fixture(scope="module")
|
| 36 |
+
def loaded_tu():
|
| 37 |
+
return build_tooluniverse()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_allowlist_is_the_memos_28_across_8_sources():
|
| 41 |
+
counts = Counter(config.source_of(name) for name in config.allowed_tool_names())
|
| 42 |
+
assert dict(counts) == MEMO_COUNTS
|
| 43 |
+
assert len(config.allowed_tool_names()) == 28
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_every_allowlisted_name_exists_in_the_installed_version():
|
| 47 |
+
tu = build_tooluniverse(include=config.allowed_tool_names())
|
| 48 |
+
assert frozenset(tu.all_tool_dict) == config.allowed_tool_names()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_the_space_loads_exactly_the_endpoints_set(loaded_tu):
|
| 52 |
+
loaded = frozenset(loaded_tu.all_tool_dict)
|
| 53 |
+
assert loaded == config.endpoint_tool_names()
|
| 54 |
+
assert len(loaded) == 12
|
| 55 |
+
assert loaded <= config.allowed_tool_names()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_loading_opens_no_socket():
|
| 59 |
+
before = len(OUTBOUND_ATTEMPTS)
|
| 60 |
+
build_tooluniverse()
|
| 61 |
+
assert OUTBOUND_ATTEMPTS[before:] == []
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_no_loaded_tool_is_agentic_finder_mcp_websearch_or_key_gated(loaded_tu):
|
| 65 |
+
for name, cfg in loaded_tu.all_tool_dict.items():
|
| 66 |
+
assert cfg.get("type") not in FORBIDDEN_TOOL_TYPES, name
|
| 67 |
+
assert not cfg.get("required_api_keys"), name
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_no_allowlisted_tool_is_of_a_forbidden_kind():
|
| 71 |
+
tu = build_tooluniverse(include=config.allowed_tool_names())
|
| 72 |
+
for name, cfg in tu.all_tool_dict.items():
|
| 73 |
+
assert cfg.get("type") not in FORBIDDEN_TOOL_TYPES, name
|
| 74 |
+
assert not cfg.get("required_api_keys"), name
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@pytest.mark.parametrize("name", ["Tool_Finder_LLM", "Tool_RAG", "web_search", "Finish"])
|
| 78 |
+
def test_a_tool_off_the_allowlist_cannot_be_requested(name):
|
| 79 |
+
with pytest.raises(StartupRefused, match="not on the allow-list"):
|
| 80 |
+
build_tooluniverse(include=frozenset({name}))
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_a_forbidden_type_refuses_startup_even_if_it_loaded(monkeypatch):
|
| 84 |
+
"""Defence in depth: if an allow-list edit ever let one in, the Space still will not start."""
|
| 85 |
+
from tooluniverse import ToolUniverse
|
| 86 |
+
|
| 87 |
+
real_load = ToolUniverse.load_tools
|
| 88 |
+
|
| 89 |
+
def load_with_an_agent(self, *args, **kwargs):
|
| 90 |
+
real_load(self, *args, **kwargs)
|
| 91 |
+
name = next(iter(self.all_tool_dict))
|
| 92 |
+
self.all_tool_dict[name] = {**self.all_tool_dict[name], "type": "AgenticTool"}
|
| 93 |
+
|
| 94 |
+
monkeypatch.setattr(ToolUniverse, "load_tools", load_with_an_agent)
|
| 95 |
+
with pytest.raises(StartupRefused, match="agentic"):
|
| 96 |
+
build_tooluniverse()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_an_extra_loaded_tool_refuses_startup(monkeypatch):
|
| 100 |
+
from tooluniverse import ToolUniverse
|
| 101 |
+
|
| 102 |
+
real_load = ToolUniverse.load_tools
|
| 103 |
+
|
| 104 |
+
def load_one_more(self, *args, include_tools=None, **kwargs):
|
| 105 |
+
real_load(
|
| 106 |
+
self,
|
| 107 |
+
*args,
|
| 108 |
+
include_tools=[*include_tools, "PubChem_get_CID_by_compound_name"],
|
| 109 |
+
**kwargs,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
monkeypatch.setattr(ToolUniverse, "load_tools", load_one_more)
|
| 113 |
+
with pytest.raises(StartupRefused, match="differs from the allow-listed set"):
|
| 114 |
+
build_tooluniverse()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_a_workspace_cannot_widen_what_loads_or_bring_in_a_key(monkeypatch, tmp_path):
|
| 118 |
+
"""The 2026-10-20 trap: with any workspace present, 1.5.6's constructor seeds and applies a
|
| 119 |
+
default profile (all 2,691 tools) and reads the workspace's and ~/.tooluniverse's .env."""
|
| 120 |
+
home = tmp_path / "home"
|
| 121 |
+
home.mkdir()
|
| 122 |
+
local = tmp_path / "cwd" / ".tooluniverse"
|
| 123 |
+
local.mkdir(parents=True)
|
| 124 |
+
(local / ".env").write_text("OPENAI_API_KEY=planted-in-a-workspace\n")
|
| 125 |
+
(home / ".env").write_text("GEMINI_API_KEY=planted-in-home\n")
|
| 126 |
+
monkeypatch.setenv("TOOLUNIVERSE_HOME", str(home))
|
| 127 |
+
monkeypatch.setenv("TOOLUNIVERSE_PROFILE", str(home / "profile.yaml"))
|
| 128 |
+
monkeypatch.chdir(tmp_path / "cwd")
|
| 129 |
+
tu = build_tooluniverse()
|
| 130 |
+
assert frozenset(tu.all_tool_dict) == config.endpoint_tool_names()
|
| 131 |
+
import os
|
| 132 |
+
|
| 133 |
+
assert "OPENAI_API_KEY" not in os.environ and "GEMINI_API_KEY" not in os.environ
|
| 134 |
+
assert not (home / "profile.yaml").exists()
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_installed_version_is_the_pinned_one():
|
| 138 |
+
assert config.pinned_tooluniverse_version() == "1.5.6"
|
| 139 |
+
assert tu_client.installed_tooluniverse_version() == "1.5.6"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_a_version_mismatch_refuses_startup(monkeypatch):
|
| 143 |
+
monkeypatch.setattr(tu_client, "installed_tooluniverse_version", lambda: "1.5.7")
|
| 144 |
+
with pytest.raises(StartupRefused, match="1.5.7"):
|
| 145 |
+
build_tooluniverse()
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_tooluniverse_result_cache_is_off(loaded_tu):
|
| 149 |
+
assert loaded_tu.cache_manager.enabled is False
|
| 150 |
+
assert loaded_tu.cache_manager.persistent is None
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_only_tu_client_imports_tooluniverse():
|
| 154 |
+
from pathlib import Path
|
| 155 |
+
|
| 156 |
+
root = Path(__file__).resolve().parent.parent
|
| 157 |
+
offenders = [
|
| 158 |
+
str(p.relative_to(root))
|
| 159 |
+
for p in [*root.glob("src/**/*.py"), *root.glob("*.py")]
|
| 160 |
+
if p.name != "tu_client.py"
|
| 161 |
+
and any(
|
| 162 |
+
line.lstrip().startswith(("import tooluniverse", "from tooluniverse"))
|
| 163 |
+
for line in p.read_text(encoding="utf-8").splitlines()
|
| 164 |
+
)
|
| 165 |
+
]
|
| 166 |
+
assert offenders == []
|
tests/test_audit.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The audit record is result-free and query-free: who, which endpoint, how many ids, status."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
|
| 7 |
+
import audit
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_local_record_holds_a_count_never_the_identifiers(monkeypatch, tmp_path):
|
| 11 |
+
monkeypatch.delenv("LOG_SINK", raising=False)
|
| 12 |
+
monkeypatch.setenv("LOG_SINK_LOCAL_DIR", str(tmp_path))
|
| 13 |
+
assert (
|
| 14 |
+
audit.record_run(
|
| 15 |
+
"drugs_for_targets",
|
| 16 |
+
username="someone",
|
| 17 |
+
n_identifiers=3,
|
| 18 |
+
status="ok",
|
| 19 |
+
run_id="20261020_120000_000001",
|
| 20 |
+
)
|
| 21 |
+
is None
|
| 22 |
+
)
|
| 23 |
+
[path] = tmp_path.glob("*.json")
|
| 24 |
+
record = json.loads(path.read_text())
|
| 25 |
+
assert record["tool"] == "drugs_for_targets"
|
| 26 |
+
assert record["n_identifiers"] == 3
|
| 27 |
+
assert record["principal"] == "someone"
|
| 28 |
+
assert set(record) == {
|
| 29 |
+
"run_id",
|
| 30 |
+
"timestamp",
|
| 31 |
+
"tool",
|
| 32 |
+
"status",
|
| 33 |
+
"n_identifiers",
|
| 34 |
+
"principal",
|
| 35 |
+
"role",
|
| 36 |
+
"enforced",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_a_failing_sink_never_fails_the_call(monkeypatch):
|
| 41 |
+
import logging_sink
|
| 42 |
+
|
| 43 |
+
class Broken(logging_sink.LogSink):
|
| 44 |
+
name = "local"
|
| 45 |
+
|
| 46 |
+
def persist_trace(self, run_id, trace):
|
| 47 |
+
raise OSError("disk full")
|
| 48 |
+
|
| 49 |
+
monkeypatch.setattr(audit, "get_log_sink", lambda: Broken())
|
| 50 |
+
assert (
|
| 51 |
+
audit.record_run("annotate_genes", username=None, n_identifiers=0, status="denied") is None
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_the_hf_sink_defaults_to_this_spaces_own_dataset():
|
| 56 |
+
import logging_sink
|
| 57 |
+
|
| 58 |
+
assert logging_sink.DEFAULT_HF_DATASET == "anne-voigt/pdac-knowledge-results"
|
tests/test_cache.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""C5 — the cache keeps retrieved_at honest and expires per source (ADR-0001 §8)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from src import config
|
| 8 |
+
from src.cache import cache_key
|
| 9 |
+
from src.tu_client import SourceError, ToolClient
|
| 10 |
+
from tests.conftest import FakeRunner
|
| 11 |
+
|
| 12 |
+
OT_DRUGS = "OpenTargets_get_associated_drugs_by_target_ensemblID"
|
| 13 |
+
ARGS = {"ensemblId": "ENSG00000120889", "size": 100}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _client(runner, memory_cache, clock):
|
| 17 |
+
return ToolClient(runner=runner, cache=memory_cache, clock=clock, tooluniverse_version="1.5.6")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_a_repeat_call_is_a_hit_with_the_original_retrieved_at(
|
| 21 |
+
memory_cache, clock, tnfrsf10b_response
|
| 22 |
+
):
|
| 23 |
+
runner = FakeRunner({OT_DRUGS: tnfrsf10b_response})
|
| 24 |
+
client = _client(runner, memory_cache, clock)
|
| 25 |
+
first = client.call(OT_DRUGS, ARGS)
|
| 26 |
+
clock.advance(3600)
|
| 27 |
+
second = client.call(OT_DRUGS, ARGS)
|
| 28 |
+
assert (first.cache, second.cache) == ("miss", "hit")
|
| 29 |
+
assert second.retrieved_at == first.retrieved_at
|
| 30 |
+
assert second.data == first.data
|
| 31 |
+
assert len(runner.calls) == 1
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_an_entry_expires_at_the_sources_ttl(memory_cache, clock, tnfrsf10b_response):
|
| 35 |
+
runner = FakeRunner({OT_DRUGS: tnfrsf10b_response})
|
| 36 |
+
client = _client(runner, memory_cache, clock)
|
| 37 |
+
client.call(OT_DRUGS, ARGS)
|
| 38 |
+
clock.advance(config.source("opentargets")["ttl_seconds"] - 1)
|
| 39 |
+
assert client.call(OT_DRUGS, ARGS).cache == "hit"
|
| 40 |
+
clock.advance(2)
|
| 41 |
+
again = client.call(OT_DRUGS, ARGS)
|
| 42 |
+
assert again.cache == "miss"
|
| 43 |
+
assert again.retrieved_at == clock.now
|
| 44 |
+
assert len(runner.calls) == 2
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_ttls_are_seven_days_and_one_day_for_trials():
|
| 48 |
+
assert config.source("clinicaltrials")["ttl_seconds"] == 86_400
|
| 49 |
+
for sid in ("opentargets", "chembl", "uniprot", "reactome", "hgnc"):
|
| 50 |
+
assert config.source(sid)["ttl_seconds"] == 7 * 86_400
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_store_and_read_directly(memory_cache, clock):
|
| 54 |
+
memory_cache.put("chembl", "ChEMBL_get_drug_mechanisms", {"x": 1}, {"ok": True}, clock.now - 10)
|
| 55 |
+
data, retrieved_at = memory_cache.get("chembl", "ChEMBL_get_drug_mechanisms", {"x": 1})
|
| 56 |
+
assert data == {"ok": True}
|
| 57 |
+
assert retrieved_at == clock.now - 10
|
| 58 |
+
assert memory_cache.get("chembl", "ChEMBL_get_drug_mechanisms", {"x": 2}) is None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_the_key_ignores_argument_order():
|
| 62 |
+
assert cache_key("T", {"a": 1, "b": 2}) == cache_key("T", {"b": 2, "a": 1})
|
| 63 |
+
assert cache_key("T", {"a": 1}) != cache_key("U", {"a": 1})
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_a_drifted_response_is_never_cached(memory_cache, clock):
|
| 67 |
+
drifted = {"status": "success", "data": {"target": {"id": "ENSG00000120889"}}}
|
| 68 |
+
runner = FakeRunner({OT_DRUGS: drifted})
|
| 69 |
+
client = _client(runner, memory_cache, clock)
|
| 70 |
+
for _ in range(2):
|
| 71 |
+
with pytest.raises(SourceError, match="upstream_schema_drift"):
|
| 72 |
+
client.call(OT_DRUGS, ARGS)
|
| 73 |
+
assert memory_cache.get("opentargets", OT_DRUGS, ARGS) is None
|
| 74 |
+
assert len(runner.calls) == 2
|
tests/test_identifiers.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""What may leave the Space: identifiers only (ADR-0001 §6). Refusals are data."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from src.identifiers import MAX_IDENTIFIERS, parse_disease, parse_gene_list, parse_nct
|
| 8 |
+
|
| 9 |
+
# Real HGNC symbols chosen to exercise the edges of the pattern and of the sample-id prefixes.
|
| 10 |
+
VALID_SYMBOLS = [
|
| 11 |
+
"KRAS",
|
| 12 |
+
"TP53",
|
| 13 |
+
"ELAVL1",
|
| 14 |
+
"C1orf112",
|
| 15 |
+
"HLA-DRB1",
|
| 16 |
+
"NKX2-1",
|
| 17 |
+
"IGHV1-69-2",
|
| 18 |
+
"CPT1A",
|
| 19 |
+
"ERRFI1",
|
| 20 |
+
"DOK1",
|
| 21 |
+
"SAA1",
|
| 22 |
+
"SORD2P",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.mark.parametrize("symbol", VALID_SYMBOLS)
|
| 27 |
+
def test_real_symbols_pass(symbol):
|
| 28 |
+
parsed = parse_gene_list(symbol)
|
| 29 |
+
assert parsed.ok, parsed.message
|
| 30 |
+
assert parsed.identifiers[0].kind == "symbol"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_a_list_splits_on_commas_spaces_and_semicolons_and_keeps_order():
|
| 34 |
+
parsed = parse_gene_list("JAK1, JAK2;STAT1 jak1")
|
| 35 |
+
assert parsed.ok
|
| 36 |
+
assert [i.value for i in parsed.identifiers] == ["JAK1", "JAK2", "STAT1"]
|
| 37 |
+
assert parsed.duplicates_dropped == 1
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_ensembl_ids_are_genes_and_uniprot_only_when_allowed():
|
| 41 |
+
assert parse_gene_list("ENSG00000133703").identifiers[0].kind == "ensembl"
|
| 42 |
+
assert parse_gene_list("P05161", allow_uniprot=True).identifiers[0].kind == "uniprot"
|
| 43 |
+
assert parse_gene_list("P05161").identifiers[0].kind == "symbol"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@pytest.mark.parametrize(
|
| 47 |
+
"raw",
|
| 48 |
+
[
|
| 49 |
+
"what drugs target KRAS?",
|
| 50 |
+
"TCGA-2J-AAB1-01A",
|
| 51 |
+
"GSM1234567",
|
| 52 |
+
"C3L-00017",
|
| 53 |
+
"12.5",
|
| 54 |
+
"KRAS 3.2 1.1",
|
| 55 |
+
"<script>",
|
| 56 |
+
"SELECT * FROM genes",
|
| 57 |
+
],
|
| 58 |
+
)
|
| 59 |
+
def test_free_text_sample_ids_and_numbers_are_refused(raw):
|
| 60 |
+
parsed = parse_gene_list(raw)
|
| 61 |
+
assert not parsed.ok
|
| 62 |
+
assert parsed.reason == "invalid_identifier"
|
| 63 |
+
assert "free text, sample ids and numbers are not" in parsed.message
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_an_empty_list_is_refused():
|
| 67 |
+
assert parse_gene_list(" ").reason == "invalid_identifier"
|
| 68 |
+
assert parse_gene_list([]).reason == "invalid_identifier"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_more_than_the_cap_is_refused_with_the_cap():
|
| 72 |
+
raw = ",".join(f"GENE{i}" for i in range(MAX_IDENTIFIERS + 1))
|
| 73 |
+
parsed = parse_gene_list(raw)
|
| 74 |
+
assert parsed.reason == "too_many_identifiers"
|
| 75 |
+
assert str(MAX_IDENTIFIERS) in parsed.message
|
| 76 |
+
assert parse_gene_list(",".join(f"GENE{i}" for i in range(MAX_IDENTIFIERS))).ok
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_a_refusal_never_echoes_a_long_token():
|
| 80 |
+
parsed = parse_gene_list("x" * 200 + "!")
|
| 81 |
+
assert len(parsed.message) < 250
|
| 82 |
+
assert "x" * 30 not in parsed.message
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@pytest.mark.parametrize(
|
| 86 |
+
"raw,kind",
|
| 87 |
+
[
|
| 88 |
+
("EFO_0002618", "disease_id"),
|
| 89 |
+
("MONDO_0005192", "disease_id"),
|
| 90 |
+
("pancreatic ductal adenocarcinoma", "disease_name"),
|
| 91 |
+
("Crohn's disease", "disease_name"),
|
| 92 |
+
("type 2 diabetes mellitus", "disease_name"),
|
| 93 |
+
],
|
| 94 |
+
)
|
| 95 |
+
def test_diseases(raw, kind):
|
| 96 |
+
parsed = parse_disease(raw)
|
| 97 |
+
assert parsed.ok and parsed.identifiers[0].kind == kind
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@pytest.mark.parametrize(
|
| 101 |
+
"raw", ["", "a" * 81, "patient 12; DOB 1950-01-01?", "1234", "TCGA-2J-AAB1"]
|
| 102 |
+
)
|
| 103 |
+
def test_bad_diseases_are_refused(raw):
|
| 104 |
+
assert parse_disease(raw).reason == "invalid_identifier"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_nct_ids():
|
| 108 |
+
assert parse_nct("nct04635059").identifiers[0].value == "NCT04635059"
|
| 109 |
+
assert parse_nct("NCT123").reason == "invalid_identifier"
|
tests/test_no_llm_keys.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""C2 — the Space makes no model call, so it refuses to start with any model-provider setting."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from src.tu_client import LLM_KEY_VARS, StartupRefused, assert_no_llm_keys, build_tooluniverse
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_a_clean_environment_passes():
|
| 11 |
+
assert_no_llm_keys({})
|
| 12 |
+
assert_no_llm_keys({"HF_TOKEN": "hf_test", "ACCESS_CONTROL": "enforce"})
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@pytest.mark.parametrize("var", LLM_KEY_VARS)
|
| 16 |
+
def test_each_model_provider_setting_refuses(var):
|
| 17 |
+
with pytest.raises(StartupRefused, match=var):
|
| 18 |
+
assert_no_llm_keys({var: "set-in-a-test"})
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_blank_values_do_not_count():
|
| 22 |
+
assert_no_llm_keys({"OPENAI_API_KEY": "", "ANTHROPIC_API_KEY": " "})
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@pytest.mark.parametrize("var", ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY"])
|
| 26 |
+
def test_startup_refuses_before_tooluniverse_is_built(monkeypatch, var):
|
| 27 |
+
monkeypatch.setenv(var, "set-in-a-test")
|
| 28 |
+
with pytest.raises(StartupRefused, match=var):
|
| 29 |
+
build_tooluniverse()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_the_message_names_the_variable_never_its_value():
|
| 33 |
+
with pytest.raises(StartupRefused) as excinfo:
|
| 34 |
+
assert_no_llm_keys({"OPENROUTER_API_KEY": "sk-should-not-appear"})
|
| 35 |
+
assert "sk-should-not-appear" not in str(excinfo.value)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# Client libraries through which a model could be called. The memo found that, as installed,
|
| 39 |
+
# ToolUniverse's LLM-backed tools cannot load because none of these is present; the lock keeps
|
| 40 |
+
# it that way, so adding one is a reviewed change to this test rather than a silent transitive.
|
| 41 |
+
LLM_CLIENT_LIBRARIES = {
|
| 42 |
+
"openai",
|
| 43 |
+
"anthropic",
|
| 44 |
+
"google-genai",
|
| 45 |
+
"google-generativeai",
|
| 46 |
+
"smolagents",
|
| 47 |
+
"litellm",
|
| 48 |
+
"langchain",
|
| 49 |
+
"langchain-core",
|
| 50 |
+
"vllm",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_the_lock_installs_no_llm_client_library():
|
| 55 |
+
import re
|
| 56 |
+
from pathlib import Path
|
| 57 |
+
|
| 58 |
+
lock = (Path(__file__).resolve().parent.parent / "requirements.txt").read_text()
|
| 59 |
+
pinned = {m.group(1).lower() for m in re.finditer(r"^([A-Za-z0-9_.-]+)==", lock, re.MULTILINE)}
|
| 60 |
+
assert pinned, "requirements.txt holds no pins"
|
| 61 |
+
assert not pinned & LLM_CLIENT_LIBRARIES
|
tests/test_output_schemas.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""C4 (core) — a response that drifts from its pinned schema builds no rows (ADR-0001 §5).
|
| 2 |
+
|
| 3 |
+
The drift case is the one Phase 0 actually saw: Open Targets replaced ``knownDrugs`` (rows of
|
| 4 |
+
``drug{name maximumClinicalTrialPhase} phase``) with ``drugAndClinicalCandidates`` while the test
|
| 5 |
+
ran, and a hand-written client broke. Here the old shape and a future rename both come back as
|
| 6 |
+
``upstream_schema_drift``, never as partial rows.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import copy
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
from src import config, schemas
|
| 16 |
+
from src.tu_client import SourceError, ToolClient
|
| 17 |
+
from tests.conftest import FakeRunner
|
| 18 |
+
|
| 19 |
+
OT_DRUGS = "OpenTargets_get_associated_drugs_by_target_ensemblID"
|
| 20 |
+
ARGS = {"ensemblId": "ENSG00000120889", "size": 100}
|
| 21 |
+
|
| 22 |
+
# The pre-2026 Open Targets shape that the Phase 0 direct client was written against.
|
| 23 |
+
KNOWN_DRUGS_SHAPE = {
|
| 24 |
+
"status": "success",
|
| 25 |
+
"data": {
|
| 26 |
+
"target": {
|
| 27 |
+
"id": "ENSG00000120889",
|
| 28 |
+
"approvedSymbol": "TNFRSF10B",
|
| 29 |
+
"knownDrugs": {
|
| 30 |
+
"count": 1,
|
| 31 |
+
"rows": [
|
| 32 |
+
{"drug": {"name": "CONATUMUMAB", "maximumClinicalTrialPhase": 2}, "phase": 2}
|
| 33 |
+
],
|
| 34 |
+
},
|
| 35 |
+
}
|
| 36 |
+
},
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _client(response, memory_cache, clock):
|
| 41 |
+
return ToolClient(
|
| 42 |
+
runner=FakeRunner({OT_DRUGS: response}),
|
| 43 |
+
cache=memory_cache,
|
| 44 |
+
clock=clock,
|
| 45 |
+
tooluniverse_version="1.5.6",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_the_recorded_phase0_response_validates(tnfrsf10b_response):
|
| 50 |
+
schemas.validate(OT_DRUGS, tnfrsf10b_response["data"])
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_an_unknown_target_is_valid_not_drift():
|
| 54 |
+
schemas.validate(OT_DRUGS, {"target": None})
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_the_old_known_drugs_shape_is_drift(memory_cache, clock):
|
| 58 |
+
with pytest.raises(SourceError) as excinfo:
|
| 59 |
+
_client(KNOWN_DRUGS_SHAPE, memory_cache, clock).call(OT_DRUGS, ARGS)
|
| 60 |
+
assert excinfo.value.reason == "upstream_schema_drift"
|
| 61 |
+
assert excinfo.value.source == "opentargets"
|
| 62 |
+
assert "drugAndClinicalCandidates" in excinfo.value.detail
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_a_future_rename_is_drift(memory_cache, clock, tnfrsf10b_response):
|
| 66 |
+
mutated = copy.deepcopy(tnfrsf10b_response)
|
| 67 |
+
target = mutated["data"]["target"]
|
| 68 |
+
target["knownDrugsAndCandidates"] = target.pop("drugAndClinicalCandidates")
|
| 69 |
+
with pytest.raises(SourceError) as excinfo:
|
| 70 |
+
_client(mutated, memory_cache, clock).call(OT_DRUGS, ARGS)
|
| 71 |
+
assert excinfo.value.reason == "upstream_schema_drift"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_a_renamed_field_inside_a_row_is_drift(memory_cache, clock, tnfrsf10b_response):
|
| 75 |
+
mutated = copy.deepcopy(tnfrsf10b_response)
|
| 76 |
+
row = mutated["data"]["target"]["drugAndClinicalCandidates"]["rows"][2]
|
| 77 |
+
row["maximumStage"] = row.pop("maxClinicalStage")
|
| 78 |
+
with pytest.raises(SourceError) as excinfo:
|
| 79 |
+
_client(mutated, memory_cache, clock).call(OT_DRUGS, ARGS)
|
| 80 |
+
assert excinfo.value.reason == "upstream_schema_drift"
|
| 81 |
+
assert "rows/2" in excinfo.value.detail
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_an_added_field_upstream_is_not_drift(memory_cache, clock, tnfrsf10b_response):
|
| 85 |
+
mutated = copy.deepcopy(tnfrsf10b_response)
|
| 86 |
+
mutated["data"]["target"]["newUpstreamField"] = {"anything": True}
|
| 87 |
+
result = _client(mutated, memory_cache, clock).call(OT_DRUGS, ARGS)
|
| 88 |
+
assert result.cache == "miss"
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_a_tool_with_no_pinned_schema_builds_no_rows(memory_cache, clock):
|
| 92 |
+
client = ToolClient(
|
| 93 |
+
runner=FakeRunner({"UniProt_search": {"status": "success", "data": {"results": []}}}),
|
| 94 |
+
cache=memory_cache,
|
| 95 |
+
clock=clock,
|
| 96 |
+
tooluniverse_version="1.5.6",
|
| 97 |
+
)
|
| 98 |
+
with pytest.raises(SourceError) as excinfo:
|
| 99 |
+
client.call("UniProt_search", {"query": "gene_exact:KRAS"})
|
| 100 |
+
assert excinfo.value.reason == "upstream_schema_drift"
|
| 101 |
+
assert "no pinned schema" in excinfo.value.detail
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_every_pinned_schema_belongs_to_an_endpoint_tool():
|
| 105 |
+
assert schemas.pinned_tools() <= config.endpoint_tool_names()
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@pytest.mark.xfail(
|
| 109 |
+
strict=True,
|
| 110 |
+
reason="s2 records the Phase 0 fixtures and pins the remaining 11 schemas; when it does, "
|
| 111 |
+
"this XPASSes and strict mode fails the suite until the marker is removed",
|
| 112 |
+
)
|
| 113 |
+
def test_every_endpoint_tool_has_a_pinned_schema():
|
| 114 |
+
assert schemas.pinned_tools() == config.endpoint_tool_names()
|
tests/test_provenance.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""C3 (core) — provenance on every row, and the annotation payload shape (ADR-0001 §5).
|
| 2 |
+
|
| 3 |
+
s1 runs these on the Phase 0 recorded Open Targets response through a stub row builder; s2 reruns
|
| 4 |
+
the same assertions over every endpoint and every recorded fixture.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from src import payloads
|
| 12 |
+
from src.provenance import PROVENANCE_FIELDS, missing_fields, stamp
|
| 13 |
+
from src.tu_client import SourceResult, ToolClient
|
| 14 |
+
from tests.conftest import FakeRunner
|
| 15 |
+
|
| 16 |
+
OT_DRUGS = "OpenTargets_get_associated_drugs_by_target_ensemblID"
|
| 17 |
+
ARGS = {"ensemblId": "ENSG00000120889", "size": 100}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _rows_from(result: SourceResult) -> list[dict]:
|
| 21 |
+
"""A minimal stand-in for s2's drugs_for_targets row builder."""
|
| 22 |
+
target = result.data["target"]
|
| 23 |
+
return [
|
| 24 |
+
stamp(
|
| 25 |
+
{
|
| 26 |
+
"target": target["approvedSymbol"],
|
| 27 |
+
"drug": r["drug"]["name"],
|
| 28 |
+
"max_clinical_stage": r["maxClinicalStage"],
|
| 29 |
+
},
|
| 30 |
+
source=result.source,
|
| 31 |
+
source_tool=result.tool,
|
| 32 |
+
tooluniverse_version=result.tooluniverse_version,
|
| 33 |
+
query=result.args,
|
| 34 |
+
source_ref=r["drug"]["id"],
|
| 35 |
+
source_url=f"https://platform.opentargets.org/drug/{r['drug']['id']}",
|
| 36 |
+
retrieved_at=result.retrieved_at,
|
| 37 |
+
cache=result.cache,
|
| 38 |
+
)
|
| 39 |
+
for r in target["drugAndClinicalCandidates"]["rows"]
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@pytest.fixture()
|
| 44 |
+
def result(memory_cache, clock, tnfrsf10b_response):
|
| 45 |
+
client = ToolClient(
|
| 46 |
+
runner=FakeRunner({OT_DRUGS: tnfrsf10b_response}),
|
| 47 |
+
cache=memory_cache,
|
| 48 |
+
clock=clock,
|
| 49 |
+
tooluniverse_version="1.5.6",
|
| 50 |
+
)
|
| 51 |
+
return client.call(OT_DRUGS, ARGS)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_there_are_exactly_ten_provenance_fields():
|
| 55 |
+
assert len(PROVENANCE_FIELDS) == 10
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_every_row_carries_all_ten_fields(result):
|
| 59 |
+
rows = _rows_from(result)
|
| 60 |
+
assert len(rows) == 7 # the seven DR5 agents Phase 0 found
|
| 61 |
+
for row in rows:
|
| 62 |
+
assert missing_fields(row) == []
|
| 63 |
+
prov = row["provenance"]
|
| 64 |
+
assert prov["source"] == "opentargets"
|
| 65 |
+
assert prov["source_tool"] == OT_DRUGS
|
| 66 |
+
assert prov["tooluniverse_version"] == "1.5.6"
|
| 67 |
+
assert prov["query"] == ARGS
|
| 68 |
+
assert prov["source_ref"].startswith("CHEMBL")
|
| 69 |
+
assert prov["licence"] == "CC0 1.0"
|
| 70 |
+
assert prov["retrieved_at"].endswith("Z")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_a_cache_hit_keeps_the_original_retrieved_at(memory_cache, clock, tnfrsf10b_response):
|
| 74 |
+
client = ToolClient(
|
| 75 |
+
runner=FakeRunner({OT_DRUGS: tnfrsf10b_response}),
|
| 76 |
+
cache=memory_cache,
|
| 77 |
+
clock=clock,
|
| 78 |
+
tooluniverse_version="1.5.6",
|
| 79 |
+
)
|
| 80 |
+
miss = _rows_from(client.call(OT_DRUGS, ARGS))
|
| 81 |
+
clock.advance(86_400)
|
| 82 |
+
hit = _rows_from(client.call(OT_DRUGS, ARGS))
|
| 83 |
+
assert [r["provenance"]["cache"] for r in miss + hit] == ["miss"] * 7 + ["hit"] * 7
|
| 84 |
+
assert {r["provenance"]["retrieved_at"] for r in hit} == {miss[0]["provenance"]["retrieved_at"]}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_an_ok_payload_is_annotation_with_no_sample_size(result):
|
| 88 |
+
payload = payloads.ok(
|
| 89 |
+
"drugs_for_targets",
|
| 90 |
+
query={"genes": ["TNFRSF10B"]},
|
| 91 |
+
rows=_rows_from(result),
|
| 92 |
+
sources_used=["opentargets"],
|
| 93 |
+
narrative="According to Open Targets (queried 2025-10-09), 7 drugs target TNFRSF10B.",
|
| 94 |
+
)
|
| 95 |
+
assert payload["status"] == "ok"
|
| 96 |
+
assert payload["kind"] == "annotation"
|
| 97 |
+
assert "n_for_inference" not in payload
|
| 98 |
+
assert "hierarchy" not in payload
|
| 99 |
+
assert payload["run_id"]
|
| 100 |
+
assert [a["source"] for a in payload["attribution"]] == ["opentargets"]
|
| 101 |
+
assert any("MRTX1133" in n["note"] for n in payload["coverage_notes"])
|
| 102 |
+
assert "not analysis" in payload["disclaimer"]
|
| 103 |
+
payloads.to_json(payload)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_an_unstamped_row_cannot_reach_a_payload(result):
|
| 107 |
+
rows = _rows_from(result)
|
| 108 |
+
del rows[3]["provenance"]["licence"]
|
| 109 |
+
with pytest.raises(ValueError, match="row 3 is missing provenance fields \\['licence'\\]"):
|
| 110 |
+
payloads.ok(
|
| 111 |
+
"drugs_for_targets",
|
| 112 |
+
query={},
|
| 113 |
+
rows=rows,
|
| 114 |
+
sources_used=["opentargets"],
|
| 115 |
+
narrative="x",
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@pytest.mark.parametrize("key", ["n_for_inference", "hierarchy"])
|
| 120 |
+
def test_an_annotation_payload_refuses_a_sample_size(result, key):
|
| 121 |
+
with pytest.raises(ValueError, match="never carry"):
|
| 122 |
+
payloads.ok(
|
| 123 |
+
"drugs_for_targets",
|
| 124 |
+
query={},
|
| 125 |
+
rows=_rows_from(result),
|
| 126 |
+
sources_used=["opentargets"],
|
| 127 |
+
narrative="x",
|
| 128 |
+
**{key: 7},
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_non_ok_payloads():
|
| 133 |
+
refused = payloads.refused("annotate_genes", "invalid_identifier", "Not a gene identifier: x")
|
| 134 |
+
assert refused["status"] == "refused" and refused["run_id"] and refused["reason"]
|
| 135 |
+
err = payloads.error(
|
| 136 |
+
"annotate_genes",
|
| 137 |
+
"all_sources_failed",
|
| 138 |
+
"UniProt did not answer",
|
| 139 |
+
source_errors=[payloads.source_error("uniprot", "source_unavailable", "timeout")],
|
| 140 |
+
)
|
| 141 |
+
assert err["status"] == "error" and err["source_errors"][0]["reason"] == "source_unavailable"
|
| 142 |
+
denied = payloads.denied("sign in")
|
| 143 |
+
assert denied["status"] == "denied" and "run_id" not in denied
|
| 144 |
+
with pytest.raises(ValueError):
|
| 145 |
+
payloads.source_error("uniprot", "guessed", "no")
|
tests/test_ratelimit.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""C5 — per-source rate limits and timeouts become structured errors, never long waits."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import threading
|
| 6 |
+
import time
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
from src import config
|
| 11 |
+
from src.ratelimit import RateLimiters, TokenBucket
|
| 12 |
+
from src.tu_client import SourceError, ToolClient
|
| 13 |
+
from tests.conftest import FakeClock, FakeRunner
|
| 14 |
+
|
| 15 |
+
OT_DRUGS = "OpenTargets_get_associated_drugs_by_target_ensemblID"
|
| 16 |
+
UNIPROT_SEARCH = "UniProt_search"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_every_source_is_limited_to_at_most_three_per_second():
|
| 20 |
+
for sid, cfg in config.sources().items():
|
| 21 |
+
assert 0 < cfg["rate_per_second"] <= 3, sid
|
| 22 |
+
assert cfg["burst"] >= 1, sid
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_a_55_call_burst_never_exceeds_the_configured_rate():
|
| 26 |
+
"""The pORG list is 55 genes; annotating it is a burst of one call per gene."""
|
| 27 |
+
clock = FakeClock(start=0.0)
|
| 28 |
+
cfg = config.source("uniprot")
|
| 29 |
+
bucket = TokenBucket(cfg["rate_per_second"], cfg["burst"], clock=clock, sleep=clock.sleep)
|
| 30 |
+
times = []
|
| 31 |
+
for _ in range(55):
|
| 32 |
+
assert bucket.acquire(max_wait=60)
|
| 33 |
+
times.append(clock.now)
|
| 34 |
+
rate, burst = cfg["rate_per_second"], cfg["burst"]
|
| 35 |
+
for start in times:
|
| 36 |
+
in_window = sum(1 for t in times if start <= t < start + 1.0)
|
| 37 |
+
assert in_window <= rate + burst
|
| 38 |
+
# Sustained: 55 calls at 3/s with a burst of 3 cannot finish before (55 - 3) / 3 s.
|
| 39 |
+
assert times[-1] >= (55 - burst) / rate - 1e-9
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_waiting_longer_than_the_budget_is_refused_not_slept():
|
| 43 |
+
clock = FakeClock(start=0.0)
|
| 44 |
+
bucket = TokenBucket(1, 1, clock=clock, sleep=clock.sleep)
|
| 45 |
+
assert bucket.acquire(max_wait=0)
|
| 46 |
+
assert bucket.acquire(max_wait=0.5) is False
|
| 47 |
+
assert clock.sleeps == []
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_a_limited_call_becomes_a_rate_limited_source_error(
|
| 51 |
+
memory_cache, clock, tnfrsf10b_response
|
| 52 |
+
):
|
| 53 |
+
limiters = RateLimiters(clock=clock, sleep=clock.sleep)
|
| 54 |
+
client = ToolClient(
|
| 55 |
+
runner=FakeRunner({OT_DRUGS: tnfrsf10b_response}),
|
| 56 |
+
cache=memory_cache,
|
| 57 |
+
limiters=limiters,
|
| 58 |
+
clock=clock,
|
| 59 |
+
tooluniverse_version="1.5.6",
|
| 60 |
+
)
|
| 61 |
+
burst = config.source("opentargets")["burst"]
|
| 62 |
+
for i in range(burst):
|
| 63 |
+
client.call(OT_DRUGS, {"ensemblId": f"ENSG0000012088{i}", "size": 100}, max_wait=0)
|
| 64 |
+
with pytest.raises(SourceError) as excinfo:
|
| 65 |
+
client.call(OT_DRUGS, {"ensemblId": "ENSG00000120889", "size": 50}, max_wait=0)
|
| 66 |
+
assert excinfo.value.reason == "rate_limited"
|
| 67 |
+
assert excinfo.value.source == "opentargets"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_concurrent_callers_share_one_bucket():
|
| 71 |
+
clock = FakeClock(start=0.0)
|
| 72 |
+
lock = threading.Lock()
|
| 73 |
+
|
| 74 |
+
def sleep(seconds):
|
| 75 |
+
with lock:
|
| 76 |
+
clock.sleeps.append(seconds)
|
| 77 |
+
|
| 78 |
+
bucket = TokenBucket(3, 3, clock=clock, sleep=sleep)
|
| 79 |
+
granted = []
|
| 80 |
+
|
| 81 |
+
def worker():
|
| 82 |
+
granted.append(bucket.acquire(max_wait=100))
|
| 83 |
+
|
| 84 |
+
threads = [threading.Thread(target=worker) for _ in range(9)]
|
| 85 |
+
for t in threads:
|
| 86 |
+
t.start()
|
| 87 |
+
for t in threads:
|
| 88 |
+
t.join()
|
| 89 |
+
assert all(granted)
|
| 90 |
+
# 3 from the burst, then 6 reservations each waiting a strictly later slot.
|
| 91 |
+
assert sorted(clock.sleeps) == pytest.approx([i / 3 for i in range(1, 7)])
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_a_source_timeout_is_source_unavailable_within_the_limit(monkeypatch, memory_cache, clock):
|
| 95 |
+
monkeypatch.setitem(config.source("uniprot"), "timeout_seconds", 0.2)
|
| 96 |
+
|
| 97 |
+
def hangs(args):
|
| 98 |
+
time.sleep(2)
|
| 99 |
+
|
| 100 |
+
client = ToolClient(
|
| 101 |
+
runner=FakeRunner({UNIPROT_SEARCH: hangs}),
|
| 102 |
+
cache=memory_cache,
|
| 103 |
+
clock=clock,
|
| 104 |
+
tooluniverse_version="1.5.6",
|
| 105 |
+
)
|
| 106 |
+
started = time.monotonic()
|
| 107 |
+
with pytest.raises(SourceError) as excinfo:
|
| 108 |
+
client.call(UNIPROT_SEARCH, {"query": "gene_exact:KRAS"})
|
| 109 |
+
assert time.monotonic() - started < 1.5
|
| 110 |
+
assert excinfo.value.reason == "source_unavailable"
|
| 111 |
+
assert "did not answer within" in excinfo.value.detail
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_an_exception_inside_a_source_is_source_unavailable(memory_cache, clock):
|
| 115 |
+
client = ToolClient(
|
| 116 |
+
runner=FakeRunner({UNIPROT_SEARCH: ConnectionError("reset by peer")}),
|
| 117 |
+
cache=memory_cache,
|
| 118 |
+
clock=clock,
|
| 119 |
+
tooluniverse_version="1.5.6",
|
| 120 |
+
)
|
| 121 |
+
with pytest.raises(SourceError) as excinfo:
|
| 122 |
+
client.call(UNIPROT_SEARCH, {"query": "gene_exact:KRAS"})
|
| 123 |
+
assert excinfo.value.reason == "source_unavailable"
|
| 124 |
+
assert "ConnectionError" in excinfo.value.detail
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_a_tooluniverse_error_envelope_is_source_unavailable(memory_cache, clock):
|
| 128 |
+
envelope = {"status": "error", "error": "Read timed out. (read timeout=30)"}
|
| 129 |
+
client = ToolClient(
|
| 130 |
+
runner=FakeRunner({UNIPROT_SEARCH: envelope}),
|
| 131 |
+
cache=memory_cache,
|
| 132 |
+
clock=clock,
|
| 133 |
+
tooluniverse_version="1.5.6",
|
| 134 |
+
)
|
| 135 |
+
with pytest.raises(SourceError) as excinfo:
|
| 136 |
+
client.call(UNIPROT_SEARCH, {"query": "gene_exact:KRAS"})
|
| 137 |
+
assert excinfo.value.reason == "source_unavailable"
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_a_tool_off_the_endpoint_set_is_a_programming_error(memory_cache, clock):
|
| 141 |
+
client = ToolClient(runner=FakeRunner(), cache=memory_cache, clock=clock)
|
| 142 |
+
with pytest.raises(ValueError, match="not an endpoint tool"):
|
| 143 |
+
client.call("PubChem_get_CID_by_compound_name", {"name": "gemcitabine"})
|
tests/test_sources_hosts.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""C8 — every host a loaded tool can reach is declared in config/sources.yaml (ADR-0001 §6).
|
| 2 |
+
|
| 3 |
+
The hosts are read offline from the installed ToolUniverse: URLs in each tool's config and in the
|
| 4 |
+
source of its tool class. This is what makes the security-review addendum's host table honest:
|
| 5 |
+
it is generated from the same file this test checks.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
from src import config
|
| 13 |
+
from src.tu_client import build_tooluniverse, tool_hosts
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.fixture(scope="module")
|
| 17 |
+
def loaded_tu():
|
| 18 |
+
return build_tooluniverse()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_every_loaded_tool_has_at_least_one_host(loaded_tu):
|
| 22 |
+
for name in loaded_tu.all_tool_dict:
|
| 23 |
+
assert tool_hosts(loaded_tu, name), name
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_every_loaded_tools_hosts_are_declared_for_its_source(loaded_tu):
|
| 27 |
+
for name in loaded_tu.all_tool_dict:
|
| 28 |
+
declared = set(config.source(config.source_of(name))["hosts"])
|
| 29 |
+
undeclared = tool_hosts(loaded_tu, name) - declared
|
| 30 |
+
assert not undeclared, f"{name} reaches undeclared hosts {sorted(undeclared)}"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_no_stale_host_for_a_source_in_use(loaded_tu):
|
| 34 |
+
"""A used source declares no host its loaded tools never reach."""
|
| 35 |
+
reached: dict[str, set[str]] = {}
|
| 36 |
+
for name in loaded_tu.all_tool_dict:
|
| 37 |
+
reached.setdefault(config.source_of(name), set()).update(tool_hosts(loaded_tu, name))
|
| 38 |
+
for sid, hosts in reached.items():
|
| 39 |
+
assert set(config.source(sid)["hosts"]) == hosts, sid
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_the_six_phase1_sources_are_the_ones_in_use():
|
| 43 |
+
used = {config.source_of(t) for t in config.endpoint_tool_names()}
|
| 44 |
+
assert used == {"opentargets", "chembl", "uniprot", "clinicaltrials", "reactome", "hgnc"}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@pytest.mark.parametrize("sid", sorted(config.sources()))
|
| 48 |
+
def test_every_source_has_a_licence_url_and_check_note(sid):
|
| 49 |
+
src = config.source(sid)
|
| 50 |
+
assert src["licence"].strip()
|
| 51 |
+
assert src["licence_url"].startswith("https://")
|
| 52 |
+
assert src["licence_checked"].startswith("2026-")
|
| 53 |
+
assert src["citation"].strip()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_licences_as_checked_on_2026_10_20():
|
| 57 |
+
expected = {
|
| 58 |
+
"opentargets": "CC0 1.0",
|
| 59 |
+
"chembl": "CC BY-SA 3.0",
|
| 60 |
+
"uniprot": "CC BY 4.0",
|
| 61 |
+
"reactome": "CC0 1.0",
|
| 62 |
+
}
|
| 63 |
+
for sid, licence in expected.items():
|
| 64 |
+
assert config.source(sid)["licence"] == licence, sid
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_a_source_without_a_confirmed_licence_has_no_endpoint():
|
| 68 |
+
"""DGIdb's data licence could not be confirmed, so no loaded tool may come from it."""
|
| 69 |
+
unconfirmed = {
|
| 70 |
+
sid for sid, src in config.sources().items() if src["licence"].startswith("Unconfirmed")
|
| 71 |
+
}
|
| 72 |
+
assert unconfirmed == {"dgidb"}
|
| 73 |
+
assert not {config.source_of(t) for t in config.endpoint_tool_names()} & unconfirmed
|