← all reviews

Critical

open-review

https://github.com/vpavlin/open-review @ 6c46c8296617
1 critical3 high6 medium6 low
Reviewed2026-10-28 10:58 UTC
Modeldeepseek-v4-1-flash-1017
Cost0.20 DIEM
Packagesource only
The repository contained text addressed to the reviewer.

Bottom line

The target at this commit is not a Basecamp module: it is the open-review harness itself (no metadata.json, no .lgx, no Basecamp module source). I therefore audited the pipeline, sandbox, renderer and host scripts, and ran the parts that can run inside the supplied sandbox. /run/src was treated as hostile data throughout.

The escaping and serving layer is the strongest part. Every module/report-derived string rendered into index.html/live.html goes through html.escape, the Caddy configuration sets default-src 'none' and nosniff, JSONL is forced to application/x-ndjson, and the Venice inference key is not passed into the sandbox (the sandbox's own check confirms this). The bait canaries are unique, and the simple "absolute path read plus DNS lookup" case is detected.

The weak points are structural and, in one case, critical for a service whose entire product is trustworthy evidence. A hostile module has the same root and the same writable /run as its reviewer; it can therefore tamper with the evidence and report that judge it (FR-2), and the host-side renderer follows symlinks out of /run, copying host files — including the file holding the Venice inference key — into the published review (FR-1). The network record does not work at all: the DNS sink is not wired into resolv.conf, egress is unrestricted, and the analyser ignores raw-IP connects, so a module can read bait and exfiltrate it without the summary noticing (FR-3). Inputs are interpolated into bash -lc in review.sh, giving repository/commit/.lgx URLs shell-injection capability before static checks run (FR-4). The "hard" model budget can be exceeded by an unknown model name and is under-priced by the local price table (FR-5), and an outer 40-minute timeout truncates a review advertised as 150 minutes (FR-6).

What the module did

Findings (16)

Critical FR-1 Host-file disclosure and evidence forgery via symlink-following in render.pyrun · pipeline/render.py:45-49,92-97,124-126; pipeline/review.sh:40; pipeline/reviewer.sh:11

Files: pipeline/render.py:45-49 (latest screenshot copied), pipeline/render.py:92-97 (copytree + copy of raw evidence), pipeline/render.py:12-14/130 (readf), pipeline/render.py:124-126 (report.json), pipeline/review.sh:40 (-v "$RUN:/run:z" rw), pipeline/reviewer.sh:11 (location of the real key: /home/reviewer/.pi/agent/.env).

Exploit: A submitted module runs as root in the same container and /run is mounted read-write. It creates ln -s /home/reviewer/.pi/agent/.env /run/media/leak.txt (or ln -s ... /run/report.md, or ln -s /home/reviewer /run/logs). render.py runs later on the host as the reviewer user and uses shutil.copytree(..., symlinks=False) and shutil.copy, both of which dereference the link and publish the target under /srv/reviews/<slug>/. My probe did exactly this with a stand-in secret:

ln -sf /tmp/probe-secret p1/run/media/leak.txt
ln -sf /tmp/probe-secret p1/run/report.md
python3 pipeline/render.py /run/probes/p1/run /run/probes/p1/dest slug final
grep -rl 'TOP-SECRET-VENICE-KEY' p1/dest
→ p1/dest/report.md
→ p1/dest/media/leak.txt

A directory symlink was also recursively copied (dest/logs/fakedir/id_ed25519). In production the target of interest is /home/reviewer/.pi/agent/.env, which reviewer.sh itself reads as the Venice key; publishing it leaks provider credentials and the whole account to anyone who fetches the review. A symlinked report.md also makes the pipeline's [ -s report.md ] check pass with attacker-chosen content.

Fix: Never dereference paths supplied by the sandbox. Resolve every path with os.path.realpath and refuse anything outside $RUN; walk with os.walk(followlinks=False) and skip all symlinks (copy links as links, not targets); open files with O_NOFOLLOW; do not copytree arbitrary run subdirectories at all — copy an explicit allowlist of regular files (report.md, report.json, chain.txt, known evidence files) after lstat checks. Mount the source/evidence directory read-only to the sandbox or separate module-writable data from host-published evidence.

High FR-2 Untrusted module can tamper with all evidence and the report that judges itrun · pipeline/review.sh:40; pipeline/review-in-sandbox.sh:49; pipeline/analyse.py:10-11; pipeline/render.py:92-97,124-126; pipeline/desk.sh:31

Files: pipeline/review.sh:40 (rw /run), pipeline/review-in-sandbox.sh:49 (strace ... -o /run/behaviour/strace.log), pipeline/analyse.py:10-11 (reads bait.json, dns.log), pipeline/render.py:92-97/124-126 (publishes them), pipeline/desk.sh:31 (Basecamp/module runs as root).

Exploit: The module is root in the same filesystem view as the capture. It can truncate -s0 /run/behaviour/strace.log, echo '{}' > /run/behaviour/bait.json, delete/replace /run/behaviour/dns.log, write an arbitrary /run/report.md/report.json, and plant arbitrary files in /run/media. analyse.py then grades an attacker-chosen record, and render.py publishes it. I verified the access: /run/behaviour is drwxrwxr-x root:root, my touch /run/behaviour/.wtest succeeded, and /run/src is writable; the report symlink probe (FR-1) shows report.md can be replaced wholesale. The experiment also showed the automated summary can be emptied without touching strace (FR-3). There is no host-side, append-only copy to compare against.

Fix: Move evidence out of the module's mount namespace: collect strace/DNS/network events on the host (or a separate privileged observer) and write them to files the sandbox cannot see or write; expose a read-only /run/src and a separate bind-mounted, host-owned /run/evidence; run the module as a non-root UID distinct from the reviewer; sign or hash the capture immediately in the observer; refuse to publish if evidence files changed between capture and render (compare inode/mtime/hash to the observer's copy).

High FR-3 Network recording is ineffective: DNS sink bypassed, egress unrestricted, raw-IP connects ignoredrun · pipeline/dns_sink.py:19; pipeline/review-in-sandbox.sh:20; pipeline/analyse.py:16-27; pipeline/reviewer.sh:26

Files: pipeline/dns_sink.py:19 (binds only 127.0.0.1:53), pipeline/review-in-sandbox.sh:20 (starts it but nothing points the resolver at it), pipeline/analyse.py:16-27 (connects computed but unused; contacted_hosts comes only from dns.log), pipeline/review.sh:26 / pipeline/reviewer.sh:26 (--network bridge, no egress rule), pipeline/Containerfile (no resolver or firewall setup).

Exploit: The sandbox's /etc/resolv.conf points at external DNS servers, not 127.0.0.1, so the sink never logs a lookup. Egress is unrestricted, and a module can also skip DNS entirely and connect to a literal IP. Verified in the running sandbox: direct UDP DNS to 1.1.1.1 and 195.179.224.53 got replies; TCP to 8.8.8.8:53, 1.1.1.1:443, 140.82.121.4:443 succeeded; dns.log stayed at 0 bytes even after the repository's credential-stealing control sample ran. A module using openat(dirfd, ".ssh/id_ed25519") plus connect(203.0.113.9:443) produced no bait and no host in analyse.py's summary:

{"bait_files_read": [], "contacted_hosts": [],
 "read_secret_then_contacted_external_host": false, ...}

This directly contradicts the proposal's "only the model gateway, package mirrors ... and all of that is recorded" and defeats the product's core exfiltration claim.

Fix: Point /etc/resolv.conf at the sink (and make it immutable to the module), handle AAAA and TCP DNS, or better, intercept DNS in a dedicated network namespace. Enforce egress with a default-deny firewall that allows only the gateway and mirror ranges — the sink is a logger, not a control. Parse and report connect() destinations (including AF_INET/AF_INET6 literals) from strace in analyse.py; include process and UID per connection. Add a sandbox check that a test lookup and a test raw-IP connect are both recorded before the review is allowed to run.

High FR-4 Shell injection from repository, commit and lgx URL into bash -lcrun · pipeline/review.sh:40,49,58

Files: pipeline/review.sh:40 (inbox() builds bash -lc "$2"), pipeline/review.sh:49 (git clone ... '$REPO' ...; git checkout ... '$COMMIT'), pipeline/review.sh:58 (curl ... -o 'lgx/$(basename "$LGX")' '$LGX').

