Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/rules/file-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ Full module index for LocalChat. **Keep this current** β€” update in the same co
| `pyproject.toml` | Tool config β€” `[tool.ruff]`, `[tool.pytest.ini_options]`, `[tool.coverage.*]`, `[tool.bandit]`, and `[tool.importlinter]`: ADR-7's layer contracts with today's recorded exceptions |
| `.gitattributes` | Forces LF on `scripts/scaleway/` β€” those files run on, or are handed to, a Linux host, and a Windows checkout with `core.autocrlf=true` otherwise ships them CRLF (a cloud-init once arrived as `#cloud-config` + CR). Exempts `static/vendor/` from any end-of-line conversion, so the vendored builds stay byte-identical to their recorded integrity |
| `docker-compose.yml` | Full stack: app + PostgreSQL + Redis + Ollama; `--profile mcp` adds MCP servers |
| `Dockerfile` | Multi-stage build on Docker Hardened Images β€” `dhi.io/python:3.12-dev` builds the venv, `dhi.io/python:3.12` runs it as uid 65532 with no shell or package manager |
| `Dockerfile` | Multi-stage build on Docker Hardened Images β€” `dhi.io/python:3.12-dev` builds the venv, `dhi.io/python:3.12` runs it as uid 65532 with no shell or package manager; source and venv owned by root, only `logs/`, `uploads/` and `data/` writable (R-11) |
| `docker-entrypoint.py` | Container entrypoint β€” expands `SERVER_PORT`/`UVICORN_WORKERS`/`UVICORN_TIMEOUT` (the hardened base has no shell to do it) and `exec`s uvicorn so it stays PID 1 |
| `docs/DEPLOYMENT.md` | Docker Compose deployment: secrets, resource limits, upgrade/rollback, TLS, security checklist |
| `docs/grafana-dashboard.json` | Importable Grafana dashboard (uid `localchat-rag-v1`, 20 panels; the Model Usage row is OBS-1a) |
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,28 @@ jobs:
fi
echo "OK: uid 65532"

- name: The runtime user can write only its own directories
# R-11: the source and the venv are root's, so code execution in the app cannot
# persist by rewriting a module; logs, uploads and data are the app's.
run: |
docker run --rm --entrypoint python localchat:smoke -c '
import os, site, sys
own = ("/app/data", "/app/logs", "/app/uploads")
writable = [
p
for top in ("/app", "/opt/venv")
for root, dirs, files in os.walk(top)
if not root.startswith(own)
for p in [root, *(os.path.join(root, f) for f in files)]
if os.access(p, os.W_OK)
]
missing = [p for p in own if not os.access(p, os.W_OK)]
on_path = [p for p in sys.path if p and os.access(p, os.W_OK)]
problems = writable[:5] + missing + on_path + (["ENABLE_USER_SITE"] if site.ENABLE_USER_SITE else [])
sys.exit("::error::write access not as expected: " + ", ".join(problems) if problems else 0)
'
echo "OK: /app and /opt/venv read-only but for logs, uploads, data; nothing writable on sys.path"

