Skip to content

permit 3.0.0: fix dependency CVEs, fix major SDK bugs, gate PRs and releases on CVE scans - #126

Merged
zeevmoney merged 92 commits into
mainfrom
per-16176/cve-gates-and-fixes
Sep 28, 2026
Merged

zeevmoney merged 92 commits into
mainfrom
per-16176/cve-gates-and-fixes

Conversation

@zeevmoney

@zeevmoney zeevmoney commented Sep 21, 2026 •

Copy link
Copy Markdown

Linear issues

  • Fixes PER-16176: dependency CVEs and CVE gates
  • Fixes PER-16174: nine major correctness bugs
  • Fixes PER-16231: typed public surface and py.typed
  • Fixes PER-11984: authorized_users() under pydantic 2
  • Fixes PER-12225: support for external type checkers
  • Fixes PER-15773: the community py.typed contribution, shipped here with the typing fixes
  • Fixes PER-14375: audit-log models reject logs without a pdp_config_id
  • Fixes PER-12884: sync/async client parity test
  • Fixes PER-16333: an offline regression test for every bug class the end-to-end checks detect
  • Fixes PER-16334: weekly schema-drift check against the public API schema
  • Fixes PER-16335: a customer migration guide and agent skill for upgrading from 2.x to 3.0
  • Part of PER-16177: test-suite gaps (only the skipped ABAC decision tests remain)
  • Related to PER-16172: the dependency advisories fixed here
  • Related to PER-15706: the misleading PDP 403 error fixed here
  • Related to PER-15190: the misleading PDP 403 error fixed here
  • Follow-ups: PER-16209, PER-16236

GitHub issues

Closes #116
Closes #122
Closes #124

Supersedes the community PRs #123 and #125. Their changes are included here, and each author is credited with a Co-authored-by trailer.

Why

This started as a CVE fix and became the 3.0.0 release.

CVEs. permitio/permit-python had no dependency scanning and no gate on PRs or releases. The resolved dependency tree was clean. All the exposure was in the floors: the package publishes open >= ranges with no lockfile, so aiohttp>=3.12.14 legitimately resolves to 3.12.14 and a consumer inherits every CVE fixed since. That was 34 advisories across aiohttp, h11, anyio and pydantic. A scanner pointed at what CI installs reports all green.

Correctness. Nine major bugs turned up along the way (PER-16174). Among them: context-dependent ABAC checks evaluated against an empty context, authorized_users() could not return under pydantic v2, and every flat permit.api method on the sync client sent its request and then raised, so a write took effect while the caller got an error. They survived because 8 of the e2e tests had been @pytest.mark.xfail for about two years. Since the Python floor already had to move (below), this ships as a major version that fixes them properly.

Open backlog. Every open issue and community PR was triaged against this release. The real, still-present ones are fixed here:

  • Python 3.14: import permit crashed on 3.14 with the pydantic versions the old ranges allowed.
  • Authorization: bearer: the SDK sent a lowercase scheme; it now sends the standard Bearer.
  • Typing: the package was untyped, so type checkers skipped it. The one-line py.typed marker a contributor proposed is shipped here together with the typing fixes that make it safe; on its own it would have produced false errors on valid code.

The other open items are already fixed or superseded. They will be closed with an explanation once this ships.

Open SDK tickets. The ones that were still real are fixed here too: audit-log models that rejected logs without a pdp_config_id, nothing guarding the sync client against drifting from the async one, and SDK surfaces with no tests (resource actions, action groups and the deprecated facade).

4.0 deprecations. 3.0.0 starts warning about the two things a future major release, 4.0, will remove: pydantic 1 support and the flat methods on permit.api. See Deprecations.

Upgrading. MIGRATION.md lists every change below with what to do. The permit-python-3-migration agent skill does the upgrade in a customer's project. See Migration guide and agent skill.

Breaking changes

All of these need a line in the release notes.

Compatibility

  1. Python 3.8 and 3.9 dropped (python_requires>=3.10). This can't be avoided: aiohttp 3.14.3 is the only release that fixes CVE-2026-69244, and it requires 3.10. 3.8 was already unsupported in practice, since the old aiohttp floor needed 3.9. A 3.9 user who runs pip install -U permit gets Requires-Python >=3.10, and pip quietly keeps the old, vulnerable version.
  2. httpx is no longer installed transitively, and neither are the packages that came only through it (h11, httpcore, anyio, certifi, and with some versions sniffio and exceptiongroup), nor zipp. The SDK never imported httpx. Anyone who relied on permit pulling it in must now declare it themselves.
  3. Higher dependency floors. These old floors no longer install or import cleanly on the Pythons the SDK supports, so they rise:
    • pydantic: >=1.10.18,<2 or >=2.4.2 on Python 3.10–3.12; >=1.10.18,<2 or >=2.8.0 on 3.13; >=1.10.25,<2 or >=2.13 on 3.14.
      • 1.10.18 is the first 1.10.x without about 2,400 import-time DeprecationWarnings on 3.13; it also ships the pydantic.v1 package the type hints need.
      • pydantic 2.0–2.4.1 are excluded on every Python. Under pydantic 2 the SDK validates emails with the pydantic.v1 copy that pydantic bundles, and only 2.4.2 and later bundle one fixed for CVE-2024-3772 (1.10.13). pydantic 2.0 exactly also fails every parsed response: its pydantic.v1.parse_obj_as rejects __root__ models.
      • On 3.13, 2.4.2–2.7.x are excluded because they pin a pydantic-core with no Python 3.13 wheels; 2.8.0 (pydantic-core 2.20.0) is the first that has them.
      • On 3.14, earlier releases crash on import permit ("unable to infer type for attribute").
    • typing-extensions: >=4.14.0. Releases before 4.6 break import permit on 3.12+, releases before 4.12 break it on 3.13+, and 4.12–4.13 lose TypedDict keys on 3.14.
    • loguru: >=0.7.3. Earlier releases warn on 3.14 about an asyncio API that Python 3.16 removes.
  4. permit is now a typed package (PEP 561 py.typed). Type checkers used to skip permit with import-untyped; now they check calls into it.
    • Consumers can drop ignore_missing_imports or # type: ignore[import-untyped] for permit, but genuine type errors in their code may now surface.
    • SDK models are typed as the pydantic v1 models they have always been at runtime, on both pydantic majors. So v2-only calls such as .model_dump() on an SDK model now fail type checking; they already failed at runtime.
    • mypy users on pydantic 2 who want plugin checking of SDK models should use the pydantic.v1.mypy plugin; no plugin is needed.

API

  1. resource_relations.list() now returns PaginatedResultRelationRead, so callers read .data.

    This is a bug fix. This method could not work before it. The SDK declared the return type as List[RelationRead], but the API returns a paginated {"data": [...], ...} envelope. So every call raised ValidationError: value is not a valid list before returning anything. No working code can depend on the old return type. The only visible change is the type: callers now read .data.

  2. permit.sync.Permit.authorized_users(), get_user_permissions() and filter_objects() are now synchronous. Callers should drop the await.

    This is a bug fix. These methods could not work before it. Only the sync client is affected; the async permit.Permit still awaits them. The sync client inherited all three unchanged from the async class, so they stayed async def, while the sync enforcer under them is already synchronous. Calling one without await returned a coroutine object instead of a result. Awaiting it raised RuntimeError: This event loop is already running. So no working code can depend on the old behaviour. The only visible change is the signature, from async def to def. They now behave like check() and bulk_check(), which were already synchronous on the sync client.

  3. Removed public symbols:
    • ContextStore.register_transform(), ContextStore.transform() and ContextTransform. A registered transform was never applied, so these did nothing.
    • ApiKeyLevel, a deprecated alias of ApiKeyAccessLevel.
    • LoginAsErrorMessages, OpaResult and the JWT alias. None of them had a caller.
    • permit.PYDANTIC_VERSION, and the same name in permit.api.models and permit.pdp_api.base, plus a few names that modules only re-exported from their own imports. MIGRATION.md lists them all (A6).
  4. Audit-log models accept what the API returns. pdp_config_id on AuditLogModel and DetailedAuditLogModel is now Optional[UUID], and DetailedAuditLogModel.objects is optional: None when the API sends null, and an empty dict when it omits the field. Engine.GENERIC and GenericEngineDecisionLog are new.

    This is a bug fix. The API returns logs without a pdp_config_id, and logs from the GENERIC engine; the old models rejected both with a ValidationError. The type change is visible only to code that type-checks pdp_config_id as a plain UUID. No SDK method returns these models.

  5. Relationship-tuple and API-key models accept what the API returns. object_id on RelationshipTupleRead and RelationshipTupleDetailedRead is now Optional[UUID], and RelationshipTupleDetailedRead's subject_details, relation_details, object_details and tenant_details are now optional. APIKeyOwnerType gains nats_pdp_config.

    This is a bug fix. relationship_tuples.list() and create() raised ValidationError on a tuple whose object_id is null or absent, which the API schema documents as a tuple on every resource of the object's type. environments.get_api_key() raised on a key owned by nats_pdp_config. Code that reads these attributes may now need a None check, and type checkers will ask for one.

