Skip to content

Commit 075e794

Browse files
authored
Merge pull request #1 from ToxMCP/codex/oqt-v0.2.0-cleanup-public
Release v0.3.1: Audit remediation for OQT-01/02/03/04/05
2 parents 5c4560a + 3b8217a commit 075e794

21 files changed

Lines changed: 1065 additions & 23 deletions

AGENTS.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Agent Guidance for O-QT MCP Server
2+
3+
## Scope
4+
This file applies to the `o-qt-mcp-server-public` repository (canonical target).
5+
6+
## Critical Controls (Added 2026-04-16)
7+
8+
### 1. Applicability Domain (AD) Gating
9+
- **Files:** `src/tools/implementations/o_qt_qsar_tools.py`, `src/tools/implementations/toolbox_execution.py`, `src/tools/implementations/workflow_runner.py`
10+
- **Behavior:** `run_qsar_prediction` and `run_qsar_model` now inspect the domain result from the QSAR Toolbox. If the domain status is `"OutOfDomain"`, the result includes:
11+
- `"ad_status": "out_of_domain"`
12+
- `"ad_warning": true`
13+
- `"ad_recommendation": "..."`
14+
- **Rule:** Do NOT remove these fields. The workflow runner surfaces AD warnings in the Markdown summary.
15+
16+
### 2. Human Review Checkpoints (OQT-02)
17+
- **Files:** `src/utils/review.py`, `src/tools/implementations/workflow_runner.py`, `config/tool_permissions.default.json`
18+
- **Behavior:** When `require_human_review=true` is passed to `run_oqt_multiagent_workflow`, the workflow creates up to three checkpoints:
19+
1. `chemical_identity` — after resolving the input identifier to a Toolbox record
20+
2. `ad_assessment` — when any QSAR prediction reports `ad_warning=true`
21+
3. `final_report` — before generating the PDF artifact
22+
- If checkpoints are pending, the workflow returns `status: "review_required"` with `workflow_id` and `review_checkpoints`. No PDF is generated.
23+
- Clients can approve/reject checkpoints via the `approve_workflow_checkpoint` tool, then resume by passing the same `workflow_id` (and optionally `checkpoint_approvals`) to the workflow.
24+
- **Rule:** Do NOT auto-generate artifacts when `require_human_review=true` and checkpoints are pending. Do NOT skip the `ad_assessment` checkpoint for out-of-domain predictions.
25+
26+
### 3. LLM Prompt-Boundary Sanitization
27+
- **File:** `src/utils/sanitization.py`, `src/integrations/oqt_assistant.py`
28+
- **Behavior:** All user-supplied identifiers and context strings are sanitized with `sanitize_for_llm()` before entering the oqt_assistant LLM pipeline.
29+
- **Rule:** If you add new LLM-facing inputs, pipe them through `sanitize_for_llm()`.
30+
31+
### 4. Privacy-Aware Audit Logging
32+
- **Files:** `src/utils/privacy.py`, `src/tools/registry.py`, `src/api/server.py`, `src/utils/logging.py`
33+
- **Behavior:**
34+
- Audit events hash SMILES, CAS numbers, chemical names, and API keys before logging.
35+
- The HTTP audit middleware parses query strings into dictionaries so parameter keys remain readable while values are hashed.
36+
- The `PrivacyLogFilter` scrubs SMILES/CAS patterns from free-text log messages and URL query parameters, and hashes whole-value identifiers in structured log extra fields.
37+
- **Rule:** Do NOT log raw chemical identifiers or secrets. Use `scrub_dict()` on params before audit emit.
38+
39+
### 5. Fallback PDF Provenance
40+
- **File:** `src/utils/pdf_generator.py`
41+
- **Behavior:** The fallback PDF includes:
42+
- A prominent disclaimer on the first page
43+
- An "Applicability Domain Warnings" section when out-of-domain predictions are present
44+
- A "Provenance" section showing model count and AD status
45+
- **Rule:** Keep the disclaimer visible. Do not remove the AD-warning block.
46+
47+
### 6. Search Defaults
48+
- **File:** `src/tools/implementations/workflow_runner.py`, `src/tools/implementations/o_qt_qsar_tools.py`
49+
- **Behavior:** `search_type` default is now `"name"` instead of `"auto"` to reduce silent wrong-chemical resolution.
50+
- **Rule:** Do not revert the default to `"auto"` without explicit user confirmation logic.
51+
52+
## Testing Expectations
53+
- Any change to AD logic must pass `test_run_qsar_prediction_ad_warning_out_of_domain`.
54+
- Any change to privacy logic must pass `tests/utils/test_privacy.py`.
55+
- Any change to sanitization must pass `tests/utils/test_sanitization.py`.
56+
- Any change to PDF generation must pass `test_generate_pdf_report_includes_disclaimer_and_ad_warnings`.

