[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "clayseal" version = "0.6.2" description = "MCP gateway and Python guardrail that authorizes agent tool calls against a session policy. Stops prompt injection and aggregate attacks that per-call checks miss." readme = "README.md" license = { text = "MIT" } keywords = ["agent", "authorization", "llm", "security", "mcp", "guardrails", "policy", "sandbox", "audit", "prompt-injection", "mcp-proxy", "tool-calling", "langchain", "langgraph", "claude", "cursor"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Security", "Topic :: Software Development :: Libraries :: Python Modules", # `Typing :: Typed` is NOT claimed and `py.typed` is NOT shipped. 93% of the # library's functions are fully annotated, and mypy now runs and is ratcheted # (see [tool.mypy] and scripts/mypy_ratchet.py), but the findings it reports # have not been triaged. Shipping the marker would make these annotations # authoritative for every downstream type-checker on the strength of a claim # nobody here has checked. The current count lives in # scripts/mypy_baseline.json rather than in this comment: the number that used # to be written here had drifted from 92 to 98 with nothing to notice. ] # The CORE library and CLI are stdlib plus two packages and run anywhere in this # range: 2,156 tests pass on 3.14 with no failures and no library deprecation. # The ceiling that used to sit here at <3.14 came from `agentdojo`, which caps at # 3.12 and is a dependency of the OPTIONAL `benchmarks` extra. Pinning the whole # distribution to an optional extra's constraint locked every user of the gateway # out of current Python for a package they were never going to install. requires-python = ">=3.10,<3.15" # The whole runtime, in two third-party packages. `cryptography` signs and # verifies; `pyyaml` parses the policy document. Everything else the gateway # needs is stdlib or lives in this repo: `clayseal.core` used to be a separate # private distribution and is vendored here now, because a hard dependency on a # repository nobody can read is not a dependency, it is a wall. dependencies = [ "cryptography>=42.0", "pyyaml>=6.0", ] # The CLI is how a deployment that does not want to write Python uses this. # `clayseal proxy` is the enforcement point that does not depend on the agent # cooperating; `clayseal policy lint` is the pre-merge gate on the authority. [project.scripts] clayseal = "clayseal.capabilities.cli:main" [project.urls] Homepage = "https://github.com/clayseal/clayseal-capabilities" Documentation = "https://github.com/clayseal/clayseal-capabilities/blob/main/docs/START.md" Repository = "https://github.com/clayseal/clayseal-capabilities" Issues = "https://github.com/clayseal/clayseal-capabilities/issues" Changelog = "https://github.com/clayseal/clayseal-capabilities/blob/main/CHANGELOG.md" # Execution-sandbox backends, resolved by name via clayseal.core.plugins. The # built-in iVisor backend needs no dependency here (it drives an external # binary); registering it makes the substrate swappable, a Linux deployment can # ship a gVisor/seccomp backend under the same Protocol without touching callers. [project.entry-points."clayseal.sandbox_backends"] ivisor = "clayseal.capabilities.sandbox.backend:IVisorBackend" [project.optional-dependencies] # The live TUI in demo/. Deliberately NOT folded into [dev]: the demo's pure # modules (reducer, escalation ladder, scenario) must import and test with rich # absent, and CI installing [dev] only is what enforces that. # openai is only needed by --provider openai/live; the mock provider # needs neither a key nor a model. demo = ["rich>=13.7", "openai>=1.40"] # The default Biscuit token backend wraps the identity layer's primitives. # Identity is a SEPARATE distribution and is not required to use this layer: # bring your own CapabilityTokenBackend via the clayseal.capability_backends # entry point. This extra is listed for deployments that already have identity # installed, and it is deliberately NOT part of [dev], because [dev] has to # resolve from a clean index for anyone who clones this repo. biscuit-service = ["agentauth-identity>=0.5,<0.6"] # Verifying identity adapters (VerifyingOidcProvider, EntraAgentIdProvider): # live JWT verification against OIDC discovery/JWKS. oidc = ["PyJWT>=2.8,<3.0", "httpx>=0.27"] # SPIFFE Workload API live-fetch (SpiffeWorkloadProvider): py-spiffe + grpcio. spiffe = ["spiffe>=0.3"] # A2A signed AgentCard verification (A2AAgentCardProvider): JWS over RFC 8785 JCS. a2a = ["rfc8785>=0.1.4", "PyJWT>=2.8,<3.0"] # Replay-defense store AND the cross-host principal ledger # (`ledger_backends.RedisPrincipalLedger`). redis = ["redis>=5.0"] dynamodb = ["boto3>=1.34"] # External agent-security corpora for the enforcement benchmark (benchmarks/). # agentdojo bundles its suite data; the loader tracks the 0.1.x suite/ # ground-truth API (get_suites -> load_and_inject_default_environment -> # task.ground_truth). agentdojo supports Python 3.10-3.12, so run the benchmark # under a 3.12 interpreter. InjecAgent/ToolEmu are separate checkouts (see # benchmarks/README.md), not pip deps. benchmarks = ["agentdojo>=0.1.35,<0.2"] # Learned goal-conditioned trajectory scorer (clayseal.capabilities.monitor). # Optional: the n-gram scorer + conformal layer run with no extra deps; torch is # only needed to train/serve the transformer scorer (do it on a GPU VM). monitor = ["torch>=2.2"] # `fakeredis` backs the cross-host ledger tests. It is a double, not a Redis: # set CLAYSEAL_TEST_REDIS_URL to run the same suite against a live server. dev = ["pytest>=8.0", "ruff>=0.4", "mypy>=1.10", "fakeredis>=2.21", "redis>=5.0", "clayseal[oidc,a2a]"] # Ship both subpackages of the `clayseal` PEP 420 namespace, plus the `agentauth` # compatibility shim. There is no `clayseal/__init__.py` on purpose: the namespace # stays open, so a separately installed `clayseal.identity` resolves alongside # these two. `agentauth/` re-exports the old paths with a DeprecationWarning for # one release; see docs/MIGRATION.md. [tool.hatch.build.targets.wheel] only-include = ["clayseal/capabilities", "clayseal/core", "agentauth"] # Lint rules are PINNED rather than inherited from ruff's defaults, because # `ruff check .` is now a CI gate and an implicit rule set makes that # gate change meaning whenever ruff releases. The families below are the ones # already firing on this tree plus the correctness core. [tool.ruff] target-version = "py310" line-length = 100 [tool.ruff.lint] # Correctness and security rules only. Style opinions (SIM, PERF, line width, # comprehension preference) are deliberately NOT gated: this tree's comments and # docstrings carry the measurement record, and a linter that reflows them costs # more than it returns. select = [ "E7", "E9", # pycodestyle: the errors, not the whitespace opinions "F", # pyflakes, undefined names, unused imports, dead locals "I", # import order "UP", # pyupgrade "B", # bugbear, mutable defaults, bare raise, zip strictness "C4", # comprehensions "PLE", # pylint errors, incl. Trojan-Source character checks "PLW1510", # subprocess.run without an explicit check= "PLR0402", "FURB", "TRY004", "BLE", # blind except, a security gate must not silently swallow "S", # bandit "RUF", ] ignore = [ # Line length is checked by eye here: the module docstrings deliberately carry # long prose and measurement tables that reflowing would make less readable. "E501", # `assert` is how the test suite works and how invariants are stated. "S101", # Subprocess use is the point of the sandbox driver, and its arguments are # built from validated policy rather than from user strings. "S603", "S607", # Typography is deliberate throughout: em-dashes, arrows and × in prose. These # rules exist to catch homoglyph attacks in identifiers, which `S` and the # `PLE` Trojan-Source checks already cover in the places that matter. "RUF001", "RUF002", "RUF003", ] [tool.ruff.lint.per-file-ignores] # S105 is a NAME heuristic: it flags any constant whose identifier contains # TOKEN or SECRET. In these three files the flagged values are a schema id and a # set of environment-variable NAMES, the things you read a credential from, not # a credential. Suppressed per file rather than globally so a genuine hardcoded # secret elsewhere in the package still fails the build. "clayseal/capabilities/commit.py" = ["S105"] "clayseal/capabilities/used_token_store.py" = ["S105"] "clayseal/capabilities/hardening/object_class.py" = ["S105"] # Same NAME heuristic in the vendored core. `TOKEN_LIMIT` is a budget-type enum # member and `SIGNING_KEY_PASSWORD_ENV` is the NAME of the variable you read a # passphrase from, not a passphrase. "clayseal/core/budget.py" = ["S105"] "clayseal/core/signing.py" = ["S105", "TRY004"] # TRY004 wants TypeError where these raise ValueError on a malformed document. # The contract callers are written against is that every parse and compile # failure in this layer is a ValueError, and there are call sites that catch it # by that type. Changing the exception to satisfy a lint preference would break # them silently, which is a worse outcome than the preference is worth. "clayseal/core/task_scope.py" = ["TRY004"] # This module DETECTS Trojan-Source bidi overrides, zero-width joiners and tag # characters, so its pattern tables necessarily contain them. Flagging the # detector for holding the thing it detects is the lint equivalent of an # antivirus quarantining its own signature file. "clayseal/capabilities/hardening/input_hardening.py" = ["PLE2502", "PLE2515", "RUF001", "RUF003"] # --------------------------------------------------------------------------- # The harness and the test suite, which are gated on the SAME correctness rules # as the library and exempted only where a rule is wrong about this code. # # `ruff check .` passing matters: it is the first command a contributor runs, and # a repository where it fails teaches them that lint output is noise. Every # exemption below is a rule that is right in general and wrong here, with the # reason attached. The correctness families -- F, E7, E9, B (except B007/B905 # below), PLE -- are NOT exempted anywhere. # --------------------------------------------------------------------------- "benchmarks/**" = [ # Seeded RNG is the point. Every corpus, every attack variant and every # shuffle here is drawn from `random.Random(seed)` so a failure reproduces. # A CSPRNG would make these benchmarks unrepeatable, which is the opposite # of what they are for. "S311", # A harness must not die because one corpus of nine is malformed; it reports # the row as NOT MEASURED and continues. That is the documented behaviour of # `scoreboard.py`, and narrowing these to specific exception types would be # a guess about what a third-party loader raises. "BLE001", "S110", "S112", # Name heuristics. These fire on fixture values called `api_key` and on # synthetic credentials that exist to be detected. "S105", "S106", # Scratch paths in throwaway harness runs, not deployment configuration. "S108", # Style, deliberately not enforced in a tree whose files are long tables of # scenario data. "C408", "RUF005", "RUF007", "RUF015", "RUF059", "B007", "B905", "E702", "E731", "E741", "C416", "F841", "FURB171", "FURB187", "FURB192", "RUF010", "RUF012", "RUF034", "RUF046", "PLW1510", "TRY004", "B904", "S104", "S603", ] "python/tests/**" = [ "S311", "BLE001", "S110", "S112", "S105", "S106", "S108", "C408", "RUF005", "RUF015", "RUF059", "B007", "B905", "E702", "E731", "C416", "F841", "RUF012", "RUF043", "PLW1510", "B904", # The Trojan-Source FIXTURES, for the same reason the detector itself is # exempted above: a test for bidi handling has to contain bidi characters. "PLE2502", "PLE2515", "RUF001", "RUF003", ] "demo/**" = [ "S311", "BLE001", "S110", "S112", "S105", "S108", "C408", "RUF005", "RUF015", "RUF059", "B007", "B905", "E702", "E731", "RUF012", "PLW1510", "B904", "S603", "S607", ] # Attack fixtures that carry bidi overrides and zero-width characters, for the # same reason `input_hardening.py` does: a corpus of Trojan-Source payloads has # to contain Trojan-Source payloads. "benchmarks/live/bpl_live.py" = ["PLE2502", "PLE2515", "RUF001", "RUF003"] "benchmarks/stress_gates.py" = ["PLE2502", "PLE2515", "RUF001", "RUF003"] # Binds 0.0.0.0 to assert that a file written there is NOT world-readable. The # bind is the thing under test. "python/tests/test_evidence_files_are_private.py" = ["S104"] # Two style preferences inside assertions whose current spelling is the clearer # statement of what is being checked. "python/tests/test_decision_sinks.py" = ["RUF007"] "python/tests/test_suite_is_collectable.py" = ["FURB192"] [tool.mypy] # Without these three, `mypy clayseal` does not run at all: it stops on "Source # file found twice under different module names", because `clayseal/` is a PEP # 420 namespace package with no `__init__.py`. A contributor following the # obvious command got an error rather than a report, which is why there was no # type gate here despite 93% of the library's functions carrying annotations. files = ["clayseal"] explicit_package_bases = true namespace_packages = true mypy_path = "." # The optional extras (torch, jwt, spiffe, redis, boto3, httpx) are absent by # design in a default install, and the layering test already asserts they are # imported lazily. Their absence is not a type error. ignore_missing_imports = true # Deliberately NOT strict yet. There are 98 findings and none has been triaged; # turning on `disallow_untyped_defs` would bury them under thousands more and # guarantee nobody reads either. `scripts/mypy_ratchet.py` is the mechanism: the # count may shrink and may not grow, which is the same shape `check_claims` uses # for benchmark debt. [tool.pytest.ini_options] # Markers so a subset is selectable. With 3,300+ tests across 160 files, `-k` on # a filename substring was the only selector, which is guesswork when 39 files # touch SessionBroker. markers = [ "floor: the refusal decision itself (SessionBroker._floor and _authorize_locked)", "broker: anything constructing or driving a SessionBroker", "slow: takes more than a second; deselect with -m 'not slow'", ] testpaths = ["python/tests"] pythonpath = ["."]