<?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: Rong Zhu</title>
    <description>The latest articles on DEV Community by Rong Zhu (@zhurong2020).</description>
    <link>https://dev.to/zhurong2020</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%2F3891514%2F14cdf7d6-53c9-4b29-a135-37236f58f224.jpeg</url>
      <title>DEV Community: Rong Zhu</title>
      <link>https://dev.to/zhurong2020</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zhurong2020"/>
    <language>en</language>
    <item>
      <title>Cloudflare blocked urllib's default User-Agent, and it took me two months to notice.</title>
      <dc:creator>Rong Zhu</dc:creator>
      <pubDate>Sun, 11 Oct 2026 05:58:50 +0000</pubDate>
      <link>https://dev.to/zhurong2020/cloudflare-blocked-urllibs-default-user-agent-and-it-took-me-two-months-to-notice-3k8e</link>
      <guid>https://dev.to/zhurong2020/cloudflare-blocked-urllibs-default-user-agent-and-it-took-me-two-months-to-notice-3k8e</guid>
      <description>&lt;p&gt;In September a customer emailed me. They had bought a Pro licence for &lt;a href="https://github.com/zhurong2020/pyobfus" rel="noopener noreferrer"&gt;pyobfus&lt;/a&gt;, my Python obfuscator, and activation kept failing with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;License verification failed and no valid cache available: Access denied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I first treated it as a problem with their machine. It wasn't. Online activation was failing for everyone, on every platform, on every release up to that point. The last successful online verification in our records was on 8 July. The email arrived on 12 September.&lt;/p&gt;

&lt;p&gt;I can't tell you exactly when it started, because I have no record of when the edge started rejecting the requests. What I can tell you is why it took two months to notice, and that part is entirely on me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually happening
&lt;/h2&gt;

&lt;p&gt;The licence client used &lt;code&gt;urllib.request&lt;/code&gt; without setting a User-Agent, so every request went out with urllib's default, &lt;code&gt;Python-urllib/3.x&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The licence server is a Cloudflare Worker. Cloudflare's edge was rejecting that User-Agent before the request reached the Worker, with an HTTP 403 and a plain-text body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error code: 1010
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudflare documents 1010 as a block based on the browser signature. In the requests I tested, changing only the User-Agent changed the result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;User-Agent sent&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;&lt;code&gt;Python-urllib/3.12&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;403, &lt;code&gt;error code: 1010&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;python-requests/2.x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Worker's own JSON reply&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a browser string&lt;/td&gt;
&lt;td&gt;Worker's own JSON reply&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no User-Agent header at all&lt;/td&gt;
&lt;td&gt;Worker's own JSON reply&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So this was not a "block everything that isn't a browser" rule. The signature my client sent was blocked, and the others I tried were not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nobody saw it
&lt;/h2&gt;

&lt;p&gt;Two things on my side turned an edge block into two months of silent failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The error message threw away the only clue.&lt;/strong&gt; The client expected JSON. When it got a plain-text 403 it couldn't parse the body, so it fell back to a hard-coded string: "Access denied". That reads like "your key is wrong". A customer who sees it is likely to blame themselves or give up rather than write in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing was watching.&lt;/strong&gt; There was no probe and no alert. The only monitoring was "a customer writes in".&lt;/p&gt;

&lt;p&gt;Once I started digging, I found older problems in the same code.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I had never checked whether paid licences had ever completed an online verification. Some had not. An empty device list doesn't prove someone never used the product, since the offline path doesn't touch the server, but it was a question I should have been asking all along.&lt;/li&gt;
&lt;li&gt;The device fingerprint included &lt;code&gt;platform.release()&lt;/code&gt;, so a minor OS update gave the same machine a new identity. The local cache stopped matching and re-registering used up another of the three device slots, and customers had no way to free one. It also used &lt;code&gt;uuid.getnode()&lt;/code&gt;, which returns a random number when Python can't read a hardware address, so a new Python process could identify the same machine differently. I had also failed to follow up on an earlier report of exactly this.&lt;/li&gt;
&lt;li&gt;A server revocation could be swallowed by the offline-cache fallback. The client treated the server's rejection as a generic failure, fell back to the cache and reported the licence as valid, with the word "revoked" still in the message.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Send a User-Agent that says who you are.&lt;/strong&gt; The client's User-Agent now starts with &lt;code&gt;pyobfus-license/&amp;lt;version&amp;gt;&lt;/code&gt;. If your &lt;code&gt;urllib&lt;/code&gt; requests pass through a content delivery network (CDN), set a User-Agent explicitly (&lt;code&gt;url&lt;/code&gt;, &lt;code&gt;payload&lt;/code&gt; and &lt;code&gt;__version__&lt;/code&gt; come from your own code, and you still send the request with &lt;code&gt;urllib.request.urlopen&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;

&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&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;Content-Type&lt;/span&gt;&lt;span class="sh"&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;application/json&lt;/span&gt;&lt;span class="sh"&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;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yourtool/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;__version__&lt;/span&gt;&lt;span class="si"&gt;}&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Say what actually failed.&lt;/strong&gt; &lt;br&gt;
The client now treats a 403 without the Worker's JSON error as an infrastructure failure. The message says the request was blocked before it reached the licence server, that the licence may still be valid, and gives the exact offline command to use instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch the real path, with a key that doesn't exist.&lt;/strong&gt; &lt;br&gt;
A scheduled GitHub Actions workflow runs twice a day. It first calls the production endpoint through the same client function customers use, with the same headers and User-Agent, sending a well-formed licence key that was never issued. A plain-text edge block, a 404 whose body doesn't match the expected unknown-key response, or a connection error fails the run, and GitHub emails me about the failure.&lt;/p&gt;

&lt;p&gt;This avoids changing customer records or device lists and keeps real licence keys out of CI secrets. I tested it three ways before trusting it: green against the fixed client, red when I put the old User-Agent back, red when the endpoint was unreachable.&lt;/p&gt;

&lt;p&gt;That wasn't enough. The first version only checked the client's error message, and the client reports every 404 the same way, so a missing route or a proxy's 404 page would have passed as healthy. A reviewer caught it while I was writing this post. The probe now repeats the request and checks that the 404 body matches the expected unknown-key JSON response, and I added a fourth test: a 404 page from a different site has to turn it red. It still only covers the rejection path. It does not prove that a valid key can activate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide which failures allow a cache fallback.&lt;/strong&gt; &lt;br&gt;
The old client rejected its cache when the device fingerprint changed, yet accepted it after a generic server rejection, which is exactly backwards. Now a signed cache survives a drifting device identity, and an online answer that explicitly says revoked or expired refuses the cache fallback. The device ID is now a random value generated once and kept in &lt;code&gt;~/.pyobfus/device_id&lt;/code&gt;, so it survives OS updates, new virtualenvs and containers rebuilt with the same home directory. On the server, a fourth device now replaces the least recently verified one instead of being refused.&lt;/p&gt;

&lt;p&gt;Both the device ID and the local cache can be copied, and there has always been an offline registration command. The old fingerprint check caused false lockouts without reliably preventing copying. The server keeps three recent device records, which is not a hard limit on offline use, and revocation takes effect when the client receives an online rejection.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part a release can't fix
&lt;/h2&gt;

&lt;p&gt;A fix in a new version only reaches people who upgrade. Every copy already installed keeps sending &lt;code&gt;Python-urllib&lt;/code&gt; and keeps failing, and a customer who has given up may never see the release notes.&lt;/p&gt;