CHANGELOG.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818

1919
---
2020

21+
## [0.3.1] - 2026-04-17
22+
23+
### Added
24+
- **Human review checkpoints** (`OQT-02`). When `require_human_review=true` is passed to `run_oqt_multiagent_workflow`, the workflow now pauses at up to three checkpoints (`chemical_identity`, `ad_assessment`, `final_report`) and returns `status: "review_required"` instead of auto-generating artifacts.
25+
- `approve_workflow_checkpoint` tool to explicitly approve or reject pending workflow checkpoints.
26+
- **Applicability-domain hard gating** (`OQT-01`). Out-of-domain predictions block PDF generation when `require_human_review=true`.
27+
- `PrivacyLogFilter` (`OQT-05`) to scrub SMILES, CAS numbers, and chemical names from free-text log messages and URL query parameters before emission.
28+
- `sanitize_for_llm()` utility (`OQT-04`) to strip control characters, backticks, and dollar signs from untrusted identifiers before they enter LLM-facing contexts.
29+
- Fallback PDF provenance enhancements (`OQT-03`): disclaimer header, applicability-domain warnings section, and provenance summary showing models run and AD warning count.
30+
- `qsar_models_executed` field in the workflow response for full QSAR transparency (`MD-004`).
31+
- Safer search defaults (`HG-001`): `search_type` now defaults to `"name"` instead of `"auto"`.
32+
- Unit tests for sanitization, privacy scrubbing, and review checkpoint orchestration.
33+
34+
### Changed
35+
- HTTP audit middleware now parses query strings into dictionaries so parameter keys remain readable while values are hashed.
36+
- Updated `AGENTS.md` with critical-control policies to prevent future regressions of AD gating, sanitization, privacy, and review defaults.
37+
38+
### Fixed
39+
- Removed raw chemical identifiers from audit logs; all SMILES, CAS, and chemical-name values are now hashed before emission.
40+
41+
---
42+
2143
## [0.3.0] - 2026-04-08
2244

2345
### Added
@@ -60,7 +82,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6082
- Dockerfile and docker-compose stack for local development with Toolbox API stub.
6183
- Taskmaster backlog, documentation set (`docs/auth_testing.md`, `docs/observability.md`, `docs/testing.md`), and CI workflow skeleton.
6284

63-
[Unreleased]: https://github.com/ToxMCP/oqt-mcp/compare/v0.3.0...HEAD
85+
[Unreleased]: https://github.com/ToxMCP/oqt-mcp/compare/v0.3.1...HEAD
86+
[0.3.1]: https://github.com/ToxMCP/oqt-mcp/releases/tag/v0.3.1
6487
[0.3.0]: https://github.com/ToxMCP/oqt-mcp/releases/tag/v0.3.0
6588
[0.2.0]: https://github.com/ToxMCP/oqt-mcp/releases/tag/v0.2.0
6689
[0.1.0]: https://github.com/ToxMCP/oqt-mcp/releases/tag/v0.1.0

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,9 @@ See [docs/auth_testing.md](docs/auth_testing.md) for token generation tips and b
271271
| `llm_provider` | string || Override LLM provider (e.g., `openai`, `openrouter`). |
272272
| `llm_model` | string || LLM model identifier. |
273273
| `llm_api_key` | string || API key when not provided via environment. |
274+
| `require_human_review` | boolean || When `true`, high-risk checkpoints require explicit approval before artifacts are generated. |
275+
| `workflow_id` | string || Optional workflow ID for resuming a review-paused workflow. |
276+
| `checkpoint_approvals` | array[{checkpoint_id, decision, comments}] || Pre-approved checkpoints to resume a paused workflow. |
274277

