Skip to the content.

experientiallabs/experiential — security scan

Repository: experientiallabs/experiential Commit scanned: 4611bc7 Scan date: 2026-09-15 Disclosure status: reported privately — detail withheld pending maintainer response

Summary

Severity Count
Critical 0
High 28
Medium 46
Low 0
Info 3

77 findings. One real, reported privately.

Why this target

An open-source gateway and router for agent workflows — 4.9k★, Apache-2.0, created in June 2026 and moving fast. It fronts hosted, BYOK and local models behind one OpenAI-compatible API, controls which identities may use which models and how much they may spend, and holds the operator’s own provider keys to do it.

Picked on responsiveness: 48 issues closed in 60 days and three distinct humans merging PRs. It is also the most interesting shape the shortlist offered. Most targets in this series are one language; this one is a Python control plane wrapped around a 34,011-line Rust data plane (axum, PyO3), and the split is not cosmetic — the Rust owns sockets and streaming, the Python owns authorization. A scan that only reads Python reads half the program, and the half it skips is the half holding the listener.

The finding: a scope that widens when the credential is removed

Withheld until the maintainers have responded. Reported privately through GitHub private vulnerability reporting (GHSA-x853-p24r-pw58, in triage), because the project has PVR enabled and an enabled private channel is signalled consent even where no SECURITY.md exists — and there is none here, at the root, in .github/, in docs/, or on the published docs site.

What can be said about the class without handing over a recipe:

A read surface on the loopback control plane answers an unauthenticated caller with a broader scope than it gives a caller who presents a valid key. The branch is taken on a condition the caller fully controls — whether to send the credential header at all — and the branch carrying less proof of identity is the branch carrying more authority. That is the conditional-verification shape with its sign flipped: not an auth check that can be skipped, but an auth check whose absence is a documented mode.

The behaviour is not a bug in the sense of code disagreeing with intent. The project documents it, accurately, in its own architecture reference. What the report disputes is not the behaviour but the stated consequence — the hardest variant of contract-versus-artifact, where docs and code agree on what happens and disagree on what it costs. The design rests on a confinement argument — the gateway “binds only 127.0.0.1” — and loopback is doing more work in that argument than it can bear. It does not authenticate the local user, and without Host validation it does not keep out a browser either.

The differential that makes it worth a maintainer’s time is intra-repo. The same process ships a credential store whose module docstring calls it an “Atomic, user-only auth.json store”, and which earns that adjective: 0o600 on the file, 0o700 on the directory, a symlink check, os.fchmod on the descriptor before any bytes are written, atomic os.replace. That is a careful, deliberate statement that other local users are not trusted. The report’s whole argument is that one process should not answer that question two different ways. This is the sibling-differential form that has been the most adoptable evidence in this series: it does not import an external rule, it shows a project its own standard.

Reported at Low/Medium, argued down on purpose. Nothing exposed is a credential or a prompt — the project’s “content-free” claim is accurate, and I checked the schema rather than taking it on faith. The disclosure is operational metadata. The report says so in its own summary, and names the browser path’s real costs (DNS rebinding is not free, and Private Network Access is gradually raising its price) rather than selling the scarier half.

The fix was left as a question

Three options were offered, in increasing order of disruption: validate Host on every route; invert the branch so that more proof of identity never yields less data; or, if neither is wanted, document the trust assumption so operators on shared hosts know which switch to flip. A PR was offered for the first and not opened unprompted, because which of the three is right depends on who the maintainers think the gateway’s local callers are — and that is a product decision, not a patch.

The control that made the finding checkable

The verification ran against the shipped PyPI wheel, not the source tree: a stub control plane handed to the real exp_gateway_native binary, echoing back the scope the Rust chose per request. Three blocks, and the two that decide it are the controls rather than the finding:

A first attempt at verification failed for an unrelated reason worth passing on: pip install experiential then exp crashes on Windows, because exp/cli/providers/experiential_cloud.py:19 does an unconditional top-level import termios — a Unix-only stdlib module. Not a security issue; included in the report as a courtesy.

What the other 76 findings were

Zero findings in 34,011 lines of Rust. Semgrep opened all 72 .rs files and returned nothing on any of them — and the one real finding of this scan lives in server.rs. This is the most concrete statement of the coverage gap the series has produced: not a tool failing to open a file, but a tool opening the file and having no rule that could express the question. No ruleset in the stack knows how to ask “does this route’s scope widen when a header is absent?”

Two active-harm false positives on the best-written file in the repository. Semgrep flagged exp/common/auth/store.py twice — once for a credential leak into a log, once for insecure file permissions. Both are wrong, and both are wrong in the direction that does damage:

That is the third scan in a month where 0o600/0o700 drew a fire-and-loosen recommendation. It has earned a standing rule rather than a per-scan note.

25 SQL findings, all of them the identifier FP. Twelfth appearance of the series’ most persistent cluster. Two reads settle it: the flagged f-strings interpolate a predicate built entirely from hardcoded literals — the widest of them is " AND i.identity_id = ?" — and every value travels separately as a bound ? parameter. Data bound, identifiers static, nothing reachable.

16 secret hits, 13 in *_test.py. Of the rest, the notable one is POSTHOG_PROJECT_API_KEY in telemetry.py, a PostHog project key — the write-only ingest kind designed to ship inside client JavaScript. Public by construction, not a leak.

18 GitHub Actions mutable-tag rows, correctly inert. Both workflows trigger on pull_request, not pull_request_target, with permissions: contents: read — so a fork PR runs with no secrets and nothing to steal. The trigger is what sets the severity. Credit where due: the one action that actually builds and publishes wheels, PyO3/maturin-action, is already SHA-pinned with a version comment. They know the practice and applied it where it counts.

The two dependency Highs split on reachability, in opposite directions. Neither httpx2 nor transformers is a declared dependency; both arrive transitively, and uv.lock covers every extra including dev and sft.

Notes on the tool

Disclosure timeline

Reproduce

git clone https://github.com/experientiallabs/experiential /tmp/scan-target
.venv/Scripts/python.exe scanner/run_scan.py \
  --repo /tmp/scan-target \
  --reports-dir ./reports/experientiallabs-experiential \
  --min-severity medium

More from this series