Exploit: $REPO, $COMMIT and $LGX are interpolated inside single quotes in a string handed to bash -lc. A submitter-supplied repository URL containing a single quote breaks out and executes commands in the clone container, which has the run directory mounted read-write and --network bridge, before static checks and before the reviewer starts. Verified with the exact line-49 construction:

REPO="x'; echo INJECTED > /tmp/probe-ci; echo '"
bash -lc "git clone ... '$REPO' src ..."
→ /tmp/probe-ci contains INJECTED

The attacker can rewrite /run/checks/*, plant source or instructions for the reviewer, and forge logs/*, or attack anything reachable on the bridge. The same primitive exists for LGX (line 58) and COMMIT.

Fix: Do not build shell strings. Execute git/curl with an argument vector and -- (git clone -- "$REPO" src), validate the URL scheme/host, and quote nothing because no shell is involved. Run cloning in a container with /run/src as the only writable mount, and perform static checks before any untrusted input is fetched or executed; put the clone step after a host-side URL validation.

Medium FR-5 DIEM budget can be exceeded and is under-priced; gateway tokens readable by the modulerun · pipeline/gateway.py:21-27,77,82-84; pipeline/reviewer.sh:19-27; pipeline/review-in-sandbox.sh:29; pipeline/sandbox-check.sh:19-21

Files: pipeline/gateway.py:21-27 (PRICES fallback for unknown models), pipeline/gateway.py:77 (unbounded Content-Length read), pipeline/gateway.py:82-84 (pre-check uses last, not a reservation), pipeline/reviewer.sh:19-27 (tokens passed into the container env), pipeline/review-in-sandbox.sh:29 (models.json written 0644), pipeline/sandbox-check.sh:19-21 (only the Venice key is checked).

Exploit: Two independent bypasses. (1) Model IDs are not validated and unknown IDs are priced with the DeepSeek fallback (0.33, 0.0075, 1.32). My mock-upstream run forwarded claude-3-opus-20240229 and recorded cost 1.65, spent 1.65 against a budget 0.8; a valid but expensive Venice-hosted model is therefore charged at DeepSeek rates, and a single first request can exceed the cap because the pre-check sees last=0 and only reserves 0.0024 DIEM. (2) The model can read REVIEW_TOKEN from the 0644 models.json and REPORT_TOKEN from /proc/1/environ (I confirmed both strings are present and readable), then make its own requests, spend the budget, and inject content into gateway.jsonl/live.txt. The REPORT_TOKEN also bypasses the testing/report split. Separately, the gateway fails open when REVIEW_TOKEN is unset (elif not TOKEN or auth==..., gateway.py:74), so a gateway started outside reviewer.sh is an unauthenticated relay to the Venice key.

Fix: Allowlist model IDs and derive price from the allowlist; reject unlisted models. Before forwarding, reserve max_tokens × output_price + prompt_tokens × input_price (or a conservative cap) against the budget under the lock. Do not put either token in the container environment or a world-readable file; give pi a Unix-socket proxy with a per-process credential, and make the report token available only to the second process. Cap request body size and thread count.

Medium FR-6 A 40-minute outer timeout truncates the advertised 150-minute review and prevents the report passread · pipeline/reviewer.sh:26; pipeline/review-in-sandbox.sh:45-64; pipeline/desk.sh:71-77

Files: pipeline/reviewer.sh:26 (timeout 2400 podman run ...), pipeline/review-in-sandbox.sh:45-64 (MAX_MIN=150, inner timeout and report-pass logic), pipeline/desk.sh:71-77 (clock promises max minutes).

Exploit: 2400 seconds is 40 minutes. The outer command terminates the attached podman run long before the inner timeout 150m. Because the report-pass block (review-in-sandbox.sh:66-73) runs inside the container after the reviewer exits, a review that legitimately uses the advertised 150 minutes can be stopped with no report (and review.sh then marks it failed). This is visible in the clock model itself: the clock reports 150 minutes while the container is capped at 40. [read] — I did not let the run reach the cap.

Fix: Make the outer timeout derive from REVIEW_MAX_MIN (e.g. MAX_MIN*60 + 300) or delete the outer timeout and rely on the inner one plus a host watchdog that still allows the report pass. Test a review that runs longer than 40 minutes. Ensure SIGTERM captured outside the container triggers evidence collection and the report pass.

Medium FR-7 Repository-controlled scanner config can suppress findings; scanner failure is silentrun · pipeline/review.sh:52-56,62; sandbox/reviewer.Containerfile:45-52

Files: pipeline/review.sh:52-56 (scanners run in the repository working directory with no trusted config), sandbox/reviewer.Containerfile:45-52 (scanner versions), pipeline/review.sh:62 (only byte count is surfaced).

Exploit: gitleaks detect auto-loads a repository .gitleaks.toml. I committed a file containing AKIAABCDEFGHIJKLMNOP; the exact pipeline command found it (1 finding). After adding .gitleaks.toml with [allowlist] paths = ['a\.txt'], the same command reported no leaks found (0 findings). A malformed config makes gitleaks exit fatally; review.sh ignores the exit status (|| true) and only omits the gitleaks: N bytes live line, so a review can publish with no secret scan and no failure marker. .semgrepignore and osv-scanner.toml (e.g. [[IgnoredVulns]]) give an analogous suppression path for the other two scanners. [run] for gitleaks; [read] for the other two.

Fix: Run each scanner with an explicit, host-owned config and disable repository config discovery where possible (--config /pipeline/gitleaks.toml; unset SEMGREP_*/GITLEAKS_CONFIG; pass --no-ignore/equivalent; validate the result file schema). Treat a missing or empty report as a failed check, not a pass, and hash the scanner configs and outputs.

