mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-10 14:27:00 -04:00
## Description
Engineering hardening derived from the Box vendor security assessment.
Each change turns a "No/Partial" questionnaire answer into a genuine
"Yes" by making the product safer — not by editing the form. The
throughline is Headroom's core promise to enterprise pilots: **it runs
inside the customer's environment and never persists or leaks their
data.** These changes make that provable.
Three themes: (1) a complete **stateless write guarantee** (a stateless
proxy writes nothing to the workspace during serving), (2)
**data-at-rest** protection (no cleartext prompts written on errors),
and (3) **supply-chain integrity** (all model downloads pinned;
SCA/SAST/secret-scanning in CI).
Closes # (no tracking issue)
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
> Note: two deliberate, reversible default changes (not breaking): the
upstream-error debug dump is now off by default
(`HEADROOM_DEBUG_DUMP=1`/`=full` to opt in), and model downloads are
pinned (`HEADROOM_HF_PIN=off` to bypass). All stateless plumbing is a
pure no-op when not stateless.
## Changes Made
- **Stateless writes** — savings tracker + ledger, TOIN (`toin.json`),
and the output-savings recorder now honor stateless (in-memory only);
persistent memory is disabled under stateless with a warning. Added a
process-wide flag `headroom.paths.process_is_stateless()` (also honors
`HEADROOM_STATELESS`).
- **Debug dump** — the Anthropic *and* OpenAI handlers wrote full
requests (cleartext prompts/tools/system) to
`~/.headroom/logs/debug_400/` on every ≥400, even stateless. Now OFF by
default, stateless-aware, with a redacted middle tier; helpers extracted
to `handlers/_debug_dump.py`.
- **Model pinning** — all model downloads pin an immutable commit SHA:
our repos, kompress, image router/SigLIP, the third-party Qdrant memory
embedder (centralized in `onnx_runtime`), and the fastembed relevance
model (via the `revision` kwarg fastembed forwards to
`snapshot_download`). `HEADROOM_HF_PIN=off` bypasses.
- **CI security gate** — new `security.yml`: dependency audit
(pip-audit, scoped to the CVE-free `[all]` set), CodeQL (Python +
JS/TS), and gitleaks secret scanning (binary, MIT-licensed; PR-diff
scoped). `.gitleaks.toml` allowlists SBOM/lockfiles.
- **Dependabot** — extended to Rust (cargo) and npm (TS SDK, plugins,
docs).
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ ruff check <10 changed source files>
All checks passed!
$ mypy <changed source files>
Success: no issues found in 7 source files # + handlers/server: no issues (annotation-unchecked notes only)
$ pytest tests/test_stateless_writers.py tests/test_stateless_toin.py \
tests/test_debug_dump_gating.py tests/test_hf_revision_pinning.py \
tests/test_proxy_savings_history.py tests/test_observability_metrics.py \
tests/test_toin.py tests/test_paths.py -q
================= 176 passed, 6 skipped, 2 warnings in 13.92s ==================
```
New tests (18): `tests/test_stateless_writers.py`,
`tests/test_stateless_toin.py`, `tests/test_debug_dump_gating.py`,
`tests/test_hf_revision_pinning.py`, plus stateless control assertions
in `tests/test_proxy_savings_history.py`. They include the non-stateless
control cases (savings/TOIN still persist) and a regression guard that
fails if any handler writes a debug dump without gating it.
## Real Behavior Proof
- **Environment:** macOS, Python 3.12 (`.venv`); CI on `ubuntu-latest`.
- **Exact command / steps:**
- Stateless guarantee: `SavingsRecorder(tmp/"output_savings.json")` +
`set_process_stateless(True)` → `flush()`; TOIN
`ToolIntelligenceNetwork(TOINConfig(storage_path=""))`;
`create_app(ProxyConfig(memory_enabled=True, stateless=True))`.
- Debug-dump gating: `_debug_dump_mode(SimpleNamespace(stateless=...))`
across env values.
- Model pinning: model SHAs fetched/verified against the live
HuggingFace API; `_resolve_revision` / `_pinned_revision` resolvers
tested.
- **Observed result:** under stateless, no `proxy_savings.json` /
`savings_events.jsonl` / `toin.json` / `output_savings.json` /
`memory.db` is created; `proxy.memory_handler is None`. With
`stateless=False` the control tests confirm each still persists. Debug
dump resolves to `off` by default and is forced off in stateless. CI:
dependency-audit, CodeQL (python + js/ts) pass; secret-scan now runs the
gitleaks binary.
- **Not tested:** `pip-audit` was not run on the local machine (broken
`ensurepip`); CI is the first real run (the committed all-extras grype
scan is clean). The fastembed download path is exercised by CI/runtime,
not in unit tests (the revision resolver is unit-tested).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Additional Notes
- **Concurrency:** `stateless` is a per-process config flag, never
per-request/per-session. Many sessions share one proxy's setting; a
stateless and a stateful proxy are separate OS processes with isolated
state. The one in-process edge (two proxies, different settings —
essentially tests) fails closed to in-memory, so a stateless proxy can
never leak.
- **Memory under stateless** is *disabled* (not in-RAM): the memory
subsystem is multi-component (SQLite + vector + markdown bridge) and a
partial in-RAM mode would be risky; ephemeral containers and
cross-session learning are contradictory. An ephemeral in-RAM memory
mode is a possible follow-up.
- **Docs/CHANGELOG** left unchecked: the two new env vars
(`HEADROOM_DEBUG_DUMP`, `HEADROOM_HF_PIN`) and the stateless behavior
changes are documented in code comments; happy to add user docs + a
CHANGELOG entry if preferred.
- CI deprecation warnings (Node 20, CodeQL Action v3) are GitHub-side
and out of scope here.
96 lines
3.4 KiB
Python
96 lines
3.4 KiB
Python
"""Tests for the upstream-error diagnostic-dump gating.
|
|
|
|
The dump can contain cleartext prompt/tool/system content, so it must be OFF by
|
|
default, never written in stateless mode, and content-redacted unless the
|
|
operator explicitly opts in to full content.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("fastapi")
|
|
|
|
from headroom.proxy.handlers._debug_dump import _debug_dump_mode, _redact_debug_value
|
|
|
|
|
|
def _config(stateless: bool = False) -> SimpleNamespace:
|
|
return SimpleNamespace(stateless=stateless)
|
|
|
|
|
|
def test_debug_dump_off_by_default(monkeypatch):
|
|
monkeypatch.delenv("HEADROOM_DEBUG_DUMP", raising=False)
|
|
assert _debug_dump_mode(_config()) == "off"
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["1", "true", "yes", "on", "redacted", "REDACTED"])
|
|
def test_debug_dump_opt_in_redacted(monkeypatch, value):
|
|
monkeypatch.setenv("HEADROOM_DEBUG_DUMP", value)
|
|
assert _debug_dump_mode(_config()) == "redacted"
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["full", "all", "content"])
|
|
def test_debug_dump_opt_in_full(monkeypatch, value):
|
|
monkeypatch.setenv("HEADROOM_DEBUG_DUMP", value)
|
|
assert _debug_dump_mode(_config()) == "full"
|
|
|
|
|
|
def test_debug_dump_unknown_value_is_off(monkeypatch):
|
|
monkeypatch.setenv("HEADROOM_DEBUG_DUMP", "maybe")
|
|
assert _debug_dump_mode(_config()) == "off"
|
|
|
|
|
|
def test_stateless_forces_dump_off_even_when_opted_in(monkeypatch):
|
|
# Stateless mode must win over any opt-in: no filesystem writes, period.
|
|
monkeypatch.setenv("HEADROOM_DEBUG_DUMP", "full")
|
|
assert _debug_dump_mode(_config(stateless=True)) == "off"
|
|
|
|
|
|
def test_redact_elides_long_strings_keeps_structure():
|
|
payload = {
|
|
"role": "user",
|
|
"type": "text",
|
|
"id": "msg_123",
|
|
"text": "secret prompt content " * 20, # long → redacted
|
|
"blocks": [
|
|
{"type": "tool_use", "name": "search", "input": "x" * 500},
|
|
{"type": "text", "text": "short"},
|
|
],
|
|
}
|
|
out = _redact_debug_value(payload)
|
|
# Short structural fields preserved:
|
|
assert out["role"] == "user"
|
|
assert out["type"] == "text"
|
|
assert out["id"] == "msg_123"
|
|
assert out["blocks"][0]["name"] == "search"
|
|
assert out["blocks"][1]["text"] == "short"
|
|
# Long content elided to a length placeholder (no original content leaks):
|
|
assert out["text"].startswith("<redacted:") and "secret prompt" not in out["text"]
|
|
assert out["blocks"][0]["input"].startswith("<redacted:")
|
|
|
|
|
|
def test_redact_passes_through_non_strings():
|
|
assert _redact_debug_value(42) == 42
|
|
assert _redact_debug_value(None) is None
|
|
assert _redact_debug_value(True) is True
|
|
|
|
|
|
@pytest.mark.parametrize("module_name", ["anthropic", "openai"])
|
|
def test_both_handlers_gate_the_dump(module_name):
|
|
"""Regression guard: every handler that writes a debug dump must gate it on
|
|
_debug_dump_mode (off by default). Prevents reintroducing an unguarded dump
|
|
that writes cleartext prompts to disk."""
|
|
import importlib
|
|
|
|
module = importlib.import_module(f"headroom.proxy.handlers.{module_name}")
|
|
src = inspect.getsource(module)
|
|
if "debug_400_dir(" in src:
|
|
assert "_debug_dump_mode(self.config)" in src, (
|
|
f"{module_name} writes a debug dump but does not gate it on _debug_dump_mode"
|
|
)
|
|
assert 'if dump_mode != "off":' in src, (
|
|
f"{module_name} debug dump is not guarded by an off-by-default check"
|
|
)
|