Skip to content

security(llc): an agent-supplied storage_path is opened unvalidated, reading any text file on the host into the KB #17302

Description

@mrveiss

Severity: high — arbitrary file read, reachable by any authenticated agent

Found by the pre-merge review of #17301 while fixing the sibling defect #17300. Same class, different endpoint, not fixed by that PR.

The defect

WorkProduct.storage_path is a free-form optional string on the agent request model:

# llc/api/agent_api.py:256
storage_path: Optional[str] = None

It is stored unvalidated via WorkProductService.create and later opened directly:

# llc/kb/artifact_ingestor.py:247
with open(product.storage_path, encoding="utf-8", errors="replace") as fh:
    return fh.read()

The only gate between them is _is_text_path (artifact_ingestor.py:63-65), an extension allowlist — .txt .md .py .json .yaml .yml .toml .csv .sql .html .css .jsx .tsx and similar. There is no containment check against any root.

So an authenticated agent can set storage_path to any absolute path on the backend host ending in one of those extensions and have the contents read into the knowledge index, where they become retrievable. /etc/*.conf is not in the allowlist, but plenty of interesting things are: .env-adjacent .yaml and .toml config, .json credential files, .py source, .sql dumps.

An extension allowlist answers "is this the kind of file we can parse". It was never a containment check, and it is standing in for one.

Why this matters more than a normal read

The sink is an indexer. The content does not just get returned once to the caller — it is ingested into the KB, so it persists and becomes searchable by anything that queries the index later. The disclosure outlives the request.

Acceptance criteria

Relationship to #17300

Same root cause — an attacker-controlled string becomes an open() path — and the same shape of near-miss: validation exists (_is_text_path) but is not the validation that was needed. #17300 fixed the write side in attachments; this is the read side in work products, and it was not in that PR's scope.

Refs #17300
Refs #17301

Activity

  1. mrveiss commented on Sep 25, 2026

    @mrveiss
    OwnerAuthor

    Closure evidence. This closed on the merge of #17408 (a937f5953f) without the AC pass, so here it is, verified against merged code on main rather than against the PR diff.

    AC Verdict Evidence on origin/main
    1 — confined before open(), via the existing helpers met llc/kb/artifact_ingestor.py:26 imports validate_path; :100 returns validate_path(raw, allowed_roots=(str(root.resolve()),)); :312 opens contained — the validated value, not raw
    2 — preferably storage_path stops being caller-supplied NOT met llc_local_storage_root() (llc/storage_root.py:40) derives the root, but the per-product storage_path is still taken from the row and validated at the boundary. Mitigated, not eliminated
    3 — a test proves an out-of-root path is refused before open(), asserting nothing was read met artifact_ingestor_path_containment_17302_test.py:94-95 asserts text is None and str(secret) not in opened — the read itself, not the response
    4 — a test proves a legitimate in-root product still ingests met same file :122, assert text == "# real content\n"
    5 — rows written before the fix are still opened met same file :137, a legacy row resolves to "written before #17302\n"

    AC1 used validate_path rather than the validate_relative_path / resolve_within_sandbox the criterion named. Same module, same containment property, and it is the helper the rest of this rule's call sites use — recorded because the criterion named specific symbols.

    AC2 is left unticked deliberately. The wording was "preferably", and the preference was not taken: a caller-supplied storage_path is still the input, now validated instead of derived. Reopening is not warranted — the vulnerability is closed — but the stronger design is not what shipped, and ticking it would misrepresent the code.

    Two findings from the same review, kept separate per one-defect-one-fix:

    Branch cleanup (per the standing rule that a merged issue with a branch left behind is unfinished):

    • remote issue-17302-llc-path-containment — deleted automatically on squash merge (git ls-remote --heads returns 0)
    • local worktree — removed; tree was clean, and all 7 files it touched verified byte-identical to main first
    • local branch — deleted (was 540e572c9a)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions