<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: vinmay</title>
    <description>The latest articles on DEV Community by vinmay (@vinmay).</description>
    <link>https://dev.to/vinmay</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F202000%2F65ee2a0d-c9a2-466d-baae-d1795223c454.jpeg</url>
      <title>DEV Community: vinmay</title>
      <link>https://dev.to/vinmay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vinmay"/>
    <language>en</language>
    <item>
      <title>ClearToList: Is that thrift find recalled? A photo check against CPSC recalls with Gemma 4</title>
      <dc:creator>vinmay</dc:creator>
      <pubDate>Sat, 17 Oct 2026 16:35:22 +0000</pubDate>
      <link>https://dev.to/vinmay/cleartolist-is-that-thrift-find-recalled-a-photo-check-against-cpsc-recalls-with-gemma-4-20ik</link>
      <guid>https://dev.to/vinmay/cleartolist-is-that-thrift-find-recalled-a-photo-check-against-cpsc-recalls-with-gemma-4-20ik</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;A $5 bassinet at a thrift store can be a recalled product that's illegal to resell, and nothing on it tells you.&lt;/p&gt;

&lt;p&gt;That's the problem I am trying to solve. Selling a recalled product is illegal in the U.S. even secondhand and even if you're one person flipping stuff on Facebook Marketplace on weekends. A lot of what gets recalled is exactly the stuff that piles up at thrift stores like cribs, bassinets, sleepers, high chairs, strollers, toys.&lt;/p&gt;

&lt;p&gt;I pulled every CPSC recall since 2015 to see how bad it is. About 1 in 5 is a kids’ product, and &lt;strong&gt;more than half of those (55%) don’t list a model number or UPC at all&lt;/strong&gt; in their product data. The only way to know is what the thing looks like.&lt;/p&gt;

&lt;p&gt;Every recall tool I found wants text. A brand, a model number, a SKU from your store catalog. A reseller's haul has none of that. No box, no SKU, half the time you don't even know the brand.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;ClearToList&lt;/strong&gt;. You take a photo of the item, it finds the closest recalls. Then you take a photo of the label and it settles it by model number or UPC. You get one of three answers, shown as a price-dot sticker on your own photo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟥 &lt;strong&gt;Recalled&lt;/strong&gt;: a model number or UPC on the item matches a recall. Don't list it.&lt;/li&gt;
&lt;li&gt;🟨 &lt;strong&gt;Check the label&lt;/strong&gt;: it looks like a recalled product. That alone means nothing, so it asks for a label photo.&lt;/li&gt;
&lt;li&gt;⬜ &lt;strong&gt;No match found&lt;/strong&gt;: nothing matched. This never means "safe". There's no green sticker on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only Recalled is an alarm. My first version also alerted when something just &lt;em&gt;looked&lt;/em&gt; like a recalled product. Turns out a lot of normal white cribs look like recalled white cribs and that evaluation talked me out of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buyers get it too.&lt;/strong&gt; Looking at a Marketplace listing? Screenshot it and check before you pay. If it looks like a recalled product, the page gives you a message to send the seller asking for a label photo. Resellers can check a whole haul at once (up to 10 photos) and get one report as CSV or PDF.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who pays for this
&lt;/h3&gt;

&lt;p&gt;Nobody yet, and nothing is charged today. Here's what I'd test first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free:&lt;/strong&gt; buyers and casual checks, a few photo checks a day. Parents shouldn't pay to find out if a crib is recalled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reseller, $9/month:&lt;/strong&gt; unlimited photo checks, bigger hauls, alerts when something you already checked gets recalled later, and a dated "checked" record for every item you list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store, $29/month per location:&lt;/strong&gt; bulk intake scanning for consignment and kids' resale shops, several staff logins and a daily pull list. They take in used baby gear from strangers every day and carry the most risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marketplace licensing:&lt;/strong&gt; the long shot. A check built into a resale app's listing flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A scan on open models costs a fraction of a cent, so even the $9 tier works with very few customers. These prices are my guess, not tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/_b715-KeWs0" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;br&gt;
Real results from the live site, I just cut the waiting. Demo photos: Wikimedia Commons (ajay_suresh, Sally V, Hardyplants, קרלוס הגדול), CC BY 2.0 / CC BY-SA / CC0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it yourself: &lt;a href="https://cleartolist.onrender.com" rel="noopener noreferrer"&gt;cleartolist.onrender.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No sign-up. Three sample photos run real scans, and everyone gets free photo checks every day. Want to use it regularly? Drop your email for an invite code and I'll send one within a day. Model number, UPC and brand lookup is always open.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frpbn8xudr2iokd3jc934.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frpbn8xudr2iokd3jc934.png" alt="The ClearToList home page on a phone" width="780" height="1688"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Take a photo, pick one from your library, or try a sample. The label is step 2.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc5kk5cioj6eom0n5qdwz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc5kk5cioj6eom0n5qdwz.png" alt="Recalled: a red Recalled sticker on the item photo, with the recall details" width="716" height="3100"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The label's model number matches a recall. Red sticker, what to do whether you're selling or buying, and the recall itself.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcvvftfgeqn428hk5s4ut.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcvvftfgeqn428hk5s4ut.png" alt="Check the label: a yellow sticker and the three closest recalls" width="716" height="3364"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Looking similar is never an alarm. Yellow sticker, the closest recalls to compare, and a button to add the label. (Play yard photo: Famartin, CC BY-SA 4.0, via Wikimedia Commons.)&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/vinmay" rel="noopener noreferrer"&gt;
        vinmay
      &lt;/a&gt; / &lt;a href="https://github.com/vinmay/cleartolist" rel="noopener noreferrer"&gt;
        cleartolist
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;
&lt;a rel="noopener noreferrer" href="https://github.com/vinmay/cleartolist/demo/static/brand/mark.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fvinmay%2Fcleartolist%2FHEAD%2Fdemo%2Fstatic%2Fbrand%2Fmark.svg" alt="" width="32" height="32"&gt;&lt;/a&gt; ClearToList&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Check secondhand products against U.S. product recalls before you list them, children's products first.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Resellers buy unboxed, unbranded secondhand items with no SKU, so catalog-based recall tools can't help them. And selling a recalled consumer product is illegal no matter how small the seller. ClearToList works in two photos: a photo of the item finds the closest CPSC recalls, and a photo of its label settles it by model number or UPC. You get one of three verdicts:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🟥 &lt;strong&gt;CONFIRMED&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;A model number or UPC read off the item matches a recalled product. Don't list it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🟧 &lt;strong&gt;POSSIBLE MATCH&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;The item looks similar to the recalls shown side by side with your photo. That alone doesn't mean it's recalled: photograph the label and scan it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⬜ &lt;strong&gt;NO MATCH FOUND&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;No match in CPSC recalls. That does &lt;strong&gt;not&lt;/strong&gt; mean the item is safe.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Only CONFIRMED is an alarm…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vinmay/cleartolist" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Data
&lt;/h3&gt;