Wire behaviour (same API, different bytes). Each change was checked against the API's request definitions:

  1. An explicitly-set None is now sent as null, so an update can clear a field. Before, exclude_none dropped it: users.update(key, UserUpdate(email=None)) sent {} and quietly did nothing. Fields you never set are still omitted.
  2. users.assign_role / unassign_role omit unset fields, matching role_assignments.assign. The API treats an omitted field and null the same for these fields.
  3. elements.login_as sends canonical hyphenated UUIDs instead of 32-character hex. The API accepts both spellings and resolves them to the same record.
  4. A 3xx response now raises instead of being treated as success. No 3xx is reachable on any path the SDK calls, and aiohttp follows redirects anyway.
  5. Every Authorization header uses Bearer, not bearer. The scheme is case-insensitive (RFC 7235), so nothing breaks; it is listed because the bytes on the wire change.
  6. The deprecated permit.api.assign_role() and unassign_role() forward to permit.api.users.assign_role() and unassign_role(), so they send the request those methods send (/users/{user}/roles) instead of /role_assignments. Both have the same effect.

Kept on purpose: PermitConnectionError still inherits from the deprecated PermitException. Moving it under PermitError would silently stop except PermitException from catching connection failures.

Deprecations (to be removed in 4.0)

permit 4.0 is a future major release, and it will remove both of these. Both still work in 3.x and warn with a DeprecationWarning that says what to use instead:

  • pydantic 1 support. On pydantic 1, import permit warns once: "Support for pydantic 1 is deprecated and will be removed in permit 4.0. Upgrade to pydantic 2."
  • The 21 flat methods on permit.api, such as permit.api.get_user(). Each warns with its replacement: "permit.api.get_user() is deprecated and will be removed in permit 4.0; use permit.api.users.get() instead."

Both clients issue the warning at the line that made the call, so Python shows it by default in a script (__main__), and -W error raises it before any request is sent. The blocking client records the calling line before it runs the coroutine, since asyncio's frames would otherwise hide the caller.

-W error::DeprecationWarning on its own fails at import permit, in 2.8.3 and 3.0.0 alike: PermitConnectionError subclasses the deprecated PermitException, whose decorator warns on subclassing. Add -W "ignore:Use PermitError instead:DeprecationWarning", as MIGRATION.md shows. PER-16331 removes the need for it.

The README's new "Deprecations" section lists both and explains how to show or silence the warnings. A project that runs its tests with warnings as errors on pydantic 1 fails on import permit until it adds the filter ignore:Support for pydantic 1:DeprecationWarning.

What changed

Migration guide and agent skill (PER-16335)

  • MIGRATION.md is the customer guide from 2.x to 3.0.0.
    • Who must act (Python 3.10+ and the new floors), and a before-you-upgrade checklist.
    • Every change: what changed, who is affected, what to do, and a before/after snippet.
    • Each change has a stable ID: P1 (the requirement), C1-C3 compatibility, A1-A6 API, W1-W6 wire behaviour, T1-T3 typing, and D1-D2 deprecations with the full 21-method mapping.
    • "Staying on 2.x for now", and "Migrate with an AI agent".
    • The README links it.
  • skills/permit-python-3-migration/ is an agent skill, built with the skill-creator process.
    • It is self-contained: a customer copies the folder into their project's .claude/skills/, or installs the .skill package attached to the 3.0.0 release.
    • The agent runs a preflight, which stops if anything pins Python below 3.10.
    • It then scans, updates the dependencies, applies the SAFE edits, brings each NEEDS-REVIEW item to the user with a recommendation, verifies (tests, type checker, linter, a run with deprecation warnings as errors, re-scan) and reports.
    • references/changes.md gives, for each ID, how to detect it, the exact edit, and whether it is SAFE.
    • scripts/scan.py is read-only and uses only the standard library. It runs on Python 3.8+, so it works before the project moves. It parses the project's code and dependency files: aliases, scope-aware names, and compiled requirements treated as locks. It reports path:line, the change ID and SAFE or NEEDS-REVIEW, and it says SAFE only where the edit can't change behaviour.
  • Tests live in skills/tests, apart from the SDK's tests. They have their own pytest.ini, helpers and CI job (Migration Skill Tests, not a required check). The SDK's pytest.ini now limits its suite to tests/.
  • The 2.x sample app pins old, vulnerable versions on purpose (permit 2.8.3, aiohttp 3.12.14). skills/tests/README.md says not to install or copy them.
    • Their dependency files are stored as *.fixture, so GitHub's dependency graph, Dependency Review, Dependabot and Snyk don't read them.
    • The Trivy steps skip skills/tests/fixtures.
  • Found while writing it (the breaking changes below now say so):
    • 3.0.0 also stops exporting permit.PYDANTIC_VERSION, plus a few names that modules only re-exported from their own imports (A6).
    • The dropped packages include certifi, sniffio and exceptiongroup, not only httpx, h11, httpcore, anyio and zipp.
    • On 2.x, the sync client's flat permit.api methods sent their request and then raised. They didn't raise before sending, as this description said earlier.
    • python -W error::DeprecationWarning fails at import permit, in 2.8.3 and 3.0.0 alike. See Deprecations.
    • On Python 3.8/3.9, the aiohttp and anyio advisories can't be cleared on 2.x, because the fixed releases need Python 3.10. The guide gives the advisories' workarounds instead.

Dependency CVE fixes

Package Before After Why
aiohttp >=3.12.14,<4 >=3.14.3,<4 Clears 32 advisories, including CVE-2026-69244: an out-of-bounds heap read in the HTTP response parser, which a client hits on every call
pydantic >=1.10.7 >=1.10.18,<2 or >=2.4.2 (Python 3.10–3.12); >=1.10.18,<2 or >=2.8.0 (3.13); >=1.10.25,<2 or >=2.13 (3.14) CVE-2024-3772 (EmailStr ReDoS) needs pydantic.v1 1.10.13: pydantic 1.10.13+, or pydantic 2.4.2+ whose bundled v1 is fixed; the higher floors are compatibility fixes (breaking change 3). Dual v1/v2 support is kept
typing-extensions >=4.5.0,<5 >=4.14.0,<5 Compatibility: older releases break import permit on 3.12+ (breaking change 3)
loguru >=0.7.0,<1 >=0.7.3,<1 Compatibility: older releases warn on 3.14 (breaking change 3)
httpx >=0.24.1,<1 removed Never imported. It was the only path by which h11 (CVE-2025-43859, CRITICAL) and anyio (CVE-2026-63374, CRITICAL) got into the tree
zipp >=3.19.1 removed Unused
werkzeug (dev) >=2.3.8 >=3.1.6 Clears six advisories
pytest (dev) unpinned >=9.0.3 CVE-2025-71176. Caught by this PR's own gate
aioresponses, pytest-mock, pytest-cov (dev) declared removed No test used them. aioresponses 0.7.9 is also incompatible with aiohttp 3.14.3

Every dev dependency now has a floor. Without one, a scanner has nothing to evaluate.

Major bug fixes (PER-16174)

  • Sync client. SyncClass now wraps each method exactly once, however deep the inheritance goes; the facade methods had been wrapped twice, so each sent its request and then raised ValueError: a coroutine was expected. A write took effect, but the caller got an error instead of the result. It detects coroutines with inspect.iscoroutinefunction, unwrapping validate_arguments first. permit.sync.Permit overrides the three enforcement methods it was missing.
  • Enforcement.
    • parse_obj_as is imported through the same v1/v2 guard the rest of the package uses.
    • bulk_check honours a per-check context, and filter_objects passes the caller's context through.
    • CheckQuery.context is NotRequired, so type checkers accept a bulk_check query without a context, as the runtime always has.
    • UserInput accepts snake_case, so first_name/last_name are no longer silently dropped from every check.
  • Serialization. dict and list request bodies now go through the encoder, so a nested datetime/UUID/Enum no longer crashes inside aiohttp. exclude_none is gone.
  • Facts proxy. Tenant bulk operations no longer post to the PDP's users endpoint.
  • PDP error reporting. The PDP sends auth rejections as plain text. Parsing them as JSON raised an exception that the connectivity handler caught, so a 403 caused by a wrong API key was reported as "cannot connect to the PDP container". The error now shows the real status code and response body.