275278
### `build_grouping_justification` parameters
276279

@@ -526,12 +529,53 @@ Portable cross-suite contract files are versioned separately under `schemas/`.
526529

527530
---
528531

532+
## Human review checkpoints
533+
534+
For scientific governance, `run_oqt_multiagent_workflow` supports an optional `require_human_review=true` mode that pauses the workflow at high-risk decision points instead of auto-generating artifacts.
535+
536+
### Checkpoints created
537+
1. **`chemical_identity`** — After resolving the input identifier to a Toolbox record.
538+
2. **`ad_assessment`** — When any QSAR prediction reports `ad_warning=true` (out of applicability domain).
539+
3. **`final_report`** — Before generating the PDF artifact.
540+
541+
### Workflow behavior
542+
- If no checkpoints are triggered, the workflow completes normally (`status: "ok"`).
543+
- If checkpoints are pending, the workflow returns:
544+
- `status: "review_required"`
545+
- `workflow_id` — ID to use when resuming
546+
- `review_checkpoints` — List of pending checkpoints with metadata
547+
- **No PDF is generated.**
548+
549+
### Approving or rejecting checkpoints
550+
Use the `approve_workflow_checkpoint` tool:
551+
552+
```json
553+
{
554+
"name": "approve_workflow_checkpoint",
555+
"arguments": {
556+
"checkpoint_id": "<checkpoint-id>",
557+
"decision": "approved",
558+
"comments": "Looks correct"
559+
}
560+
}
561+
```
562+
563+
Decisions: `approved` | `rejected`
564+
565+
### Resuming the workflow
566+
Pass the same `workflow_id` back to `run_oqt_multiagent_workflow` (with `require_human_review=true`). The server will detect approved checkpoints and complete the workflow, returning `status: "ok"` and the PDF.
567+
568+
> **Note:** Checkpoint state is held in memory. If the server restarts, pending checkpoints are lost. Do not use this feature for long-lived review cycles without external persistence.
569+
570+
---
571+
529572
## Security checklist
530573

531574
- ✅ Use OAuth2/OIDC in production (`BYPASS_AUTH=false`).
532575
- ✅ Terminate TLS at a reverse proxy.
533576
- ✅ Configure RBAC in `config/tool_permissions.default.json`.
534577
- ✅ Enable audit log shipping (see [docs/observability.md](docs/observability.md)).
578+
- ✅ Turn on `require_human_review=true` for high-stakes workflows to enforce explicit checkpoint approval.
535579
- ✅ Rotate secrets via platform-specific secret stores.
536580
- ✅ Regularly update the Docker base image (see [Dockerfile](Dockerfile)).
537581

config/tool_permissions.default.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"list_qsar_models",
1414
"list_all_qsar_models",
1515
"build_grouping_justification",
16+
"approve_workflow_checkpoint",
1617
"run_oqt_multiagent_workflow",
1718
"run_qsar_workflow",
1819
"canonicalize_structure",
@@ -38,6 +39,7 @@
3839
"list_qsar_models",
3940
"list_all_qsar_models",
4041
"build_grouping_justification",
42+
"approve_workflow_checkpoint",
4143
"run_oqt_multiagent_workflow",
4244
"run_qsar_workflow",
4345
"run_qsar_model",
@@ -72,6 +74,7 @@
7274
"list_qsar_models",
7375
"list_all_qsar_models",
7476
"build_grouping_justification",
77+
"approve_workflow_checkpoint",
7578
"run_oqt_multiagent_workflow",
7679
"run_qsar_workflow",
7780
"run_qsar_model",
@@ -106,6 +109,7 @@
106109
"list_qsar_models",
107110
"list_all_qsar_models",
108111
"build_grouping_justification",
112+
"approve_workflow_checkpoint",
109113
"run_oqt_multiagent_workflow",
110114
"run_qsar_workflow",
111115
"run_qsar_model",