&lt;p&gt;All CPSC recalls since 2015 come from the CPSC Recalls API: about 3,400 in scope and around 12.7k product photos. The ingest only pulls what changed, backs off politely and falls back to a cache if the API is down. A GitHub Actions job refreshes it every night and redeploys the site's data. Car seats are out because NHTSA handles those, not CPSC.&lt;/p&gt;

&lt;h3&gt;
  
  
  The scan
&lt;/h3&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    P[Item photo] --&amp;gt; A[SigLIP 2&amp;lt;br/&amp;gt;image search over&amp;lt;br/&amp;gt;~12.7k recall photos]
    P --&amp;gt; G[Gemma 4 reads the photo&amp;lt;br/&amp;gt;JSON: item type, brand,&amp;lt;br/&amp;gt;text, model no., UPC]
    G --&amp;gt; E[Exact match&amp;lt;br/&amp;gt;model no. / UPC]
    G --&amp;gt; T[EmbeddingGemma&amp;lt;br/&amp;gt;text search]
    A --&amp;gt; F{Weighted rank fusion}
    T --&amp;gt; F
    E --&amp;gt;|boost to top| F
    F --&amp;gt; S[Top 3 shortlist]
    S --&amp;gt; V{Rule}
    V --&amp;gt;|code matches| C[Recalled]
    V --&amp;gt;|closest photo very similar| M[Check the label&amp;lt;br/&amp;gt;show shortlist,&amp;lt;br/&amp;gt;ask for label photo]
    V --&amp;gt;|otherwise| N[No match found]
    L[Label photo] --&amp;gt; G2[Gemma 4 reads&amp;lt;br/&amp;gt;model no. / UPC] --&amp;gt; E2[Exact match] --&amp;gt; C&lt;/code&gt;&lt;/pre&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 4 E4B reads the photo&lt;/strong&gt; and returns a JSON: item type, brand, any visible text, model number, UPC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search.&lt;/strong&gt; SigLIP 2 searches the recall photos, model numbers and UPCs are matched exactly, brands are matched, and EmbeddingGemma searches the recall text. Weighted reciprocal rank fusion combines it all. For a normal product photo the image leads. Text only leads when the photo is basically a label and Gemma actually read words off it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dumb rule decides, not a model.&lt;/strong&gt; Code match means Recalled, unless the brand on the item contradicts the recall. Very similar closest photo means Check the label. Otherwise, No match found. I wanted the answer to come from something I can read in ten lines and not from a model non-deterministically.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Picking the reader
&lt;/h3&gt;

&lt;p&gt;I tried Gemma 4 E2B and E4B on 30 held-out photos. First prompt was bad because Gemma kept saying "baby product" instead of "bassinet". Asking for a specific item type got both to 15/30, and E4B was faster (3.6 s vs 6.2 s median), so E4B it is. A full scan is about 7s.&lt;/p&gt;

&lt;p&gt;One gotcha is SigLIP on the GPU left too little room for Gemma and EmbeddingGemma to stay loaded, so Ollama swapped them on every single scan and added 25s. So, I had to move SigLIP to the CPU and that solved the problem and it's still well under a second there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The site and not getting wrecked by a public upload form
&lt;/h3&gt;

&lt;p&gt;The site runs on Render's free tier and runs no models itself. Photo checks go into a small in-memory queue, and a separate GPU worker running the open models connects &lt;em&gt;out&lt;/em&gt; to the site over signed HTTPS to pick up jobs. It accepts no incoming connections. It sends back only recall ids and matched codes, and Render re-checks any code against its own copy of the data before it ever shows Recalled.&lt;/p&gt;

&lt;h3&gt;
  
  
  Security
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Uploads are checked by their actual bytes, capped at 8 MB and ~30 megapixels, and re-encoded, which strips GPS and anything hidden in the file.&lt;/li&gt;
&lt;li&gt;Photos live in memory only and get deleted right after the check.&lt;/li&gt;
&lt;li&gt;Per-person rate limits, a daily cap on free checks, a small queue that says no when it's full, and a kill switch for uploads.&lt;/li&gt;
&lt;li&gt;Worker requests are HMAC-signed with a timestamp and nonce.&lt;/li&gt;
&lt;li&gt;Anything Gemma reads off a label is treated as untrusted. It can't set the verdict, and the page sits behind a strict Content Security Policy.&lt;/li&gt;
&lt;li&gt;No cookies, no IPs, no tracking. Just anonymous daily counts and a "Was this helpful?" button.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Evaluation
&lt;/h3&gt;

&lt;p&gt;Every test query is a held-out recall photo roughed up to look like a phone shot (crop, rotation, dim light, blur), and that photo &lt;strong&gt;plus every near-duplicate of it&lt;/strong&gt; is hidden from the index. The only way to score is to find &lt;em&gt;other&lt;/em&gt; photos of the same recall. For negatives I used licensed Wikimedia photos of normal, non-recalled products in the same categories. Rules were picked on a dev split and the test ran once.&lt;/p&gt;

&lt;p&gt;There are three different numbers here and they're easy to mix up, so here's what each one means for someone holding the item:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;From one item photo&lt;/th&gt;
&lt;th&gt;Kids' products&lt;/th&gt;
&lt;th&gt;Other categories&lt;/th&gt;
&lt;th&gt;Label close-ups&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Flagged "Check the label" with the right recall&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;34%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;37%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Right recall shown at all (incl. grey "closest recalls to check")&lt;/td&gt;
&lt;td&gt;63%&lt;/td&gt;
&lt;td&gt;38%&lt;/td&gt;
&lt;td&gt;57%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Right recall somewhere in the internal top 3 (not shown to you)&lt;/td&gt;
&lt;td&gt;67%&lt;/td&gt;
&lt;td&gt;64%&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recalled verdicts (right / total)&lt;/td&gt;
&lt;td&gt;0 / 0&lt;/td&gt;
&lt;td&gt;2 / 3&lt;/td&gt;
&lt;td&gt;2 / 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False Recalled alarms on normal lookalikes&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The top row is the one that matters: &lt;strong&gt;from a photo alone, ClearToList points you at the right recall about a third of the time.&lt;/strong&gt; The 67% in the third row only says the search is good enough to build on. I hide most of that shortlist on purpose, because showing it every time flagged almost every normal product.&lt;/p&gt;

&lt;p&gt;The grey suggestions in row two raise it to 63% for kids' products, but 98% of normal lookalikes get suggestions too. That's why they're grey and never called a match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The label photo is what makes it reliable.&lt;/strong&gt; Recalled needs a model number or UPC off the item. Of the five Recalled verdicts in the test, four were correct. None of the 124 tested lookalikes triggered a Recalled verdict. These are encouraging early results, but the sample is too small to establish real-world reliability. Cribs and sleepers only get 45% top 3 from the image, because one white crib looks a lot like another. On label close-ups, Gemma reading the label finds the recall where image search can't (60% vs 18%).&lt;/p&gt;

&lt;p&gt;I also set a kill criterion before building the app: about 60% internal top-3 on kids' products, or I drop the idea. The spike hit 71% and the test landed at 67-69%. That number answers "is the search good enough to build on", not "what does a user see". The user number is the 34%.&lt;/p&gt;

&lt;p&gt;It took three runs to get these numbers. Run 1 had a Gemma "verifier" that was right about half the time and raised alarms on 31% of normal kids' items, so I cut it. The full run history is in the &lt;strong&gt;&lt;a href="https://github.com/vinmay/cleartolist/blob/main/eval/README.md" rel="noopener noreferrer"&gt;Full evaluation&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The math only works if a scan is basically free.&lt;/strong&gt; A reseller might check hundreds of items a week. On open models a scan costs a fraction of one API call, and there's no per-call bill to scale with usage. That's the difference between "free for parents" being a real promise or a trial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photos of people’s stuff stay private.&lt;/strong&gt; People photograph things in their homes, sometimes with their kids' stuff around. Those photos go to my worker, get checked and get deleted. They never get sent to a third-party model API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could actually tune it.&lt;/strong&gt; Forcing strict JSON out of Gemma, swapping E2B for E4B, moving SigLIP to the CPU, picking thresholds on a dev split: all of that is versioned in the repo, so nothing shifts under the evaluation because a vendor updated a model. And the whole pipeline can be fine-tuned on recall photos later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My First User
&lt;/h2&gt;

&lt;p&gt;It went live this weekend, so I don't have a real user story yet. I'll update this section with actual numbers and whatever people tell me.&lt;/p&gt;

&lt;p&gt;If you flip on eBay, Poshmark or Marketplace or you're buying used baby gear, run one item through it and tell me in the comments what it got right or wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma&lt;/strong&gt;: Gemma 4 reads the item and its label, which is the core of the whole thing. EmbeddingGemma powers the text search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt;: the hosted site and job queue, deployed from &lt;code&gt;render.yaml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Copilot (GitHub Actions)&lt;/strong&gt;: Actions is what keeps ClearToList current without me touching it. A scheduled workflow runs every night at 09:17 UTC: it pulls only the recalls CPSC added or changed since the last run, rebuilds the site’s SQLite database (full-text search plus exact model-number and UPC tables), publishes it as a data-latest release asset and calls Render’s deploy hook, so the live site picks up a new recall the next morning. A second workflow runs ruff, the full pytest suite and a gitleaks scan over the whole git history on every push. During launch weekend a third one pings the site every 10 minutes so the free instance doesn’t fall asleep on a visitor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Per-category thresholds to cut down noise outside kids' products (sports gear and kitchen stuff are the worst)&lt;/li&gt;
&lt;li&gt;More recall sources: EU Safety Gate (it has photos) and Health Canada&lt;/li&gt;
&lt;li&gt;Real thrift store photos in the eval&lt;/li&gt;
&lt;li&gt;More scanning capacity by running the same worker on rented GPUs&lt;/li&gt;
&lt;li&gt;Alerts that re-check your past items when a new recall drops&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;This is a screening aid only, not legal advice. Recall data is from the U.S. Consumer Product Safety Commission. Not affiliated with or endorsed by CPSC.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Doppel: find two different things that look similar, outside</title>
      <dc:creator>vinmay</dc:creator>
      <pubDate>Mon, 12 Oct 2026 04:07:28 +0000</pubDate>
      <link>https://dev.to/vinmay/doppel-find-two-different-things-that-look-similar-outside-2o77</link>
      <guid>https://dev.to/vinmay/doppel-find-two-different-things-that-look-similar-outside-2o77</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Doppel&lt;/strong&gt; is a walking game about lookalikes. You take a photo of anything outside then walk about 50 meters, then photograph another one which could be its twin: a &lt;em&gt;different kind&lt;/em&gt; of thing that looks the same (a pinecone and an artichoke, or moss and broccoli). &lt;strong&gt;EmbeddingGemma 2&lt;/strong&gt; runs on your phone which judges how alike the two photos look and shows its reasons.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7mlqsx3vu2911ipl4wd.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7mlqsx3vu2911ipl4wd.gif" alt="Dopple Reveal" width="300" height="750"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's for anyone on a walk who wants a reason to look at things more closely around them. You can do three pairs a day for 10 to 15 minutes then it tells you to stop. You can't lose and you have fun looking around during your walk and your photos never leave the phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://doppel-ws59.onrender.com" rel="noopener noreferrer"&gt;https://doppel-ws59.onrender.com&lt;/a&gt; (Chrome on Android or desktop; needs WebGPU; downloads a 388 MB model once, then works offline)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No time to go outside?&lt;/strong&gt; &lt;a href="https://doppel-ws59.onrender.com/#/demo" rel="noopener noreferrer"&gt;Try a demo round&lt;/a&gt;: one full round with bundled CC0 photos and the real model, no camera needed, nothing saved. On a desktop it's the first button you see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video:&lt;/strong&gt;
  &lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/_MHpp2ZZ4nM" width="315" height="560"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;