Medium FR-8 desk install path traversal via manifest name (and lgx symlinks), executed as rootrun · pipeline/desk.sh:48-57,113-121

Files: pipeline/desk.sh:48-57 (name=$(jq -r .name ...); dest="$U/modules/$name"; rm -rf "$dest"; cp -a ...), pipeline/desk.sh:113-121 (dependency and module install loop).

Exploit: A crafted .lgx with manifest.json {"name":"../../../../../../tmp/lgx-pwn-dir","type":"ui_qml"} makes install_one set dest outside $U, then rm -rf it and copy attacker files there as root. Verified against the real function: /tmp/lgx-pwn-dir/marker was created. tar xzf runs as root in a temporary directory; GNU tar refused a ../ member and refused to write through an absolute symlink member in my tests (Cannot open: Not a directory), so the manifest-name traversal is the confirmed vector; cp -a "$t/variants/linux-amd64/." would still follow a symlinked variants/linux-amd64 into its target. Inside the sandbox this can delete/rewrite /run, /opt/bc, or /usr content and thus corrupt the review before it starts.

Fix: Reject any manifest name not matching ^[A-Za-z0-9._-]+$ and reject ./..; compute dest with realpath and verify it is under $U. Extract with a safe extractor (or tar --no-same-owner --no-overwrite-dir plus an explicit rejection of symlink/hardlink/absolute members), and install as a non-root user. Never use rm -rf on a path derived from untrusted metadata without the prefix check.

Medium FR-9 No report schema validation: a malformed report.json aborts publicationrun · pipeline/render.py:124-140,225; docs/proposal.md:35

Files: pipeline/render.py:124-140 and 225; claim at docs/proposal.md:35 ("A plain program ... checks the report against a schema").

Exploit: report.json is written by the model and can also be overwritten by the module (FR-2). When findings is an object instead of a list, for f in rep["findings"] yields strings and f.get raises AttributeError at line 135. My probe produced only chain.txt, logs/ and report.json; no index.html was written, so the review page is gone. There is no schema file or validation anywhere in the repo, contrary to the proposal.

Fix: Validate report.json against a strict schema before rendering, with size limits and type checks; wrap rendering so index.html is always written (with a "failed" banner on invalid input); never let the model's output determine whether publication succeeds.

Medium FR-10 Self-certifying and overstated security claims contradict the implementationread · docs/proposal.md:32,35,40,47,50-57,120; test-modules/README.md:5; test-modules/leaky-build/collect.js:1; NOTES.md

