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
@@ -1,63 +0,0 @@
# Phase 84 — Mask credentials in docs-push and LLM error surfaces
**Source:** `.agents/remediation_plan.md` SEC-08 (security audit 2026-09-07, severity Medium — "Docs-push 502 surfaces git stderr verbatim (possible credential echo)"), with the SEC-13 fold-in (Low — "LLM error strings echo `llm_base_url`")
**Story:** n/a (security hardening — audit-derived, no user story)
**Context:** `app/api/sync.py` — the existing masker: `_CREDS_RE = re.compile(r"[A-Za-z0-9._~%*-]+:[A-Za-z0-9._~%*-]+@")` + `_sanitize_error(message)` (masks `user:pass@` in any error text; the sync failure detail runs through it — "no secrets in the UI"). `app/api/git_sources.py` — imports it (`from app.api.sync import _sanitize_error`) for upload-failure details. `app/api/doc_drafts.py::push_doc_draft` — the GAP: `except DocsPushError as exc: raise HTTPException(502, detail=str(exc))` — git's stderr verbatim (a `BOR_DOCS_REPO` URL with embedded credentials would reach the browser + server log on a failed push). `app/core/docs_push.py` — `push_document` raises `DocsPushError(str(err))` carrying `GitSyncError`'s "git … failed (exit N): <stderr>" text. `scripts/git_sync.py::run_git` — the single git invocation point (list args, no shell — the error text is git's own stderr). `app/rag/llm.py` — SEC-13: five error f-strings embed `self.settings.llm_base_url` raw (lines 288, 327, 331, 337, 486 — embed + chat + stream failure paths) — a base URL configured with embedded credentials would echo into SSE `error` frames and logs. `tests/integration/test_doc_drafts_api.py` (the existing 502-contract pins — git stderr in the detail) and `tests/unit/test_llm_client.py` (the error-string pins) are the regression anchors.
## Objective
No error surface ever ships a `user:pass@` credential: the sync masker moves to a shared core module, the docs-push 502 detail runs through it (SEC-08), and the LLM error construction sanitizes the base URL (SEC-13) — while every non-credential error string (the sync/git/LLM tests' pinned copies) stays byte-identical.
## Audit basis (read this, not the chat)
- SEC-08: `BOR_DOCS_REPO` is documented as "any remote (URL or local path)" — an `https://user:token@host/…` URL is a normal config shape. On a failed push (revoked token, network), git's stderr echoes the remote URL; `push_doc_draft` returns it verbatim as the 502 `detail` → the credential lands in the browser (admin screen) and in the server log (`logger` writes the 502? the detail is the response body — and `run_git`'s stderr also surfaces into any traceback logging). The sync path already solved this exact problem (`_sanitize_error`) — the docs-push path simply never got the treatment.
- SEC-13 (fold-in): the five `llm.py` error f-strings interpolate `llm_base_url` raw — same class of leak on the LLM side (the real deployment uses a bare URL + header key, so today it is latent).
- The masker is deliberately a NARROW regex (only the `user:pass@` userinfo shape — git/HTTP convention): it must not rewrite ordinary text (`a: b @ c` without the userinfo run, plain hosts, emails in prose stay untouched as long as they don't match the userinfo pattern — the existing sync tests pin the behavior; the move must be byte-identical).
## Owner decisions (chat, 2026-09-07 — recorded per AGENTS.md rule 3)
- **A1 — shared core module:** `app/core/errors.py` (new) owns `sanitize_error(message: str) -> str` — the regex + logic move VERBATIM from `app/api/sync.py` (same `_CREDS_RE` pattern, same `sub("*****@", …)` replacement — byte-identical behavior). `app/api/sync.py` keeps the private name as an alias (`from app.core.errors import sanitize_error as _sanitize_error`) so `app/api/git_sources.py`'s existing import and every sync test stay untouched (zero caller diff outside the two target surfaces).
- **A2 — SEC-08 application point:** `app/api/doc_drafts.py::push_doc_draft` — the `except DocsPushError` clause becomes `raise HTTPException(502, detail=sanitize_error(str(exc))) from None` (the ONLY line that changes there).
- **A3 — SEC-13 application point:** `app/rag/llm.py` — the five error f-strings sanitize the URL at construction: a module-level `self._base = sanitize_error(settings.llm_base_url)` is NOT introduced (the client is constructed with settings; simpler and more local: each f-string uses `sanitize_error(self.settings.llm_base_url)`). With a credential-free URL (every real deployment) `sanitize_error` is a no-op → the existing error-string pins in `tests/unit/test_llm_client.py` stay byte-identical green.
- **A4 — no other surfaces in scope:** the sync/git-sources upload details already sanitize; the chat SSE error frames carry fixed operator copy (not git/URL text) — untouched.
## Design (shared by all tasks — the executor reads this, not the chat)
- **`app/core/errors.py` (new):**
```python
_CREDS_RE = re.compile(r"[A-Za-z0-9._~%*-]+:[A-Za-z0-9._~%*-]+@")
def sanitize_error(message: str) -> str:
"""Mask user:pass@ userinfo in an error string (no secrets in the UI/logs)."""
return _CREDS_RE.sub("*****@", message)
```
Module docstring: the audit basis (SEC-08/SEC-13), the "narrow userinfo-regex only" contract (byte-identical for credential-free text — the sync tests are the proof), the replacement shape (`*****@` — the existing sync copy).
- **`app/api/sync.py`** — delete the local `_CREDS_RE` + `_sanitize_error` definitions; add `from app.core.errors import sanitize_error as _sanitize_error` (the `_run_sync` failure path and its tests see the identical function under the identical private name).
- **`app/api/doc_drafts.py`** — import `sanitize_error` from `app.core.errors`; the `push_doc_draft` except clause per A2; the route docstring's outcome-4 line updated (`502 with the SANITIZED git stderr in the detail — credential userinfo masked, the phase-59 `GitSyncError → detail` mapping kept`).
- **`app/rag/llm.py`** — import `sanitize_error`; the five f-strings (lines 288/327/331/337/486 today) wrap the URL: `f"embeddings request to {sanitize_error(self.settings.llm_base_url)} failed: {e}"` etc. (the message copy around it unchanged).
- **Not touched:** `scripts/git_sync.py` (the stderr source — unchanged), `app/core/docs_push.py` (raises the raw text — the SANITIZE point is the API boundary, where the response is built), the sync/upload failure paths' behavior (byte-identical via the alias).
## Dependencies
— (none; standalone error-surface hardening — builds on the completed phase-32 sync sanitizer and phase-59 docs-push; no behavior change for credential-free errors)
## Tasks
1. `01_shared_sanitizer.md` — `app/core/errors.py` + the sync alias refactor + unit suite.
2. `02_apply_docs_push_and_llm.md` — the docs-push 502 + the five LLM error sites + the integration/unit pins.
3. `03_verify_and_commit.md` — full gate + atomic commit.
## Testing & Quality
- Unit — `tests/unit/test_error_sanitization.py` (new): the exact existing sync behavior is pinned — `https://user:pass@host/x` → `https://*****@host/x`; multiple userinfo occurrences all masked; an ssh-style `git:token@host` userinfo masked; credential-free text (a git failure line with a bare `https://github.com/owner/repo.git` URL, a plain error sentence, an email-shaped `a@b.c` — no userinfo run) **byte-identical**; idempotent (sanitize(sanitize(x)) == sanitize(x)).
- Unit — `tests/unit/test_llm_client.py` (extend): with `llm_base_url="https://svc:topsecret@llm.local/v1"`, a forced transport failure on the embed path → the `EmbeddingError` message contains `https://*****@llm.local/v1` and NOT `topsecret` (one representative path — the embed failure; the other four sites share the same construction, pinned by the same test style if cheap); the credential-free base-URL error pins stay byte-identical (existing tests green).
- Integration — `tests/integration/test_doc_drafts_api.py` (extend): monkeypatch `app.core.docs_push.push_document` (or the doc_drafts module's reference) to raise `DocsPushError("git push failed (exit 128): fatal: Authentication failed for 'https://bot:ghp_LEAK@github.com/owner/docs.git/'")` → `POST /api/doc-drafts/{token}/push` → **502** whose `detail` contains `*****@github.com` and NOT `ghp_LEAK` (and the repo/exit/`fatal:` context still readable); the existing 502 pins (plain stderr detail) stay byte-identical green.
- Regression: `tests/integration/test_git_sources_upload.py` + the sync-status tests (the alias refactor's proof — the `_sanitize_error` import in `git_sources.py` still works; the sync `failed`-state error is masked exactly as before).
- E2E (isolation gate per AGENTS.md rule 9): `uv run pytest tests/e2e/test_smoke.py -v --no-cov` green — no UI change (the doc-edit screen renders whatever detail string the API returns; the shape is unchanged).
- Coverage: **>90%** on `app/` — the new module is two lines + fully unit-pinned; `app/rag/llm.py`'s changed lines hit by the new unit tests + the existing error-path tests.
## Completion Criteria
- [ ] A docs-push failure with userinfo in git's stderr → 502 detail shows `*****@` and never the token (integration pin); the row is untouched (status/branch/sha as found — the existing pin).
- [ ] A LLM transport failure with a userinfo-bearing `llm_base_url` → the error message is masked (unit pin); credential-free error strings byte-identical (existing pins green).
- [ ] `app/api/sync.py` no longer defines its own `_CREDS_RE`/`_sanitize_error` (the alias is the import); `git grep "_CREDS_RE" app/` shows only `app/core/errors.py`; the sync + git-sources upload suites green untouched.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run pytest tests/e2e/test_smoke.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean.
- [ ] `git diff --stat` limited to `app/core/errors.py`, `app/api/sync.py`, `app/api/doc_drafts.py`, `app/rag/llm.py`, the three test files, phase files.
- [ ] One atomic `--no-gpg-sign` commit (e.g. `fix(security): mask credentials in docs-push and LLM error surfaces`); phase dir moved to `.agents/phases/complete/`.
## Locked decisions
- **Sanitize at the API boundary** (A2) — `DocsPushError` keeps carrying the full stderr (logs/inspection value); the RESPONSE is where the secret would leak, so that is where it is masked.
- **Byte-identical for credential-free text** (A1/A3) — the narrow-regex contract; the existing sync/LLM error pins are the tripwire, and they must pass unchanged.
- **No logging changes** — this phase changes what responses carry; the server-log side of SEC-08/SEC-13 is improved as a consequence (the 502 detail is the logged-adjacent surface) but no log-format change is in scope.
@@ -1,29 +0,0 @@
# Task 01 — The shared sanitizer module + sync alias refactor + unit suite
**Phase:** `84_docs_push_error_sanitization` · **Story:** n/a (security hardening — audit SEC-08)
## Objective
`app/core/errors.py` owns `sanitize_error` (verbatim behavior of the sync masker), `app/api/sync.py` becomes a thin alias caller (zero behavior change), and `tests/unit/test_error_sanitization.py` pins the masking + the byte-identical-for-plain-text contract.
## Work
1. `app/core/errors.py` (new) — per the phase overview's design block: `_CREDS_RE` (the exact pattern from `app/api/sync.py`) + `sanitize_error(message: str) -> str` (`_CREDS_RE.sub("*****@", message)`) + the module docstring content (audit basis SEC-08/SEC-13, the narrow-userinfo contract, the `*****@` replacement shape — the sync copy).
2. `app/api/sync.py` — remove the local `_CREDS_RE` definition and the `_sanitize_error` function body; add `from app.core.errors import sanitize_error as _sanitize_error`; the `_run_sync` failure path (`_status.error = _sanitize_error(str(e))`) and everything else in the file stays byte-identical (the private name keeps working — `app/api/git_sources.py`'s `from app.api.sync import _sanitize_error` continues to import the same function through the alias).
- Update the `app/api/sync.py` module docstring's mention of the masker (one line: it now lives in `app/core/errors.py`, imported under the private name).
3. `tests/unit/test_error_sanitization.py` (new):
- `https://user:pass@host/repo.git` → `https://*****@host/repo.git`;
- two userinfo occurrences in one string → both masked;
- `git push failed (exit 128): fatal: Authentication failed for 'https://bot:tok@github.com/o/r.git/'` → the token masked, `fatal:`/`exit 128`/host intact;
- byte-identical cases (the contract): a plain git error with a bare `https://github.com/owner/repo.git` (no userinfo), a sentence with a colon + space, an email `owner@example.com` (no userinfo run before the `@`… assert exactly what the regex does — if the pattern masks it, pin THAT and note it; the point is determinism, not guessing), the empty string;
- idempotence: `sanitize_error(sanitize_error(x)) == sanitize_error(x)` for the masked cases.
4. Run the sync-related suites to prove the alias refactor: `uv run pytest tests/integration/test_git_sources_upload.py -q` + any existing sync-status integration test file (`tests/integration/` — locate the sync suite, e.g. via `rg -l "sync" tests/integration/`) → all green untouched.
## Testing & Quality
- `uv run pytest tests/unit/test_error_sanitization.py -v` green.
- The sync/upload regression suites green (the alias is behavior-identical).
- Coverage: **>90%** on `app/core/errors.py` (trivial — both branches of the regex hit).
## Completion Criteria
- [ ] `git grep -n "_CREDS_RE" app/` shows exactly one definition — `app/core/errors.py` — and `app/api/sync.py` imports under the private alias.
- [ ] `uv run pytest tests/unit/test_error_sanitization.py -v` green.
- [ ] The sync/upload integration suites green with NO test edits.
- [ ] `uv run ruff check . && uv run pyright` clean.
@@ -1,29 +0,0 @@
# Task 02 — Apply the sanitizer to the docs-push 502 and the LLM error sites
**Phase:** `84_docs_push_error_sanitization` · **Story:** n/a (security hardening — audit SEC-08 + SEC-13)
## Objective
The two leaking surfaces are closed: `push_doc_draft`'s 502 detail is sanitized (SEC-08), and the five `llm.py` error f-strings sanitize the base URL (SEC-13) — with integration/unit pins for both and the credential-free error strings byte-identical.
## Work
1. `app/api/doc_drafts.py` — import `from app.core.errors import sanitize_error`; in `push_doc_draft`'s `except DocsPushError as exc:` clause change the raise to `raise HTTPException(status_code=502, detail=sanitize_error(str(exc))) from None`; update the route docstring's outcome-4 line (502 detail = the SANITIZED git stderr — userinfo masked, the `GitSyncError → detail` mapping otherwise kept) and the module docstring's one-line error-contract sentence if it names the raw stderr.
2. `tests/integration/test_doc_drafts_api.py` — extend (the existing 502 pins stay green):
- a draft with `status="draft"` + `docs_configured` (the file's existing configured-settings pattern — monkeypatch `get_settings` or use the fixture's env);
- monkeypatch the module-level `push_document` reference in `app.api.doc_drafts` to `raise DocsPushError("git push origin bor-docs failed (exit 128): fatal: Authentication failed for 'https://bot:ghp_LEAKTOKEN@github.com/owner/docs.git/'")`;
- `POST /api/doc-drafts/{token}/push` → **502**; the `detail` contains `*****@github.com` and `exit 128` and `fatal: Authentication failed` but NOT `ghp_LEAKTOKEN`;
- the row is untouched (status/branch/commit_sha as found — the existing pin style).
3. `app/rag/llm.py` — import `sanitize_error`; wrap the base URL in the five error f-strings (the lines building `EmbeddingError`/`LLMError` with `…to/from {self.settings.llm_base_url}…`): each becomes `{sanitize_error(self.settings.llm_base_url)}`; the surrounding copy is byte-identical.
4. `tests/unit/test_llm_client.py` — extend:
- a client built with `llm_base_url="https://svc:topsecret@llm.local/v1"` (the file's existing fake-settings pattern) + a forced transport failure on the EMBED path → the raised `EmbeddingError`'s message contains `https://*****@llm.local/v1` and NOT `topsecret`;
- if the file's structure makes it cheap, the same for one of the chat paths (the `chat_stream`/`chat` failure f-string) — otherwise the embed path alone plus a comment (the five sites share the construction; the byte-identical regression is the existing pins);
- the existing credential-free error-string pins (bare `https://aipi.example/v1`-style base URLs) stay byte-identical green.
## Testing & Quality
- `uv run pytest tests/integration/test_doc_drafts_api.py -v` green (new + existing).
- `uv run pytest tests/unit/test_llm_client.py -v` green (new + existing — the byte-identical proof).
- Coverage: **>90%** on `app/` — the changed lines in `doc_drafts.py`/`llm.py` are hit by the new pins; the other four LLM sites share the identical construction (the existing error-path tests still exercise them).
## Completion Criteria
- [ ] The 502 pin passes: `*****@github.com` present, `ghp_LEAKTOKEN` absent, context readable; the row untouched.
- [ ] The LLM unit pin passes: masked URL in the error message, `topsecret` absent; the pre-existing error-string tests green unchanged.
- [ ] `uv run ruff check . && uv run pyright` clean.
@@ -1,26 +0,0 @@
# Task 03 — Full gate + atomic commit
**Phase:** `84_docs_push_error_sanitization` · **Story:** n/a (security hardening — audit SEC-08 + SEC-13)
## Objective
Run the complete phase gate, land the phase as one atomic commit, and move the phase directory to `complete/`.
## Work
1. **Full regression gate** (AGENTS.md rule 9):
- `uv run pytest` — unit + integration green (the sync/upload + doc-drafts + LLM suites are the regression anchors for the alias refactor and the byte-identical contract).
- `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage **>90%**.
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` — green **in isolation** (this phase's E2E contract: no UI change — the doc-edit screen renders the detail string it always rendered; the shape is unchanged, only the userinfo run is masked).
- `uv run ruff check . && uv run pyright` — clean.
2. **Commit** (AGENTS.md rule 8 — one atomic, Conventional-Commits commit, always `--no-gpg-sign`), staging `app/core/errors.py`, `app/api/sync.py`, `app/api/doc_drafts.py`, `app/rag/llm.py`, `tests/unit/test_error_sanitization.py`, `tests/unit/test_llm_client.py`, `tests/integration/test_doc_drafts_api.py`, and the phase files:
`fix(security): mask credentials in docs-push and LLM error surfaces`
— body: security audit SEC-08 + SEC-13 (2026-09-07) — a docs-push failure returned git's stderr verbatim as the 502 detail (a `BOR_DOCS_REPO` URL with embedded credentials would reach the browser + logs), and the LLM error f-strings echoed `llm_base_url` raw; the sync-phase userinfo masker (`user:pass@` → `*****@`) now lives in `app/core/errors.py`, the docs-push 502 and the five LLM error sites run through it, and the sync import keeps working via a private alias. Credential-free error strings are byte-identical (existing pins green).
3. Move the phase directory: `mv .agents/phases/todo/84_docs_push_error_sanitization .agents/phases/complete/` and include the move in the same commit.
## Testing & Quality
- This task IS the phase-level gate — the commands above are the completion evidence.
- Coverage: >90% held.
## Completion Criteria
- [ ] `uv run pytest` green (all regression anchors included); coverage >90%; `tests/e2e/test_smoke.py` green in isolation; ruff + pyright clean.
- [ ] Exactly one new commit; `git show --stat HEAD` lists the files above + the phase files (todo → complete move) — in particular NO `scripts/`, NO `app/core/docs_push.py`, NO `frontend/`, NO `pyproject.toml`/`uv.lock`.
- [ ] `.agents/phases/complete/84_docs_push_error_sanitization/` exists; `todo/` no longer contains it.