&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp1e6pwlstt0dtnepbelw.png" alt="The first screen, with a practice reveal playing while the model downloads" width="800" height="1731"&gt;
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8b72o5sik4muua2tt0v2.png" alt="Right after the first photo: the real photo, what Doppel sees, what to look for, and three twins to try" width="800" height="1731"&gt;
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv6o27gf64pecc3la5l2c.png" alt="A reveal: the two real photos in Wada-colored mats, the score, and what they share" width="800" height="1731"&gt;
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyqjy7ot60ozpoh8mrb06.png" alt="The demo round on a desktop: pinecone and artichoke" width="800" height="500"&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/vinmay" rel="noopener noreferrer"&gt;
        vinmay
      &lt;/a&gt; / &lt;a href="https://github.com/vinmay/doppel" rel="noopener noreferrer"&gt;
        doppel
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;Doppel started as a fork of Sense Hunt and Warmr (same author, same challenge window).&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Doppel&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Find two different things that look the same. Outside.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Photograph anything outdoors. Walk about 50 metres. Then find its twin: a &lt;em&gt;different kind&lt;/em&gt; of thing that looks the same, like a pinecone and an artichoke, moss and broccoli, or bark and elephant skin. &lt;strong&gt;EmbeddingGemma 2 runs on your phone&lt;/strong&gt; and judges how alike the two photos look, and it shows its reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Doppel sees:&lt;/strong&gt; what it thinks each photo is ("pinecone ↔ artichoke"), so you know it understood you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A twin score&lt;/strong&gt; from 0 to 100, in four bands: Uncanny, Cousins, Distant, Strangers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What they share:&lt;/strong&gt; "Both: ridged · layered · wrinkled", the visual qualities both photos have.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Three pairs a day, about 10 to 15 minutes, then "Done for today". You can't lose. Photos never leave the phone, and after the first load…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vinmay/doppel" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How I got here
&lt;/h3&gt;

&lt;p&gt;Doppel was my third try at this challenge.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sense Hunt.&lt;/strong&gt; A daily list of prompts ("a tree with peeling bark"); you photograph one and the model checks it. It worked but it was getting too hard as the prompt generator needed to have a controlled list of words and something achievable for the player.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warmr.&lt;/strong&gt; Hot/cold for the real world: find a hidden daily secret using photos. It depended too much on where you lived. If your street didn’t have the secret, you couldn’t win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doppel.&lt;/strong&gt; I was then looking for a judgement that could be done similarly by a player as well as the model. We are very good at "these two look alike" games and so is image embedding. Doppel is that judgment.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  How it works
&lt;/h3&gt;

&lt;p&gt;EmbeddingGemma 2 came out the day after the challenge opened. It can embed both photos and text into the same space and it's small enough to run in the browser on WebGPU and Doppel leans on it for everything.&lt;/p&gt;

&lt;p&gt;The score is just how close your two photos are scaled to 0-100 which is calibrated on 23 of my own outdoor photos.&lt;/p&gt;

&lt;p&gt;The rest comes from comparing photos to text. At build time I embedded a list of about 200 everyday things ("a tire", "moss") with a few things you shouldn't photograph (screens, rooms, people), and 45 visual words like "round" or "ridged". The closest thing is what "Doppel sees". A visual word shows up under "What they share" when it fits both photos noticeably better than usual. The same lists suggest what to look for on your walk ("Try: seed pod · corn on the cob · seashell") and catch you photographing the same kind of thing twice.&lt;/p&gt;

&lt;p&gt;A pair of very different things that still look alike gets a small bonus, so a pinecone and an artichoke beat a cloud and fog. The "Need an idea?" hints were written by Gemma 4 E4B, running locally through Ollama.&lt;/p&gt;

&lt;p&gt;Nothing leaves the device. Your photos and scores live in IndexedDB, and a service worker keeps the app and the model available offline. The colors come from Sanzo Wada's &lt;em&gt;A Dictionary of Color Combinations&lt;/em&gt; (1933).&lt;/p&gt;

&lt;h3&gt;
  
  
  On my walk
&lt;/h3&gt;

&lt;p&gt;I played a full day in my neighborhood with three pairs and these are the results Doppel showed me -&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tire ↔ manhole cover: 49, Distant.&lt;/strong&gt; Both round, ridged, gray. Fair.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Power lines ↔ street lamp: 37, Distant.&lt;/strong&gt; Doppel's verdict: "If you squint. Hard."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manhole cover ↔ sidewalk: 83, Uncanny.&lt;/strong&gt; The one that surprised me: up close, the cover and the speckled concrete really do read as the same texture.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One observation I had was on my Pixel 8 Pro each photo takes 15-20 seconds to read which is much slower than the 1.7 s on a laptop. It turned out fine because that's time spent looking around instead of at the screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it work?
&lt;/h3&gt;

&lt;p&gt;These numbers come from the real model running in a browser.&lt;/p&gt;