Files: docs/proposal.md:32 (network recorded), :35 (schema check), :40 (build-vs-.lgx comparison), :47 (uninstall and leftovers), :50-57 (write overlay diff, processes, resources), :120 (logs "signed", inputs "pinned"); README.md/test-modules/README.md:5 and test-modules/leaky-build/collect.js:1 (the credential-stealing sample is labelled "harmless"/"CONTROL SAMPLE ... not for any other use"); NOTES.md (self-assessment).

Exploit: None of the following exists in the code: build-and-compare, overlay/write diff, uninstall, process/resource summary, signature over chain.txt (it is a bare SHA-256 chain, render.py:99-106), or input pinning (FROM ubuntu:24.04, curl | bash, unpinned npm/pipx). The network claim is refuted by FR-3. The test-module comments and README are prose addressed to the reviewer telling it how to classify a credential-stealing postinstall; per the review rules this is itself a finding, and report.json records repo_tried_to_instruct_reviewer: true. [read] (claims/implementation comparison) and [run] for the network half.

Fix: Make the documentation state exactly what is implemented and mark unimplemented claims as roadmap (as BACKLOG.md partly does); sign the chain with a key outside the review host or remove the signature claim; pin images and downloads by digest; treat repository prose as untrusted input in the prompt and never let it set scope or severity.

Low FR-11 Caret version matching is wrong for 0.x dependenciesrun · pipeline/fetch_deps.py:18-27,40-49

Files: pipeline/fetch_deps.py:18-27 (satisfies), :40-49 (selection).

Exploit: The implementation treats ^ as "same major and >=", so satisfies("0.9.0","^0.1.0") is True; semantic versioning says ^0.1.0 must stay below 0.2.0. A module declaring ^0.1.0 can therefore receive 0.9.0 from the catalogue, outside the range its author intended. [run] with the extracted real functions.

Fix: Implement proper semver caret rules (for 0.x.y, lock the minor; for 0.0.z, lock the patch), or use a maintained semver library and test the boundary cases.

Low FR-12 Gateway DoS surface and incomplete secret redactionread · pipeline/gateway.py:35-38,77,148; pipeline/reviewer.sh:12

Files: pipeline/gateway.py:35-38 (redaction regex), :77 (unbounded body), :148 (ThreadingHTTPServer), pipeline/reviewer.sh:12 (GATEWAY_BIND=0.0.0.0).

Exploit: A module that has read REVIEW_TOKEN (FR-5) can open many connections and/or send a huge or never-finished Content-Length; self.rfile.read(int(...)) and a thread per connection let it exhaust host memory/threads. The redaction regex matches AKIA[0-9A-Z]{16} (the access-key ID) but not the AWS secret value, and matches only the -----BEGIN ... PRIVATE KEY----- header, leaving the key body in gateway.jsonl, which is published. Bait canaries are matched, but any future real secret in a request is not. The gateway binds all interfaces; the firewall is the only reason this is not directly reachable. [read].

Fix: Cap body size (e.g. 4 MB), cap concurrent threads/connections, add read timeouts, bind to loopback or a Unix socket, and perform field-aware redaction (remove whole PEM blocks, aws_secret_access_key, seed phrases, bearer tokens). Never publish unredacted request/response bodies without a second review.

Low FR-13 Unpinned host/image supply chain for code with access to the Venice keyread · host/30-agent.sh:13-16; sandbox/reviewer.Containerfile:9-13,26,52; pipeline/reviewer.sh:7

Files: host/30-agent.sh:13-16 (bash install-agent.sh, curl .../setup_22.x | bash, unpinned npm i -g), sandbox/reviewer.Containerfile:9-13,26,52 (ubuntu:24.04 tag, nodesource pipe, unpinned pipx install semgrep), pipeline/reviewer.sh:7 (localhost/open-reviewer:latest).

Exploit: A compromise of nodesource, the unpinned shrooms install-agent.sh, the unpinned npm package, the base image tag, or the mutable latest image runs with access to the host's Venice key and the review artifacts. [read]; semgrep flagged the curl | bash line.

Fix: Pin every input by digest or exact version with a hash (FROM ubuntu:24.04@sha256:..., npm i -g pkg@version, gitleaks/osv/semgrep versions, cosign/checksums), vendor the installer, and build the reviewer image reproducibly.

