{"slug": "python-obfuscation-for-ai-assistants-runnable-workspaces-and-off-disk-secrets", "title": "Python obfuscation for AI assistants: runnable workspaces and off-disk secrets", "summary": "A developer building Python obfuscation tools for AI assistants must shift from Java's compile-time validation model to a runtime-dependent approach, as Python lacks a compilation step to catch renamed identifiers. The obfuscator must handle silent contract breaks across frameworks like Pydantic, Flask, and Django, where renamed class names, function names, or field references cause errors only when code executes. To maintain security, the developer implements a `promptcape run` pattern that supplies environment variables at launch time without writing them to disk in the AI-visible workspace.", "body_md": "*Why obfuscating Python for AI tools requires a different mental model than Java — and how .env handling becomes the load-bearing question.*\n\nObfuscating Java for an AI assistant is — at heart — about **producing a workspace that still compiles**. The developer rarely runs the obfuscated workspace directly; they let the AI work in it, apply the changes back to source, and run the app from there. Compilation is the contract. If `mvn test-compile`\n\npasses after obfuscation, you're 95% done.\n\nPython is a fundamentally different game. There is no compile step. The workspace's \"validation\" happens **at runtime**, when the developer fires up:\n\n```\nstreamlit run dashboard.py\npytest -v\npython main.py\nuvicorn main:app --reload\n```\n\nIf a framework introspects a class name, a function name, a string in a URL pattern, or a Pydantic field — and that name was rewritten by the obfuscator — the error surfaces only when Python tries to call it. There is no compiler to catch it for you.\n\nThat changes the obfuscator's job in three concrete ways:\n\n`--verify`\n\nstep can't compile; it has to do static import resolution and let runtime catch the rest.`.env`\n\nto the workspace) instantly defeats the obfuscation's purpose.This article walks through each of those, then explains the `promptcape run`\n\npattern: how to give the Python workspace the env vars it needs at launch time without ever writing them to disk in the AI-visible location.\n\nIn Java, framework conventions usually leave a compile-time trace: a missing `getName()`\n\nfrom a Lombok-renamed field throws `cannot find symbol`\n\nat `javac`\n\ntime. You can detect it, you can auto-fix it. Spring Data derived queries (`findByActiveTrue`\n\n) are the rare exception that bites at startup, not at compile time — and that's already documented as a hard case.\n\nPython frameworks are full of conventions like Spring Data. Names are silent contracts:\n\n| Framework | Identifier | Contract |\n|---|---|---|\nPydantic v2 |\n`class User(BaseModel): email: str` |\n`email` is the JSON key in every `user.model_dump()` call. Rename it, every API consumer breaks silently. |\nFlask |\n`def index(): ...` decorated with `@bp.route(\"/\")`\n|\nThe function name becomes the default endpoint string for `url_for(\"blog.index\")` and `{{ url_for('blog.index') }}` . Rename it, every redirect and template link 500s with `werkzeug.routing.BuildError` . |\nDjango |\n`class Post(models.Model)` |\nThe class name drives the DB table (`app_label_post` ) AND every migration reference. Rename it, your `INSERT` query targets a table that doesn't exist. |\nSQLAlchemy |\n`id = Column(Integer, primary_key=True)` |\n`id` is the column name on the table. Plus it's accessed as `instance.id` everywhere. |\npytest |\n`def test_login_succeeds(...)` |\npytest discovers tests by the `test_` prefix. Rename to `mtd_xxx` , pytest collects 0 tests — your CI silently passes with no signal. |\ndataclass / attrs |\n`@dataclass class Post: title: str` |\nField names are accessed as `obj.title` , dumped via `asdict()` and rendered in Jinja templates as `{{ post.title }}` . |\nDjango forms / DRF serializers |\n`def clean_email(self): ...` |\nDjango discovers field-level validators by the literal `clean_<field>` / `validate_<field>` name. Rename it, your validation silently disappears. |\nCelery |\n`@shared_task def send_email(recipient, subject)` |\n`send_email.delay(recipient=\"alice@…\")` serialises the kwarg name through the broker (Redis, RabbitMQ). The worker reconstructs the call as `send_email(recipient=…)` ; rename the parameter to `p_xxx` and the worker raises `TypeError: got an unexpected keyword argument 'recipient'` . Affects function name AND every parameter name. |\nClick / Typer |\n`@click.option(\"--config\") def run(config)` |\nClick maps the CLI option `--config` to the Python kwarg `config` by string. Rename the parameter and the CLI call `run --config foo.yaml` raises `TypeError: got an unexpected keyword argument 'config'` . Affects every option/argument parameter. |\n\nAll of these are invisible at \"compile\" time (which doesn't exist anyway). They fail at runtime, often in the form of a 500 in the second route the AI touches.\n\nThe fix has to be **proactive detection**, not reactive. For each framework, scan the project for the relevant declarations and add the discovered names to a project-wide exclusion list **before** identifier collection. The PromptCape codebase has 16 Python detectors today, 11 of which run an AST scan (the rest are pure import-check + fixed name lists):\n\n```\nPydanticDetector       AST scan: every BaseModel/RootModel field name\nSqlalchemyDetector     AST scan: every declarative-model column / relationship\nStreamlitDetector      AST scan: every top-level callable in streamlit scripts\nFlaskDetector          AST scan: every @bp.route / @bp.get / @bp.errorhandler view\nDataclassDetector      AST scan: every @dataclass / @attrs.define field\nPytestDetector         AST scan: every def test_* / class Test* in the project\nDjangoDetector         AST scan: model class+fields, CBV+FBV view names, form fields + clean_X methods\nCeleryDetector         AST scan: every @app.task / @shared_task — function name + every parameter\nClickTyperDetector     AST scan: every @click.command / @app.command — function name + every parameter\nRequestsHttpxDetector  Fixed list: ~110 names (Response attrs, request kwargs, exceptions); no AST scan\nStdlibCommonAttrsDetector   ~230 stdlib method names (close, year, keys, items, split, …) — fixed list\n```\n\nThe AST scans run via a bundled Python sidecar that parses each candidate file with LibCST and emits the discovered names back to the Java engine as JSON. The engine merges every detector's output into a single exclusion set before the obfuscation pass starts.\n\nJava's `--verify`\n\nruns `mvn test-compile`\n\nand reads `javac`\n\noutput. There's a one-line equivalent on the Python side: there is none.\n\nPython's closest analogue is `importlib.util.find_spec(...)`\n\n. Given a dotted name like `staffing.database`\n\n, it returns `None`\n\nif the module can't be located, or a `ModuleSpec`\n\nif it can. The catch: it **executes** the parent package's `__init__.py`\n\nwhile looking. If `staffing/__init__.py`\n\ndoes `from .database import sqlalchemy_stuff`\n\n, then `find_spec`\n\ntransitively imports SQLAlchemy, your DB driver, and probably half your app.\n\nThat's a non-starter for an obfuscation verification step: you don't want to import the user's code, you don't want the user's third-party dependencies installed in the sidecar's Python interpreter, and you definitely don't want side effects (database connections opened at module import time — a real Python anti-pattern, but common).\n\nThe strategy PromptCape ended up with classifies each import statement at the AST level and routes it to a different check:\n\n| Import shape | Check |\n|---|---|\n`import xmlrpc.client` (top-level is a stdlib name) |\n`importlib.util.find_spec(\"xmlrpc.client\")` — safe, stdlib never has side effects |\n`from staffing.database import X` (top-level is a workspace-local directory) |\nCheck that `workspace/staffing/database.py` or `workspace/staffing/database/__init__.py` exists on disk. Never imports the file.\n|\n`import sqlalchemy` (third-party) |\nSkipped. Can't verify without the project's virtualenv installed alongside the sidecar — too much false-positive noise. Trust it. |\n\nThis catches the canonical bug — `import xmlrpc.client`\n\nrewritten to `import xmlrpc.fld_b8460726`\n\nwhen a user identifier `client`\n\nlands in the registry — without needing any project dependencies to be installed where the obfuscator runs.\n\nIt does NOT catch runtime `AttributeError`\n\non stdlib instances (e.g. `today.year`\n\nwhere `year`\n\nwas renamed because the user has a function called `year`\n\n). For those, the proactive detector pattern is the only option: a `StdlibCommonAttrsDetector`\n\nwith ~210 of the most-commonly-accessed stdlib attribute names, applied unconditionally. The trade-off is real (user methods literally called `year`\n\nwon't be obfuscated either) but the alternative is a workspace that crashes on the first date in the codebase.\n\nJava obfuscation strips comments to `// Processed.`\n\nwhile preserving line count, because the reverse-apply 3-way merge needs 1:1 line correspondence between the source and the obfuscated cache.\n\nPython has the same requirement but two distinct constructs:\n\n`# something`\n\n) — analogous to Java's `// something`\n\n.`\"\"\"multi-line\"\"\"`\n\n) — strings that are the first statement of a `Module`\n\n/ `FunctionDef`\n\n/ `ClassDef`\n\nbody.Stripping both is straightforward. The line-count preservation is what takes care:\n\n```\n# Original                          # After obfuscation\n\"\"\"Module docstring                 \"\"\"Processed.\nspanning four\nlines.\n\"\"\"                                 \"\"\"\n                                    (4 newlines, same span)\n\ndef foo():                          def mtd_xxx():\n    \"\"\"Function docstring.\"\"\"           \"\"\"Processed.\"\"\"\n    return 42                           return 42\n\n# A line comment                    # Processed.\n```\n\nFor multi-line docstrings the rule is: count the `\\n`\n\ncharacters in the original string value, emit `\"\"\"Processed.`\n\n+ N newlines + `\"\"\"`\n\n. Stays on the same number of source lines so any `File \"...\", line 243`\n\nin a traceback still points at the same source line in both versions.\n\nThe first version of the docstring stripper had a subtle bug: it assumed `FunctionDef.body`\n\nwas always an `IndentedBlock`\n\n(the multi-line form, `def foo():\\n body`\n\n). One-liner functions like `def foo(): return 1`\n\nuse a `SimpleStatementSuite`\n\nbody — a totally different LibCST node type — and the stripper crashed with `'SimpleStatementSuite' object is not subscriptable`\n\n. The exception was caught and the whole file was silently copied verbatim to the workspace, which manifested days later as `ImportError: cannot import name 'OdooClient'`\n\n(the import line was preserved as-is in the verbatim copy while the class definition was renamed in the obfuscated `odoo_client.py`\n\n).\n\nThe fix is mechanical (handle both body shapes), but the lesson is general: in Python obfuscation, the **silent verbatim fallback** is a foot-gun. The diagnostic command is worth memorising:\n\n```\n# Lists every .py in the workspace that has zero obfuscation markers\nfor f in $(find ~/.promptcape/cache/<hash> -name \"*.py\" -size +10c); do\n  count=$(grep -c \"fld_\\|mtd_\\|Cls_\\|Processed\" \"$f\")\n  [ \"$count\" = \"0\" ] && echo \"VERBATIM: $f\"\ndone\n```\n\nFiles that come out are either empty placeholders (fine — `conftest.py`\n\nis often empty in test suites) or fell through the fallback (file the bug).\n\nThis is the question that splits Python obfuscation from Java obfuscation more than anything else.\n\nA Java workspace is typically **read-only for the AI**. The developer obfuscates, the AI works in the obfuscated copy, the developer applies changes back to source, and the app runs from the source project (with the real `.env`\n\n, the real `application.properties`\n\n, the real DB). The obfuscated workspace's job is to be readable, not runnable.\n\nA Python workspace gets **run** by the developer. They iterate. They open Streamlit. They run pytest. They start the dev server. That requires real config values at runtime — but `.env`\n\nfiles are pure secrets: API keys, database URLs, OAuth client secrets. There is no \"structure\" to preserve in a `.env`\n\nfile the way there is in `application.properties`\n\n(where keys are part of the architecture and values are leaf secrets). It's secrets all the way down.\n\nThe first iteration of the Python pipeline ran the existing Java sanitizer on `.env`\n\n:\n\n```\n# Original .env\nDATABASE_URL=postgres://prod-db.acme.com:5432/myapp\nSECRET_KEY=hunter2\nACTIVITY_MONTHS=6\n\n# Sanitized .env (copied to workspace)\nDATABASE_URL=REDACTED\nSECRET_KEY=REDACTED\nACTIVITY_MONTHS=REDACTED\n```\n\nThe first time the developer ran `streamlit run`\n\nfrom the workspace, it crashed instantly:\n\n```\nValueError: invalid literal for int() with base 10: 'REDACTED'\n  File \".../dashboard.py\", line 243, in <module>\n    ACTIVITY_MONTHS = int(os.getenv('ACTIVITY_MONTHS', '6'))\n```\n\n`ACTIVITY_MONTHS=6`\n\nis not a secret. It's a config knob. But the sanitizer was uniform: redact everything because some entries are sensitive. That works for Java where the workspace doesn't run, but it instantly bricks the Python use case.\n\nThree options surfaced:\n\n| Option | Workspace runs? | AI sees secrets? |\n|---|---|---|\nA. Copy `.env` verbatim |\nYes | Yes (any tool that reads files sees them) |\nB. Sanitize all values |\nNo (crashes on first int/bool/URL parse) | No |\nC. Sanitize selectively (heuristics for \"looks like a secret\") |\nMaybe (depends on heuristic quality) | Mostly no |\n\nA and B are bad in different ways. C is fragile — every secret format you don't think of becomes a leak, and every config value that happens to match the heuristic becomes a crash.\n\nThe fix that actually worked is to recognise that the workspace **doesn't need .env on disk at all**. It needs the env vars at the moment a child process starts. There's a layer between \"secrets at rest\" and \"secrets in the running app's environment\" that PromptCape can sit on.\n\n`promptcape run`\n\n: inject `.env`\n\nat subprocess launch, never on disk\nThe pattern is borrowed from how 12-factor apps deploy in containers: the orchestrator reads the secret store at container start time and exports keys into the process environment. The container image itself contains no secrets.\n\nTranslated to PromptCape:\n\n`promptcape obfuscate`\n\nwrites the workspace `.env`\n\n. A small file `.env.promptcape-pointer`\n\nis written instead, with the absolute path to the source `.env`\n\nand instructions to use `promptcape run`\n\n. The AI sees the pointer if it opens it — that's intentional; we want the indirection documented.`promptcape run <command>`\n\nis a wrapper that:\n`promptcape apply`\n\n/ `promptcape status`\n\n).`<source>/.env`\n\nand `<source>/.env.local`\n\nwith a minimal python-dotenv-compatible parser.`<command>`\n\nwith `cwd = workspace`\n\n, the child's environment populated from the current OS env layered with the parsed `.env`\n\nentries.The flow:\n\n```\n# Source project: ~/projects/my-streamlit-app/.env\n# DATABASE_URL=postgres://prod-db.acme.com:5432/myapp\n# SECRET_KEY=hunter2\n# ACTIVITY_MONTHS=6\n\ncd ~/projects/my-streamlit-app\npromptcape obfuscate --language python --verify .\n# -> ~/.promptcape/cache/a1b2c3d4/\n#    ├── (the obfuscated code)\n#    └── .env.promptcape-pointer    (text file, no values)\n\ncd ~/.promptcape/cache/a1b2c3d4\npromptcape run streamlit run dashboard.py\n# 1. reads ~/projects/my-streamlit-app/.env\n# 2. spawns `streamlit run dashboard.py` in cwd=workspace\n# 3. child environment: OS env + DATABASE_URL=postgres://... + SECRET_KEY=hunter2 + ACTIVITY_MONTHS=6\n# 4. streamlit starts normally; os.getenv('DATABASE_URL') returns the real value\n```\n\nFor pytest, the same shape:\n\n```\npromptcape run pytest -v\n# tests run against the obfuscated source, with real env vars injected at child launch\n```\n\nFor Java apps using Spring Boot's relaxed binding (`DATABASE_PASSWORD`\n\nenv var overrides `database.password`\n\nproperty), the SAME command works without any extra plumbing:\n\n```\npromptcape run mvn spring-boot:run\n# Spring Boot reads OS env vars (precedence rank 5) before application.properties (rank 8).\n# The sanitized application.properties in the workspace has database.password=REDACTED.\n# The OS env var DATABASE_PASSWORD=real overrides it. App starts with real credentials.\n# No .env ever copied to the workspace.\n```\n\nThree properties this gets right:\n\n`~/.promptcape/cache/<hash>`\n\nall it wants — there are no values to find.`pytest`\n\ndirectly (without `promptcape run`\n\n), the app starts with no env vars and crashes at the first `os.getenv('REQUIRED_KEY')`\n\n. That's loud, it's traceable, and it's correct — they're missing the wrapper.`load_dotenv()`\n\ncalls in the user's code become a graceful no-op (no `.env`\n\nto find), but `os.getenv('KEY')`\n\nfinds the value in the child environment. The framework's startup path is unchanged.The downside: developers have to remember to use `promptcape run`\n\n. The mitigation is documentation (`.env.promptcape-pointer`\n\nis the first place they look when something doesn't read env vars), the proxy/Cursor-terminal integration (which can wrap launches automatically), and a clear failure message when the wrapper is forgotten.\n\n``` php\n1. pytest                              -> GREEN (source is healthy)\n2. promptcape obfuscate --verify       -> Obfuscated workspace created\n                                          .env NOT copied; pointer file written\n3. promptcape run pytest               -> GREEN (workspace runs with real env vars\n                                          injected at subprocess launch)\n4. AI modifies obfuscated code\n5. promptcape run pytest               -> GREEN (AI changes work in the runtime)\n6. promptcape apply                    -> Changes applied to source\n7. pytest                              -> GREEN (de-obfuscated changes work)\n```\n\nEach step has a specific failure mode:\n\n`grep -rn \"mtd_098fd2b6\" .`\n\n) to see what real name the AI sees in context, then add it to the relevant detector. Real-world examples that surfaced this way: `cursor.close()`\n\n(sqlite3 Cursor method), `today.year`\n\n(datetime.date attribute), `df.value_counts().to_dict()`\n\n(pandas chain), `engine.connect()`\n\n(SQLAlchemy lifecycle). Each got added to the protected list once.`Cls_e5f6a7b8`\n\npatterns back to known real names. Same mechanism as Java.`--compile-gate`\n\ncheck (which for Python is the static import verifier) catches most of these.It is worth being explicit about the threat boundary, because Python's open-source nature makes the question come up naturally: *if my distributed Python app ships as .py files anyone can read, why bother obfuscating it for the AI in the first place?*\n\nThe answer is that those are two different threats living in two different lifecycle stages.\n\n| Threat | When | Who reads the source | What protects |\n|---|---|---|---|\nAI-provider transit |\nDevelopment sessions (Claude Code, Cursor, Aider…) | Anthropic / OpenAI / Mistral on their servers | PromptCape — obfuscate before sending, reverse-map the reply |\nEnd-user inspection |\nAfter product release | Anyone who installs the `.py` , `.pyc` , or PyInstaller bundle |\nNative compilation (Nuitka, Cython), commercial obfuscators (PyArmor), or SaaS-only deployment |\n\nPromptCape's obfuscated workspace lives in `~/.promptcape/cache/<hash>/`\n\non the developer's own machine, only during AI sessions. It never ships with the product. After `promptcape apply`\n\n, the developer's source tree is back to real names. Whatever the developer builds and distributes is independent of whether they used PromptCape that day or not.\n\nThe two layers are also independent in the opposite direction: a Nuitka-compiled binary doesn't help the developer at all while they're prompting Claude with their real source code — that's not when end users are looking, that's when the AI provider's logs are being written. A developer who needs both protections uses both: PromptCape during development, Nuitka at release. The combination covers the full lifecycle.\n\nSpecifically on Python distribution effort levels:\n\n`.py`\n\nfiles`.pyc`\n\n-only`python -m compileall`\n\n): decompiles cleanly in seconds with `decompyle3`\n\nor `uncompyle6`\n\n.`.pyc`\n\ninside a bundle that `pyinstxtractor`\n\ncracks open in 5–10 minutes.This isn't a Python-specific issue. Java has the same shape — `.class`\n\nfiles in a `.jar`\n\ndecompile cleanly with `jd-gui`\n\n/ CFR / Procyon, and the traditional answer is ProGuard or R8 name-mangling at release-build time, which is conceptually identical to what PromptCape does at AI-session time but applied at a different lifecycle point. The two layers don't replace each other; a Java product that ships obfuscated bytecode AND uses PromptCape during development covers both transit and distribution leaks.\n\nPython obfuscation for AI assistants is not a port of the Java pipeline. The fundamental shift — the developer runs the workspace, not just reads it — changes every layer: what you protect (name contracts, not just identifiers), how you verify (file existence, not compilation), and how you handle secrets (inject at subprocess launch, never on disk).\n\nThe three insights from building this:\n\n`def index()`\n\n→ `def mtd_xxx()`\n\nwhen Flask looks up the endpoint string `\"blog.index\"`\n\n. The detector has to know the framework's discovery rules in advance.`.env.promptcape-pointer`\n\nand the verbatim-detection grep snippet exist precisely because the failure mode is silent otherwise.`.env`\n\ndoesn't need to be on disk in the workspace.`promptcape run`\n\n) gives the workspace real values at runtime without ever writing them to the AI-readable directory. This is the load-bearing pattern that makes the rest of the security story coherent: if the developer can run the workspace and the secrets never leave the source project, the AI assistant has zero attack surface on credentials.PromptCape ships open for trial at [https://promptcape.com/](https://promptcape.com/) — free for 3 months, no credit card required. The Python pipeline, the 16 framework detectors, and the `promptcape run`\n\nwrapper ship in the same JAR as the Java pipeline; the language is auto-detected from the source tree.", "url": "https://wpnews.pro/news/python-obfuscation-for-ai-assistants-runnable-workspaces-and-off-disk-secrets", "canonical_source": "https://dev.to/genevieve_breton_cb795f52/python-obfuscation-for-ai-assistants-runnable-workspaces-and-off-disk-secrets-172i", "published_at": "2026-06-03 08:21:06+00:00", "updated_at": "2026-06-03 08:42:57.785271+00:00", "lang": "en", "topics": ["ai-tools", "ai-infrastructure", "ai-agents", "mlops", "artificial-intelligence"], "entities": ["Python", "Java", "Pydantic", "Streamlit", "pytest", "uvicorn", "FastAPI", "promptcape"], "alternates": {"html": "https://wpnews.pro/news/python-obfuscation-for-ai-assistants-runnable-workspaces-and-off-disk-secrets", "markdown": "https://wpnews.pro/news/python-obfuscation-for-ai-assistants-runnable-workspaces-and-off-disk-secrets.md", "text": "https://wpnews.pro/news/python-obfuscation-for-ai-assistants-runnable-workspaces-and-off-disk-secrets.txt", "jsonld": "https://wpnews.pro/news/python-obfuscation-for-ai-assistants-runnable-workspaces-and-off-disk-secrets.jsonld"}}