phase: 84_docs_push_error_sanitization

**Phase 84 — final verification pass: all green, no defects found**

- Verified implementation: `app/core/errors.py` (verbatim lift of sync masker), `app/api/sync.py` alias import, docs-push 502 `detail=sanitize_error(str(exc))`, all five `llm.py` error sites sanitized; new/extended test pins in place
- Tests: `uv run pytest` → **1714 passed, 0 failed**; targeted pins (new unit ×2 + integration ×1, existing 502 pin) → 13 passed; sync/git-sources regression → 67 passed
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` → **99%** (`app/core/errors.py` 100%, `app/rag/llm.py` 100%) — >90% met
- E2E isolation: `uv run pytest tests/e2e/test_smoke.py -v --no-cov` → **3 passed**
- Lint/types: `uv run ruff check .` → clean; `uv run pyright` → **0 errors**
- Criteria: 502 masks `*****@`/never token + row untouched ✅; LLM base-URL masked, credential-free strings byte-identical ✅; `_CREDS_RE` only in `app/core/errors.py` (working-tree grep) ✅; full gate green ✅; `git diff --stat` limited to the 4 app files + 2 modified test files + 3 phase task files (untracked: new module, new unit test, complete/ dir, reports, audit plan) ✅
- Commit/phase move left to the harness per instructions (task files already in `complete/`)
- No deviations; nothing to fix
- Next pending phase: **85_mobile_menu_gate_overlap**
This commit is contained in:
2026-09-08 01:11:45 -04:00
parent fa189dede7
commit f4150421bb
23 changed files with 732 additions and 29 deletions
@@ -0,0 +1,11 @@
**Phase 84 — final verification pass: all green, no defects found**
- Verified implementation: `app/core/errors.py` (verbatim lift of sync masker), `app/api/sync.py` alias import, docs-push 502 `detail=sanitize_error(str(exc))`, all five `llm.py` error sites sanitized; new/extended test pins in place
- Tests: `uv run pytest` → **1714 passed, 0 failed**; targeted pins (new unit ×2 + integration ×1, existing 502 pin) → 13 passed; sync/git-sources regression → 67 passed
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` → **99%** (`app/core/errors.py` 100%, `app/rag/llm.py` 100%) — >90% met
- E2E isolation: `uv run pytest tests/e2e/test_smoke.py -v --no-cov` → **3 passed**
- Lint/types: `uv run ruff check .` → clean; `uv run pyright` → **0 errors**
- Criteria: 502 masks `*****@`/never token + row untouched ✅; LLM base-URL masked, credential-free strings byte-identical ✅; `_CREDS_RE` only in `app/core/errors.py` (working-tree grep) ✅; full gate green ✅; `git diff --stat` limited to the 4 app files + 2 modified test files + 3 phase task files (untracked: new module, new unit test, complete/ dir, reports, audit plan) ✅
- Commit/phase move left to the harness per instructions (task files already in `complete/`)
- No deviations; nothing to fix
- Next pending phase: **85_mobile_menu_gate_overlap**
@@ -0,0 +1,85 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 63%]
........................................................................ [ 67%]
........................................................................ [ 71%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 84%]
........................................................................ [ 88%]
........................................................................ [ 92%]
........................................................................ [ 96%]
.......................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,18 @@
All criteria verified. Final report:
**Task 01 — shared sanitizer module + sync alias refactor: complete**
- `app/core/errors.py` (new): owns `_CREDS_RE` (exact phase-32 pattern) + `sanitize_error()` (`sub("*****@")`), with module docstring covering SEC-08/SEC-13 audit basis, narrow-userinfo byte-identity contract, and the `*****@` replacement shape.
- `app/api/sync.py`: local `_CREDS_RE`/`_sanitize_error` deleted; now `from app.core.errors import sanitize_error as _sanitize_error`; one-line module-docstring note added; now-unused `import re` removed. `_run_sync` failure path byte-identical; `git_sources.py`/`docs.py` alias imports untouched.
- `tests/unit/test_error_sanitization.py` (new, 9 tests): single/multiple userinfo masking, SEC-08 git push-failure line (token masked, `fatal:`/`exit 128`/host intact), byte-identity pins (bare https URL, colon+space prose, email `owner@example.com` — pattern doesn't match it, pinned as unchanged, empty string), idempotence on all masked forms, and `sync_api._sanitize_error is sanitize_error` alias identity.
Results:
- `uv run pytest tests/unit/test_error_sanitization.py -v` → 9 passed
- `uv run pytest tests/integration/test_git_sources_upload.py tests/integration/test_sync_api.py` → 44 passed (no test edits)
- `uv run pytest --cov=app --cov-report=term-missing` → 1711 passed; `app/core/errors.py` 100%, app/ total 99%
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
- `grep -rn _CREDS_RE app/` → exactly one definition, in `app/core/errors.py`
Decisions: none — verbatim lift per design block. No pre-existing defects found. No git commits made (left for harness).
Next pending task: `.agents/phases/todo/84_docs_push_error_sanitization/02_apply_docs_push_and_llm.md`
@@ -0,0 +1,85 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 63%]
........................................................................ [ 67%]
........................................................................ [ 71%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 84%]
........................................................................ [ 88%]
........................................................................ [ 92%]
........................................................................ [ 96%]
....................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3235 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,18 @@
All green. Final report:
**Phase 84, Task 02 — docs-push 502 + LLM error sanitization**
Implemented:
- `app/api/doc_drafts.py`: `push_doc_draft` 502 detail now `sanitize_error(str(exc))` (SEC-08); route + module docstrings updated to name the SANITIZED stderr
- `app/rag/llm.py`: all five error f-strings (embed, chat×3, chat_stream) wrap `self.settings.llm_base_url` in `sanitize_error(...)` (SEC-13) — surrounding copy byte-identical
- `tests/integration/test_doc_drafts_api.py`: new pin — faked `DocsPushError` with `bot:ghp_LEAKTOKEN@github.com` → 502 detail has `*****@github.com`, `exit 128`, `fatal: Authentication failed`, never `ghp_LEAKTOKEN`; row untouched
- `tests/unit/test_llm_client.py`: new pins — embed + chat transport failures with `https://svc:topsecret@llm.local/v1` → masked `https://*****@llm.local/v1`, no `topsecret`; plus a byte-identical credential-free message assertion
Results:
- `uv run pytest tests/integration/test_doc_drafts_api.py -v` — 28 passed; `uv run pytest tests/unit/test_llm_client.py -v` — 57 passed (pre-existing error-string pins green unchanged)
- `uv run pytest --cov=app --cov-report=term-missing` — full suite green, TOTAL 99% (>90%); doc_drafts.py / llm.py / errors.py 100%
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` — 3 passed in isolation
- `uv run ruff check .` — clean; `uv run pyright` — 0 errors
- No deviations; diff limited to the four task files (task 01's files remain in the tree for the harness's atomic phase commit)
Next pending task: `.agents/phases/todo/84_docs_push_error_sanitization/03_verify_and_commit.md`
@@ -0,0 +1,85 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 63%]
........................................................................ [ 67%]
........................................................................ [ 71%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 84%]
........................................................................ [ 88%]
........................................................................ [ 92%]
........................................................................ [ 96%]
.......................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,21 @@
All gates verified. Every task-03 completion criterion is met in the working tree; no defects found in prior tasks' work.
**Phase 84 gate report — task 03 (verify) complete**
Implemented (by tasks 01–02, verified by me):
- `app/core/errors.py` (new): `sanitize_error` — verbatim lift of the sync `user:pass@` masker
- `app/api/sync.py`: local regex+function deleted, private alias import (callers `git_sources.py`/`docs.py` untouched)
- `app/api/doc_drafts.py`: docs-push 502 detail runs through `sanitize_error` (SEC-08)
- `app/rag/llm.py`: all five LLM error f-strings sanitize `llm_base_url` (SEC-13)
- New/extended pins: `tests/unit/test_error_sanitization.py`, `test_llm_client.py` (mask + byte-identical), `test_doc_drafts_api.py` (502 masks `ghp_LEAKTOKEN`, row untouched)
Gate results (all green):
- `uv run pytest` → 1714 passed, exit 0 (regression anchors incl. sync/upload, doc-drafts, LLM)
- `uv run pytest --cov=app --cov-report=term-missing` → `app/` TOTAL **99%** (>90% required)
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` → 3 passed in isolation
- `uv run ruff check . && uv run pyright` → clean (0 errors/warnings)
- `grep -rn _CREDS_RE app/ scripts/` → only `app/core/errors.py`; diff scope limited to the 7 code/test files + phase files; NO `scripts/`, `docs_push.py`, `frontend/`, `pyproject.toml`/`uv.lock`
Deviations (per harness executor rules, overriding task step 2–3): no `git add`/`commit` and no phase-dir move by me — all changes left in the working tree for the harness's one atomic `fix(security)` commit + todo→complete move.
Next pending task: none in phase 84 (03 was last); next phase: `85_mobile_menu_gate_overlap`.
@@ -0,0 +1,85 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 63%]
........................................................................ [ 67%]
........................................................................ [ 71%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 84%]
........................................................................ [ 88%]
........................................................................ [ 92%]
........................................................................ [ 96%]
.......................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
+20 -8
View File
@@ -26,8 +26,10 @@ triggers — commit + ``git push`` the draft's file to the
:func:`app.core.docs_push.push_document`; success records :func:`app.core.docs_push.push_document`; success records
``status`` / ``branch`` / ``commit_sha`` on the row and returns ``status`` / ``branch`` / ``commit_sha`` on the row and returns
``DocDraftPushed``; 409 while unconfigured, 422 on a path that no ``DocDraftPushed``; 409 while unconfigured, 422 on a path that no
longer passes the guard-rails, 502 on git failure with git's stderr longer passes the guard-rails, 502 on git failure with the
in the detail — the row untouched). SANITIZED git stderr in the detail (credential ``user:pass@``
userinfo masked by :func:`app.core.errors.sanitize_error` — phase
84, SEC-08) — the row untouched).
Every ``path`` (create, update **and** push) passes the shared Every ``path`` (create, update **and** push) passes the shared
:func:`validate_draft_path` guard, so no draft can ever be created, :func:`validate_draft_path` guard, so no draft can ever be created,
@@ -45,6 +47,7 @@ from sqlalchemy.orm import Session
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.core.auth import require_admin from app.core.auth import require_admin
from app.core.docs_push import DocsPushError, push_document from app.core.docs_push import DocsPushError, push_document
from app.core.errors import sanitize_error
from app.db import get_db from app.db import get_db
from app.models import DocDraft from app.models import DocDraft
from app.schemas import DocDraft as DocDraftOut from app.schemas import DocDraft as DocDraftOut
@@ -251,11 +254,16 @@ def push_doc_draft(
3. the stored ``path`` re-runs :func:`validate_draft_path` → 422 3. the stored ``path`` re-runs :func:`validate_draft_path` → 422
on the first violated rule (a row must not be pushable into a on the first violated rule (a row must not be pushable into a
bad path, whatever wrote it); bad path, whatever wrote it);
4. :class:`DocsPushError` → 502 with ``detail=str(exc)`` — git's 4. :class:`DocsPushError` → 502 with the SANITIZED git stderr in
stderr, the ``GitSyncError`` → ``detail`` mapping from the detail (phase 84, SEC-08): ``str(exc)`` runs through
:mod:`app.api.git_sources`. Only a SUCCESS mutates the row: :func:`app.core.errors.sanitize_error`, which masks any
the failed push leaves ``status`` / ``branch`` / ``commit_sha`` ``user:pass@`` userinfo git's stderr may echo (a
exactly as found (no partial commit). ``BOR_DOCS_REPO`` URL with embedded credentials) — the failing
repo and git's reason stay readable, and the
``GitSyncError`` → ``detail`` mapping from
:mod:`app.api.git_sources` is otherwise kept. Only a SUCCESS
mutates the row: the failed push leaves ``status`` / ``branch``
/ ``commit_sha`` exactly as found (no partial commit).
On success the row becomes ``status = "pushed"`` with the landed On success the row becomes ``status = "pushed"`` with the landed
``branch`` and ``commit_sha`` (must equal ``git rev-parse ``branch`` and ``commit_sha`` (must equal ``git rev-parse
@@ -283,7 +291,11 @@ def push_doc_draft(
commit_message=f"docs: {row.title}", commit_message=f"docs: {row.title}",
) )
except DocsPushError as exc: except DocsPushError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from None # Phase 84 (SEC-08): git's stderr may echo the remote URL with
# embedded credentials — the RESPONSE is where the secret would
# leak, so it is sanitized here (the DocsPushError itself keeps
# carrying the full stderr for logs/inspection).
raise HTTPException(status_code=502, detail=sanitize_error(str(exc))) from None
row.status = "pushed" row.status = "pushed"
row.branch = branch row.branch = branch
row.commit_sha = sha row.commit_sha = sha
+5 -15
View File
@@ -57,12 +57,15 @@ carries the phase-64 per-file progress — ``current_file`` (the
``files_done`` / ``files_total`` — null/0/0 before the import starts ``files_done`` / ``files_total`` — null/0/0 before the import starts
(clone/pull reports no file yet) and in terminal states, which clear (clone/pull reports no file yet) and in terminal states, which clear
``current_file`` but keep the run's final counts. ``current_file`` but keep the run's final counts.
The ``failed`` state's ``error`` string is masked by the shared
sanitizer — the ``user:pass@`` masker now lives in :mod:`app.core.errors`
(imported here under the private name ``_sanitize_error``).
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
@@ -72,6 +75,7 @@ from fastapi import APIRouter, Depends, HTTPException
from app.config import get_settings from app.config import get_settings
from app.core.auth import require_admin from app.core.auth import require_admin
from app.core.errors import sanitize_error as _sanitize_error
from app.db import SessionLocal from app.db import SessionLocal
from app.rag.git_sources import effective_sources from app.rag.git_sources import effective_sources
from app.rag.importer import ImportSummary, import_sources from app.rag.importer import ImportSummary, import_sources
@@ -89,20 +93,6 @@ router = APIRouter(
dependencies=[Depends(require_admin)], # phase 16 pattern: admin-only surface dependencies=[Depends(require_admin)], # phase 16 pattern: admin-only surface
) )
#: ``user:pass@`` inside any error text (git stderr, endpoint URLs) —
#: masked so a sync failure can never leak credentials into the UI.
_CREDS_RE = re.compile(r"[A-Za-z0-9._~%*-]+:[A-Za-z0-9._~%*-]+@")
def _sanitize_error(message: str) -> str:
"""Mask credentials embedded in an error string (no secrets in the UI).
Git's stderr is otherwise surfaced verbatim (phase locked decisions) —
it names the failing repo and git's reason, which is what the admin
needs to fix things.
"""
return _CREDS_RE.sub("*****@", message)
@dataclass @dataclass
class SyncStatus: class SyncStatus:
+57
View File
@@ -0,0 +1,57 @@
"""Shared error-string sanitizer (phase 84 — audit SEC-08 / SEC-13).
``sanitize_error`` masks ``user:pass@`` userinfo in an error string so
no error surface ever ships an embedded credential into the UI, a
response body, or an SSE frame. It is the verbatim lift of the phase-32
sync masker (same regex, same ``*****@`` replacement — byte-identical
behavior); ``app/api/sync.py`` keeps the private name ``_sanitize_error``
as an alias import, and the docs-push 502 detail (SEC-08) and the LLM
error messages (SEC-13) run through the same function.
Audit basis (``.agents/remediation_plan.md``, security audit 2026-09-07):
* SEC-08 (Medium) — the docs-push 502 surfaced git's stderr verbatim;
``BOR_DOCS_REPO`` is documented as "any remote (URL or local path)",
so an ``https://user:token@host/...`` URL is a normal config shape,
and a failed push (revoked token, network) echoes the remote URL in
git's stderr straight into the browser and the logs. The sync path
already solved exactly this problem — the docs-push and LLM surfaces
simply never got the treatment.
* SEC-13 (Low) — the LLM error f-strings interpolated
``settings.llm_base_url`` raw; a base URL configured with embedded
credentials would echo into SSE ``error`` frames and logs. Same class
of leak, same fix.
Contract (narrow userinfo-regex only — byte-identical for
credential-free text):
* Only the git/HTTP ``user:pass@`` userinfo shape is rewritten — a run
of userinfo characters, the separating colon, a second run, and the
``@``. Ordinary text survives character for character: plain hosts,
``https://host/...`` URLs without userinfo, prose with a colon +
space (``fatal: ...``), emails in prose (no colon userinfo run
before the ``@``). The existing sync / git-sources / LLM error-string
tests are the tripwire proving the move is behavior-identical.
* The replacement shape is ``*****@`` — the existing sync copy, so
every already-pinned masked error stays byte-identical.
* Idempotent for the masked forms the surfaces produce (sanitizing an
already-sanitized git/URL failure string is a no-op).
"""
from __future__ import annotations
import re
#: ``user:pass@`` inside any error text (git stderr, endpoint URLs) —
#: the narrow userinfo run (git/HTTP convention) that carries
#: credentials; everything else is left untouched.
_CREDS_RE = re.compile(r"[A-Za-z0-9._~%*-]+:[A-Za-z0-9._~%*-]+@")
def sanitize_error(message: str) -> str:
"""Mask credentials embedded in an error string (no secrets in the UI).
Git's stderr is otherwise surfaced verbatim (the admin needs the
failing repo and git's reason to fix things) — only the ``user:pass@``
userinfo shape is rewritten, to ``*****@``.
"""
return _CREDS_RE.sub("*****@", message)
+16 -6
View File
@@ -29,6 +29,7 @@ from openai import AsyncOpenAI, AsyncStream
from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.core.errors import sanitize_error
if TYPE_CHECKING: if TYPE_CHECKING:
# Phase 71: the filter type is only needed for typing (the module # Phase 71: the filter type is only needed for typing (the module
@@ -285,7 +286,11 @@ class LLMClient:
raise raise
except Exception as e: # noqa: BLE001 — wrap transport-level failures except Exception as e: # noqa: BLE001 — wrap transport-level failures
raise EmbeddingError( raise EmbeddingError(
f"embeddings request to {self.settings.llm_base_url} failed: {e}" # Phase 84 (SEC-13): a base URL configured with
# embedded ``user:pass@`` credentials must not reach
# the error string — sanitized at construction.
f"embeddings request to {sanitize_error(self.settings.llm_base_url)} "
f"failed: {e}"
) from e ) from e
self.embed_batches += 1 self.embed_batches += 1
self._check_dims(vecs) self._check_dims(vecs)
@@ -324,18 +329,20 @@ class LLMClient:
raise raise
except Exception as e: # noqa: BLE001 — wrap transport-level failures except Exception as e: # noqa: BLE001 — wrap transport-level failures
raise LLMError( raise LLMError(
f"chat completion from {self.settings.llm_base_url} failed: {e}" # Phase 84 (SEC-13): sanitize the base URL (see embed).
f"chat completion from {sanitize_error(self.settings.llm_base_url)} "
f"failed: {e}"
) from e ) from e
if not resp.choices: if not resp.choices:
raise LLMError( raise LLMError(
f"chat completion from {self.settings.llm_base_url} " f"chat completion from {sanitize_error(self.settings.llm_base_url)} "
"returned no choices" "returned no choices"
) )
content = resp.choices[0].message.content content = resp.choices[0].message.content
if content is None or not content.strip(): if content is None or not content.strip():
raise LLMError( raise LLMError(
f"chat completion from {self.settings.llm_base_url} returned " f"chat completion from {sanitize_error(self.settings.llm_base_url)} "
"empty content — refusing to store a silent summary" "returned empty content — refusing to store a silent summary"
) )
return content.strip() return content.strip()
@@ -483,7 +490,10 @@ class LLMClient:
except LLMError: except LLMError:
raise raise
except Exception as e: # noqa: BLE001 — wrap transport-level failures except Exception as e: # noqa: BLE001 — wrap transport-level failures
raise LLMError(f"chat stream from {self.settings.llm_base_url} failed: {e}") from e # Phase 84 (SEC-13): sanitize the base URL (see embed).
raise LLMError(
f"chat stream from {sanitize_error(self.settings.llm_base_url)} failed: {e}"
) from e
finally: finally:
# Phase 48: deterministic teardown — whenever ``create()`` # Phase 48: deterministic teardown — whenever ``create()``
# succeeded, close the endpoint's stream on every subsequent # succeeded, close the endpoint's stream on every subsequent
+61
View File
@@ -23,7 +23,9 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import select, text from sqlalchemy import select, text
import app.api.doc_drafts as doc_drafts
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.core.docs_push import DocsPushError
from app.main import app as fastapi_app from app.main import app as fastapi_app
from app.models import DocDraft from app.models import DocDraft
@@ -483,6 +485,65 @@ def test_push_non_repo_dir_returns_502_with_git_stderr(
assert body["commit_sha"] is None assert body["commit_sha"] is None
def test_push_502_detail_masks_git_stderr_credentials(
admin_client: TestClient,
db,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 84 (SEC-08): a ``DocsPushError`` whose git stderr carries a
``user:pass@`` remote URL (a ``BOR_DOCS_REPO`` configured with
embedded credentials) → the 502 detail is the SANITIZED stderr — the
token never reaches the browser or the logs, while the failing repo
and git's reason stay readable; the row is untouched (only a success
mutates). ``push_document`` is monkeypatched, so no real git is
needed."""
def failing_push(**kwargs: Any) -> tuple[str, str]:
raise DocsPushError(
"git push origin bor-docs failed (exit 128): "
"fatal: Authentication failed for "
"'https://bot:ghp_LEAKTOKEN@github.com/owner/docs.git/'"
)
monkeypatch.setattr(doc_drafts, "push_document", failing_push)
fastapi_app.dependency_overrides[
get_settings
] = lambda: _settings(
docs_repo="/nonexistent/docs.git", # configured — push_document is faked
docs_branch=DOCS_BRANCH,
docs_base_branch=BASE_BRANCH,
docs_work_dir=str(tmp_path / "docs-workdir"),
)
created = _create(admin_client)
try:
r = admin_client.post(f"/api/doc-drafts/{created['token']}/push")
finally:
fastapi_app.dependency_overrides.pop(get_settings, None)
assert r.status_code == 502
detail = r.json()["detail"]
# The credential is masked to the sync masker's shape ...
assert "*****@github.com" in detail
assert "ghp_LEAKTOKEN" not in detail
# ... but the failure context stays readable (the GitSyncError →
# detail mapping, otherwise kept).
assert "exit 128" in detail
assert "fatal: Authentication failed" in detail
assert "owner/docs.git" in detail
# The row is untouched (status/branch/commit_sha as found).
body = admin_client.get(f"/api/doc-drafts/{created['token']}").json()
assert body["status"] == "draft"
assert body["branch"] is None
assert body["commit_sha"] is None
row = db.execute(
select(DocDraft).where(DocDraft.token == uuid.UUID(created["token"]))
).scalars().one()
assert row.status == "draft"
assert row.branch is None
assert row.commit_sha is None
def test_push_unknown_token_returns_404( def test_push_unknown_token_returns_404(
admin_client: TestClient, docs_push_settings: Settings admin_client: TestClient, docs_push_settings: Settings
) -> None: ) -> None:
+119
View File
@@ -0,0 +1,119 @@
"""Unit tests: the shared error-string sanitizer (phase 84, task 01).
``app.core.errors.sanitize_error`` is the verbatim lift of the phase-32
sync masker — a NARROW ``user:pass@`` userinfo regex (git/HTTP
convention) with the ``*****@`` replacement. These pins fix both sides
of the contract:
* masking — every pinned ``user:pass@`` occurrence is rewritten to
``*****@`` (a single userinfo, two in one string, and one inside a
realistic git push-failure line — where ``fatal:`` / ``exit 128`` /
the host survive);
* byte-identity — credential-free text (a bare https URL without
userinfo, prose with a colon + space, a plain email, the empty
string) comes back unchanged, character for character;
* idempotence — sanitizing an already-sanitized (masked) string is a
no-op.
Plus the refactor pin: ``app.api.sync._sanitize_error`` is the shared
function under the private alias name (the phase-32 callers —
``app/api/git_sources.py``, ``app/api/docs.py`` — see the identical
function through the import).
"""
from __future__ import annotations
import app.api.sync as sync_api
from app.core.errors import sanitize_error
TWO_USERINFO = "https://alice:secret1@repo.example.com/a.git and https://bob:secret2@repo.example.com/b.git"
GIT_PUSH_FAILURE = (
"git push failed (exit 128): fatal: Authentication failed for "
"'https://bot:tok@github.com/o/r.git/'"
)
# --- The masked forms (the regex must fire) --------------------------------
def test_masks_single_userinfo_in_url() -> None:
"""The canonical shape: ``user:pass@`` → ``*****@``, host intact."""
assert sanitize_error("https://user:pass@host/repo.git") == "https://*****@host/repo.git"
def test_masks_every_userinfo_occurrence() -> None:
"""Two userinfo runs in one string → both masked, nothing else touched."""
masked = sanitize_error(TWO_USERINFO)
assert masked == "https://*****@repo.example.com/a.git and https://*****@repo.example.com/b.git"
assert "secret1" not in masked
assert "secret2" not in masked
def test_masks_token_in_git_push_failure_line() -> None:
"""The SEC-08 shape: git's auth-failure stderr with a token-bearing
remote URL → the token is masked, the actionable context survives."""
masked = sanitize_error(GIT_PUSH_FAILURE)
assert masked == (
"git push failed (exit 128): fatal: Authentication failed for "
"'https://*****@github.com/o/r.git/'"
)
assert "tok" not in masked
assert "exit 128" in masked # the reason survives
assert "fatal:" in masked
assert "github.com/o/r.git" in masked # host + repo path survive
# --- The byte-identity contract (credential-free text) ---------------------
def test_plain_url_without_userinfo_is_unchanged() -> None:
"""A bare https URL (no userinfo) plus a realistic git failure line
→ byte-identical."""
message = (
"git push failed (exit 128): fatal: repository "
"'https://github.com/owner/repo.git/' not found"
)
assert sanitize_error(message) == message
def test_prose_with_colon_and_space_is_unchanged() -> None:
"""Colons in prose are followed by spaces, not by userinfo runs —
the narrow regex must not fire (the sync tests pin the same copy)."""
model_message = (
"The embedding model ('embed') is not available — check the model endpoint and retry."
)
assert sanitize_error(model_message) == model_message
sentence = "note: the sync failed: see the log for details"
assert sanitize_error(sentence) == sentence
def test_email_in_prose_is_unchanged() -> None:
"""``owner@example.com`` carries no colon userinfo run before the
``@`` — the pattern does not match it, so it stays byte-identical
(pinned for determinism: the masker only rewrites ``user:pass@``)."""
message = "please contact owner@example.com about the failing push"
assert sanitize_error(message) == message
def test_empty_string_is_unchanged() -> None:
assert sanitize_error("") == ""
# --- Idempotence (masked forms) ---------------------------------------------
def test_idempotent_on_masked_forms() -> None:
"""Sanitizing an already-sanitized string is a no-op — the ``*****@``
replacement never forms a new ``user:pass@`` run in the pinned cases."""
for message in ("https://user:pass@host/repo.git", TWO_USERINFO, GIT_PUSH_FAILURE):
once = sanitize_error(message)
assert sanitize_error(once) == once
# --- The sync alias refactor (phase 84, task 01) ----------------------------
def test_sync_keeps_the_private_alias_of_the_shared_function() -> None:
"""``app.api.sync._sanitize_error`` IS the shared sanitizer under the
private name — the phase-32 callers (``git_sources.py``, ``docs.py``)
import it through ``app.api.sync`` and see the identical function."""
assert sync_api._sanitize_error is sanitize_error
assert sync_api._sanitize_error(GIT_PUSH_FAILURE) == sanitize_error(GIT_PUSH_FAILURE)
+46
View File
@@ -189,6 +189,36 @@ def test_http_error_surfaces_status() -> None:
assert llm.embed_batches == 0 assert llm.embed_batches == 0
def test_embed_transport_failure_masks_credentials_in_base_url() -> None:
"""Phase 84 (SEC-13): a base URL configured with embedded
``user:pass@`` credentials never reaches the error message — the URL
in the raised :class:`EmbeddingError` is sanitized (``*****@``),
while the failure context stays readable."""
llm, _ = _make_client(
_FakeEmbeddingsService(fail=RuntimeError("connection refused")),
llm_base_url="https://svc:topsecret@llm.local/v1",
)
with pytest.raises(EmbeddingError) as exc:
asyncio.run(llm.embed(["hello"]))
msg = str(exc.value)
assert "https://*****@llm.local/v1" in msg
assert "topsecret" not in msg
assert "connection refused" in msg
assert llm.embed_batches == 0
# The credential-free pin stays byte-identical (sanitize is a no-op
# without userinfo): the default base URL appears verbatim.
llm_plain, _ = _make_client(
_FakeEmbeddingsService(fail=RuntimeError("connection refused"))
)
with pytest.raises(EmbeddingError) as exc_plain:
asyncio.run(llm_plain.embed(["hello"]))
assert (
str(exc_plain.value)
== "embeddings request to https://aipi.reeseapps.com/v1 failed: "
"connection refused"
)
def test_missing_vector_row_is_rejected() -> None: def test_missing_vector_row_is_rejected() -> None:
llm, _ = _make_client(_FakeEmbeddingsService(drop_index=1)) llm, _ = _make_client(_FakeEmbeddingsService(drop_index=1))
with pytest.raises(EmbeddingError, match="returned 1 vectors for 2 inputs"): with pytest.raises(EmbeddingError, match="returned 1 vectors for 2 inputs"):
@@ -925,6 +955,22 @@ def test_chat_transport_failure_wrapped_as_llm_error_with_base_url() -> None:
assert "aipi.reeseapps.com" in str(exc.value) assert "aipi.reeseapps.com" in str(exc.value)
def test_chat_transport_failure_masks_credentials_in_base_url() -> None:
"""Phase 84 (SEC-13): the chat failure f-string sanitizes the base
URL the same way the embed path does (the five sites share the
construction; this is the chat representative)."""
llm, _ = _make_chat_client(
fail=RuntimeError("HTTP 502 Bad Gateway"),
llm_base_url="https://svc:topsecret@llm.local/v1",
)
with pytest.raises(LLMError) as exc:
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
msg = str(exc.value)
assert "https://*****@llm.local/v1" in msg
assert "topsecret" not in msg
assert "HTTP 502 Bad Gateway" in msg
def test_chat_llm_error_passes_through_unwrapped() -> None: def test_chat_llm_error_passes_through_unwrapped() -> None:
llm, _ = _make_chat_client(fail=LLMError("already wrapped")) llm, _ = _make_chat_client(fail=LLMError("already wrapped"))
with pytest.raises(LLMError, match="already wrapped"): with pytest.raises(LLMError, match="already wrapped"):