&lt;p&gt;So two things went out with the fix. The new error message names the offline path, which bypasses this network block because it never contacts the server (replace &lt;code&gt;&amp;lt;KEY&amp;gt;&lt;/code&gt; with your licence key):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pyobfus-license register &amp;lt;KEY&amp;gt; &lt;span class="nt"&gt;--no-verify&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And I wrote to the customers I knew were affected the day I found the cause, before the fix was released, including one who had never complained.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you ship anything that phones home
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Set an explicit User-Agent. The default one is a shared signature, and you don't control how the CDN in front of you treats it.&lt;/li&gt;
&lt;li&gt;Never let a fallback message replace the evidence. If the response isn't in the shape you expected, say so and show the status code, without echoing keys or personal data.&lt;/li&gt;
&lt;li&gt;Probe production with an input that has no side effects. A made-up ID that the server should reject is often enough for the rejection path. Break the probe on purpose once to see it go red, and check that the alert reaches you.&lt;/li&gt;
&lt;li&gt;Check whether paid licences have ever completed an online verification. Support tickets won't show you everyone who gave up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fixes shipped in pyobfus 0.5.26 on 13 September, and online activation has worked for new purchases since. As of 11 October the monitor's only red run was a bug in the workflow itself, not an outage. This gives me a way to catch this kind of failure without waiting for a customer to report it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I used an AI assistant to help organise this post. I checked the technical claims against the code, and the incident timeline against my own records.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>postmortem</category>
      <category>devops</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>I built a Python obfuscator that keeps production traces debuggable</title>
      <dc:creator>Rong Zhu</dc:creator>
      <pubDate>Wed, 22 Jul 2026 02:22:06 +0000</pubDate>
      <link>https://dev.to/zhurong2020/i-built-a-python-obfuscator-that-keeps-production-traces-debuggable-1mp8</link>
      <guid>https://dev.to/zhurong2020/i-built-a-python-obfuscator-that-keeps-production-traces-debuggable-1mp8</guid>
      <description>&lt;p&gt;Python obfuscation usually creates a second problem: the same renamed symbols&lt;br&gt;
that slow down a casual reader also make production crashes useless to the&lt;br&gt;
developer and their coding assistant.&lt;/p&gt;

&lt;p&gt;That trade-off is why I built&lt;br&gt;
&lt;a href="https://github.com/zhurong2020/pyobfus" rel="noopener noreferrer"&gt;pyobfus&lt;/a&gt;, an AST-based Python&lt;br&gt;
obfuscator with a reversible debugging path. The Apache-2.0 core is public, the&lt;br&gt;
commercial Pro source stays separately licensed, and both are developed in the&lt;br&gt;
same public repository.&lt;/p&gt;

&lt;p&gt;The basic workflow is deliberately ordinary:&lt;br&gt;
&lt;/p&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;pyobfus
pyobfus &lt;span class="nt"&gt;--check&lt;/span&gt; src/ &lt;span class="nt"&gt;--json&lt;/span&gt;
pyobfus src/ &lt;span class="nt"&gt;-o&lt;/span&gt; dist/ &lt;span class="nt"&gt;--save-mapping&lt;/span&gt; mapping.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The distributed code contains renamed symbols. The mapping file stays with the&lt;br&gt;
developer. When a crash arrives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pyobfus &lt;span class="nt"&gt;--unmap&lt;/span&gt; &lt;span class="nt"&gt;--trace&lt;/span&gt; error.log &lt;span class="nt"&gt;--mapping&lt;/span&gt; mapping.json &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result restores the original identifiers before the trace goes back to&lt;br&gt;
Claude Code, Cursor, or a human debugger. Framework presets preserve reflective&lt;br&gt;
APIs for FastAPI, Django, Flask, Pydantic, Click, and SQLAlchemy. A separate&lt;br&gt;
&lt;code&gt;pyobfus-mcp&lt;/code&gt; package exposes the risk scan, configuration, obfuscation, and&lt;br&gt;
reverse-mapping workflow as machine-readable MCP tools.&lt;/p&gt;
&lt;h2&gt;
  
  
  What changed in 0.5.4
&lt;/h2&gt;