Python 3.14 support

  • Declared (Programming Language :: Python :: 3.14) and tested. The dependency floors above keep resolvers from picking versions that crash on 3.14.
  • permit/utils/deprecation.py uses inspect.iscoroutinefunction instead of the asyncio one, which 3.16 removes. This removes 21 import-time warnings on 3.14.
  • The pydantic version parser accepts pre-releases such as 2.14.0b2. They used to crash import permit with a ValueError.
  • A new compatibility CI job runs the offline suite on Python 3.10–3.14 against the lowest allowed dependency versions (uv pip compile --resolution lowest-direct), the lowest allowed pydantic 2 (lowest-direct with a pydantic>=2 constraint) and the newest, plus 3.14 on pydantic 1. uv is pinned to 0.12.18. It is not a required check, so the existing required pytest contexts are unchanged.

Typed public surface

  • Type checkers see the SDK models as the pydantic v1 models that run: each version-conditional import gains an if TYPE_CHECKING: branch, and mypy uses the pydantic.v1.mypy plugin.
  • Generated model defaults are keyword arguments (Field(default=...)), so optional fields no longer read as required to pyright and Pylance. generate-models passes --use-default-kwarg.
  • API methods that accept dicts at runtime accept them in their annotations. ModelInput/ModelListInput widen the type for type checkers only; at runtime the parameter is still the model, so an invalid dict still fails validation before any request is sent.
  • The sync client is typed as synchronous through a generated stub, permit/_sync_types.pyi, built by scripts/generate_sync_stubs.py (make generate-sync-stubs). A test fails if the stub drifts from the async classes.
  • Smaller typing fixes so a consumer type-checks clean under mypy --strict and pyright:
    • plain str is accepted for EmailStr fields;
    • UserInput accepts both field spellings;
    • explicit re-exports in permit/__init__.py;
    • deprecated() keeps the decorated signature;
    • PermitConfig() without a token is now a type error, as it already was at runtime.
  • permit/py.typed and the stub ship in the wheel and sdist, and both the release build and one compatibility leg assert that they do. The README has a short "Type checking" section.
  • Runtime behaviour is unchanged. A snapshot of every public name, signature, @validate_arguments model and model field (defaults and aliases included) matches the previous commit on both pydantic majors and on Python 3.11 and 3.14.

Audit-log models (PER-14375)

  • Only the audit-log classes in permit/api/models.py changed. They now match what the pinned generator (datamodel-code-generator 0.33.0) emits from the current public API schema. The rest of the file is unchanged; a full regeneration belongs to PER-16236.
  • tests/test_fix_audit_logs.py parses logs with pdp_config_id null or missing, detailed logs without objects, and GENERIC-engine logs.

Relationship-tuple and API-key models (PER-16334)

  • The schema-drift check below found them. APIKeyOwnerType, RelationshipTupleRead and RelationshipTupleDetailedRead now match what the pinned generator emits from the current public API schema; the rest of permit/api/models.py is unchanged.
  • tests/test_fix_read_models.py parses a wildcard tuple (object_id null and absent) through relationship_tuples.list() and create(), detailed tuples with and without detail blocks, and an API key owned by nats_pdp_config.

Minor bug fixes

every client now sends Authorization: Bearer (was bearer), checked on the wire by an offline test · resource_instances.list(detailed_key=True) always raised · users.sync() mutated the caller's dict and removed the key field the API requires, so that path always returned 422 · SyncPDPApi never called super().__init__ · pdp_timeout was silently ignored by every permit.pdp_api.* call · a dead access-level branch that could never run was removed · docstrings corrected: resource-instance idents are resource:key or a uuid, never a bare key; a resource role's permissions are bare action keys like read.

