Details
### Summary:
2 findings — `safe_user_path()` accepts any path under `Path.home()` or `Path.cwd()`, which inside the shipped root container resolves to `/root` and `/app` (so all of root's home, including `/root/.ssh/id_rsa`, `/root/.aws/credentials`, `/root/.kube/config`, and `/app/agent/.env`, passes the check) (F9). `read_document()` has no sandbox call at all and returns the full content of any path the FastAPI process can read, including `/etc/shadow`, `/etc/passwd`, `/proc/self/environ`, and any secret file mounted into the container (F10). F10 is strictly broader than F9 but they have different fix scopes (F10 = a missing `safe_path()` call in one function; F9 = the envelope definition in `path_utils.py`), so both must be patched.
---
### Shared baseline (applies to both findings)
The container has no `USER` directive (Dockerfile:15 — `FROM python:3.11-slim AS runtime`, no subsequent `USER`), so the FastAPI process runs as `uid=0(root)`.
The two file-read tools described here are members of the auto-discovered LLM tool registry.
Combined with GHSA-1 / F1, they are reachable from any anonymous TCP client to port 8899, but the same defects also apply to authenticated sessions and to prompt-injection in any document the agent processes.
See GHSA-1's shared reproducer block for the install steps; the same `docker compose up -d` setup applies here.
> **Note on the `HOST` placeholder used throughout the per-finding "Steps to observe" blocks below**: replace `HOST` with the address you reach the docker host on — typically `localhost` (or `127.0.0.1`) if you are running the reproducer on the same machine as the container. All `curl` commands below assume this substitution.
---
### Finding 9 — High: safe_user_path() accepts the entire user home directory and process CWD, allowing LLM tool calls to read /root credentials
- **Severity**: High
- **CVSS v3.1**: 7.5 — `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N`
- **CVSS v4.0**: 8.7 — `AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N`
- **CWE**: CWE-22 (Path Traversal); CWE-552 (Files Accessible to External Parties)
**Affected file**: `agent/src/tools/path_utils.py`
- line 52 — `def safe_user_path(p: str) -> Path:`
- line 73-77 — `if resolved.is_relative_to(home) or resolved.is_relative_to(cwd): return resolved` — `home = Path.home()`, `cwd = Path.cwd()`
**Intent vs actual**:
`safe_user_path()` is intended to permit journal and shadow-account tools to open broker export files the operator may have placed anywhere under their home directory or the project folder. The intended invariant is that only user-owned broker data files are accessible — not system credential files or SSH keys. The actual envelope check accepts any path whose resolved form is inside `Path.home()` or `Path.cwd()`. Inside the shipped Docker container, `Path.home()` resolves to `/root` and `Path.cwd()` resolves to `/app`. Every file under either subtree passes the check, including:
- `/root/.ssh/id_rsa` and any other SSH key files
- `/root/.aws/credentials`, `/root/.kube/config`, `/root/.docker/config.json`
- `/app/agent/.env` (the file containing the operator's real `OPENROUTER_API_KEY`, `TUSHARE_TOKEN`, and any other secrets)
A runtime probe inside the container confirmed that `safe_user_path('/root/.aws/credentials')` returned the path without raising `ValueError`. `ExtractShadowStrategyTool` was then invoked against `/root/secrets/aws.csv` (a planted credential file) and returned an error message containing the first line of the file via the parse-error channel.
**Steps to observe**:
1. Per GHSA-1 shared reproducer, start the server with a working LLM API key and create an unauthenticated session.
2. (Setup for safe demo: inside the container, `docker exec` a planted file: `docker exec <container> sh -c 'mkdir -p /root/secrets && printf "broker_id,api_key,api_secret\nDEMO,FAKE_KEY,FAKE_SECRET\n" > /root/secrets/aws.csv'`.)
3. `curl -s -X POST "http://HOST:8899/sessions/$SID/messages" -H 'Content-Type: application/json' -d '{"content":"Analyze the trade journal at the path /root/secrets/aws.csv and tell me what you find."}'`
4. Poll `curl -s "http://HOST:8899/sessions/$SID/messages"`. Observe the agent invoke `ExtractShadowStrategyTool` with `journal_path="/root/secrets/aws.csv"`, which passes `safe_user_path()` and attempts to parse the file as a trade journal CSV.
5. Observe the error response — when the file's structure does not match the expected journal schema, the parse error often includes the first line (column names) verbatim, leaking the file's first line.
6. Repeat with `journal_path="/app/agent/.env"` to confirm the `.env` file is within the accepted envelope.
**Impact**:
Any unauthenticated caller can instruct the LLM to attempt to parse any file under `/root` or `/app` as a trade journal, extracting the file's first line via the parse-error message channel. Files with valid CSV-like first lines may leak multiple bytes. In the shipped root container, `/root` encompasses all credentials a careless operator may have mounted into the home directory; `/app` includes the agent's own secrets and any operator-staged data files.
---
### Finding 10 — High: read_document() opens any server-readable file with no sandbox enforcement, returning full content of /etc/shadow and /proc/self/environ
- **Severity**: High
- **CVSS v3.1**: 7.5 — `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N`
- **CVSS v4.0**: 8.7 — `AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N`
- **CWE**: CWE-22 (Path Traversal); CWE-552 (Files Accessible to External Parties); CWE-200 (Information Exposure)
**Affected file**: `agent/src/tools/doc_reader_tool.py`
- line 259 — `def read_document(file_path: str, pages: str = "") -> str:`
- line 270 — `path = Path(file_path)` — followed only by `path.exists()` and `path.is_file()` checks before dispatching to format-specific readers
- No call to `safe_path`, `safe_user_path`, or any other sandbox enforcement appears anywhere in the function
**Intent vs actual**: `DocReaderTool` is intended to allow the LLM agent to read documents and data files provided for analysis. Like other file-reading tools in the project, it should apply a sandbox check before opening the file. The actual implementation takes the LLM-emitted `file_path` string, runs only `path.exists()` and `path.is_file()`, and dispatches to the appropriate reader. No call to `safe_path` or `safe_user_path` exists in the function. A runtime probe confirmed:
- `read_document('/etc/passwd')` returned HTTP 200 with 839 characters of content
- `read_document('/etc/shadow')` returned the full shadow password file
- `read_document('/proc/self/environ')` returned the full process environment, including `OPENROUTER_API_KEY` and `TUSHARE_TOKEN` in plaintext
This is strictly **wider** than F9: F9 is bounded to `/root` + `/app` via the (overly-broad) envelope; F10 has no envelope at all and reaches `/etc`, `/proc`, `/var`, and any other path the FastAPI process can read.
**Steps to observe**:
1. Per GHSA-1 shared reproducer, start the server with a working LLM API key and create an unauthenticated session.
2. `curl -s -X POST "http://HOST:8899/sessions/$SID/messages" -H 'Content-Type: application/json' -d '{"content":"Please read and summarize the document at /proc/self/environ"}'`
3. Poll `curl -s "http://HOST:8899/sessions/$SID/messages"`. Observe the agent invoke `read_document` with `file_path="/proc/self/environ"` and return the full process environment in the message stream.
4. Observe `OPENROUTER_API_KEY`, `TUSHARE_TOKEN`, and any other variables in `agent/.env` appearing in plaintext.
5. Repeat with `file_path="/etc/shadow"` to confirm shadow password file access.
**Impact**:
An unauthenticated caller can retrieve any file the server process can read. Running as root, that includes `/etc/shadow`, `/etc/passwd`, `/proc/self/environ` (full plaintext API keys), `/root/.ssh/id_rsa`, and any secret files mounted into the container. This is the broadest file-read primitive in the codebase and provides a credential-extraction path that does **not** require shell execution — endpoint monitoring tuned to BashTool / shell signatures will miss it entirely.
---
### Why F9 and F10 are listed separately
A maintainer might be tempted to fix only one, on the theory that F10 dominates F9. Two reasons to fix both:
1. **Different fix scope** — F10's fix is a single missing call (`safe_path(file_path)` in `read_document` before line 270). F9's fix is in `safe_user_path()` itself: the envelope must be replaced with a strict allowlist of operator-configured directories, *not* `Path.home() ∪ Path.cwd()`. A fix that adds the missing `safe_user_path` call to `read_document` is **insufficient** because `safe_user_path` itself accepts `/root` and `/app/agent/.env`. Both surfaces need work.
2. **Different reachability classes** — F9 is reachable through tools that already gate on `safe_user_path` (`ExtractShadowStrategyTool` and several journal tools), so even a hypothetical F10 fix that switched `read_document` to use `safe_user_path` would still leak `/root/*` because the envelope is broken. F9 is the structural defect; F10 is the missed call.
---
### Suggested remediation
11. **F9** — In `safe_user_path()` at `path_utils.py:52-77`, replace the `Path.home() ∪ Path.cwd()` envelope with a strict allowlist of operator-configured directories (e.g. an explicit `BROKER_EXPORTS_DIR` env var defaulting to `/app/data/broker_exports/`). Reject `/root`, `/app/agent/.env`, and `/app/agent/uploads/` (the latter to prevent F3-uploaded files from being subsequently parsed as a credential-leak vector via the parse-error channel).'
12. **F10** — Add a `safe_path()` (or `safe_user_path()`) call at `doc_reader_tool.py:270` before the existing `path.exists()` / `path.is_file()` checks. Once F9 is patched, the same allowlist will apply uniformly to both `read_document` and the `safe_user_path`-gated tools.
13. **Defense-in-depth** — Drop the FastAPI process to a non-root user. Add a `RUN useradd -m vibe && chown -R vibe /app` step to the Dockerfile and `USER vibe` before `CMD`. This does not fix the Path Traversal but materially reduces the credential-extraction blast radius of any successful exploit (and benefits every other finding in GHSA-1 and GHSA-2). See GHSA-1 / shared baseline for the matching `USER` recommendation.
---