- name: No shell and no package manager
run: |
docker run --rm --entrypoint python localchat:smoke -c '
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ OpenAI, `https://api.openai.com/v1`) and drop any `provider/` prefix from `CLOUD
- **`litellm` is gone from the image (QP-11, ADR-4).** It carried CVE-2026-114612, in a
proxy server LocalChat never ran, and every fixed version pulls `boto3` into the image.
The `pip-audit` suppression for it is removed; `SECURITY.md` item 12 is resolved.
- **The image's code is no longer writable by the process that runs it (R-11).** The source and the virtual environment were owned by uid 65532, so code execution in the app could persist by rewriting a module. Both now belong to root. The runtime user writes only `/app/logs`, `/app/uploads` and a new `/app/data`.
- **The public webhook receiver no longer reveals which connector ids exist.** It answered
404 for an unknown id and 400 for a connector of another type before checking the secret,
so an anonymous caller could probe for ids and for which were webhooks. Every refusal
Expand Down Expand Up @@ -45,6 +46,7 @@ OpenAI, `https://api.openai.com/v1`) and drop any `provider/` prefix from `CLOUD
`CLOUD_PRICE_OUTPUT_PER_MTOK`; without both, only tokens are counted. A provider that is
not OpenAI-shaped (Anthropic's native API, Vertex) is no longer reachable, as ADR-4
accepted.
- **Settings and fine-tuned rerankers live in `/app/data`, on a new `app_data` volume.** `STATE_FILE` (new, default `app_state.json`) and `RERANKER_MODEL_PATH` point there in the image, and a fine-tune now writes its versions beside `RERANKER_MODEL_PATH` instead of a fixed `./models/reranker`. The active model and RAG parameters therefore survive a container recreation, which they never did. **Upgrading:** the settings page's values held in the old container are not carried over; set them once more after the upgrade. A fine-tuned reranker in the old container's `/app/models` is likewise left behind and falls back to the base model until retrained; promoting or rolling back to a version trained there is refused rather than marked active. Host runs are unchanged.
- **A purge destroys only what was retired first (R-9).** The single-object purges of a
document, conversation, workspace, connector and user checked references and nothing
else, so a live row could be hard-deleted in one call, skipping the reversible step
Expand Down
23 changes: 17 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -57,28 +57,37 @@ RUN python -m venv --copies /opt/venv && \

# Writable runtime directories, created here because the runtime stage has no
# shell to mkdir with. COPY is the only way to introduce a directory there.
RUN mkdir -p /skel/logs /skel/uploads
RUN mkdir -p /skel/logs /skel/uploads /skel/data


# ---- Stage 2: runtime ----------------------------------------
FROM dhi.io/python:3.12@sha256:7c247af7f603bba8197ad5c34595066e1e6b81644c5a37b576d157979ceb4ea6 AS runtime

# The base image already runs as 65532, and WORKDIR creates its directory as the
# current user β€” so without this, /app itself was the runtime user's, and a module
# dropped there would shadow any import (R-11). Switched back below.
USER 0:0

# No libpq layer: psycopg[binary] vendors libpq inside the wheel, and the
# hardened base has no apt to install a system copy with. Verified by importing
# psycopg in the built image β€” see the docker-smoke job in tests.yml.

# Copy virtual environment from builder
COPY --from=builder --chown=65532:65532 /opt/venv /opt/venv
# Copy virtual environment from builder. Owned by root, like the source below: the
# runtime user reads and runs it but cannot rewrite it, so code execution in the app
# cannot persist by editing a module (QP-9 review, R-11).
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

WORKDIR /app

# Copy application source
COPY --chown=65532:65532 . /app
# Copy application source, owned by root (see the venv above)
COPY . /app

# Runtime directories (see the builder stage for why they arrive by COPY)
# The only directories the runtime user may write (see the builder stage for why they
# arrive by COPY). data/ holds the settings page's state and fine-tuned rerankers.
COPY --from=builder --chown=65532:65532 /skel/logs /app/logs
COPY --from=builder --chown=65532:65532 /skel/uploads /app/uploads
COPY --from=builder --chown=65532:65532 /skel/data /app/data

# uid:gid of the hardened base image's own nonroot user. Numeric because the
# image carries no /etc/passwd entry this file could refer to by name.
Expand All @@ -90,6 +99,8 @@ USER 65532:65532
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
APP_ENV=production \
STATE_FILE=/app/data/app_state.json \
RERANKER_MODEL_PATH=/app/data/reranker/latest \
SERVER_HOST=0.0.0.0 \
SERVER_PORT=5000 \
LOG_FORMAT=json
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ fails on the code before it.
| R-8 | Low | Feedback and annotations accept conversation and chunk ids from another workspace; a viewer can make any document un-purgeable | **Fixed** β€” every id a rating or annotation names is looked up in the caller's scope first, the global tier's chunks included except for a workspace API key; the message id too, which the review did not name. `tests/integration/test_object_authorization_over_the_wire.py`, `tests/integration/test_rating_ids_are_in_scope.py` |
| R-9 | Low | Single-object purges do not require the row to be retired first | **Fixed** β€” each of the five purges locks its row and refuses unless it is retired, inside its own transaction: a live row is a 409 and survives, a missing one a 404. A user purge thereby cannot reach an active admin. `tests/unit/test_purge_requires_retired.py`, `tests/integration/test_purge_requires_retired.py` |
| R-10 | Low | Admin user routes take raw dicts; the last-admin count ignores `is_active` | **Fixed** β€” the three routes validate a Pydantic body, so a string `"false"` no longer passes the last-admin rule (Postgres stored it as false), and the rule counts active admins only. The env-var admin's bootstrap check still counts live admins. `tests/unit/test_admin_user_bodies_are_validated.py`, `tests/integration/test_admin_count_is_of_active_admins.py`. Known residual: the rule reads the count and writes on separate connections, so the last two active admins deactivating each other at the same moment both succeed; the repair is SQL by hand (older than R-10) |
| R-11 | Low | The image's source is owned by the runtime user | **Open** |
| R-11 | Low | The image's source is owned by the runtime user | **Fixed** β€” the source and the venv belong to root; the runtime user writes only `/app/logs`, `/app/uploads` and `/app/data`, where the settings state and fine-tuned rerankers now live. A model the reranker loads must lie in that directory and may bring no code of its own (`trust_remote_code=False`). `docker-smoke` walks `/app` and `/opt/venv` and fails on anything else writable, or anything writable on `sys.path` |
| R-12 | Low | GraphRAG's entity expansion is not scoped (off by default) | **Fixed** β€” a co-occurrence counts only through a live document of the caller's workspace; retired documents contributed too. `tests/integration/test_graph_expansion_scope.py` |
| β€” | β€” | `GET /api/workspaces/active` and `POST /api/workspaces/switch` returned any named workspace, its system prompt included, to anyone signed in; found by R-5's new probes | **Fixed** (#510) β€” `tests/unit/test_active_workspace_is_authorised.py` |

Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ services:
volumes:
- app_uploads:/app/uploads
- app_logs:/app/logs
- app_data:/app/data
# MM-2: ingestion (chunk + batch-embed) is the spiky path here, so the
# limit sits above the idle footprint with room for a large upload.
deploy:
Expand Down Expand Up @@ -479,6 +480,8 @@ volumes:
driver: local
app_logs:
driver: local
app_data:
driver: local

# ──────────────────────────────────────────────────────────────
# Networks
Expand Down
3 changes: 2 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ to empty, in which case the router falls back to the active model.
| `RERANK_MIN_SCORE` | `-5.0` | Cross-encoder score below which a chunk is dropped |
| `RERANK_LOW_RELEVANCE_LIMIT` | `3` | Chunks kept when everything scores below the threshold |
| `RERANKER_WEIGHT` | `0.3` | The reranker score's share of the final blend |
| `RERANKER_MODEL_PATH` | `./models/reranker/latest` | Fine-tuned model directory; falls back to the base cross-encoder |
| `RERANKER_MODEL_PATH` | `./models/reranker/latest` (host-run); `/app/data/reranker/latest` (image) | Fine-tuned model directory; falls back to the base cross-encoder. A fine-tune writes its versions beside it |
| `FEEDBACK_FINETUNE_MIN_PAIRS` | `50` | Feedback pairs required before a fine-tune run starts |
| `EMBEDDING_CONCURRENT_BATCHES` | `2` | Parallel embedding batches during ingest |
| `PDF_LOADER` | `auto` | PDF extraction backend |
Expand Down Expand Up @@ -442,6 +442,7 @@ to empty, in which case the router falls back to the active model.
| Variable | Default | Effect |
|---|---|---|
| `APP_VERSION` | `3.5.0` | Reported as `system.app_version` by `GET /api/settings/stats` (admin). See the note below |
| `STATE_FILE` | `app_state.json` (host-run); `/app/data/app_state.json` (image) | Where the settings page persists the active model and RAG parameters. In compose it lives on the `app_data` volume |
| `MAX_CONTENT_LENGTH` | `16777216` | Upload ceiling in bytes (16 MB), enforced while the upload streams to disk |
| `UVICORN_WORKERS` | `1` | Worker processes. **Only `1` is supported** β€” the app refuses to boot otherwise |
| `UVICORN_TIMEOUT` | `600` | uvicorn's `--timeout-keep-alive`, applied by `docker-entrypoint.py`. **Not a request timeout**: it closes a connection that has been idle this long, which for an SSE stream means one that has produced nothing. Keep it β‰₯ `OLLAMA_EMBED_TIMEOUT`; behind the nginx overlay, `proxy_read_timeout 300s` in `nginx/nginx.conf` is the lower and therefore real ceiling |
Expand Down
5 changes: 3 additions & 2 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,9 @@ into a **restart loop**:
PermissionError: [Errno 13] Permission denied: '/app/logs/app.log'
```

The runtime image runs as **uid 65532**, and the `Dockerfile` creates `/app/logs` and
`/app/uploads` owned by it. But both paths are **named volumes**, and a volume keeps the
The runtime image runs as **uid 65532**, and the `Dockerfile` creates `/app/logs`,
`/app/uploads` and `/app/data` owned by it β€” the only paths it may write; the source and
the venv belong to root (R-11). But logs and uploads are **named volumes** that may predate it, and a volume keeps the
ownership it was created with β€” uid 1000 under the pre-hardening image. The image is
correct; the volume predates it.

Expand Down
4 changes: 2 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1925,7 +1925,7 @@ From `SECURITY.md`'s QP-9 register, in this order:
migration 0024).
3. βœ… R-10: admin user routes take raw dicts; the last-admin count ignores `is_active`.
4. βœ… R-5b: a ratchet over the `ALL_WORKSPACES` call sites, each with a recorded reason.
5. R-9 βœ…, R-11, R-12 βœ…: purge preconditions, the image's file ownership, GraphRAG's scope.
5. βœ… R-9, R-11, R-12: purge preconditions, the image's file ownership, GraphRAG's scope.

#### QP-11 β€” Replace `litellm` (ADR-4) βœ… (built 2026-11-07, taken ahead of the rest of QP-10)

Expand Down Expand Up @@ -2093,7 +2093,7 @@ Findings from the 2026-07 external code-quality audit that are real but delibera
- **`STATE_FILE` (`app_state.json`, `src/config.py`) resolves relative to the working directory, not to a guaranteed-writable path.** `AppState.set_active_model()` / `set_rag_param()` write it; `_save_state()` catches and logs any failure rather than crashing, so every failure mode here is silent-degrade.
**Rewritten 2026-08-27 β€” the evidence this entry was built on no longer exists.** It argued from the Helm chart's `readOnlyRootFilesystem: true`, concluded that "on a real Helm deployment today, every write fails", and set its re-review trigger as "before the Helm chart is used for a real deployment". **PG-0 deleted the Helm chart on 2026-08-05** (`8294604`), three weeks before this was last edited. The failure it described is now unreachable and the trigger it named can never fire β€” an entry that had quietly become undismissable rather than parked.
**What is actually true under the two supported topologies**, re-derived from the code:
- **Docker.** `WORKDIR /app`, `/app` is `chown`ed to uid 65532, and nothing sets `read_only`. The write **succeeds**. But the `app` service mounts only `app_uploads:/app/uploads` and `app_logs:/app/logs`, so `/app/app_state.json` sits in the container's writable layer and is **lost on every container recreation** β€” `docker compose up -d --build`, an image bump, any `docker compose down`. The active model and RAG parameters silently revert to their config defaults.
- **Docker.** *Since R-11 (2026-11-08) `/app` belongs to root and the image sets `STATE_FILE=/app/data/app_state.json` on the `app_data` volume, so the state now survives recreation; the coherence argument below still stands.* Before that: `WORKDIR /app`, `/app` is `chown`ed to uid 65532, and nothing sets `read_only`. The write **succeeds**. But the `app` service mounts only `app_uploads:/app/uploads` and `app_logs:/app/logs`, so `/app/app_state.json` sits in the container's writable layer and is **lost on every container recreation** β€” `docker compose up -d --build`, an image bump, any `docker compose down`. The active model and RAG parameters silently revert to their config defaults.
- **Host run.** Writes land in the working directory and persist. No failure.
So the defect survived the chart's deletion, but it is *lost on redeploy*, not *fails on write* β€” a different symptom, a different trigger, and one that fires on the topology the project actually ships.
**The fix is unchanged, and the reasoning for it is stronger.** `AppState` is shared mutable config; a writable path or a volume mount gives persistence without coherence. The destination is a small `app_settings` table in the Postgres this app already requires, read and written through `src/db/` like every other piece of durable state. That closes the redeploy loss, the multi-instance divergence in the first bullet of this section, and any future read-only-filesystem constraint, instead of patching each symptom separately. **Re-review trigger**: the first time a support question is "why did my active model reset?", or before any deployment where redeploys are routine β€” which is every containerised one, so this is closer to scheduled than parked.
Expand Down
5 changes: 3 additions & 2 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -640,8 +640,9 @@ def _production_errors(errors: list[str]) -> None:
# this project's log sanitiser. Set to DEBUG to get it back while diagnosing one.
LOG_THIRD_PARTY_LEVEL: str = os.environ.get('LOG_THIRD_PARTY_LEVEL', 'WARNING')

# State persistence file
STATE_FILE: str = 'app_state.json'
# State persistence file. The image points it into /app/data, the one directory there
# the runtime user may write besides logs and uploads.
STATE_FILE: str = os.environ.get('STATE_FILE', 'app_state.json')

# ============================================================================
# RERANKER CONFIGURATION (Feature 5.2)
Expand Down
Loading
Loading