src/api/server.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,29 @@ async def add_security_headers(request: Request, call_next):
9898
# Placeholder: In production, this must log to a centralized, tamper-evident system.
9999
@app.middleware("http")
100100
async def audit_log_middleware(request: Request, call_next):
101+
from urllib.parse import parse_qs
102+
from src.utils.privacy import scrub_dict, scrub_value
103+
101104
correlation_id = str(uuid.uuid4())
102105
request.state.correlation_id = correlation_id
103106
start = time.perf_counter()
104107

108+
# Scrub potentially sensitive path segments (e.g., SMILES in URL paths)
109+
scrubbed_path = scrub_value("path", request.url.path)
110+
query_str = str(request.url.query) if request.url.query else ""
111+
# Parse query string into a dict so keys remain readable and only values are scrubbed
112+
if query_str:
113+
parsed_query = parse_qs(query_str, keep_blank_values=True)
114+
scrubbed_query = scrub_dict(parsed_query)
115+
else:
116+
scrubbed_query = {}
117+
105118
log.debug(
106119
"Incoming request",
107120
extra={
108121
"cid": correlation_id,
109122
"method": request.method,
110-
"path": request.url.path,
123+
"path": scrubbed_path,
111124
},
112125
)
113126

@@ -120,10 +133,12 @@ async def audit_log_middleware(request: Request, call_next):
120133
"correlation_id": correlation_id,
121134
"user_id": user_id,
122135
"method": request.method,
123-
"path": request.url.path,
136+
"path": scrubbed_path,
124137
"status_code": response.status_code,
125138
"duration_ms": round(duration_ms, 3),
126139
}
140+
if scrubbed_query:
141+
event["query"] = scrubbed_query
127142
audit.emit(event)
128143

129144
response.headers["X-Request-ID"] = correlation_id

src/integrations/oqt_assistant.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from dataclasses import dataclass
2424
from typing import Any, Dict, List, Optional, Tuple
2525

26+
from src.utils.sanitization import sanitize_for_llm
27+
2628
logger = logging.getLogger(__name__)
2729

2830
try:
@@ -313,21 +315,26 @@ async def _run_agents(
313315
"""
314316
Execute the oqt_assistant specialist agents and synthesiser.
315317
"""
318+
# Harden LLM-facing inputs against prompt-boundary confusion (OQT-04 / SEC-02)
319+
safe_identifier = sanitize_for_llm(identifier)
320+
safe_context = sanitize_for_llm(context)
321+
316322
specialist_outputs: Dict[str, str] = {}
317323
logger.info(" [Analysis] Starting assistant agents.")
318324

319325
try:
320326
identity_txt = await analyze_chemical_context(
321327
{"basic_info": bundle.get("chemical_data", {}).get("basic_info", {})},
322-
context,
328+
safe_context,
323329
llm_config,
324330
)
325331
except Exception as exc: # pragma: no cover - defensive branch
326332
logger.error("Chemical context agent failed: %s", exc)
327333
identity_txt = f"[Chemical Context agent failed: {exc}]"
328334

329-
specialist_outputs["Chemical_Context"] = identity_txt
330-
analysis_context = f"{identity_txt}\n\nUser Goal: {context}"
335+
safe_identity_txt = sanitize_for_llm(identity_txt)
336+
specialist_outputs["Chemical_Context"] = safe_identity_txt
337+
analysis_context = f"{safe_identity_txt}\n\nUser Goal: {safe_context}"
331338

332339
props = bundle.get("chemical_data", {}).get("properties", {})
333340
profiling = bundle.get("profiling", {})
@@ -389,11 +396,12 @@ async def _run_agents(
389396
logger.error("Read-Across agent failed: %s", exc)
390397
read_across = f"[Read Across agent failed: {exc}]"
391398

392-
specialist_outputs["Read_Across"] = read_across
399+
safe_read_across = sanitize_for_llm(read_across)
400+
specialist_outputs["Read_Across"] = safe_read_across
393401

394402
try:
395403
final_report = await synthesize_report(
396-
identifier, core_outputs, read_across, context, llm_config
404+
safe_identifier, core_outputs, safe_read_across, safe_context, llm_config
397405
)
398406
except Exception as exc: # pragma: no cover - defensive branch
399407
logger.error("Report synthesis failed: %s", exc)

src/qsar/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ async def _execute_request() -> Tuple[Any, Dict[str, Any]]:
142142
"last_attempt_ms": round(elapsed_attempt, 3),
143143
"timeout_profile": profile,
144144
"status_code": response.status_code,
145+
"api_versions": response.headers.get("api-supported-versions"),
146+
"server_date": response.headers.get("date"),
145147
}
146148
return data, meta
147149

src/tools/implementations/o_qt_qsar_tools.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class ChemicalSearchParams(BaseModel):
6363
..., description="The search term (Name, CAS number, or SMILES)."
6464
)
6565
search_type: str = Field(
66-
"auto", description="Type of search (e.g., 'auto', 'name', 'cas', 'smiles')."
66+
"name", description="Type of search (e.g., 'auto', 'name', 'cas', 'smiles')."
6767
)
6868