Packaging and cleanup

  • setup.py no longer ships a top-level tests package to consumers. A bare find_packages() put it in their site-packages, where it shadows their own tests module. The published permit==2.8.3 does this today.
  • Removed: duplicate ClientConfig/pagination_params code in pdp_api, a duplicate _model_dump, and unused TypeVars and helpers.
  • Repo files: removed .isort.cfg (isort isn't run), a stub uv.lock declaring requires-python >=3.14, and the Makefile publish target, which bypassed the gated release. Fixed the Makefile's .DEFAULT_GOAL, which pointed at a target that didn't exist. Fixed a .gitignore rule that did nothing.
  • The package author is Permit.io (support@permit.io) instead of an individual. The field is informational and does not affect publishing.
  • Version bumped to 3.0.0.

CVE gates

  • Dependency Audit runs Trivy over four resolved trees: runtime ceiling, runtime floor, runtime floor with pydantic held to 2, and dev.
    • A plain lowest-direct floor always lands on pydantic 1, so the third tree is the only scan of the lowest pydantic 2. Trivy treats pydantic 2.4.0 as fixed for CVE-2024-3772 and rates it MEDIUM, below the gate, so the offline test test_pydantic_requirement_allows_no_release_affected_by_cve_2024_3772 is what blocks pydantic 2.0–2.4.1; the tree gives visibility.
    • Two Trivy behaviours both pass silently on a scan that never ran, so both are handled explicitly. Trivy only understands == pins and keys on the filename requirements.txt, so the trees are compiled with uv pip compile. It also writes Results: null and exits 0 when it finds nothing to scan, so that case is detected and fails the gate.
    • The runtime floor is compiled on its own. When it was compiled together with dev deps, mypy pulled typing-extensions up and hid the version a consumer can actually get.
    • Blocks only on HIGH/CRITICAL advisories that have a fix. Advisories without a fix are still reported.
    • Posts a sticky PR comment and GitHub annotations (the only channel that reaches fork PRs).
    • The audit job is read-only. The comment is posted from a separate job, so PR-authored setup.py code never runs in a job that holds a write token.
  • Release now runs as build → scan → publish with hard needs: edges, so publish can't run unless the scan passed. The gate covers the runtime trees only.
  • Weekly cron (Mondays 09:00 UTC) posts the actual findings to Slack: packages, counts and upgrade targets. It warns and skips if SLACK_WEBHOOK_URL isn't set.
  • pip-audit audits each of the four compiled trees directly (--no-deps --disable-pip), alongside Trivy. It only reports and never blocks, because it gives no severity. If it cannot check a tree, the PR comment, the Slack message and the job summary say so.
  • Dependabot: weekly, with 7/14-day cooldowns. versioning-strategy: increase is required. With a setup.py present, Dependabot's default widen would never raise a >= floor.

Workflow hardening

  • zizmor: 48 findings (12 HIGH) down to 0. actionlint is clean.
  • Every action is pinned to a SHA, and each SHA was checked against the GitHub API.
  • persist-credentials: false everywhere, least-privilege permissions:, template injection removed, and the release-tag validation now checks the whole string.
  • Every action runs on Node 24: upload-artifact v7.0.1, download-artifact v8.0.1 and slack-github-action v4.0.0. The pre-commit job runs pre-commit directly, because the latest pre-commit/action release pins a Node 20 cache action.
  • Every job runs on ubuntu-24.04 instead of ubuntu-latest, which moves to Ubuntu 26 in October.
  • The audit script tests have their own pytest.ini, so the SDK's pytest settings don't apply to them.
  • Schema Drift (PER-16334). .github/scripts/check_schema_drift.py generates models from https://api.permit.io/v2/openapi.json with the pinned generator and compares them with permit/api/models.py by structure: classes, fields, types, required or optional, defaults, aliases, Config.extra and enum members.
    • Exit 1 means a difference that makes the SDK send what the API rejects, or reject what it returns. A class or optional field the SDK lacks is listed but does not fail.
    • Known differences live in .github/scripts/schema_drift_allowlist.json, one reason each (22 today). An entry that no longer matches fails until it is removed.
    • Exit 2 means the comparison did not run (download failed after two retries, generator failure, any other error). It is never reported as clean.
    • .github/workflows/schema-drift.yml runs weekly, on manual dispatch, and on pull requests that change the models, the script, the allowlist or the workflow. It is not a required check. A scheduled run that does not pass posts counts and a link to Slack; a manual run always posts.
    • The required Audit Script Tests job also runs its 45 unit tests, including one that keeps the script's generator flags identical to the Makefile's.
  • make generate-models pins its generator: datamodel-code-generator 0.33.0, the release that built models.py, with --exclude-newer 2025-09-18T00:00:00Z and Python 3.11, run through uvx, so contributors need uv.
  • Deleted release.yml. It ran on release: created while python-sdk-publish.yml ran on published, so every release uploaded the same version twice.
  • The PDP now starts as a CI step instead of a service container. A service container starts before any step runs, so it could only be given PROJECT_API_KEY. The tests authenticate with the per-run environment key, the PDP rejected every decision with a 403, and that is why the RBAC/ReBAC decision tests could never pass.

Test suite

  • All 8 xfail markers removed. Those tests now run and pass.
  • The e2e tests are isolated from each other. They used to fight over fixed keys (admin, viewer, a shared urn), assert environment-wide counts, and fail the test when cleanup got a 404. Each test now uses unique keys, asserts only on its own objects, and tolerates "already gone" during teardown.
  • Rate limiting (HTTP 429) is handled in conftest for the test session only. Requests retry with backoff, honour Retry-After, and add jitter. The xfail markers had been hiding these 429s. The SDK itself doesn't retry: adding hidden retries to a published client would change behaviour callers never asked for.
  • test_bulk_operations fixed. It expected a role assignment to survive deleting the user who owns it.
  • httpserver_listen_address lives in conftest.py and binds a free port. Its port used to depend on which test file pytest collected first, and parallel local runs collided.
  • Sync/async parity (PER-12884). tests/test_fix_sync_parity.py walks permit.Permit and permit.sync.Permit and fails if a sub-API or method exists only on the async client, if something callable there isn't callable on the sync one, or if anything reachable from the sync client is a coroutine function. It replaces a hard-coded list of five names.
  • New offline coverage: resource_actions and resource_action_groups (async and sync, every method), all 21 deprecated facade methods (request, result and 4.0 warning), and the audit-log models.
  • e2e marker. Tests that need credentials, the API or a PDP are marked e2e, so pytest -m "not e2e" runs everything else with no setup. The compatibility job selects tests this way instead of by file name.
  • An invite test's cleanup deletes its resource instance by resource:key instead of leaking it.
  • Shared offline helpers. tests/utils.py holds the offline PermitConfig and request-capture helpers that the offline test files share.
  • Offline regression tests (PER-16333), so every bug class the end-to-end checks detect also fails CI without a backend:
    • request bodies keep every key and each value's JSON type (bools, ints, whole floats, nulls, unicode) under both pydantic majors, for 8 models including ResourceCreate and RelationshipTupleCreate; users.update sends a field set to None as null;
    • with proxy_facts_via_pdp, each single-object write (users.create, tenants.create, resource_instances.create, relationship_tuples.create, role_assignments.assign, users.assign_role) goes to its own PDP /facts/... route;
    • users.get keeps each attribute's JSON type and its nulls;
    • no SDK module imports the top-level pydantic namespace outside its pydantic 1 branch;
    • get_user_permissions unwraps both PDP response shapes; projects.create with an environment key is refused before any request; delete_tenant_user, environments.copy and user_invites.get send the documented request, and an unknown invite raises a 404 PermitApiError.

Architectural changes

No architectural change to the SDK. The release job graph changes:

flowchart TD
  subgraph After["After: publish is unreachable without a passing scan"]
    B2["build: version, sdist and wheel"] --> S2["scan: compile trees, Trivy, gate"]
    S2 --> P2["publish: PyPI"]
  end
  subgraph Before["Before: two workflows raced"]
    R1["release.yml on 'created'"] --> PY1["twine upload"]
    R2["python-sdk-publish.yml on 'published'"] --> PY2["pypi-publish"]
  end
Loading

How it was tested

CI: all 26 checks are green:

  • 308 passed, 7 skipped on both required pydantic legs. At the start of this PR it was 45 passed with 8 permanently xfail.
  • All 16 compatibility legs (Python 3.10–3.14: lowest dependencies, lowest pydantic 2, newest dependencies, plus 3.14 on pydantic 1) are green, with 291 offline tests each.

The 7 skips:

  • Three cloud-PDP error tests. CI never reaches the cloud PDP, so they skip with a stated reason.
  • The decision assertions in test_abac_e2e, waiting on PER-16209. The control-plane half of that test still runs.
  • Three tests of the pydantic 1 deprecation warning that apply only to the other pydantic major.

End-to-end harness (internal) against a local Permit stack with a real permitio/pdp-v2: 58 passed, 0 failed, 0 skipped, with 95 data-integrity round-trips and 0 differences.

Against the API: every wire-affecting change was checked against the API's route and request/response definitions. Every one was safe.

Offline:

  • 291 offline tests (pytest -m "not e2e"), green on pydantic 1.10.26 and 2.13.5, on every Python from 3.10 to 3.14, and on the pydantic 2 floors (2.4.2 on 3.10–3.12, 2.8.0 on 3.13). Checked that they're real: reverting permit/ makes them fail.
  • A consumer fixture type-checks with mypy --strict as part of the suite, and also passes pyright strict against the installed wheel. Each typing fix was mutation-checked: undoing any one of them makes the fixture fail.
  • 106 tests for the CI scripts: the audit report renderer and the schema-drift check.
  • 86 tests for the migration guide and skill, in skills/tests, green in the Migration Skill Tests job on pydantic 1 and 2.
    • The one skip is the skill-creator validator, which runs only where skill-creator is installed. An always-on test checks the same frontmatter rules.
    • The job also runs the scanner on Python 3.9.
    • The scanner finds every expected site in a 2.x sample app, and reports nothing in the same app migrated to 3.0.
    • Aliases, scopes and locks are handled.
    • The guide's before/after snippets run against 3.0 over a local HTTP server.
    • The documented -W commands run in a subprocess.
    • Every change ID, floor table and the 21-method mapping match across MIGRATION.md, changes.md, the scanner and the SDK.
    • Mutation-checked: 25 targeted breaks, each caught.
  • The scanner gives identical output on Python 3.8, 3.9 and 3.13.
  • An agent followed the skill end to end on a sample 2.x customer app, using only the skill folder: scan, dependency update, edits, then the app's own checks on 3.0. The first run found gaps, which were fixed. On the final skill, the app's 16 tests pass, mypy is clean, and the deprecation-warnings-as-errors run passes.
  • The schema-drift check passes against the live API schema, and the Schema Drift check is green on this PR.
  • 24 request bodies are byte-identical across both pydantic majors.

The gate itself: it fails (exit 1) on the old vulnerable floor and passes (exit 0) on the fixed one.

The weekly audit: a manual run of the Security workflow on this branch finished with no warnings; pip-audit checked all four trees and the Slack message was posted.

Manual test plan

  1. Confirm Dependency Audit posts a sticky comment on this PR.
  2. Push a commit setting aiohttp>=3.12.14,<4. The check should go red and the comment should list CVE-2026-69244. Revert it.
  3. Run gh workflow run security.yml --ref <branch> to run the weekly audit on demand; it posts to Slack.
  4. In a scratch project, pip install this branch and run mypy --strict on code that uses permit.Permit and permit.sync.Permit. Expect no errors, and sync calls typed as their results rather than coroutines.
  5. Publish as 3.0.0 (a major release). The release notes need everything in the breaking-changes section above.

Blast radius and isolation

  • Blast radius:
    • consumers on Python 3.8/3.9, or pinned below the new dependency floors;
    • consumers who type-check against permit;
    • consumers of the API changes above;
    • CI for every future PR;
    • the release pipeline.
  • Isolation: isolated.

Follow-ups

Already applied outside the diff: secret scanning with push protection, Dependabot security updates, and Dependency Audit / Audit Script Tests / Workflow Hardening added as required checks on main.

Still open:

  • PyPI Trusted Publishing. A publisher has to be registered on PyPI before the workflow can switch over.
  • PER-16209
  • PER-16177: the ABAC decision tests that still skip.
  • PER-16236: native pydantic 2 models. Recent FastAPI rejects pydantic.v1 models as request or response bodies.

Scope and size

  • SDK runtime: ~1,850 lines added, about 630 of them the mechanical keyword-default rewrite of the generated models. The generated 2,351-line permit/_sync_types.pyi comes on top.
  • SDK tests: ~5,600. CI workflows and scripts: ~2,730. Their tests: ~1,100.
  • Migration guide and skill: MIGRATION.md ~560 lines, the skill ~2,500 (its scanner ~1,970), their tests ~1,740 plus two small sample apps.
  • Single responsibility: no. This combines the CVE fixes, the CI gates, the 3.0.0 correctness work and the open SDK tickets. Kept as one PR for speed, then widened to a major version.

🤖 Generated with Claude Code

The resolved dependency tree was clean, but the published `>=` floors let a
consumer install versions carrying 34 known advisories. Because this package
ships open ranges with no lockfile, the floor is the real exposure -- so the
scan covers both the current resolution and the lowest versions the specs
permit.

Dependency fixes:
- aiohttp >=3.14.3 (clears 32 advisories, incl. CVE-2026-69244, an
  out-of-bounds heap read in the HTTP response parser this client exercises
  on every call)
- pydantic >=1.10.13 (CVE-2024-3772, EmailStr ReDoS; the SDK uses EmailStr)
- werkzeug >=3.1.6, pytest >=9.0.3
- drop httpx: never imported, and the only path by which h11
  (CVE-2025-43859, CRITICAL) and anyio entered the tree
- drop zipp and aioresponses: both unused, and aioresponses 0.7.9 is
  incompatible with aiohttp 3.14.3
- python_requires >=3.10; the declared >=3.8 was already unachievable

Gates:
- Trivy over three trees (runtime ceiling, runtime floor, dev), sticky PR
  comment, blocking on fixable HIGH/CRITICAL only
- release split into build -> scan -> publish, so publish is unreachable
  unless the scan passed
- weekly cron posting the findings themselves to Slack, not just a verdict
- Dependabot with cooldowns and versioning-strategy: increase
- delete release.yml, which raced python-sdk-publish.yml on every release
- existing workflows hardened: 48 zizmor findings (12 high) to zero

Also fixes 10 minor SDK bugs with 33 offline regression tests. Nine major
correctness bugs found along the way are tracked in PER-16174 rather than
changed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

zeevmoney and others added 3 commits September 21, 2026 17:25
…endent

pytest_httpserver's `httpserver` fixture is session-scoped: the first test
that requests it binds the one shared server for the entire run. The address
override lived in test_rbac_e2e.py, so it only applied when that module
happened to touch the fixture first.

Adding tests/test_offline_regressions.py broke that assumption -- it sorts
earlier, claimed the session server on a random port, and test_api_timeout
and test_pdp_timeout then failed against their hardcoded localhost:9999 with
"Cannot connect to host".

Moving the fixture to conftest.py makes the address apply session-wide and
removes the latent ordering dependency, which any future test using
httpserver would otherwise have tripped over too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zeevmoney

zeevmoney commented Sep 21, 2026 •

Copy link
Copy Markdown
Author

Update (2026-09-25): resolved. test_bulk_operations now passes. Commit 1e6b9e6 corrected the test: its expectation was wrong, because deleting a user also deletes that user's role assignments, so the count returns to the original. All CI checks on this PR are green. The rest of this comment is kept as the original record.

The one remaining red check is pre-existing — here is the proof

tests/endpoints/test_bulk_operations.py::test_bulk_operations fails on this branch. I did not want to hand that over as an unexplained red check, so I isolated it rather than assert it.

Experiment: pushed commit 160f129, which reverted only permit/ back to origin/main while keeping the CI and test changes. Reverted in f9b4857.

Result — with the SDK code identical to main, it still fails identically:

FAILED tests/endpoints/test_bulk_operations.py::test_bulk_operations - assert 0 == (0 + 1)
 +  where 0 = len([])

Corroborated independently: the same test also fails against a local Permit backend + PDP stack built during this work — a completely separate control plane from the shared CI project.

Why it fails. The assertion at line 227 expects a role assignment to survive the deletion of the user who owns it:

await permit.api.users.bulk_delete([user.key for user in CREATED_USERS])
assignments = await permit.api.role_assignments.list()
assert len(assignments) == len_assignments_original + 1  # (tenant role)

The surviving +1 is RoleAssignmentCreate(user=USER_A, role=ADMIN, tenant=TENANT_1). Line 218 asserts the same thing after deleting resource instances and passes, so exactly one assignment exists at that point. users.bulk_delete then removes USER_A, and their tenant role goes with them — leaving 0. The test encodes an assumption that a user's role assignment outlives the user, which the backend does not honour. Nothing in this diff touches users.bulk_delete, role_assignments.bulk_assign or role_assignments.list.

Worth fixing separately — either the test's assumption or the cascade behaviour. Not folded into this PR, which is already larger than it should be.

A useful side effect of the same experiment

With permit/ reverted, the new regression tests failed, which is what should happen:

FAILED test_resource_instances_list_sends_detailed_filter_as_query_string
  - TypeError: Invalid variable type: value should be str, int or float, got True of type <class 'bool'>
FAILED test_users_sync_does_not_mutate_the_caller_dict
  - AssertionError: assert {'email': 'not-an-email'} == {'key': 'user...

So the tests genuinely catch the bugs they target rather than passing vacuously.

Two regressions I did introduce, and fixed

Adding tests/test_offline_regressions.py broke test_api_timeout and test_pdp_timeout. pytest_httpserver's httpserver fixture is session-scoped — the first test to request it binds the one shared server — and the address override lived in test_rbac_e2e.py, so it only applied if that module got there first. The new file sorts earlier, claimed the server on a random port, and those two then failed against their hardcoded localhost:9999. Fixed in e5a88c1 by moving the fixture to conftest.py, which also removes the latent ordering dependency any future test would have tripped over. Both now pass (47 passed, up from 45).

zeevmoney and others added 11 commits September 22, 2026 12:40
get, get_by_key, update and delete all interpolate their argument straight
into the path, and the backend validates it with
validate_resource_instance_ident(instance_id, allow_uuids=True) -- a bare
instance key is rejected with a 422, not accepted. The docstrings said "the
key of the resource instance", which sends callers straight into that error.

Wording matches what bulk_delete already documented correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bumps to 3.0.0 and fixes the nine major bugs tracked in PER-16174, so the
eight permanently-xfail tests can assert for real.

Sync client (permit/utils/sync.py, permit/sync.py):
- SyncClass is now idempotent. It was inherited, so a subclass re-wrapped
  methods its base had already converted, giving async_to_sync(async_to_sync(f));
  all 21 deprecated-facade methods raised "a coroutine was expected" before
  issuing a request.
- Coroutine detection uses inspect.iscoroutinefunction and unwraps
  functools/validate_arguments wrappers, instead of assuming every object whose
  class is named "function" is async.
- permit.sync.Permit now overrides authorized_users, get_user_permissions and
  filter_objects, which were inherited as `async def` over a synchronous
  enforcer and returned un-awaitable coroutines.

Enforcement (permit/enforcement/):
- parse_obj_as is imported through the pydantic v1/v2 guard the rest of the
  package uses; authorized_users() could not return at all under pydantic v2.
- bulk_check honours a per-check context and filter_objects forwards the
  caller's context. It was silently dropped, so context-dependent ABAC
  evaluated against {} and could return the wrong subset.
- UserInput accepts snake_case as well as the camelCase aliases; first_name
  and last_name were silently discarded from every check.

Serialization (permit/api/base.py):
- dict and list bodies go through the encoder, so nested datetime/UUID/Enum
  no longer dies inside aiohttp.
- exclude_none is dropped, so an explicitly-set None is transmitted as null
  and an update can clear a field. exclude_unset still omits untouched fields.

Facts proxy (permit/api/tenants.py):
- tenants bulk operations addressed the PDP's users endpoint.

tests/endpoints/test_bulk_operations.py asserted that a tenant role assignment
outlives the user who owns it; deleting the user removes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The un-xfailed tests all run against one shared environment and were fighting
each other: fixed keys (admin, viewer on the built-in __tenant resource), a
shared resource urn, assertions on global object counts, and teardown that
called pytest.fail on a 404 so "already deleted by another test" turned a
passing test red. Several also leaked every object they created.

Each test now derives its keys from tests/utils.unique_key, asserts against
its own objects rather than environment-wide counts, tears down in a finally
via handle_cleanup_error, and polls with a bounded retry where it waits for a
fact to reach the PDP. Verified by running twice in a row against a
deliberately dirty local environment.

test.yml starts the PDP as a step rather than a service container. A service
container is created before the first step runs, so it could only be given the
long-lived PROJECT_API_KEY while the tests authenticate with the per-run
scratch environment key. The PDP rejected every decision with a 403, which is
why the ReBAC and RBAC decision tests could never pass.

That 403 also surfaced as "cannot connect to the PDP container": the enforcer
read error bodies with response.json(), and the PDP sends auth rejections as
plain text, so ContentTypeError -- an aiohttp.ClientError -- was caught by the
connectivity handler and the real status was lost. Error bodies are now read
without assuming JSON, and the message names the status and body.

tests/test_abac_pdp.py's three cloud-PDP tests now skip with a reason instead
of failing: as CI is configured they never reach the cloud PDP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PDP reports 503 on /healthy until its horizon component finishes pulling
config and a policy bundle. Waiting for it immediately after docker run made
that bootstrap serial with the job; one leg was ready in 29s and the other
still was not at 60s. The wait now happens after dependency installation, so
the bootstrap overlaps with it, with a 180s ceiling.

Changing an ABAC condition set makes the policy generator recompile the
environment's rego and redistribute the bundle, which is much slower than the
fact sync RBAC uses. test_abac_e2e timed out at 90s against the real cloud PDP;
raised to 300s. The poll returns as soon as the rule lands, so a healthy run is
no slower.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
setup.py used a bare find_packages(), which ships a TOP-LEVEL `tests` package
into every consumer's site-packages where it shadows their own `tests` module.
Verified against the published permit==2.8.3, which does exactly that. Now
excluded, along with `harness`.

permit.pdp_api never passed a timeout to its HTTP client, so the documented
pdp_timeout was silently ignored on every permit.pdp_api.* call while the
enforcer honoured it. It also duplicated ClientConfig and pagination_params
verbatim from permit.api.base; it imports them now.

Removed, none of which had a single caller in permit/, tests/ or harness/:
  set_if_not_none (enforcer), OpaResult and the JWT alias (interfaces),
  ApiKeyLevel (a self-declared deprecated alias of ApiKeyAccessLevel),
  LoginAsErrorMessages (never compared against or returned), and three unused
  TypeVars in the PDP base module.

_model_dump was defined identically in both arms of the pydantic version
split; hoisted to one definition. Its `mode` parameter stays and stays
ignored on purpose -- it absorbs a v2-style argument that pydantic v1's
.dict() would reject.

Repo cruft: .isort.cfg (isort is not run; ruff's I rules are), uv.lock (a
three-line stub declaring requires-python >=3.14, contradicting setup.py),
the Makefile publish target (a second release path that bypasses the gated
build -> scan -> publish workflow) and a .DEFAULT_GOAL pointing at a help
target that did not exist. .gitignore's .DS_Store rule was inert because of
an inline comment.

Dependencies: dropped pytest-mock (no test uses it) and pytest-cov (coverage
is never requested, including in CI). Corrected the werkzeug comment -- it is
now a direct test import, not just a pytest_httpserver transitive.

Also dropped two references to .trivyignore, which audit-deps.sh deliberately
disables with --ignorefile /dev/null, so both were advertising a suppression
mechanism that does not work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The condition sets and rule this test creates never reach the PDP's policy
bundle, so the decision it waits for never becomes true. The PDP says so in
the debug.abac payload the SDK already logs: ~90s of no_matching_usersets
with "known usersets: ['rules']" (the empty-package placeholder), then one
bundle carrying only the condition sets autogenerated by the resource and
role creates ten seconds earlier, then nothing for the remaining 300s. The
data channel stayed healthy throughout.

The pipeline is event-driven with no polling fallback (the default scope is
created with poll_updates=False and batching drains rather than waits), so
this is a stall, not slowness, and no timeout makes it pass. Skipped rather
than xfailed so it reports honestly instead of looking like coverage.

Only the three decision assertions are skipped. Everything above them still
runs against the real control plane -- condition set and rule create, type
round-trip, paginated list, filtered list, permission-format assertion -- and
so does the teardown, because pytest.Skipped derives from BaseException and
escapes the test's except Exception.

Ruled out as causes: resource_id passed as .hex (the generator keys on the
resource key, never the id), inline check attributes (they win the
object.union_n in the generated rego and the PDP echoed them back), and a
missing setup step.

No other test is exposed: condition_set_changes.py is the only policy
synchronizer handler that generates rego, so RBAC and ReBAC decisions resolve
against data.* on the fact channel, and this is the only test that touches
condition sets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
resource_relations.list() declared List[RelationRead], but the route is
declared response_model=PaginatedResult[RelationRead], so against current
backend main the call raised "ValidationError: value is not a valid list" --
the method was unusable. It now returns PaginatedResultRelationRead; callers
read .data. BREAKING, and in the 3.0.0 notes.

(That change was written earlier and swept into the previous commit by a
bare `git add -A`; this records what it actually is.)

Two docstrings corrected against the backend, both of which sent callers into
a confusing error:

- resource_roles.assign_permissions/remove_permissions said permissions are
  <resourceKey:actionKey>. A resource role is scoped to its own resource, so
  each entry is a BARE action key. Passing the qualified form makes the server
  read the whole string as an action key and reject it with a 404 naming
  '<resource>:<resource>:<action>' -- a doubled prefix that reads like the SDK
  concatenated wrongly, when it is the server quoting what it was given.

- role_assignments.list(resource_instance_key=...) takes a
  `resource_type:instance_key` ident or an instance uuid, never a bare key.

Regression tests pin the exact wire strings on both pydantic majors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Every remaining CI failure was one cause: HTTP 429 on a cleanup call. Enabling
the eight previously-xfail tests and giving each its own objects made the suite
create and tear down far more than before, and teardown is where the burst
lands -- one leg reported 3 failed and 2 teardown errors, the other 7 failed,
all of them 429 on a delete.

handle_cleanup_error now tolerates 429 alongside 404, for the same reason 404
is tolerated: neither leaves the test's assertions in doubt. A throttled delete
leaks an object, and CI deletes the whole scratch environment afterwards, so it
is reclaimed. Any other status still fails the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The previous commit tolerated 429 during teardown. That was wrong in a way the
next CI run made obvious: a tolerated DELETE leaves the object alive, so the
assert-it-is-gone check that follows failed with "DID NOT RAISE
PermitApiError". The tolerance manufactured a worse failure than the one it
hid. 429 is no longer tolerated.

It was also the wrong layer. The run after showed 429 arriving in test BODIES
as well -- test_rebac_e2e, test_sync_client and test_user_invites_complete_e2e
all failed mid-test -- so cleanup was never the whole problem. The suite runs
against one environment on a shared cloud project and now creates and tears
down considerably more than it used to, which exceeds the burst limit. The
eight tests that were xfail until this branch had been swallowing these 429s
all along.

conftest wraps the SDK's five HTTP verbs for the test session only, retrying a
429 with exponential backoff so the call actually succeeds. The SDK is
untouched: adding implicit retries to a published client would be a behaviour
change callers did not ask for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Six attempts (~63s of backoff) still ran out on one teardown, leaving CI at
1 failed / 102 passed. Raised to nine, which caps a single call at roughly two
minutes of waiting and exits the moment it succeeds.

Also honours the server's Retry-After when it sends one, and adds jitter to
the exponential fallback so concurrent callers do not retry in lockstep and
re-trip the limit together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
@zeevmoney zeevmoney changed the title Fix dependency CVEs and add blocking CVE gates on PRs, releases and a weekly scan permit 3.0.0: fix dependency CVEs, fix major SDK bugs, gate PRs and releases on CVE scans Sep 22, 2026
bulk_check() reads each query's context with .get(), so a query without
one is valid at run time, but the TypedDict declared the key as required
and mypy rejected every bulk_check([{"user", "action", "resource"}]) call.
TypedDict comes from typing_extensions so NotRequired is honoured on 3.10.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
zeevmoney and others added 9 commits September 23, 2026 14:41
The REST API client, the PDP API client and the enforcer sent
"bearer <token>". The scheme is case-insensitive per RFC 7235, but
"Bearer" is the canonical form every other Permit SDK sends, and at least
one server once rejected the lowercase form with a 401. A facade-level
offline test now reads the header each client actually puts on the wire.

Co-authored-by: Suren <suren@cercli.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
On Python 3.14, pydantic 1.x before 1.10.25 and 2.x before 2.13 crash on
import permit ("unable to infer type for attribute"), so the pydantic
requirement is split by Python version and excludes those releases there.
pydantic 2.0 is excluded everywhere: its pydantic.v1.parse_obj_as rejects
the SDK's __root__ models, failing every parsed API response.

The typing-extensions and loguru floors could not import on current
Pythons (typing-extensions before 4.6 breaks on 3.12+, before 4.12 on
3.13+, 4.12-4.13 lose TypedDict keys on 3.14; loguru before 0.7.3 warns on
3.14), so they rise to 4.14.0 and 0.7.3. deprecation.py uses
inspect.iscoroutinefunction instead of the asyncio one 3.16 removes, and
the pydantic version parser accepts pre-releases such as 2.14.0b2, which
crashed the import.

A new compatibility CI job runs the offline suite on Python 3.10-3.14 at
both the lowest allowed and the newest dependency versions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
permit now declares itself typed, and type checkers see what actually
runs: the SDK models are typed as the pydantic.v1 models they are on both
pydantic majors (TYPE_CHECKING import branches, pydantic.v1.mypy plugin),
generated model defaults are keyword arguments so optional fields no
longer read as required, API methods that accept dicts at runtime accept
them in their annotations (typing-only ModelInput/ModelListInput, runtime
validation unchanged), and the sync client is typed as synchronous through
a generated stub (permit/_sync_types.pyi, with a drift test).

The pre-3.14 pydantic floor rises to 1.10.18: 1.10.17 is the first release
with the pydantic.v1 package, and 1.10.13-1.10.17 emit about 2,400
DeprecationWarnings on Python 3.13. A consumer fixture is type-checked
with mypy --strict in the test suite on every CI leg, and the release and
compatibility builds assert the wheel ships py.typed and the stub.

Runtime behaviour is unchanged: a snapshot of every public name,
signature, validate_arguments model and model field matches the previous
commit on both pydantic majors.

Co-authored-by: Tarcio Silva <luan.coc13@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Under pydantic 2, permit validates emails with the pydantic.v1 copy that
pydantic bundles. That copy is fixed for CVE-2024-3772 (ReDoS in email
validation) only from pydantic 2.4.2, which bundles 1.10.13: 2.0.1
bundles 1.10.11, and 2.4.0 and 2.4.1 bundle 1.10.12. Below Python 3.14
the spec still allowed 2.0.1-2.4.1.

The pre-3.14 requirement is now two lines. Python 3.10-3.12 allow
pydantic 2 from 2.4.2. Python 3.13 allows it from 2.8.0, because
2.4.2-2.7.x pin a pydantic-core with no Python 3.13 wheels. The pydantic
1 floor (1.10.18) and the 3.14 line are unchanged.

Nothing resolved the pydantic 2 floor before: lowest-direct over
requirements.txt picks pydantic 1, so the floor CI legs and the audit's
runtime-floor tree only ever saw 1.10.18, and Trivy treats 2.4.0 as
fixed. A pydantic-v2-floor compatibility leg on every Python and a
runtime-floor-pydantic-v2 audit tree now resolve lowest-direct with
pydantic held to >=2, and every format_audit.py call reads the new tree.

Every setup-uv step pins uv 0.12.18, so a uv release cannot change which
floor is tested or scanned.

The offline tests check, per Python, that no allowed pydantic is affected
by the CVE and that each major is allowed from its floor up.

Part of PER-16176.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The comment claimed py.typed and _sync_types.pyi ship only because
package_data lists them. setuptools 69 and later include them by default;
68.2.2 does not. The project has no [build-system] table, so a build can
still run with an older setuptools, which is what package_data guards
against.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The docstrings in tests/test_fix_permissions.py and
tests/test_fix_relations.py now state what the API does: how it reads a
role's permission strings, which resource_instance filter values it
rejects, and the paginated envelope the relations list returns. They no
longer point at server source files. The Dependabot cooldown comment no
longer names a policy kept outside this repository.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
PYDANTIC_CANDIDATES is now built by explicit loops instead of a
triple-nested comprehension. The list is unchanged (931 entries).
audit-deps.sh no longer runs mkdir -p on the output directory before
writing the pydantic constraint file: compile_tree has already created
it at that point.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
zeevmoney and others added 12 commits September 27, 2026 19:13
A requirements.txt produced by pip-compile or `uv pip compile` was read
as the project's own declarations. `httpx==0.28.1  # via permit` then
hid the C2 finding for `import httpx`, and permit's pin came back as a
hand edit. After the lock was regenerated for 3.0, httpx was gone and a
fresh install failed on the import.

The scanner now recognises such a file by its header or `# via` lines.
It records only the permit pin, as a lock to regenerate; its other pins
count as neither declarations nor C3 floors. The skill says to
regenerate compiled files rather than edit them and to re-scan after
regenerating. Project.scan() also resets its state, so calling it twice
no longer duplicates the summary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011amjNR48yL6inEFkjoUA1S
When a log has no objects, DetailedAuditLogModel.objects is the field's
default: an empty dict, not an AuditLogObjectsModel and not None. The
`is not None` guard the guide recommended lets it through, and the next
attribute read raises AttributeError.

The guide, the catalogue and the scanner message now say so and
recommend isinstance(log.objects, AuditLogObjectsModel). The scanner no
longer treats `is not None` as a guard for objects; it accepts
isinstance() and truthiness checks, and isinstance() now counts as a
guard for the other optional fields too. A test pins the field's
default, so the docs fail loudly if the SDK changes it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011amjNR48yL6inEFkjoUA1S
Resolving permit 2.8.3's requirements against 3.0.0's shows more
packages leaving the tree than C2 named: certifi on every Python,
exceptiongroup on Python 3.10, and sniffio with httpx before 0.28 or
older anyio releases. Code that imports certifi only because httpx
brought it, for an aiohttp SSL context say, then fails with ImportError.

The scanner reports those imports as C2 NEEDS-REVIEW, and each message
now says how permit 2.x installed the package (zipp was declared, not
brought by httpx). The guide, the catalogue and the skill list the same
packages, and a test ties the scanner's set to both docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011amjNR48yL6inEFkjoUA1S
The scanner followed client annotations but not model ones, so
`def dump(user: UserRead): return user.model_dump()` went unreported
although mypy rejects it on permit 3. A parameter or variable annotated
with a model class from permit or permit.api.models, alone or as
Optional[...] or `X | None`, now counts as an SDK model. A union with
another type, or a container of models, does not.

The sample apps gain that case, and optional_annotation() now shares
the union parsing with the new check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011amjNR48yL6inEFkjoUA1S
permit 2.x warned "use permit.api.users.get() instead"; 3.0 warns
"permit.api.get_user() is deprecated and will be removed in permit
4.0; ...". Warning filters match from the start of the message, so a
filter written for the old text silences nothing in 3.0 and is dead
configuration once the calls are migrated. The scanner never pointed
at one.

It now reports D2 NEEDS-REVIEW for pytest filterwarnings and -W
settings in pyproject.toml, setup.cfg, tox.ini and pytest.ini, and for
strings in Python code, whose message starts `use permit.api` or
`use permit.elements`. Filters for the 3.x text, and the 3.x message
itself, are not reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011amjNR48yL6inEFkjoUA1S
The guide, the catalogue and the scanner said ContextStore.transform()
returned the context unchanged. It applied every function registered
with register_transform() when the project's own code called it; the
SDK itself never called it, which is why a registered transform never
affected a check. Both docs and the scanner messages now say so, and
tell the project to call its registered functions directly where it
called transform().

The scanner also follows star imports to removed names, such as
ApiKeyLevel after `from permit.api.context import *`. The catalogue's
list of what the scan cannot see adds the Python-specific pydantic
floors and warning settings outside pytest's configuration.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011amjNR48yL6inEFkjoUA1S
The approved workflow stops when the project's Python is below 3.10.
The skill stopped only if the project "must keep running" on 3.8 or
3.9, and otherwise left raising the Python floor as a step 5 review
item, after it had already changed the dependencies and applied the
SAFE edits. An agent on a 3.9 project carried on.

Step 1 now stops, edits nothing and explains the stay-on-2.x path
whenever the runtime or any C1 pin is below 3.10, and continues only
once the user confirms that every place the project runs is on 3.10+.
Step 3 then raises the approved pins. The skill also removes imports
its edits leave unused and runs the project's linter. Tests pin these
rules.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011amjNR48yL6inEFkjoUA1S
- Who must act: W1 covers None in create, sync and update calls, as a
  model field or a dict value, and W1 names users.sync().
- Before you upgrade: the scanner path assumed a clone of this
  repository. It now points at the installed skill.
- C1 and Staying on 2.x: aiohttp 3.14.3 and anyio 4.14.2 are the first
  fixed releases, not the only ones.
- T1: type checkers reject a nested dict in a model constructor.
- Other fixes: the blocking client's flat permit.api methods sent their
  request and then raised ValueError, so writes took effect; tenant bulk
  operations with proxy_facts_via_pdp use /facts/bulk/tenants;
  resource_instances.list(detailed_key=...) no longer raises; every
  non-200 PDP response carries its status and body. The catalogue lists
  the same fixes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011amjNR48yL6inEFkjoUA1S
Eleven mutations to the docs and the scanner survived the migration
tests. New tests guard what they changed:

- The guide's diffs run on 3.0 against a local HTTP server: A1, A2, A3,
  A4, A5, A6, T2, W1, W5 and D2. The after side works, the before side
  fails the way the guide says, and the D2 replacement sends the same
  requests as the deprecated calls.
- The documented `-W` pytest runs, the narrow `error:permit.api.` form
  and the in-code ignore filter each behave as described.
- The floor tables in both docs, and the scanner's floors, match
  requirements.txt on every supported Python.
- The A3 and A6 tables in both docs match the scanner's removed names
  and their safety. The catalogue's A6 table now lists each module's
  names exactly instead of a cross product.
- The catalogue states, per change, the safeties the scanner reports,
  and never calls an untraced receiver SAFE.
- Both Staying on 2.x sections keep the verified facts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011amjNR48yL6inEFkjoUA1S
The skill's step 5 still said a ContextStore transform "was never
applied"; it now says what the catalogue says: the SDK never applied a
registered transform to a check, and a direct transform() call did.
The guide's "Who must act" list and checklist named only httpx for C2;
they now point at the other packages C2 lists too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011amjNR48yL6inEFkjoUA1S
asyncio.run() given a result instead of a coroutine raises ValueError
before Python 3.14 and TypeError from 3.14 on. The test of the guide's
A2 diff expected ValueError only, so it failed on 3.14.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011amjNR48yL6inEFkjoUA1S
Importing a fixture module by hand leaves a __pycache__ directory that
git rightly ignores, and the test then failed locally. It now checks
every fixture file except those caches.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011amjNR48yL6inEFkjoUA1S
Copilot AI review requested due to automatic review settings September 27, 2026 16:41
@github-actions

Copy link
Copy Markdown

Dependency Review

The following issues were found:
  • ❌ 1 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ✅ 0 package(s) with unknown licenses.
See the Details below.

Vulnerabilities

tests/migration_fixtures/v2_app/pyproject.toml

NameVersionVulnerabilitySeverity
aiohttp3.12.14AIOHTTP: Out-of-bounds heap read in C HTTP response parser error path (malformed chunked response)high
AIOHTTP's HTTP Parser auto_decompress feature is vulnerable to zip bombhigh
Only included vulnerabilities with severity high or higher.

OpenSSF Scorecard

Scorecard details
PackageVersionScoreDetails
actions/actions/checkout 3d3c42e5aac5ba805825da76410c181273ba90b1 🟢 6.6
Details
CheckScoreReason
Maintained🟢 79 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 7
Code-Review🟢 10all changesets reviewed
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Binary-Artifacts🟢 10no binaries found in the repo
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Pinned-Dependencies🟢 3dependency not pinned by hash detected -- score normalized to 3
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Packaging⚠️ -1packaging workflow not detected
Signed-Releases⚠️ -1no releases found
Security-Policy🟢 9security policy file detected
SAST🟢 10SAST tool is run on all commits
Branch-Protection🟢 5branch protection is not maximal on development and all release branches
actions/actions/setup-python 5fda3b95a4ea91299a34e894583c3862153e4b97 🟢 6.6
Details
CheckScoreReason
Maintained🟢 1016 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 10
Code-Review🟢 10all changesets reviewed
Binary-Artifacts🟢 10no binaries found in the repo
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Packaging⚠️ -1packaging workflow not detected
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
Pinned-Dependencies🟢 7dependency not pinned by hash detected -- score normalized to 7
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Security-Policy🟢 9security policy file detected
SAST🟢 9SAST tool is not run on all commits -- score normalized to 9
Branch-Protection⚠️ 0branch protection not enabled on development/release branches
actions/astral-sh/setup-uv c18668ad3cf93ea998bef934396af7bb5c839dc7 UnknownUnknown
pip/aiohttp >= 3.14.3,< 4 UnknownUnknown
pip/loguru >= 0.7.3,< 1 UnknownUnknown
pip/pydantic >= 1.10.18,< 2.0.* || > 2.0.*,< 2.1.* || > 2.1.*,< 2.2.* || > 2.2.*,< 2.3.* || > 2.3.*,< 2.4.0 || > 2.4.0,< 2.4.1 || > 2.4.1 UnknownUnknown
pip/pydantic >= 1.10.18,< 2.0.* || > 2.0.*,< 2.1.* || > 2.1.*,< 2.2.* || > 2.2.*,< 2.3.* || > 2.3.*,< 2.4.* || > 2.4.*,< 2.5.* || > 2.5.*,< 2.6.* || > 2.6.*,< 2.7.* || > 2.7.* UnknownUnknown
pip/pydantic >= 1.10.25,< 2.0.* || > 2.0.*,< 2.1.* || > 2.1.*,< 2.2.* || > 2.2.*,< 2.3.* || > 2.3.*,< 2.4.* || > 2.4.*,< 2.5.* || > 2.5.*,< 2.6.* || > 2.6.*,< 2.7.* || > 2.7.*,< 2.8.* || > 2.8.*,< 2.9.* || > 2.9.*,< 2.10.* || > 2.10.*,< 2.11.* || > 2.11.*,< 2.12.* || > 2.12.* UnknownUnknown
pip/typing-extensions >= 4.14.0,< 5 UnknownUnknown
pip/aiohttp 3.12.14 🟢 7.5
Details
CheckScoreReason
Code-Review🟢 6Found 7/11 approved changesets -- score normalized to 6
Maintained🟢 1030 commit(s) and 9 issue activity found in the last 90 days -- score normalized to 10
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Binary-Artifacts🟢 10no binaries found in the repo
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing🟢 10project is fuzzed
License🟢 10license file detected
Pinned-Dependencies⚠️ 0dependency not pinned by hash detected -- score normalized to 0
Branch-Protection⚠️ -1internal error: error during branchesHandler.setup: internal error: some github tokens can't read classic branch protection rules: https://github.com/ossf/scorecard-action/blob/main/docs/authentication/fine-grained-auth-token.md
Security-Policy🟢 10security policy file detected
Packaging🟢 10packaging workflow detected
SAST🟢 10SAST tool is run on all commits
Signed-Releases🟢 85 out of the last 5 releases have a total of 5 signed artifacts.
pip/permit >= 2.8,< 3 UnknownUnknown
pip/pydantic UnknownUnknown
pip/loguru 0.7.2 🟢 6.5
Details
CheckScoreReason
Code-Review🟢 4Found 8/19 approved changesets -- score normalized to 4
Maintained🟢 1016 commit(s) and 16 issue activity found in the last 90 days -- score normalized to 10
Security-Policy🟢 10security policy file detected
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
Binary-Artifacts🟢 10no binaries found in the repo
Pinned-Dependencies⚠️ 0dependency not pinned by hash detected -- score normalized to 0
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Branch-Protection⚠️ -1internal error: error during branchesHandler.setup: internal error: some github tokens can't read classic branch protection rules: https://github.com/ossf/scorecard-action/blob/main/docs/authentication/fine-grained-auth-token.md
SAST🟢 10SAST tool is run on all commits
Packaging🟢 10packaging workflow detected
pip/permit 2.8.3 UnknownUnknown
pip/typing-extensions >= 4.5,< 4.12 UnknownUnknown
pip/aiohttp >= 3.14.3,< 4 UnknownUnknown
pip/httpx >= 0.24.1,< 1 UnknownUnknown
pip/permit >= 3.0.0,< 4 UnknownUnknown
pip/pydantic UnknownUnknown
pip/loguru >= 0.7.3,< 1 UnknownUnknown
pip/permit >= 3.0.0,< 4 UnknownUnknown
pip/typing-extensions >= 4.14.0,< 5 UnknownUnknown

Scanned Files

  • .github/workflows/release.yml
  • .github/workflows/test.yml
  • requirements.txt
  • tests/migration_fixtures/v2_app/pyproject.toml
  • tests/migration_fixtures/v2_app/requirements.txt
  • tests/migration_fixtures/v3_app/pyproject.toml
  • tests/migration_fixtures/v3_app/requirements.txt

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Under their real names, GitHub's dependency graph read the 2.x sample
app's deliberately old pins (aiohttp 3.12.14) as this repository's
dependencies, so Dependency Review failed the pull request and
Dependabot would raise alerts for them. The tests now copy each sample
app to a temporary directory and restore the real names there, and a
test fails if a fixture file is stored under a manifest name again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011amjNR48yL6inEFkjoUA1S
Copilot AI review requested due to automatic review settings September 27, 2026 16:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The tests for MIGRATION.md, the skill and its scanner now live next to
the skill, apart from the SDK's tests. They have their own pytest.ini
and helpers, and run in a new Migration Skill Tests job on both
pydantic majors. That job also runs the scanner on Python 3.9. The
SDK's pytest.ini limits its suite to tests/.

skills/tests/README.md warns that the 2.x sample app pins old,
vulnerable versions on purpose and must not be installed or copied.
The Trivy steps skip skills/tests/fixtures.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011amjNR48yL6inEFkjoUA1S
Copilot AI review requested due to automatic review settings September 27, 2026 17:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The README, MIGRATION.md, the migration skill, the scanner's messages
and two test docstrings described what permit 4.0 removes in the present
tense, as if it were released. They now say it is a future major
release that will remove pydantic 1 support and the flat permit.api
methods.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoCAyNyBAmzgSkofgvKjEg
Copilot AI review requested due to automatic review settings September 27, 2026 19:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@zeevmoney
zeevmoney merged commit 99707ce into main Sep 28, 2026
30 of 31 checks passed
@zeevmoney
zeevmoney deleted the per-16176/cve-gates-and-fixes branch September 28, 2026 09:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants