[project] name = "codecalc" version = "0.12.0" description = "Universal code + logic calculator for AI models: execute 31 languages, evaluate logic, analyze complexity. Exposed via MCP." license = "Apache-2.0" requires-python = ">=3.11" # The wheel carried NONE of this and its METADATA came to 700 bytes: name, # version, summary, licence, dependencies, nothing else. The PyPI page for a # package whose function is running untrusted code would have rendered with no # description and no link to its own SECURITY.md or AUDIT.md. README.md was # already IN the sdist — it was simply never declared as the long description. # # The asymmetry is what makes this worth a change rather than a nit: the crate # got `repository`, `homepage`, `keywords` and `categories` in #96 specifically # because crates.io warns without them. PyPI does not warn, so the same gap on # the Python side was silent — and 0.1.0 cannot be re-uploaded once published. readme = "README.md" # Deliberately no `License ::` classifier: `license = "Apache-2.0"` above is a # PEP 639 SPDX expression, and pairing it with the legacy classifier is the # combination modern build backends reject as ambiguous. classifiers = [ "Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "Operating System :: POSIX :: Linux", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Rust", "Topic :: Security", "Topic :: Software Development :: Interpreters", "Topic :: Software Development :: Quality Assurance", "Typing :: Typed", ] keywords = ["mcp", "sandbox", "executor", "code-execution", "llm", "agent", "symbolic-math"] dependencies = [ # The official SDK, NOT fastmcp. fastmcp 3.x pins mcp>=1.24,<2.0 and so # cannot reach protocol 2026-07-28 — the resolver is explicit about it. # Bounded on both ends: `fastmcp>=2.0` with no ceiling had already carried # this project from 2.x to 3.4.6, a major bump nobody chose. "mcp>=2.0,<3", # `mcp` already pulls this in (its own OAuth client code needs it), so # declaring it costs nothing extra on disk — every install already has # it. The point of listing it explicitly is narrower: `codecalc/auth/` # does `import jwt` for `serve-http --oauth-issuer`'s JWT verification, # and a name only reachable because a DEPENDENCY happens to need it is # one `mcp` could drop in a future release without codecalc's own # manifest saying otherwise. No `codecalc[oauth]` extra was added: an # extra exists to make an ABSENT library optional, and this one is never # absent — `mcp>=2.0,<3` forces it in unconditionally. "pyjwt[crypto]>=2.10,<3", ] # The heavy half, measured: sympy 40.4 MB + z3-solver 43.1 MB + # tree-sitter-language-pack 5.0 MB = 88.6 MB, against a base install that is # the MCP surface and the executor. Split out because a caller who only runs # code should not download an SMT solver to do it (#88). # # THE TRADE IS REAL AND IS NOT HIDDEN. `pip install codecalc` no longer brings # the symbolic tools; it brings a server whose symbolic tools return an error # naming the extra that provides them. `codecalc[full]` is what the README # tells people to install, and `codecalc doctor` reports which extras are # present, so the state is visible before a tool call rather than after one. [project.optional-dependencies] symbolic = [ "sympy>=1.13,<2", "z3-solver>=4.13,<6", ] parsing = [ "tree-sitter-language-pack>=1.14,<2", ] full = [ "codecalc[symbolic]", "codecalc[parsing]", ] [project.scripts] codecalc = "codecalc.server:main" # an installed user has no `scripts/` directory to run # `prefetch_grammars.py` from — it is not in the wheel — so the offline-warm-up # instructions need a shipped entry point instead. `codecalc.prefetch` is the # 'parsing' extra's own README-documented command; it still works (and returns # exit 2) without the extra installed, per its own docstring. codecalc-prefetch-grammars = "codecalc.prefetch:main" # Placed AFTER every array in [project], not before them. A `[project.urls]` # header opens a new TOML table, so everything following it belongs to that # table until the next header — putting this above `dependencies` silently # reparented the dependency list into it and the build failed with # "URL `dependencies` of field `project.urls` must be a string". Caught by the # build rather than by review, which is the argument for building the wheel as # part of changing its metadata. [project.urls] # Source first: for a package whose function is executing code, the shortest # path from the index page to the audit and the threat model is the point. Source = "https://github.com/The-40-Thieves/codecalc" Issues = "https://github.com/The-40-Thieves/codecalc/issues" Security = "https://github.com/The-40-Thieves/codecalc/blob/main/SECURITY.md" Audit = "https://github.com/The-40-Thieves/codecalc/blob/main/AUDIT.md" Changelog = "https://github.com/The-40-Thieves/codecalc/releases" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["codecalc"] # bin/codecalc-exec* and bin/blocknet.so are gitignored build artifacts (see # .gitignore) — this exempts them from hatchling's default VCS-ignore # filtering so hatch_build.py's force_include (below) can actually find them # on disk. force_include itself bypasses file-selection, but `artifacts` is # what stops a *different* code path (packages/only-include walking bin/ some # other way) from silently excluding the same gitignored files. # codecalc/py.typed is tracked, not a build artifact, so it needs no entry # here — `packages = ["codecalc"]` above already carries it into the wheel. # Verified by inspecting the built wheel rather than assumed. artifacts = [ "bin/codecalc-exec", "bin/codecalc-exec.exe", "bin/blocknet.so", "bin/blocknet.dylib", ] # hatch_build.py — see that file for what it does and why. Wheel-only: an # sdist is source-only and force_include-ing one platform's binary into it # would be wrong, not merely unnecessary. [tool.hatch.build.targets.wheel.hooks.custom] path = "hatch_build.py" # ── ruff ──────────────────────────────────────────────────────────────────── # This config exists so `ruff check` is a GATE rather than a report. Before it, # an unconfigured run produced 65 findings on correct code and AUDIT.md recorded # "0 non-policy findings" — a judgement that lived only in prose, so nobody # cloning the repo could tell which findings were reviewed and which were new. # # Every entry in `ignore` below was derived by reading the actual findings, and # each carries the reason. The residual on codecalc/ and tests/ is a genuine 0, # so a NEW finding of any selected rule fails CI. [tool.ruff] target-version = "py311" line-length = 120 [tool.ruff.lint] # Deliberately broader than ruff's default (E4,E7,E9,F). The default would not # have caught a single one of the security-relevant rules below. # # NOT selected: the PLR complexity family (PLR0911/0912/0913/0915/0917/2004). # Those are opinions about shape — 31 magic-value comparisons and 8 "too many # arguments" on an MCP server whose tools ARE wide keyword APIs. Reaching zero # would mean restructuring working code to satisfy a metric, so gating on them # would buy churn, not correctness. select = [ "E", "W", # pycodestyle "F", # pyflakes "I", # import sorting "UP", # pyupgrade — already at zero, keeps it there "B", # bugbear "C4", # comprehensions "SIM", # simplify "S", # bandit — the security family; see the per-rule notes below "RUF", # ruff-specific "PTH", # pathlib over os.path "ISC", "ICN", "PIE", "TID", "ASYNC", # async correctness (the streaming tool is async) "PLE", # pylint ERRORS only, not the complexity metrics ] ignore = [ # ── style, not correctness ────────────────────────────────────────────── "E501", # line length: prose comments and doc tables are deliberately long "E741", # `l` as a loop var over a list of languages, 4 sites "B905", # zip(strict=) — every zip here is over equal-length sequences "SIM105", # contextlib.suppress over try/except/pass — see S110 below "SIM108", # ternary instead of if/else — hurts readability at these sites "RUF001", # "ambiguous" unicode: the em dashes and arrows in docstrings are intentional "RUF005", # list concat over unpacking — 2 sites building argv incrementally "ASYNC109", # `timeout` param on an async def: execute_code_stream's timeout is # part of its MCP tool SCHEMA (the model passes it), not an internal # deadline, so it cannot be replaced with asyncio.timeout() "ASYNC240", # blocking Path method in an async def: ONE site, unlinking a small # stdin temp file after the child exits. Satisfying it means taking # a trio/anyio dependency for a single syscall, in a coroutine that # already does blocking json.loads on the child's output. # ── bandit rules that are this project's PURPOSE, not a defect ────────── # codecalc is a code EXECUTION service. Spawning subprocesses is the product. # S602 (shell=True) is NOT ignored and stays armed — that is the rule that # actually matters here, and the codebase has zero of them. "S603", # subprocess call without shell=True — every call site is argv, by design "S607", # partial executable path — resolution is via registry.RUNTIME_PATH on purpose # ── reviewed in AUDIT.md LOW-08, kept with reasons ───────────────────── "S110", # try/except/pass: best-effort sandbox hardening (setrlimit, unlink, # probe). Each site was reviewed; none swallows an actionable error. "S112", # try/except/continue: same, in the tempdir-nonce retry loop "S102", # exec(): ONE site, codecalc/_worker_bootstrap.py, running user code # inside the isolated REPL subprocess. Documented in AUDIT.md item 1. # scripts/check_no_eval.py is the real gate on this — it allows exec # ONLY in that file, where a blanket ruff ignore cannot express that. ] [tool.ruff.lint.per-file-ignores] # Tests deliberately assign results they only assert on indirectly, print # progress, and import after a sys.path insert. "tests/*" = ["S101", "T201", "F841", "E402"] "scripts/*" = ["T201"]