6969

@@ -375,13 +375,28 @@ async def run_qsar_prediction(smiles: str, model_id: str) -> dict:
375375
)
376376
return _attach_toolbox(result, toolbox_meta)
377377

378+
# Light-weight applicability-domain gating (OQT-01)
379+
domain_value = ""
380+
if isinstance(domain, dict):
381+
domain_value = domain.get("DomainResult") or domain.get("Domain") or ""
382+
elif isinstance(domain, str):
383+
domain_value = domain
384+
domain_normalized = str(domain_value).strip().replace(" ", "").replace("-", "").lower()
385+
ad_warning = domain_normalized in {"outofdomain", "out_of_domain"}
386+
378387
result = {
379388
"chem_id": chem_id,
380389
"model_id": model_id,
381390
"prediction": prediction,
382391
"domain": domain,
383-
"search_hits": hits,
392+
"ad_status": "out_of_domain" if ad_warning else ("in_domain" if domain_normalized in {"indomain", "in_domain", "insideapplicabilitydomain"} else "unknown"),
393+
"ad_warning": ad_warning,
384394
}
395+
if ad_warning:
396+
result["ad_recommendation"] = (
397+
"This prediction is outside the model's applicability domain. "
398+
"Treat with caution and consider experimental validation or read-across."
399+
)
385400
if model_provenance:
386401
result["model_provenance"] = model_provenance
387402
toolbox_meta = _aggregate_meta(

src/tools/implementations/toolbox_execution.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,12 +254,34 @@ async def run_qsar_model(qsar_guid: str, chem_id: str) -> dict:
254254
_format_meta("qsar/domain", domain_meta),
255255
_format_meta("about/object", model_meta),
256256
)
257+
# Light-weight applicability-domain gating (OQT-01)
258+
domain_value = ""
259+
if isinstance(domain, dict):
260+
domain_value = domain.get("DomainResult") or domain.get("Domain") or ""
261+
elif isinstance(domain, str):
262+
domain_value = domain
263+
domain_normalized = str(domain_value).strip().replace(" ", "").replace("-", "").lower()
264+
ad_warning = domain_normalized in {"outofdomain", "out_of_domain"}
265+
257266
result = {
258267
"qsar_guid": qsar_guid,
259268
"chem_id": chem_id,
260269
"prediction": prediction,
261270
"domain": domain,
271+
"ad_status": "out_of_domain"
272+
if ad_warning
273+
else (
274+
"in_domain"
275+
if domain_normalized in {"indomain", "in_domain", "insideapplicabilitydomain"}
276+
else "unknown"
277+
),
278+
"ad_warning": ad_warning,
262279
}
280+
if ad_warning:
281+
result["ad_recommendation"] = (
282+
"This prediction is outside the model's applicability domain. "
283+
"Treat with caution and consider experimental validation or read-across."
284+
)
263285
if model_provenance:
264286
result["model_provenance"] = model_provenance
265287
return _attach_toolbox(result, toolbox_meta)

0 commit comments

Comments
 (0)