&lt;p&gt;I tested on 44 CC0 photos with the real model in a headless browser on a laptop iGPU. (&lt;a href="https://github.com/vinmay/doppel/blob/main/docs/eval.md" rel="noopener noreferrer"&gt;docs/eval.md&lt;/a&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Twins vs strangers, mean score&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;58.6 vs 19.9&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twins scoring Cousins or better&lt;/td&gt;
&lt;td&gt;6 of 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strangers scoring Distant or worse&lt;/td&gt;
&lt;td&gt;8 of 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same-kind pairs refused&lt;/td&gt;
&lt;td&gt;3 of 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screens, rooms, documents, blurry photos refused&lt;/td&gt;
&lt;td&gt;4 of 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Photo embed time (laptop iGPU)&lt;/td&gt;
&lt;td&gt;median 1.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first run passed five of the six original criteria and only 5 of 8 twins reached Cousins (moss and broccoli sat at exactly 50).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It works on a walk with no signal.&lt;/strong&gt; The model is open-weight and small enough (388 MB) to run in the browser on WebGPU, so after one download Doppel works in airplane mode, in a park, on a trail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your photos never leave your phone.&lt;/strong&gt; A game that asks you to photograph your street all day shouldn't upload your street. Here there's nothing to upload to: no server, no account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs nothing to run.&lt;/strong&gt; No API bill per photo means no ads, no subscription, no limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model is swappable.&lt;/strong&gt; The game only needs "embed a photo" and "embed a text". The calibration script re-derives every number from photos, so a better open model is a re-run, not a rewrite.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; EmbeddingGemma 2 (released Oct 6) is the core of the game: every photo is embedded on-device in the browser (LiteRT-LM, WebGPU), and the score, the labels, "What they share" and the twin suggestions all come from it. Gemma 4 E4B, run locally through Ollama, wrote the idea hints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Render:&lt;/strong&gt; Doppel is deployed on Render as a static site (&lt;code&gt;render.yaml&lt;/code&gt; in the repo). Render serves the app and the LiteRT-LM WebGPU runtime that each phone downloads to run EmbeddingGemma 2 locally.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>webgpu</category>
    </item>
    <item>
      <title>Your `pip install` Just Stole Your SSH Keys: The LiteLLM Supply Chain Attack Explained</title>
      <dc:creator>vinmay</dc:creator>
      <pubDate>Tue, 24 Mar 2026 20:19:13 +0000</pubDate>
      <link>https://dev.to/vinmay/your-pip-install-just-stole-your-ssh-keys-the-litellm-supply-chain-attack-explained-4l52</link>
      <guid>https://dev.to/vinmay/your-pip-install-just-stole-your-ssh-keys-the-litellm-supply-chain-attack-explained-4l52</guid>
      <description>&lt;p&gt;A single &lt;code&gt;pip install litellm==1.82.8&lt;/code&gt; was enough to drain everything off your machine. No suspicious imports. No weird prompts. Just a package install, and your AWS credentials, SSH keys, and API keys were already heading to an attacker's server.&lt;/p&gt;

&lt;p&gt;Here's what happened, why it's scary, and what you can actually do about it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Happened
&lt;/h2&gt;

&lt;p&gt;On March 24, 2026, LiteLLM version 1.82.8 landed on PyPI with a malicious file bundled inside: &lt;code&gt;litellm_init.pth&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;.pth&lt;/code&gt; extension is why this attack is so nasty.&lt;/p&gt;

&lt;p&gt;Python automatically runs &lt;code&gt;.pth&lt;/code&gt; files in your &lt;code&gt;site-packages&lt;/code&gt; directory &lt;strong&gt;every time the Python interpreter starts&lt;/strong&gt;. No import needed, no user interaction. The attacker hid a double base64-encoded payload inside this file. The moment Python ran, the payload ran too.&lt;/p&gt;

&lt;p&gt;What did it grab? Pretty much everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All your environment variables (&lt;code&gt;OPENAI_API_KEY&lt;/code&gt;, &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt;, all of it)&lt;/li&gt;
&lt;li&gt;SSH private keys (&lt;code&gt;~/.ssh/id_rsa&lt;/code&gt;, &lt;code&gt;id_ed25519&lt;/code&gt;, and more)&lt;/li&gt;
&lt;li&gt;AWS, GCP, Azure, and Kubernetes credentials&lt;/li&gt;
&lt;li&gt;Git credentials and &lt;code&gt;.gitconfig&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Shell history (&lt;code&gt;~/.bash_history&lt;/code&gt;, &lt;code&gt;~/.zsh_history&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Docker configs, npm tokens, database passwords&lt;/li&gt;
&lt;li&gt;Crypto wallet files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then it encrypted everything with AES-256, wrapped the key with a hardcoded RSA-4096 public key, and shipped it all off to &lt;code&gt;https://models.litellm.cloud/&lt;/code&gt;. Note that domain: &lt;code&gt;litellm.cloud&lt;/code&gt;, not the real &lt;code&gt;litellm.ai&lt;/code&gt;. Classic.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the Scale Is Scary
&lt;/h2&gt;

&lt;p&gt;LiteLLM gets &lt;strong&gt;97 million downloads per month&lt;/strong&gt;. That alone is a huge problem.&lt;/p&gt;

&lt;p&gt;But supply chain attacks don't stop at the direct install. They travel through dependency trees. If you installed &lt;code&gt;dspy&lt;/code&gt;, &lt;code&gt;langchain&lt;/code&gt;, or any of the other popular AI packages that depend on &lt;code&gt;litellm&amp;gt;=1.64.0&lt;/code&gt;, you were also exposed without ever typing &lt;code&gt;pip install litellm&lt;/code&gt; yourself.&lt;/p&gt;

&lt;p&gt;The attack was only live for about an hour. It got discovered almost by accident: a developer's machine ran out of RAM and crashed because the payload was executing inside Cursor through an MCP plugin that pulled in litellm as a transitive dependency. A bug in the attacker's own code gave them away.&lt;/p&gt;

&lt;p&gt;If that bug wasn't there, this could have run quietly for days or weeks across thousands of CI/CD pipelines, dev machines, and prod servers. Nobody would have noticed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Problem: You Can't See What's Inside Your Dependencies
&lt;/h2&gt;

&lt;p&gt;When you run &lt;code&gt;pip install something&lt;/code&gt;, you're not just installing one thing. You're pulling in a whole tree of packages, and any one of them could be compromised.&lt;/p&gt;

&lt;p&gt;This isn't new, but it's getting worse as the AI package ecosystem keeps exploding. New packages, new versions, new dependencies dropping every single day. The attack surface is growing way faster than anyone can audit it.&lt;/p&gt;

&lt;p&gt;We're taught to think of dependencies as a good thing. Reusable building blocks, standing on the shoulders of giants, all that. The LiteLLM incident is a reminder that every dependency is also a trust decision, and most of us are making those decisions without really thinking about it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Should Actually Do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you installed litellm 1.82.8:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check for &lt;code&gt;litellm_init.pth&lt;/code&gt; in your &lt;code&gt;site-packages/&lt;/code&gt; directory&lt;/li&gt;
&lt;li&gt;Rotate everything: every API key, SSH key, and cloud credential that was on that machine&lt;/li&gt;
&lt;li&gt;Check any CI/CD environment where litellm might have been installed too&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Going forward:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin your dependencies.&lt;/strong&gt; Exact version locks (&lt;code&gt;==&lt;/code&gt;) in production instead of &lt;code&gt;&amp;gt;=&lt;/code&gt;. It won't stop a poisoned release from getting in if you're on that version, but it stops silent upgrades pulling in something bad later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use lockfiles.&lt;/strong&gt; &lt;code&gt;pip-compile&lt;/code&gt;, &lt;code&gt;poetry.lock&lt;/code&gt;, &lt;code&gt;uv.lock&lt;/code&gt;, whatever fits your setup. Know exactly what you're running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit transitive dependencies.&lt;/strong&gt; &lt;code&gt;pip-audit&lt;/code&gt; and &lt;code&gt;safety&lt;/code&gt; scan your full dependency tree for known issues. Worth running in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't pip install as root.&lt;/strong&gt; Limits how much damage a compromise can actually do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep an eye out for &lt;code&gt;.pth&lt;/code&gt; files.&lt;/strong&gt; They're a legit Python feature, but they're also a perfect delivery mechanism for malware. If you see one in &lt;code&gt;site-packages&lt;/code&gt; from a package you don't recognise, that's worth investigating.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Can We Do Better Than Grep?
&lt;/h2&gt;

&lt;p&gt;Most of the advice above is reactive. It helps you recover or reduce damage after something gets in. What's actually hard is knowing &lt;em&gt;before&lt;/em&gt; you run anything that a package can even reach your credentials.&lt;/p&gt;

&lt;p&gt;This is the gap I've been trying to close with something I'm building called &lt;a href="https://github.com/vinmay/reachscan" rel="noopener noreferrer"&gt;ReachScan&lt;/a&gt;. The idea is pretty simple: instead of just matching against a list of known bad packages, it maps what a codebase or its dependencies can actually &lt;em&gt;reach&lt;/em&gt;: filesystem paths, environment variables, system resources. If a package has no business touching &lt;code&gt;~/.ssh/&lt;/code&gt;, you should know that before it runs, not after.&lt;/p&gt;

&lt;p&gt;It won't catch everything. But knowing the capability surface of what you're about to install is a lot better than just hoping nothing in the tree is malicious.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Uncomfortable Truth
&lt;/h2&gt;

&lt;p&gt;Karpathy put it well after this incident: the way we think about dependencies needs to change. The whole "building pyramids from bricks" model assumes the bricks are trustworthy. In 2026, that's a harder assumption to stand behind.&lt;/p&gt;

&lt;p&gt;That doesn't mean stop using dependencies. That's not realistic. It just means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Be deliberate about what you pull in&lt;/li&gt;
&lt;li&gt;Actually understand what each dependency can do on your machine&lt;/li&gt;
&lt;li&gt;Have a rotation plan for credentials that treats compromise as a when, not an if&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The LiteLLM attack got caught by luck. The next one might not be.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>python</category>
      <category>llm</category>
    </item>
    <item>
      <title>I built "npm audit" for AI agents</title>
      <dc:creator>vinmay</dc:creator>
      <pubDate>Sat, 21 Mar 2026 02:23:50 +0000</pubDate>
      <link>https://dev.to/vinmay/i-built-npm-audit-for-ai-agents-5fc8</link>
      <guid>https://dev.to/vinmay/i-built-npm-audit-for-ai-agents-5fc8</guid>
      <description>&lt;p&gt;I was adding MCP tools to a project when I realized something uncomfortable: I had no idea what the code I was installing could actually do.&lt;/p&gt;

&lt;p&gt;The README said "connects Claude to Blender." What it didn't say was that one of the registered tools passes a raw string parameter to Python's &lt;code&gt;exec()&lt;/code&gt; with no builtin restriction. The LLM doesn't get "Blender API access." It gets full Python execution on the host machine.&lt;/p&gt;

&lt;p&gt;I wanted a way to know this &lt;em&gt;before&lt;/em&gt; running the code. So I built one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What reachscan does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/vinmay/reachscan" rel="noopener noreferrer"&gt;reachscan&lt;/a&gt; is a static analysis CLI for Python and TypeScript/JavaScript AI agent codebases. Point it at a repo, a PyPI package, or an MCP endpoint, and it tells you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What the code can do (shell exec, file access, network calls, credential access, dynamic code execution)&lt;/li&gt;
&lt;li&gt;Which of those capabilities the LLM can actually trigger (reachability analysis)&lt;/li&gt;
&lt;li&gt;The exact call path from the LLM entry point to the dangerous code
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;reachscan

&lt;span class="c"&gt;# Scan a GitHub repo&lt;/span&gt;
reachscan https://github.com/user/repo

&lt;span class="c"&gt;# Scan a PyPI package before installing&lt;/span&gt;
reachscan pypi:some-agent-package

&lt;span class="c"&gt;# Scan local code&lt;/span&gt;
reachscan ./my-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No config, no API keys, no cloud service. It runs offline and produces a report in about 2 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;When you give an LLM tools, you're granting it real-world capabilities like file access, shell commands, network calls, credential reads. Most frameworks make it easy to add tools and hard to audit what you've exposed.&lt;/p&gt;

&lt;p&gt;Here's real code from a popular MCP server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute_blender_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Execute arbitrary Python code in Blender.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;blender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_blender_connection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;execute_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;code: str&lt;/code&gt; parameter? It ends up here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bpy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bpy&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c1"&gt;# No __builtins__ restriction
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;namespace = {"bpy": bpy}&lt;/code&gt; &lt;em&gt;looks&lt;/em&gt; like a sandbox. It isn't. Without explicitly setting &lt;code&gt;__builtins__&lt;/code&gt;, Python injects the full builtins module. The LLM can &lt;code&gt;import os&lt;/code&gt;, run &lt;code&gt;subprocess&lt;/code&gt;, read your files — anything.&lt;/p&gt;

&lt;p&gt;Here's what reachscan shows for this server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  DYNAMIC  exec()                  server.py:431  reachable
           path: execute_blender_code → send_command → execute_code

  EXECUTE  subprocess.run()        addon.py:89    reachable

  SEND     requests.post()         server.py:198  reachable
           path: generate_3d_model → _call_api

  SECRETS  os.environ[...]         server.py:12   module_level
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;reachable&lt;/code&gt; tag is the key part. It means the LLM can trigger this code through a registered tool and not just that the code exists somewhere in the repo. &lt;code&gt;module_level&lt;/code&gt; means it runs on import. &lt;code&gt;unreachable&lt;/code&gt; means the code exists but no LLM call path leads to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works (briefly)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Detectors&lt;/strong&gt; scan the AST for 7 capability categories: EXECUTE, READ, WRITE, SEND, SECRETS, DYNAMIC, AUTONOMY&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Entry point detection&lt;/strong&gt; finds the functions exposed to the LLM — &lt;code&gt;@tool&lt;/code&gt;, &lt;code&gt;@mcp.tool()&lt;/code&gt;, &lt;code&gt;@function_tool&lt;/code&gt;, &lt;code&gt;BaseTool&lt;/code&gt; subclasses, etc. across LangChain, OpenAI Agents SDK, MCP, Pydantic AI, CrewAI, Semantic Kernel, and AutoGen&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call graph + BFS&lt;/strong&gt; traces up to 8 hops from each entry point to determine which capabilities are actually reachable&lt;/li&gt;
&lt;li&gt;Every finding gets one of 5 states: &lt;code&gt;reachable&lt;/code&gt;, &lt;code&gt;unreachable&lt;/code&gt;, &lt;code&gt;module_level&lt;/code&gt;, &lt;code&gt;unknown&lt;/code&gt;, &lt;code&gt;no_entry_points&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The false positive rate is 0.47% across 1,912 labeled findings on 10 real-world repos. I care about this number a lot because a noisy scanner is a useless scanner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;The short version: I was evaluating third-party MCP servers and realized there was no &lt;code&gt;npm audit&lt;/code&gt; equivalent for AI agent code. I could run &lt;code&gt;pip audit&lt;/code&gt; to check for known vulnerabilities in dependencies, but nothing told me "this package gives the LLM shell access on your machine."&lt;/p&gt;

&lt;p&gt;The existing tools I found either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Require API calls per scan (expensive, not offline)&lt;/li&gt;
&lt;li&gt;Produce flat capability lists without reachability context (noisy)&lt;/li&gt;
&lt;li&gt;Don't handle the MCP/agent-specific entry point patterns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built the tool I wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it found across 50 real MCP servers
&lt;/h2&gt;

&lt;p&gt;I ran reachscan against 50 of the most popular MCP server repos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 in 3&lt;/strong&gt; has shell execution capability&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 in 3&lt;/strong&gt; has outbound network I/O&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 in 4&lt;/strong&gt; accesses credentials from environment variables&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 of 50&lt;/strong&gt; had 4+ capabilities active simultaneously&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The highest-risk combination: credential access + network egress. That appeared in 8 of 50 repos. If the LLM can read your AWS keys AND make HTTP calls, that's an exfiltration path.&lt;/p&gt;

&lt;p&gt;Not all of these are bugs. An AWS MCP server &lt;em&gt;should&lt;/em&gt; talk to AWS. The question is whether the LLM can misuse those capabilities — and whether you know about them before you deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;reachscan

&lt;span class="c"&gt;# Scan any GitHub repo&lt;/span&gt;
reachscan https://github.com/ahujasid/blender-mcp

&lt;span class="c"&gt;# Scan a PyPI package before installing&lt;/span&gt;
reachscan pypi:openai-agents

&lt;span class="c"&gt;# JSON output for CI&lt;/span&gt;
reachscan &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="nt"&gt;--severity&lt;/span&gt; high
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apache 2.0, pure Python, runs offline. No API keys, no cloud service.&lt;/p&gt;

&lt;p&gt;If something looks wrong — false positive, missed pattern, bad output — &lt;a href="https://github.com/vinmay/reachscan/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/vinmay/reachscan" rel="noopener noreferrer"&gt;vinmay/reachscan&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;PyPI:&lt;/strong&gt; &lt;a href="https://pypi.org/project/reachscan/" rel="noopener noreferrer"&gt;reachscan&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Full scan results (50 repos):&lt;/strong&gt; &lt;a href="https://medium.com/@vinmayN/i-scanned-50-mcp-servers-to-see-what-they-can-actually-do-46144659ceca" rel="noopener noreferrer"&gt;Medium writeup&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>agents</category>
      <category>security</category>
      <category>python</category>
    </item>
    <item>
      <title>I scanned 50 MCP servers to see what they can actually do — here's what I found</title>
      <dc:creator>vinmay</dc:creator>
      <pubDate>Thu, 12 Mar 2026 03:35:44 +0000</pubDate>
      <link>https://dev.to/vinmay/i-scanned-50-mcp-servers-to-see-what-they-can-actually-do-heres-what-i-found-49m5</link>
      <guid>https://dev.to/vinmay/i-scanned-50-mcp-servers-to-see-what-they-can-actually-do-heres-what-i-found-49m5</guid>
      <description>&lt;p&gt;One of the 50 MCP servers I scanned gives the LLM a full Python shell &lt;br&gt;
on your machine. The tool is called &lt;code&gt;execute_blender_code&lt;/code&gt;. The &lt;code&gt;exec()&lt;/code&gt; &lt;br&gt;
call has no builtin restriction. I verified it — imports, file reads, &lt;br&gt;
subprocess execution all work.&lt;/p&gt;

&lt;p&gt;That's what I built &lt;a href="https://github.com/vinmay/reachscan" rel="noopener noreferrer"&gt;reachscan&lt;/a&gt; to find.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;MCP servers aren't plugins in a sandboxed browser extension model. They &lt;br&gt;
run as normal OS processes with your user permissions. If a server calls &lt;br&gt;
&lt;code&gt;subprocess.run()&lt;/code&gt;, the LLM can trigger shell commands. If it calls &lt;br&gt;
&lt;code&gt;exec()&lt;/code&gt; without restricting builtins, the LLM gets Python execution on &lt;br&gt;
your machine.&lt;/p&gt;

&lt;p&gt;Most people don't know which of the servers they're running fall into &lt;br&gt;
which category.&lt;/p&gt;
&lt;h2&gt;
  
  
  What reachscan does
&lt;/h2&gt;

&lt;p&gt;It's a static analysis CLI for Python and TypeScript/JavaScript agent &lt;br&gt;
codebases. It maps seven capability categories:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EXECUTE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shell execution via subprocess, os.system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;READ/WRITE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Local filesystem access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SEND&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Outbound HTTP, sockets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SECRETS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Env var credential access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DYNAMIC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;exec(), eval(), importlib&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AUTONOMY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Background threads, autonomous loops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entry points&lt;/td&gt;
&lt;td&gt;LLM-callable tool registrations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key distinction from a linter: reachscan tracks &lt;strong&gt;reachability&lt;/strong&gt;. &lt;br&gt;
A &lt;code&gt;subprocess.run()&lt;/code&gt; buried in dead code is a different risk than one &lt;br&gt;
called directly from a registered MCP tool.&lt;/p&gt;

&lt;p&gt;Install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;reachscan
reachscan https://github.com/any-org/any-mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;False positive rate: &lt;strong&gt;0.47%&lt;/strong&gt; across a labeled corpus of ~3,900 findings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline finding: blender-mcp
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/ahujasid/blender-mcp" rel="noopener noreferrer"&gt;blender-mcp&lt;/a&gt; has a registered &lt;br&gt;
MCP tool called &lt;code&gt;execute_blender_code&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute_blender_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Execute arbitrary Python code in Blender.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;blender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_blender_connection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;execute_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That string travels over a local TCP socket to the Blender addon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;namespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bpy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bpy&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# ← line 431
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why &lt;code&gt;namespace = {"bpy": bpy}&lt;/code&gt; doesn't protect you
&lt;/h2&gt;

&lt;p&gt;This looks like a sandbox. It isn't.&lt;/p&gt;

&lt;p&gt;When you call &lt;code&gt;exec(code, namespace)&lt;/code&gt; without setting &lt;br&gt;
&lt;code&gt;namespace["__builtins__"]&lt;/code&gt;, Python automatically injects the full &lt;br&gt;
builtins module. I verified this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;namespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bpy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;

&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;import os; print(os.getcwd())&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# → /home/user/...  ✓
&lt;/span&gt;
&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;print(open(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/etc/hostname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;).read())&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# → my-machine  ✓
&lt;/span&gt;
&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;import subprocess; r = subprocess.run([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;], &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s"&gt;capture_output=True, text=True); print(r.stdout)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# → uid=1000(user) gid=1000(user)  ✓
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM doesn't just get Blender API access. It gets full Python &lt;br&gt;
execution on the host.&lt;/p&gt;
&lt;h2&gt;
  
  
  The same primitive, done right
&lt;/h2&gt;

&lt;p&gt;Finding &lt;code&gt;exec()&lt;/code&gt; in a scan result doesn't automatically mean critical. &lt;br&gt;
Two projects in this dataset handle it correctly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;awslabs/mcp&lt;/strong&gt; — strips builtins before exec():&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__builtins__&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_SAFE_BUILTINS&lt;/span&gt;
&lt;span class="c1"&gt;# Removes: __import__, exec, eval, compile, open, getattr...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;StarRocks MCP&lt;/strong&gt; — validates the AST before eval():&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;validate_plotly_expr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plotly_expr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# must be exactly px.&amp;lt;method&amp;gt;(...)
&lt;/span&gt;&lt;span class="n"&gt;fig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plotly_expr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;px&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;local_vars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same pattern. Very different trust boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results across 50 repos
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;1,139 findings across 18 repos with non-zero activity&lt;/li&gt;
&lt;li&gt;1 in 3 repos has shell execution capability&lt;/li&gt;
&lt;li&gt;1 in 4 accesses credentials from environment variables&lt;/li&gt;
&lt;li&gt;10 of 50 had 4+ capabilities active simultaneously&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The TypeScript gap
&lt;/h2&gt;

&lt;p&gt;22 repos showed 0 capability findings — many are TypeScript-only. &lt;br&gt;
reachscan detects TypeScript entry points (452 found) but doesn't yet &lt;br&gt;
analyze TypeScript function bodies. "Clean" for a TS-heavy repo means &lt;br&gt;
"no Python capability findings" — not verified safe.&lt;/p&gt;

&lt;p&gt;TypeScript capability analysis is next.&lt;/p&gt;
&lt;h2&gt;
  
  
  Main takeaway
&lt;/h2&gt;

&lt;p&gt;Adoption is moving faster than visibility. Treat MCP servers like &lt;br&gt;
privileged code, not plugins. Audit tool boundaries before you deploy.&lt;/p&gt;

&lt;p&gt;Try it on any MCP server or agent repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;reachscan
reachscan https://github.com/any-org/any-mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub: &lt;a href="https://github.com/vinmay/reachscan" rel="noopener noreferrer"&gt;github.com/vinmay/reachscan&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Full writeup with all 50 results and methodology: &lt;br&gt;
&lt;a href="https://medium.com/@vinmayN/i-scanned-50-mcp-servers-to-see-what-they-can-actually-do-46144659ceca" rel="noopener noreferrer"&gt;Medium article&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>mcp</category>
      <category>python</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