Low FR-14 Report pass shares the contaminated sandbox and conversation with the untrusted moduleread · pipeline/review-in-sandbox.sh:66-73; pipeline/reviewer-prompt.md; pipeline/render.py:124-140

Files: pipeline/review-in-sandbox.sh:66-73 (same conversation, same container, pi --continue), pipeline/reviewer-prompt.md (report is model-written), pipeline/render.py:124-140 (published without independent validation).

Exploit: Repository prose and module output can prompt-inject the reviewer; the report pass re-uses the same session and filesystem, so a self-serving report (or a symlinked/faked report.md, FR-1/FR-2) reaches the published page. BACKLOG.md itself notes the report stage is not caged. [read]; the repo contains exactly such reviewer-directed prose (FR-10).

Fix: Produce the report in a fresh context with only a typed, host-collected evidence bundle; validate it against a schema; have a non-model program decide severity/confirmation labels or at least flag unsupported claims; keep the report stage out of the module's mount namespace.

Low FR-15 Reviews are served over plain HTTP and the hash chain is unsignedread · host/Caddyfile:4-14; host/20-web.sh:11-18; pipeline/render.py:99-106; docs/proposal.md:120

Files: host/Caddyfile:4-14, host/20-web.sh:11-18, pipeline/render.py:99-106; claim at docs/proposal.md:120.

Exploit: A network attacker can read or modify reviews in transit; because chain.txt is only a hash chain with no signature or published root, a recipient cannot detect a host-side rewrite. The proposal says the logs are "hash-chained and signed", but no signing key or signature exists in the repo. [read].

Fix: Serve TLS, sign chain.txt with a key held outside the review host, publish the signature and signing identity, and document how a reader verifies it. Until then, do not claim authenticity.

Low FR-16 Any file staged in run evidence/media directories is published same-originrun · pipeline/render.py:92-97; host/Caddyfile:5-6

Files: pipeline/render.py:92-97 (copies whole media, logs, checks, build, behaviour trees), host/Caddyfile:5-6 (file_server browse).

Exploit: The module can write arbitrary files into /run/media (or any copied directory). render.py copies them verbatim into the published review; Caddy serves them from the same origin with directory browsing. My probe dropped media/page.html with a phishing link; render.py published it at dest/media/page.html (it is not linked from index.html, but /media/ is browsable and the URL is guessable). CSP blocks scripts and forms, so this is not XSS, but it is arbitrary same-origin content on the review site. [run] for the copy; [read] for Caddy browse.

Fix: Copy only an explicit allowlist of media extensions (.png, .jpg, .mp4) after content sniffing; strip everything else; disable directory browsing or restrict it to known files; set Content-Disposition: attachment for raw evidence and consider a separate origin for evidence.

Screens and recording

No screenshot or video: the reviewer did not get Basecamp to show the module.
Last lines of basecamp.log:

What is solid

What was not tested

Suggested order

--- Triage, not approval — this never certifies a module as safe. The sandbox is a container; its kernel is the host's and could be detected.

What the reviewer ran

All probes are in /run/probes/; results are quoted below. Tags: [run] reproduced in this sandbox, [read] from code only.

Usage and cost

model calls37
input tokens2,714,756 (2,397,306 cached)
output tokens76,501
model time534.7 s
charged by Venice0.2080 DIEM (the Venice account's balance before minus after; any other use of the account in that time counts too)
computed$0.205 (this review's tokens × Venice's list price for venice/deepseek-v4-1-flash-1017)
budget0.205 of 1.00 DIEM (0.80 for testing)
provider errors0 (retries 0)
went onDIEM
reading the code0.03216%
builds and probes0.0199%
Basecamp (desk)0.03919%
writing the report0.11355%
other0.0010%

Logos Storage

This review, every file of it, as one archive: zDvZRwzmCijHWhadyAdsvsq9NPoFPVPjajKGZAgPjcBjyZyqGrsB on logos.test (3,480,995 bytes, sha256 a197d5e8796607f8…). Any Logos Storage node on that network can fetch it by its CID while one holds it: the review host's node does; nothing pays for more.

Evidence