&lt;p&gt;The latest release closes a concrete device-binding gap in the Pro pipeline.&lt;br&gt;
Before 0.5.4, &lt;code&gt;--bind-device&lt;/code&gt; protected the Selective Opacity L3 key but Runtime&lt;br&gt;
String Vault keys could still be emitted as baked constants. In 0.5.4, every&lt;br&gt;
vault gets its own salt and derives its key from the bound machine at runtime.&lt;br&gt;
The normal syntax is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pyobfus src/ &lt;span class="nt"&gt;-o&lt;/span&gt; dist/ &lt;span class="nt"&gt;--level&lt;/span&gt; pro &lt;span class="nt"&gt;--vault&lt;/span&gt; &lt;span class="nt"&gt;--bind-device&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;pyobfus build&lt;/code&gt; subcommand. I am calling that out because several&lt;br&gt;
older release notes used the wrong shorthand and copying it produced a path&lt;br&gt;
error.&lt;/p&gt;

&lt;p&gt;The release CI recorded 1,046 passing core tests, one skip, and 90% coverage.&lt;br&gt;
Core, MCP, and end-to-end suites run separately across Python 3.9 through 3.14&lt;br&gt;
on Linux, macOS, and Windows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The threat model is intentionally limited
&lt;/h2&gt;

&lt;p&gt;The community tier raises the cost of casual source inspection; it is not a&lt;br&gt;
claim of irreversible protection. Pro can encrypt selected function bodies and&lt;br&gt;
vaulted strings, bind decryption to a machine, seal code objects, and scrub&lt;br&gt;
tracebacks. A determined attacker controlling a running process can still use&lt;br&gt;
dynamic analysis or extract material from memory. The local Pro trial is also a&lt;br&gt;
convenience control, not a security boundary; that limitation is documented and&lt;br&gt;
pinned by tests.&lt;/p&gt;

&lt;p&gt;If the requirement is nation-state resistance, an encrypted VM or hardware&lt;br&gt;
boundary is the more honest answer. If the requirement is distributing Python&lt;br&gt;
to customers while keeping routine debugging workable, this is the niche I am&lt;br&gt;
trying to serve.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I need feedback on
&lt;/h2&gt;

&lt;p&gt;The next work should come from real use rather than another speculative feature.&lt;br&gt;
The candidates are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an ML/model-serving preset;&lt;/li&gt;
&lt;li&gt;a signed build-provenance manifest;&lt;/li&gt;
&lt;li&gt;a PyInstaller integration cookbook;&lt;/li&gt;
&lt;li&gt;integrity verification for MCP tool descriptions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I am especially interested in reproducible cases where a framework breaks,&lt;br&gt;
where the mapping workflow is awkward, or where the documented threat model is&lt;br&gt;
wrong. Issues and code are at&lt;br&gt;
&lt;a href="https://github.com/zhurong2020/pyobfus" rel="noopener noreferrer"&gt;github.com/zhurong2020/pyobfus&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you use pyobfus in research, the project has a version-independent Zenodo&lt;br&gt;
DOI: &lt;a href="https://doi.org/10.5281/zenodo.20846053" rel="noopener noreferrer"&gt;10.5281/zenodo.20846053&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I maintain pyobfus and license the optional Pro edition. Nobody&lt;br&gt;
sponsored this post.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>opensource</category>
      <category>security</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>Let Claude Code Debug Your Obfuscated Python: A Guide to the pyobfus MCP Integration</title>
      <dc:creator>Rong Zhu</dc:creator>
      <pubDate>Thu, 07 May 2026 14:58:30 +0000</pubDate>
      <link>https://dev.to/zhurong2020/let-claude-code-debug-your-obfuscated-python-a-guide-to-the-pyobfus-mcp-integration-3epm</link>
      <guid>https://dev.to/zhurong2020/let-claude-code-debug-your-obfuscated-python-a-guide-to-the-pyobfus-mcp-integration-3epm</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I maintain pyobfus and the pyobfus-mcp server.&lt;/em&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Why I built pyobfus
&lt;/h3&gt;

&lt;p&gt;Story starts about six months back. Cardiac imaging research project. Real patent filings in flight, software-copyright applications half-submitted, the kind of work where the lawyers actually read commit messages. The team needed algorithm modules they could hand to outside collaborators as binaries, no readable source. Python, naturally. Every research project is Python now. So: an obfuscator. &lt;/p&gt;

&lt;p&gt;PyArmor is the answer when you ask the internet. I pulled up the docs, read the feature matrix, checked the pricing page. The serious protection — bytecode-level encryption, control-flow flattening, the things that actually slow a determined reverse engineer down — sits behind the paid Pro tier. Fine, that's a normal business model. But it made me stop for a second and ask the obvious question. Was I about to pay a license fee for a tool that fits my workflow, or was I about to pay a license fee for a tool that fits &lt;em&gt;somebody's&lt;/em&gt; workflow, just not mine?                           &lt;/p&gt;

&lt;p&gt;My workflow had a specific shape. The thing writing half my code those evenings was Claude Code, vibe coding sessions where I'd describe what I needed and iterate on output. The thing I'd reach for to debug a production trace was also Claude Code. If I shipped binaries where every class name was now &lt;code&gt;I0&lt;/code&gt; and every method was &lt;code&gt;I2&lt;/code&gt;, what happened the first&lt;br&gt;
 time something crashed in production? I'd paste the trace into Claude, and Claude would say &lt;em&gt;"I have no idea what &lt;code&gt;I0&lt;/code&gt; or &lt;code&gt;I2&lt;/code&gt; refer to. Could you share the source?"&lt;/em&gt; The protection meant for keeping outsiders out would also lock out the assistant I was already paying for. The only people it would actually help were the attackers, who have all the time in the world to unmangle.&lt;/p&gt;

&lt;p&gt;PyArmor was designed for a workflow where the human reads the production logs. Cython compiles to machine code, even further from anything LLM-readable. Both made sense in 2013, in 2017. Neither made sense for an evening-vibes project where the model was the one in the debug seat.&lt;/p&gt;

&lt;p&gt;So instead of paying for a tool I'd then have to fight, I started building a smaller one. About a month of evenings vibe-coding with Claude Code itself, organized around a single trade-off: keep the obfuscator's output opaque to outsiders, keep one tiny mapping file readable to me. That's pyobfus 0.4.0, which I shipped on 2026-04-22.&lt;/p&gt;


&lt;h3&gt;
  
  
  The tools on PyPI were built for a different workflow
&lt;/h3&gt;

&lt;p&gt;Quick history check. PyArmor: 2013. Cython: older still. Oxyry: showed up around 2017. None of them were designed for a world where the thing reading your production logs is a language model. They all assume the same thing: you write code, you obfuscate, you ship, then &lt;em&gt;you&lt;/em&gt; read the production logs.&lt;/p&gt;

&lt;p&gt;For about a decade that worked fine. Friction on the obfuscator side was friction for attackers (good), and you paid a small ergonomics tax to debug your own production crashes (acceptable, fair trade).&lt;/p&gt;

&lt;p&gt;Trade went sideways the year an LLM took over the debug seat. Models can read your trace and your source code side-by-side in the same window (they're disturbingly good at it), but the names have to line up. Trace says &lt;code&gt;I0&lt;/code&gt;, source still says &lt;code&gt;UserService&lt;/code&gt;, the model has nothing to anchor on. (Polite stranger problem above.)&lt;/p&gt;

&lt;p&gt;Used to be a free, invisible cost, paid by humans doing that lookup mentally. Now it's a visible cost, every crash, every customer report, every time.&lt;/p&gt;

&lt;p&gt;So the fix can't be "obfuscate less." Obfuscate just as much. Keep one mapping file somewhere only you can reach.&lt;/p&gt;


&lt;h3&gt;
  
  
  What's in 0.4.0
&lt;/h3&gt;

&lt;p&gt;The release is built around closing that mapping gap. There are four pieces, but really only one matters. I added the other three so that one could be used without ceremony.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Preflight check.&lt;/strong&gt; Run &lt;code&gt;pyobfus --check src/&lt;/code&gt; and the tool walks your AST looking for things that obfuscation tends to break (&lt;code&gt;eval&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, dynamic &lt;code&gt;getattr&lt;/code&gt;, framework reflection, &lt;code&gt;__all__&lt;/code&gt; exports, &lt;code&gt;__name__&lt;/code&gt; string compares). With &lt;code&gt;--json&lt;/code&gt; you get a structured report with an &lt;code&gt;ai_hint&lt;/code&gt; field at the bottom that just spells out the next command in plain English. So if it spots FastAPI in your project and finds two high-severity issues, the hint reads &lt;em&gt;"Start with: pyobfus src/ -o dist/ --preset fastapi --dry-run"&lt;/em&gt;. That hint is the small trick that makes the rest agent-friendly. An MCP-enabled IDE can read the JSON, find the suggested command, and chain it without anyone in the loop typing anything.&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.amazonaws.com%2Fuploads%2Farticles%2F1rl3uoqr8t6msxs4pe9a.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.amazonaws.com%2Fuploads%2Farticles%2F1rl3uoqr8t6msxs4pe9a.png" alt=" " width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero-config init.&lt;/strong&gt; &lt;code&gt;pyobfus --init src/&lt;/code&gt; looks at your imports, decides whether you're on FastAPI / Django / Flask / Pydantic / Click / SQLAlchemy, and drops a &lt;code&gt;pyobfus.yaml&lt;/code&gt; next to your code with the matching preset. The YAML has inline comments so when an LLM later reads it back, it has context for why each setting is there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Save-mapping and unmap.&lt;/strong&gt; This is the one I wrote the whole release for. When you obfuscate, you pass &lt;code&gt;--save-mapping mapping.json&lt;/code&gt;. The &lt;code&gt;dist/&lt;/code&gt; you ship goes to customers. The &lt;code&gt;mapping.json&lt;/code&gt; goes wherever you keep secrets (password manager, encrypted vault, private S3, anywhere that isn't inside the artifact and isn't in git). Then a few weeks later when a production trace lands in your inbox, you run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pyobfus &lt;span class="nt"&gt;--unmap&lt;/span&gt; &lt;span class="nt"&gt;--trace&lt;/span&gt; error.log &lt;span class="nt"&gt;--mapping&lt;/span&gt; mapping.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and what comes back is that same trace with every identifier restored to what it was before obfuscation. You paste &lt;em&gt;that&lt;/em&gt; into Claude Code (or Cursor, or Windsurf) and the AI reads it as if the code had never been obfuscated. The customer's copy is still mangled. Yours isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP server.&lt;/strong&gt; &lt;code&gt;pyobfus-mcp&lt;/code&gt; wraps all of the above as a Model Context Protocol server. Once it's installed and your IDE is pointed at it, the assistant can call any of the obfuscation tools from inside a chat turn, without you dropping out to a shell. The five exposed tools are &lt;code&gt;check_obfuscation_risks&lt;/code&gt;, &lt;code&gt;generate_pyobfus_config&lt;/code&gt;, &lt;code&gt;unmap_stack_trace&lt;/code&gt;, &lt;code&gt;list_presets&lt;/code&gt;, and &lt;code&gt;explain_preset&lt;/code&gt;, and they all return the same &lt;code&gt;{status, payload, ai_hint}&lt;/code&gt; JSON envelope.&lt;/p&gt;




&lt;h3&gt;
  
  
  60-second setup
&lt;/h3&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;pyobfus pyobfus-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Claude Desktop, add to &lt;code&gt;claude_desktop_config.json&lt;/code&gt; (macOS path: &lt;code&gt;~/Library/Application Support/Claude/claude_desktop_config.json&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pyobfus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pyobfus-mcp"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart Claude Desktop, then try a prompt like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Check if the src/ folder in my project is safe to obfuscate, and if it's a FastAPI app, generate a pyobfus.yaml for me."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What happens next is that Claude calls &lt;code&gt;check_obfuscation_risks(path="src/")&lt;/code&gt;, reads the JSON it gets back, notices &lt;code&gt;suggested_preset: fastapi&lt;/code&gt;, then calls &lt;code&gt;generate_pyobfus_config(path="src/", preset_override="fastapi")&lt;/code&gt; on its own and hands you the result. Zero shell commands. Cursor, Windsurf, and Zed have slightly different config files; the recipes are in the pyobfus-mcp README.&lt;/p&gt;

&lt;p&gt;One nice side effect: the package is live in the official MCP Registry under the name &lt;code&gt;io.github.zhurong2020/pyobfus-mcp&lt;/code&gt;, so any MCP client that queries the registry for "python obfuscator" finds it without you doing anything else.&lt;/p&gt;




&lt;h3&gt;
  
  
  End to end on a toy FastAPI project
&lt;/h3&gt;

&lt;p&gt;Six commands, each a one-liner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-flight scan&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pyobfus &lt;span class="nt"&gt;--check&lt;/span&gt; src/ &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes &lt;code&gt;frameworks: [{"name": "FastAPI"}]&lt;/code&gt;, &lt;code&gt;suggested_preset: "fastapi"&lt;/code&gt;, and an &lt;code&gt;ai_hint&lt;/code&gt;. Zero high-severity findings, we're clear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generate config&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pyobfus &lt;span class="nt"&gt;--init&lt;/span&gt; src/ &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This writes &lt;code&gt;src/pyobfus.yaml&lt;/code&gt; with &lt;code&gt;preset: fastapi&lt;/code&gt; already selected, framework-aware excludes, and &lt;code&gt;preserve_param_names: true&lt;/code&gt; (which you need for FastAPI's &lt;code&gt;Depends()&lt;/code&gt; and Pydantic's field-name-to-JSON-key binding).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Obfuscate with a mapping file&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pyobfus src/ &lt;span class="nt"&gt;-o&lt;/span&gt; dist/ &lt;span class="nt"&gt;-c&lt;/span&gt; src/pyobfus.yaml &lt;span class="nt"&gt;--save-mapping&lt;/span&gt; mapping.json &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;dist/&lt;/code&gt; directory is what you ship. The &lt;code&gt;mapping.json&lt;/code&gt; is what you keep. Password manager, encrypted vault, private S3, anywhere that's not in the artifact and not in git.&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.amazonaws.com%2Fuploads%2Farticles%2Fe9lgw9hfdjc83mmg1r0v.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.amazonaws.com%2Fuploads%2Farticles%2Fe9lgw9hfdjc83mmg1r0v.png" alt=" " width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weeks later&lt;/strong&gt;, a customer crash:&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;File&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dist/routers/users.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="mi"&gt;23&lt;/span&gt;
&lt;span class="nb"&gt;AttributeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;I0&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="nb"&gt;object&lt;/span&gt; &lt;span class="n"&gt;has&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;attribute&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;I2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Reverse it&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pyobfus &lt;span class="nt"&gt;--unmap&lt;/span&gt; &lt;span class="nt"&gt;--trace&lt;/span&gt; error.log &lt;span class="nt"&gt;--mapping&lt;/span&gt; mapping.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&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;File&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dist/routers/users.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="mi"&gt;23&lt;/span&gt;
&lt;span class="nb"&gt;AttributeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;UserService&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="nb"&gt;object&lt;/span&gt; &lt;span class="n"&gt;has&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;attribute&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;get_profile&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line numbers still point at the obfuscated file (known limitation, sitting on the v0.5 list), but every identifier is the original. Paste that into Claude Code and you're effectively back to debugging your own source. The AI suggests a fix, you apply it, you ship the patch, you move on with your evening.&lt;/p&gt;

&lt;p&gt;The customer-facing copy is still as mangled as it was the day you shipped it. They still see &lt;code&gt;I0&lt;/code&gt; and &lt;code&gt;I2&lt;/code&gt;. The only thing that links the two halves of the world is &lt;code&gt;mapping.json&lt;/code&gt;, which lives on your machine and nowhere else.&lt;/p&gt;




&lt;h3&gt;
  
  
  Threat model + what you actually get
&lt;/h3&gt;

&lt;p&gt;I should be honest about what pyobfus is. It's name-mangling plus optional string encryption. It's not bytecode-level encryption, it's not VM-style virtualization (which is the lane PyArmor 9.2 went down in late 2025), and a sufficiently motivated reverse engineer with enough hours on their hands can take most of it apart. The community tier in particular is friction, not a wall. If you're worried about nation-state-grade adversaries, this isn't your tool, and frankly Python probably isn't your language.&lt;/p&gt;

&lt;p&gt;What pyobfus does buy you, against the threat model most of us actually have, is roughly four things. Casual scanning of your &lt;code&gt;dist/&lt;/code&gt; directory for class names, API endpoints, or business-logic strings turns up mangled noise. The Pro tier's string encryption hides literal secrets from a &lt;code&gt;strings&lt;/code&gt;-style inspection pass. Pro's control-flow flattening makes static analysis genuinely painful. And, the reason you're reading this post: AI-assisted debugging keeps working on production traces, as long as you've kept &lt;code&gt;mapping.json&lt;/code&gt; somewhere your AI can reach but your customers can't.&lt;/p&gt;

&lt;p&gt;The other obfuscators on PyPI mostly force you to pick between protection and debuggability. pyobfus is my attempt at a third option, where the only thing standing between the two is a single small file you control.&lt;/p&gt;




&lt;h3&gt;
  
  
  Try it
&lt;/h3&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;pyobfus pyobfus-mcp
pyobfus &lt;span class="nt"&gt;--check&lt;/span&gt; your-project/ &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/zhurong2020/pyobfus" rel="noopener noreferrer"&gt;https://github.com/zhurong2020/pyobfus&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MCP server: &lt;a href="https://github.com/zhurong2020/pyobfus/tree/main/pyobfus_mcp" rel="noopener noreferrer"&gt;https://github.com/zhurong2020/pyobfus/tree/main/pyobfus_mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Drop-in AI integration templates (CLAUDE.md, .cursorrules, AGENTS.md, etc.): &lt;a href="https://github.com/zhurong2020/pyobfus/tree/main/templates/ai-integration" rel="noopener noreferrer"&gt;https://github.com/zhurong2020/pyobfus/tree/main/templates/ai-integration&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Full JSON schemas + CLI reference for AI agents: &lt;a href="https://github.com/zhurong2020/pyobfus/blob/main/llms-full.txt" rel="noopener noreferrer"&gt;https://github.com/zhurong2020/pyobfus/blob/main/llms-full.txt&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;v0.5 is in planning. The headline items are layered protection (so you can pick per-module what your AI is allowed to see), a VS Code extension, and the long-overdue dropping of Python 3.8 (EOL was 2024-10 and our CI matrix has been carrying that weight for over a year now). If there's a specific pain you'd like prioritized, GitHub issues are the place.&lt;/p&gt;

&lt;p&gt;One last thing. If you do ship with pyobfus, please put &lt;code&gt;mapping.json&lt;/code&gt; somewhere safe. It's a small boring JSON file, and six months from now when a customer pings you about a crash, it's the only thing standing between you and 40 minutes of doing what I did manually that first night.&lt;/p&gt;




</description>
      <category>ai</category>
      <category>python</category>
      <category>mcp</category>
      <category>claudecode</category>
    </item>
  </channel>
</rss>
