Skip to content

PER-16680: log through an SDK logger that applies the log settings - #163

Merged
EliMoshkovich merged 17 commits into
mainfrom
eli/per-16680-sdk-logging
Oct 30, 2026
Merged

EliMoshkovich merged 17 commits into
mainfrom
eli/per-16680-sdk-logging

Conversation

@EliMoshkovich

@EliMoshkovich EliMoshkovich commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Linear: PER-16680

This ports #137 (Zeev) to main, with review follow-ups on top. #137 is stacked on the release-CI draft #136; this PR carries only #137's own changes, so the logging fix can ship in a patch release without that stack.

Changes

  • Start-up line. Creating a client logs api_url and pdp at debug, without any user name or password in them.
  • permit.utils.sdk_logger is what the SDK logs through:
    • It applies log.level and log.label to the SDK's own records, without adding a sink or changing the application's loguru setup.
    • level takes a name in any case, or a loguru severity number. An unknown one is warned about once per process.
    • A record is built, labelled and scrubbed only when loguru passes it on (opt(lazy=True)). The enforcer's debug lines pass a function, so their pformat runs only for a record a sink receives.
  • Redaction.
    • Every client's API key, as given and in lower case, is replaced with [REDACTED] in the records the SDK logs, in the messages and error bodies of the exceptions it raises (PermitConnectionError scrubs its own message when it is a string, and keeps any other value as it is), and in the aiohttp objects those exceptions carry. The aiohttp error the SDK caught is always scrubbed. So are the exceptions chained under it (as a cause or a context, or as a member of an exception group) that aiohttp and the libraries it uses raised during the request: their string and bytes arguments and attributes, and an invalid-URL error's URL. The README lists what is not covered.
    • The SDK doesn't change an exception it didn't raise during the request, such as one the application was handling when it called the SDK (sys.exc_info() at each entry point, passed to the scrub as handled). A value is written only when it holds a key, and scrub_request_info never raises.
    • If the key can't be removed from the aiohttp error, or is still in an exception under it that the SDK leaves as it is (other than the application's), PermitConnectionError attaches a plain aiohttp.ClientError in its place, and a PermitApiError for an error body that isn't JSON has no cause. Every raise site raises from original_error, after its except block, so an error that wasn't attached isn't the context either.
    • An error response labelled JSON whose body isn't JSON raises PermitApiError with the scrubbed body as its details.
    • A PDP error body that isn't valid in its charset is decoded with replacement characters, so check, bulk_check and authorized_users raise PermitConnectionError for it.
    • PDP error messages and the start-up line show URLs without a user name or password (without_userinfo, which never raises: an unparseable URL shows as <invalid URL>, IPv6 brackets are kept).
    • A secret shorter than 12 characters isn't replaced, so a short test token doesn't mangle the SDK's text.
    • PermitConfig.token is repr=False.
  • wait_for_sync() builds a client of the same class again, so a subclass's own setup runs. The keep_log_settings context variable keeps the process's log settings unchanged while it does.
  • Concurrency. The SDK's enable/disable switch changes under a lock.
  • Lint. ruff bans calling loguru.logger directly, and the standard logging module, inside the package.
  • Dependencies. multidict>=4.5,<7 (aiohttp's own range) is declared, since permit/exceptions.py imports it; uv.lock adds it to the package's dependency list (no new package). The lowest-direct floor lanes therefore run multidict 4.5.0, as pure Python.
  • README. A Logging section on what each option does.
  • Client construction is as on main: Permit.__init__ builds the four clients, and sync.Permit.__init__ calls super().__init__() and then builds the blocking ones.

Behaviour change

log.level and log.label used to be ignored. With log={"enable": True} and nothing else, the defaults now apply:

  • DEBUG records are dropped (the default level is info).
  • Each SDK message starts with [Permit] .

Set level="debug" or label="" to keep the old output.

Left as is

  • Process-wide settings. loguru's logger is process-wide, so the last client created decides the log settings for every client, as documented. A logger per client would be a larger redesign.
  • No removal of old keys. The set of keys to redact grows with each distinct key a process uses. That's one entry per key, and removing one could un-redact a client still using it.

Tests

tests/test_fix_logging.py: 233 tests, covering:

  • redaction in REST and PDP API errors, including bodies labelled JSON that aren't JSON, a followed redirect, list bodies and object keys
  • redaction in PermitConnectionError for every enforcer call and the REST API, checked against the whole formatted traceback and the lower-cased key
  • aiohttp errors and the errors chained under them, from a local raw-socket server, for the PDP and the REST API on both clients, including text kept as bytes, bytearray and a yarl URL, an error chained only as the context, a __cause__ cycle, and exception-group members; the raw-server cases run again on aiohttp's pure-Python parser
  • the error the SDK caught is scrubbed whatever its traceback frames, and an exception left as it is that still holds the key makes the SDK attach the plain error instead
  • an exception the application is handling when it calls the SDK (a frozen dataclass, read-only args, headers with an int or holding the key, a refused connection) is left unchanged for all five calls, and PermitConnectionError still carries the aiohttp error
  • an error that can't be scrubbed is neither attached, nor the cause, nor the context, at every raise site, and a scrub that fails part-way still scrubs the rest
  • a PDP error body that isn't valid UTF-8, and a PermitConnectionError message that isn't a string
  • the echoed-key test and the level sweep on both clients, at every level and with logging off
  • URL user info in the start-up line
  • a subclass in wait_for_sync, and a subclass's own _connect left alone
  • short tokens and the 12-character boundary
  • numeric, padded and unknown levels, and the one-time warning
  • records loguru drops are not built or scrubbed, for all four enforcer debug lines

Offline suite (pytest -m "not e2e"): 537 passed, 4 skipped on Python 3.10 and 3.13 with pydantic 2, on Python 3.10 with pydantic 1, and on the Python 3.10 floor lane. The e2e tests need a live PDP_API_KEY. ruff, ruff format and mypy pass.

The failing Dependency Audit check also fails on main, with the same advisories (uv, aiohttp, filelock, dnspython). This PR's uv.lock change is only the two multidict lines.

Description updated during review to match fbc59c0 (the client-construction bullet: sync.Permit.__init__ built the sync clients, not only called super(); it is now a _connect() override; redaction wording made neutral).
Description updated during review to match bce2118 (no _connect() hook any more, lazy records, PermitConnectionError scrub, non-JSON error bodies, logging ban, multidict dependency and uv.lock, test counts).
Description updated during review to match 1eea9d8 (lower-case key, chained exceptions, non-string PermitConnectionError messages, PDP error bodies in another charset, multidict floor, test counts).
Description updated during review to match d2d1f86 (which chained exceptions are scrubbed, application exceptions left alone, fail-closed PermitConnectionError, tests and counts).
Description updated during review to match 43454fd (the caught error always scrubbed, exception-group members, handled, fail closed on a key left in place, raise after the except block, tests and counts).

🤖 Generated with Claude Code

Ported to main from #137 (Zeev Manilovich), which is stacked on the release-CI
draft #136; this carries only #137's own changes.

- The client no longer logs its config on creation; it logs the PDP and API URL.
- permit.utils.sdk_logger applies log.level and log.label to the SDK's records
  without touching the application's loguru sinks, and replaces every client's
  API key with [REDACTED] in what the SDK logs and in PDP error bodies.
- PermitConfig.token is repr=False.
- ruff bans logging straight to loguru inside the package.
- README: what each log option does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 27, 2026 23:05
@linear-code

linear-code Bot commented Oct 27, 2026

Copy link
Copy Markdown

PER-16680

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 insufficient remaining quota.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Dependency Security Audit

Scanned: pyproject.toml dependencies + dev group, resolved at Python 3.10 (the current resolution, and the lowest versions the published specs permit under each pydantic major)

❌ 6 fixable HIGH/CRITICAL advisories -- blocking this build.

Severity Count
‼️ CRITICAL 1
🔴 HIGH 5
🔶 MEDIUM 18
⚪ LOW 5
❔ UNKNOWN 28
Severity Package Installed Fixed in Advisory
‼️ CRITICAL uv 0.12.17 0.13.4 GHSA-hq85-5cpj-mr62
🔴 HIGH aiohttp 3.14.3 3.14.4 CVE-2026-111064
🔴 HIGH aiohttp 3.14.3 3.14.4 CVE-2026-111066
🔴 HIGH aiohttp 3.14.3 3.14.4 GHSA-2g87-pp6c-29x5
🔴 HIGH dnspython 2.8.0 2.9.0 CVE-2026-114605
🔴 HIGH filelock 4.1.0 4.2.1 CVE-2026-112940
🔶 MEDIUM aiohttp 3.14.3 3.14.4 CVE-2026-111063
🔶 MEDIUM aiohttp 3.14.3 3.14.4 CVE-2026-111065
🔶 MEDIUM aiohttp 3.14.3 3.14.4 CVE-2026-111748
🔶 MEDIUM aiohttp 3.14.3 3.14.4 CVE-2026-111750
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112906
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112908
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112909
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112910
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112911
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112914
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112915
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112934
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112935
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112937
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112938
🔶 MEDIUM filelock 4.1.0 4.2.1 CVE-2026-112939
🔶 MEDIUM uv 0.12.17 0.12.18 CVE-2026-104843
🔶 MEDIUM werkzeug 3.1.8 3.1.9 CVE-2026-102598
⚪ LOW aiohttp 3.14.3 3.14.4 CVE-2026-111749
⚪ LOW filelock 4.1.0 4.2.1 CVE-2026-112907
⚪ LOW filelock 4.1.0 4.2.1 CVE-2026-112912
⚪ LOW filelock 4.1.0 4.2.1 CVE-2026-112913
⚪ LOW filelock 4.1.0 4.2.1 CVE-2026-112941
❔ UNKNOWN aiohttp 3.14.3 3.14.4 CVE-2026-111063 (GHSA-494j-54xm-rwp3)
❔ UNKNOWN aiohttp 3.14.3 3.14.4 CVE-2026-111064 (GHSA-5wr8-gcqf-gqf3)
❔ UNKNOWN aiohttp 3.14.3 3.14.4 CVE-2026-111065 (GHSA-6cjc-53gc-5rqm)
❔ UNKNOWN aiohttp 3.14.3 3.14.4 CVE-2026-111066 (GHSA-9xw5-w5jj-pvcg)
❔ UNKNOWN aiohttp 3.14.3 3.14.4 CVE-2026-111748 (GHSA-f9xw-jh8m-3mwq)
❔ UNKNOWN aiohttp 3.14.3 3.14.4 CVE-2026-111749 (GHSA-gx76-c99x-2c96)
❔ UNKNOWN aiohttp 3.14.3 3.14.4 CVE-2026-111750 (GHSA-pqxq-w44r-73xw)
❔ UNKNOWN dnspython 2.8.0 2.9.0 CVE-2026-114605 (GHSA-45c3-73f7-pv4m)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112906 (GHSA-2g95-wg33-c5x2)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112907 (GHSA-567q-5qvw-pf92)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112908 (GHSA-6627-m3xw-93mp)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112909 (GHSA-69hj-f68j-2879)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112910 (GHSA-8mx5-7m5m-r77g)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112911 (GHSA-9c4x-gm8v-wgc4)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112912 (GHSA-9j6q-wvj5-jgjg)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112913 (GHSA-9vhv-jxww-xfm5)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112914 (GHSA-crv4-6mhj-j5jh)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112915 (GHSA-cwqr-hh33-gh44)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112934 (GHSA-fj95-fr6c-h845)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112935 (GHSA-fq56-3cx3-fjxp)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112937 (GHSA-jqc4-5cvw-7w85)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112938 (GHSA-m462-7rxw-wvpg)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112939 (GHSA-rfpc-qcw5-3pc4)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112940 (GHSA-v4x8-3wr5-3wq8)
❔ UNKNOWN filelock 4.1.0 4.2.1 CVE-2026-112941 (GHSA-wfhq-q4j2-c37m)
❔ UNKNOWN uv 0.12.17 0.12.18 PYSEC-2026-4305 (CVE-2026-104843, GHSA-2cv4-cqwr-gwf7)
❔ UNKNOWN uv 0.12.17 0.13.4 PYSEC-2026-4321 (GHSA-hq85-5cpj-mr62)
❔ UNKNOWN werkzeug 3.1.8 3.1.9 PYSEC-2026-4312 (CVE-2026-102598, GHSA-g6x2-hccm-hh4m)
Advisory details

CRITICAL -- GHSA-hq85-5cpj-mr62 (uv 0.12.17)

Found by: trivy:dev-ceiling

uv's bundled CA store trusts an emSign root whose intermediate CA keys are compromised

HIGH -- CVE-2026-111064 (aiohttp 3.14.3)

Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: aiohttp: Denial of Service via missing initial request timeout

HIGH -- CVE-2026-111066 (aiohttp 3.14.3)

Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: aiohttp: Denial of Service via file descriptor exhaustion in multipart request processing

HIGH -- GHSA-2g87-pp6c-29x5 (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2, trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: Multiple instances of excessive or unbounded memory/CPU use

HIGH -- CVE-2026-114605 (dnspython 2.8.0)

Found by: trivy:dev-ceiling, trivy:runtime-ceiling, trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

dnspython: dnspython: Denial of Service via excessive DNS compression pointer traversal

HIGH -- CVE-2026-112940 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Arbitrary file overwrite via insecure database file reopening

MEDIUM -- CVE-2026-111063 (aiohttp 3.14.3)

Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: aiohttp: Information disclosure via unenforced Secure attribute on shared cookies

MEDIUM -- CVE-2026-111065 (aiohttp 3.14.3)

Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: aiohttp: Denial of Service via Max-Age processing overflow

MEDIUM -- CVE-2026-111748 (aiohttp 3.14.3)

Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: aiohttp: Security control bypass via request scheme spoofing

MEDIUM -- CVE-2026-111750 (aiohttp 3.14.3)

Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: aiohttp: HTTP request smuggling via ambiguous request framing

MEDIUM -- CVE-2026-112906 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Mutual exclusion bypass via improper stale lock removal

MEDIUM -- CVE-2026-112908 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: StrictSoftFileLock creates its claim directories without the lock mode, so another local user with a write bit on them can end a live hold

MEDIUM -- CVE-2026-112909 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: arbitrary file truncation via lock path replacement

MEDIUM -- CVE-2026-112910 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: WindowsFileLock ignores mode: a 0o600 lock file and the ReadWriteLock database inherit the directory ACL

MEDIUM -- CVE-2026-112911 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Lock bypass via pre-created shared protocol directory

MEDIUM -- CVE-2026-112914 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Unauthorized file exposure via symbolic link manipulation in SoftFileLock

MEDIUM -- CVE-2026-112915 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Denial of Service via crafted SQLite lock database

MEDIUM -- CVE-2026-112934 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Denial of Service via insecure holder record refresh

MEDIUM -- CVE-2026-112935 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Unauthorized concurrent access via stale lock cleanup race condition

MEDIUM -- CVE-2026-112937 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock is a platform-independent file lock for Python. From 4.1.0 un ...

MEDIUM -- CVE-2026-112938 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: StrictSoftFileLock follows a symbolic link swapped in at <path>.filelock and creates claim files in a directory another local user chooses

MEDIUM -- CVE-2026-112939 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Unauthorized lock acquisition via forged process identifier in SoftFileLock

MEDIUM -- CVE-2026-104843 (uv 0.12.17)

Found by: trivy:dev-ceiling

uv: uv: Arbitrary code execution via malicious package extraction on Windows

MEDIUM -- CVE-2026-102598 (werkzeug 3.1.8)

Found by: trivy:dev-ceiling

werkzeug: werkzeug: Denial of Service via safe_join() Windows special device paths

LOW -- CVE-2026-111749 (aiohttp 3.14.3)

Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2

aiohttp: aiohttp: Host-Only cookie protection bypass via untrusted subdomain

LOW -- CVE-2026-112907 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: On Windows an oplock on a soft lock's marker holds SoftFileLock.acquire() past its timeout and blocks the stale break

LOW -- CVE-2026-112912 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Information disclosure via symbolic link following in SoftFileLock

LOW -- CVE-2026-112913 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: ReadWriteLock on Windows acts on SQLite side files another local user places beside its database

LOW -- CVE-2026-112941 (filelock 4.1.0)

Found by: trivy:dev-ceiling

filelock: filelock: Unauthorized file write via lock directory replacement

UNKNOWN -- CVE-2026-111063 (GHSA-494j-54xm-rwp3) (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Summary The Secure attribute was not being enforced for the shared cookie. ### Impact It may be possible to send a shared cookie marked Secure through an unencrypted request.

UNKNOWN -- CVE-2026-111064 (GHSA-5wr8-gcqf-gqf3) (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Summary The keepalive timeout was not used during the initial portion of a request. ### Impact An attacker could keep an idle connection open indefinitely before sending any data. ### Workaround Use a reverse proxy (as recommended for all production deployments) with sensible keepalive limits.

UNKNOWN -- CVE-2026-111065 (GHSA-6cjc-53gc-5rqm) (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Summary An overflow in Max-Age processing can skip closing a connection. ### Impact An attacker may be able to exhaust the connection pool temporarily, until garbage collection resolves the connections.

UNKNOWN -- CVE-2026-111066 (GHSA-9xw5-w5jj-pvcg) (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Summary An attacker can exhaust file descriptors with specially crafted multipart requests. ### Impact If a handler uses `Request.post()`, it may be possible for an attacker to use an unlimited number of file descriptors, potentially resulting in resource exhaustion.

UNKNOWN -- CVE-2026-111748 (GHSA-f9xw-jh8m-3mwq) (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Summary An attacker sending an absolute-form request can spoof the scheme used. ### Impact If user code depends on the `Request.scheme` attribute for anything security related, an attacker may be able to circumvent this logic, tricking the application into thinking a connection is encrypted when it is not, or vice versa. ### Workaround A WAF rule to reject absolute-form requests (legitimate requests should only use this form for proxies).

UNKNOWN -- CVE-2026-111749 (GHSA-gx76-c99x-2c96) (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Summary A subdomain can remove the Host-Only flag from an existing cookie to a parent domain. ### Impact A user accessing untrusted subdomains could see their Host-Only protections stripped from cookies. Given most usecases won't reflect these cookies to a browser, the impact is limited.

UNKNOWN -- CVE-2026-111750 (GHSA-pqxq-w44r-73xw) (aiohttp 3.14.3)

Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Summary The pure Python parser is vulnerable to an HTTP request smuggling attack. ### Impact If users are using the Python parser (`AIOHTTP_NO_EXTENSIONS`, or not a standard wheel installed), then an application may be vulnerable to a request smuggling attack. ### Workaround Switch to the default C parser.

UNKNOWN -- CVE-2026-114605 (GHSA-45c3-73f7-pv4m) (dnspython 2.8.0)

Found by: pip-audit:dev-ceiling, pip-audit:runtime-ceiling, pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2

### Impact A maliciously crafted DNS message may cause dns.name.from_wire_parser() to use excessive CPU for DNS name decompression. Applications that process untrusted DNS wire form messages with dnspython may be degraded or DoS'd by the extra CPU use required. ### Patches This problem is fixed in dnspython 2.9.0. ### Workarounds There are no known workarounds. ### Credit Evgenios Gkritsis, Department of Informatics, University of Piraeus & Athena Research Center, Greece Constantinos Patsakis, Department of Informatics, University of Piraeus & Athena Research Center, Greece George Stergiopoulos, Department of Informatics, Athens University of Economics and Business, Greece

UNKNOWN -- CVE-2026-112906 (GHSA-2g95-wg33-c5x2) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`SoftFileLock` and `MarkerSoftFileLock` evict an unparsable marker (the lock file at `<path>`, whose presence says a process holds the lock) once a waiting contender has watched it unchanged for two seconds. `SoftFileLease` expires a lease the same way. The watch tells two looks apart by the marker's modification time (mtime) and inode number (the kernel's identity for a file, independent of its name) alone. Another local user (the other user below) who can create files in the lock's directory, a sticky `/tmp` included (the sticky bit is a directory flag that lets only a file's owner remove it), parks an empty file of its own at the lock path. It dates that file to a future tick of the kernel's coarse clock (a clock that advances in steps of 4 ms), lets the waiters of the user running the lock (the lock user below) watch it past two seconds, and unlinks it in that tick. On a filesystem that hands a freed inode number straight back (ext4, overlayfs on ext4) the lock user's next marker repeats both values. It inherits the time spent watching the other user's file, and a second waiter removes it while its owner holds the lock. Two of the lock user's processes then run the critical se…

UNKNOWN -- CVE-2026-112907 (GHSA-567q-5qvw-pf92) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

On Windows the soft locks (`SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease`, their async forms, and a `FileLock` that fell back to a soft lock) read their marker by path. The marker is the lock file whose presence says a process holds the lock. Each `acquire()` attempt that finds a marker opens it with `os.open` to read the holder's process id, and the lock renames and deletes a marker it judges stale, by path. On Windows each of these calls waits for an oplock. An oplock (opportunistic lock) is a request by a process that holds a handle on a file to be told before another process opens, renames or deletes that file; the other process waits until the holder answers the break or closes its handle. Another local account that can open the marker for reading can take a Level 1 oplock on it while no process holds it open, and leave the break unanswered. Every `acquire()` by the user running the lock (below: the lock user) then waits inside the open, past its `timeout`, `blocking=False` and `cancel_check`, and a stale marker under that oplock is not broken. Mutual exclusion holds and the lock touches no other file. Affected versions are filelock 3.22.0 through 4.2.0 on Windows. The…

UNKNOWN -- CVE-2026-112908 (GHSA-6627-m3xw-93mp) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`StrictSoftFileLock` and `AsyncStrictSoftFileLock` apply the caller's `mode` to the sentinel (the lock file at `<path>`) and to both claim files (the files whose presence says a process wants or holds the lock). They create the two directories that hold the claims, `<path>.filelock` and `<path>.filelock/claims`, with a bare `mkdir()`. That gives `0o777` filtered through the umask (a per-process mask that removes permission bits from new files and directories) or, under a parent with a default ACL (an access control list on a directory that sets the permissions of entries created inside it), through that ACL. A hold consists of two claim names in that directory, which has no sticky bit (a directory flag that lets only a file's owner remove it). When the environment leaves another local user (the other user below) a write bit on the directory, that user can unlink the holder's `0600` claims. Three settings do this: umask `000` in a sticky directory such as `/tmp`, umask `002` with the other user in the directory's group, or a default ACL on the lock's parent that grants the group write (the kernel then ignores the umask). After the unlink, the next contender of the holder's account …

UNKNOWN -- CVE-2026-112909 (GHSA-69hj-f68j-2879) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`UnixFileLock`, the backend of `FileLock` and `AsyncFileLock` on Unix, empties whatever regular file it finds at the lock path once it holds the `flock` (a kernel lock on an open file). With an explicit `mode` it also chmods that file (sets its permission bits), whether or not this acquire created it. Another local user who can rename or hard-link one of the files of the user running the lock (below: the lock user) onto the lock path gets that file truncated by the lock user's next `acquire()`. A hard link is a second name for the same file. With a mode such as `0o666` the file, still owned by the lock user, also becomes readable and writable by that other user. The other user can then read and alter everything the lock user writes to it afterwards. `O_NOFOLLOW`, the defence added for the symlink variant (CVE-2025-68146), does not see a renamed file or a hard link. The rename route needs the file's directory to be writable without the sticky bit (a directory flag that lets only a file's owner remove or rename it). The hard-link route into a sticky directory such as `/tmp` needs `fs.protected_hardlinks=0`, a Linux kernel setting. Affected versions are every filelock release up to a…

UNKNOWN -- CVE-2026-112910 (GHSA-8mx5-7m5m-r77g) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

filelock on Windows ignores the `mode` argument. `FileLock(path, mode=0o600)` creates the same file as `FileLock(path)`: one with no security descriptor of its own (the Windows record that says who may access a file). Its access list, or ACL, is whatever the lock directory hands down to new files. filelock creates the `ReadWriteLock` database, which it asks to be `0o600`, the same way. In a directory whose inheritable ACL grants other local accounts access to new files, those accounts can open the lock file or database. They can take the byte-range lock (a lock on a span of bytes in a file) once the owner releases it and keep the owner's later `acquire()` calls waiting or timing out. They can read holder metadata an `on_acquired` hook left in a `preserve_lock_file=True` file. Where the directory hands down modify access, they can write to both files. `lock.mode` still reports `0o600`. The documentation neither promises an ACL on Windows nor says that Windows drops the mode. On Unix the same mode keeps other accounts out. Mutual exclusion between processes that follow the protocol holds on either platform. Affected versions are filelock 3.10.0 through 4.2.0 on Windows. Anthropic's …

UNKNOWN -- CVE-2026-112911 (GHSA-9c4x-gm8v-wgc4) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`StrictSoftFileLock`, `AsyncStrictSoftFileLock`, `SoftReadWriteLock` and `AsyncSoftReadWriteLock` keep their whole ownership state as files in a directory derived from the lock path (`<path>.filelock/claims`, `<path>.rw/{gen,holders}`). When that directory already exists they adopt it after checking only that it is a real directory, not who owns it. Another local user who can create names in the lock's directory can create the tree before the first acquire on that path by the user running the lock (below: the lock user). In `/tmp`, `/var/tmp` or another sticky shared directory any user can do that; the sticky bit is a directory flag that lets only a file's owner remove or rename it. The other user then owns the tree. They can unlink the lock user's claims while the lock user holds the lock, so a second process of the lock user, root included, enters the same critical section and neither process sees an error. Against `SoftReadWriteLock` the same position also admits two writers with `on_compromise` silent. It redirects the lock user's own creates and renames into a directory the other user picks (the scanner replaced a repository's `.git/HEAD` with a filelock record written with t…

UNKNOWN -- CVE-2026-112912 (GHSA-9j6q-wvj5-jgjg) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`raise_on_not_writable_file()` is the check that `SoftFileLock` and the locks sharing its acquire path run before every attempt. It looks at the lock path with `os.lstat`, so that it judges a planted symlink (a file that points at another path) by itself. When the node has no owner-write bit, it then calls `os.access(filename, os.W_OK)`, which follows symlinks. A local user who can create entries in the lock's directory can swap the path between a mode-`0466` file and a symlink to a path of their choosing. The second call then reports on the symlink's target with the permissions of the user running the lock (below: the lock user). The lock user's `acquire()` ends in `PermissionError` within a fraction of a second when the target is missing or not writable by the lock user, and in the ordinary `Timeout` otherwise. That yields one bit per watched acquire. The bit says whether a path exists and is writable by the lock taker. It covers paths under directories the other user cannot search, or any path when root takes the lock. The lock does not open, create or modify anything through the symlink. On Linux, `fs.protected_symlinks` (a kernel setting that refuses to follow another user's …

UNKNOWN -- CVE-2026-112913 (GHSA-9vhv-jxww-xfm5) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

On Windows, `ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) connect SQLite to the lock database by its path, in every directory. SQLite derives three side files from that path: `<database>-journal`, the rollback journal (pages saved to undo an interrupted write); `<database>-wal`, the write-ahead log (changes kept outside the main file); and `<database>-shm`, the shared-memory index of the WAL. SQLite acts on whichever of these it finds beside the database before the lock runs any statement. Another local account that can create files in the database's directory, but not delete files of other accounts there, places such a side file beside the database and denies everyone `DELETE` on it, which a file's creator may do. `C:\Windows\Temp` (the temporary directory of services, granting `BUILTIN\Users` create but not delete) and `C:\Users\Public` are such directories. With a `-wal` in place, every `read_lock()` and `write_lock()` of the user running the lock (below: the lock user) raises `sqlite3.OperationalError: disk I/O error` at once, whatever `timeout` says, until that account removes its file; the lock user cannot. With a `-journal` beside a lock da…

UNKNOWN -- CVE-2026-112914 (GHSA-crv4-6mhj-j5jh) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

When a soft-lock stale break finds that the marker (the lock file at `<path>`) it renamed aside was a live one recreated by a successor, the restore branch of `break_lock_file()` checks the break name with `lstat` (a check that reads a name without following a symbolic link) and then calls `os.link(break_path, lock_file)` by name. On CPython 3.12 and later on Linux (and on macOS) that call follows a symbolic link (a file that redirects to another path). A local user (the other user below) who can write a shared lock directory without the sticky bit (a directory flag that lets only a file's owner rename or remove it) replaces the break name with a symbolic link between those two calls. The `SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease` or `lifetime` break of the user running the lock (the lock user below) then creates a hard link (a second name for the same file), in that directory, to any of the lock user's own files on the same filesystem. That includes files inside directories the other user cannot enter and links that `fs.protected_hardlinks` (a Linux setting that forbids hard links to files one does not own) refuses the other user when it asks itself. The link exposes t…

UNKNOWN -- CVE-2026-112915 (GHSA-cwqr-hh33-gh44) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) run `PRAGMA journal_mode=MEMORY` on a fresh connection for every acquire. That statement makes SQLite load the whole schema (the `sqlite_master` table, which lists every table and index with its `CREATE` text) of whatever file sits at the lock path. The caller's `timeout` reaches SQLite only as `busy_timeout`, which bounds waits for another connection's lock, not schema loading. The only content checks reject non-regular files and WAL mode (write-ahead log, an SQLite mode that keeps changes in a side file). A local user who can place or overwrite the file at a `ReadWriteLock`'s database path can therefore make every `acquire_read` / `acquire_write` of every process using that lock ignore its `timeout` and `blocking=False`. That position needs a non-sticky shared directory, or a database owned by the user running the lock (below: the lock user) that other users may write. One 37 KB crafted file stalls each acquire for as long as its author chooses while the process allocates about 1 GB. A second 28 KB layout makes the acquire of a free lock run without end, spinning at 100% CPU and ignoring Ctrl-C. The …

UNKNOWN -- CVE-2026-112934 (GHSA-fj95-fr6c-h845) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`SoftReadWriteLock` (and `AsyncSoftReadWriteLock`, which wraps one) refreshes its holder record (the file under `<path>.rw/holders/` that says a process holds or wants the lock) on every poll of a waiting acquire and on every heartbeat (the periodic refresh a holder runs to keep its record fresh). It does so by reopening `<path>.rw/holders/<token>` by name with a plain blocking `O_WRONLY | O_NOFOLLOW` open and writing at offset 0, without checking what it opened. `O_NOFOLLOW` makes the open fail only when the final name is a symbolic link (a file that redirects to another path). A different local user (the other user below) who owns the lock's `.rw` tree reads the token of the user running the lock (the lock user below) from the `holders/` listing and renames another node over the record. The other user owns the tree because it created it first in a sticky directory such as `/tmp` (the sticky bit is a directory flag that lets only a file's owner rename or remove it), or because it can write a lock directory without the sticky bit. A FIFO (a named pipe: a file whose open for writing blocks until some process opens it for reading) makes the lock user's `write_lock()` / `read_lock()`…

UNKNOWN -- CVE-2026-112935 (GHSA-fq56-3cx3-fjxp) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

The stale-lock break shared by `SoftFileLock`, `MarkerSoftFileLock` and `SoftFileLease` (and their async variants, and a `FileLock` that has fallen back to a soft lock on `ENOSYS`, the "not implemented" error) recognises the marker it judged stale by two values. The marker is the lock file at `<path>`, whose presence says a process holds the lock. The values are its inode number (the kernel's identity for a file, independent of its name) and its modification time (mtime) not being newer. The check runs after the contender has already closed the marker, so nothing keeps the inode allocated. On a filesystem that hands a freed inode number straight to the next file (ext4, xfs, overlayfs on them) the next holder's marker can carry that number. A contender still between its read and its break then renames and unlinks the live marker. Another local user (the other user below) who can create files in the lock's directory, a sticky `/tmp` included (the sticky bit is a directory flag that lets only a file's owner remove it), forces this at will. It plants a marker with a stale owner record and a modification time in the future and unlinks it as soon as a contender has read it. Two processe…

UNKNOWN -- CVE-2026-112937 (GHSA-jqc4-5cvw-7w85) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) remove their private `.filelock-*` link directories with `shutil.rmtree` (a recursive delete) on a remembered path. They do not check that the path still names the directory they created. This happens on the hard-link route (a hard link is a second name for the same file). That route is every connection on macOS since 4.1.1 and any POSIX process without a usable `/proc/self/fd` (a Linux path that names the process's own open files), such as a chroot or container that does not mount `/proc`. Another local user who can rename entries in the directory that holds the lock database can rename one of the directories of the user running the lock (below: the lock user) onto the link directory's name, or onto a forged dead-holder name. Such a lock directory is a `0777` or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). The lock user's process then deletes that tree and everything in it with the lock user's permissions, and reports no error. Stock Linux with `/proc` mounted is not affected. Affected versions are fileloc…

UNKNOWN -- CVE-2026-112938 (GHSA-m462-7rxw-wvpg) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`StrictSoftFileLock` and `AsyncStrictSoftFileLock` check `<path>.filelock` (the coordination directory that holds the lock's state) with one `lstat` call on its name. `lstat` reports what a name is without following a symbolic link (a file that redirects to another path). After that check, the code reaches `<path>.filelock/claims` by pathname for every later operation (`mkdir`, scan, publish, link, unlink). `_open_directory` passes `O_NOFOLLOW` (a flag that makes an open fail when the final name is a symbolic link), but the flag covers only the final `claims` component. The kernel follows a symbolic link in any earlier component. Another local user (the other user below) who can rename `<path>.filelock` exchanges it for a symbolic link after the check. In a shared directory without the sticky bit (a directory flag that lets only a file's owner rename or remove it), any user with write access can do this. Where the shared directory is sticky, such as `/tmp`, only the user who created `<path>.filelock` before the user running the lock (the lock user below) first used it can. The lock user's process (root's, if root takes the lock) then creates a `claims` directory and claim files (t…

UNKNOWN -- CVE-2026-112939 (GHSA-rfpc-qcw5-3pc4) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`SoftFileLock`, `MarkerSoftFileLock`, `SoftFileLease`, their async variants and a `FileLock` that has fallen back to a soft lock on `ENOSYS` decide from the record inside the marker file whether its owner is gone. The marker is the file whose presence says a process holds the lock. They do not check who could have written that record. A local user who can write the marker but cannot remove it rewrites the holder's record to name a dead PID (process id). That position arises with an explicit `mode` with group or other write bits, or the default mode under a lax umask (the process setting that strips permission bits from new files), in a sticky or otherwise non-removable directory. The sticky bit is a directory flag that lets only a file's owner remove or rename it. The next contender running as the holder's account, or root, judges the lock stale, removes the live marker on that user's behalf and acquires it. Two processes then run the critical section together and neither gets an error. Affected versions are filelock 3.22.0 through 4.2.0 (finding `ANT-2026-W1H9NXUH`). ## Severity **CVSS 3.1 `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N` scores 4.7 (Medium).** `AV:L` (local): the o…

UNKNOWN -- CVE-2026-112940 (GHSA-v4x8-3wr5-3wq8) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`ReadWriteLock` and `AsyncReadWriteLock` (the SQLite-backed reader/writer lock) hand SQLite a name they do not control. On Linux the lock validates its database through a descriptor (the number the kernel gives a process for an open file). It then connects SQLite to `/proc/self/fd/<n>`, a Linux path that names the process's own open file `n`. SQLite turns that path back into a plain path and reopens it by name, following symlinks (a symlink is a file that points at another path). On every POSIX route SQLite also acts on `-journal`, `-wal` and `-shm` files it finds beside that name. Those are SQLite's side files: the rollback journal (pages saved to undo an interrupted write), the write-ahead log or WAL (changes kept outside the main file), and the shared-memory index for the WAL. Another local user who can create or rename entries in the directory that holds the lock database can turn this against the user running the lock (below: the lock user). Such a directory is a `0777` or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). That other user can make the lock user's ordinary `read_lock()` …

UNKNOWN -- CVE-2026-112941 (GHSA-wfhq-q4j2-c37m) (filelock 4.1.0)

Found by: pip-audit:dev-ceiling

`SoftReadWriteLock` (and `AsyncSoftReadWriteLock`, which wraps one) checks once per lock object, by name, that `<path>.rw`, `<path>.rw/gen` and `<path>.rw/holders` are real directories and not symbolic links (files that redirect to another path). It then keeps only a boolean. Every later record operation resolves the full path again, and `O_NOFOLLOW` (a flag that makes an open fail when the final name is a symbolic link) protects the last component only. Another local user (the other user below) who can write the directory holding the lock, or who created `<path>.rw` first in a sticky directory such as `/tmp` (the sticky bit is a directory flag that lets only a file's owner rename or remove it), can put a tree under that name whose `gen` or `holders` is a symbolic link into a directory of the user running the lock (the lock user below). A long-running process of the lock user that has already passed the check then creates, renames over and deletes files there with the lock user's privileges, including in `0700` directories the other user cannot open. The names and bytes are filelock's own lock records, not chosen by the other user. The scanner's reproducer replaces `.git/HEAD` in …

UNKNOWN -- PYSEC-2026-4305 (CVE-2026-104843, GHSA-2cv4-cqwr-gwf7) (uv 0.12.17)

Found by: pip-audit:dev-ceiling

### Impact In versions of uv from 0.12.7 to 0.12.18 on Windows, uv could be induced into writing a file outside of the installation prefix during wheel installation. A malicious wheel could use this to place an executable outside of the intended environment, including in a directory already present on the user's PATH. This vulnerability only affects Windows hosts; no other platforms are affected. ### Patches uv 0.12.18 and newer address this vulnerability. Users are encouraged to upgrade to 0.12.18. ### Workarounds There is no workaround other than upgrading to uv 0.12.18.

UNKNOWN -- PYSEC-2026-4321 (GHSA-hq85-5cpj-mr62) (uv 0.12.17)

Found by: pip-audit:dev-ceiling

By default, uv verifies TLS connections against a vendored copy of the `webpki-root-certs` bundle. Versions before 0.13.4 include the `emSign ECC Root CA - G3` root. Eight eMudhra intermediate CA certificates that chain to that root have compromised private keys, and the CA has revoked them. An attacker with one of the compromised keys can issue certificates that uv accepts for any hostname, and can be used to issue certificates to enable MITM attacks. uv 0.13.4 removes the root from its vendored bundle, following `webpki-root-certs` 1.0.10. Upgrade to 0.13.4 or later.

UNKNOWN -- PYSEC-2026-4312 (CVE-2026-102598, GHSA-g6x2-hccm-hh4m) (werkzeug 3.1.8)

Found by: pip-audit:dev-ceiling

Werkzeug's `safe_join` function allows Windows device names as filenames when they have an empty ADS marker on NTFS. This was previously reported as https://github.com/pallets/werkzeug/security/advisories/GHSA-hgf8-39gv-g3f2, but the added filtering failed to account for the fact Windows allows special device names with an empty ADS marker, such as `NUL:`. `send_from_directory` uses `safe_join` to safely serve files at user-specified paths under a directory. If the application is running on Windows and NTFS, and the requested path ends with a special device name, the file will be opened successfully, but reading will hang indefinitely.

How to fix

Raise the affected lower bound in pyproject.toml ([project].dependencies, or the pin in the dev dependency group) to at least the Fixed in version above, then run uv lock. Because this package publishes open >= ranges, the floor is what consumers can actually install -- bumping only the resolved version does not close the hole.

This audit resolves without the 7-day publish-age cooldown (exclude-newer) in [tool.uv], but uv lock applies it. If uv lock says the fixed version was filtered by exclude-newer, add exclude-newer-package = { <package> = false } under [tool.uv] in pyproject.toml, run uv lock and commit both files. Once the release is 7 days old, remove the entry and run uv lock again.

An advisory with no fix available does not block the build -- it is reported here so it can be tracked, but no version bump can resolve it. Suppression files are deliberately not honoured: the scan runs with --ignorefile /dev/null so nothing can disappear from this report silently.

… info, wait_for_sync

- handle_api_error scrubs the registered keys from the body it puts in an
  exception, text or JSON, so the REST API and the PDP's local API raise
  without them, as the enforcer already did.
- The start-up line logs api_url and pdp without a user name or password.
- wait_for_sync builds a client of the same class again, so a subclass's own
  setup runs, and keeps the process's log settings through the
  keep_log_settings context variable.
- A secret shorter than 12 characters is not redacted, so a short test token
  doesn't mangle the SDK's text.
- log.level takes a loguru severity number and surrounding whitespace; an
  unknown level is warned about once per process.
- The SDK's enable/disable switch changes under a lock.
- Drop sync.Permit.__init__, which only called super().
- README and docstrings match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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 insufficient remaining quota.

…L helper

- aiohttp keeps the request's headers, Authorization included, on the
  ClientResponse and ClientResponseError the SDK's exceptions carry
  (original_error, response, the cause), and their repr prints them.
  scrub_request_info replaces every registered key there before an exception
  holds them, including the response's cached request_info.
- without_userinfo moves to permit.utils.sdk_logger. It never raises (a bad
  port no longer fails client creation, an unparseable URL shows as
  <invalid URL>) and keeps IPv6 brackets.
- PDP error messages show the PDP URL without a user name or password.
- README: wait_for_sync builds a new client of the same class; what the key
  is replaced in.
- Tests: REST and PDP errors for a non-JSON answer (200 and 502) and for an
  echoed key carry no key in their cause, context, original_error or response;
  without_userinfo cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 28, 2026 18:05

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 insufficient remaining quota.

- handle_api_error's JSON scrubber replaces registered keys in object keys as
  well as values, so an error body that uses the key as a key doesn't expose it.
- The SDK logger scrubs the message after the label is added, so a key placed
  in log.label is replaced too.
- Tests: a key echoed as a JSON object key; a key in the label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 28, 2026 19:37

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 insufficient remaining quota.

…d redirect history

A ClientResponseError keeps its RequestInfo as args[0] as well as in
request_info, and the redirect responses in its (or a response's) history
keep their own. scrub_request_info replaces the key in all of them.
Test: neither the carried objects nor their args print the key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 28, 2026 19:51

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 insufficient remaining quota.

@zeevmoney zeevmoney left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved — no CRITICAL or HIGH issues found.

Non-blocking:

  • MEDIUM permit/exceptions.py:309 — an error body labelled JSON that isn't valid JSON skips the scrub and escapes as a raw JSONDecodeError
  • MEDIUM permit/permit.py:37 — new _connect hook changes how subclasses are built; the logging fix doesn't need it
  • MEDIUM permit/exceptions.py:269 — redirect-history and list-body scrub branches have no test
  • MEDIUM tests/test_fix_logging.py:247 — "no key at any level, for both clients" is only partly tested
  • LOW permit/config.py:81 — the repr=False comment claims more than it delivers in tracebacks
  • LOW permit/exceptions.py:352 — the log line is scrubbed, the exception message built from the same text isn't
  • LOW README.md:58 — understates what "enable": True undoes
  • LOW README.md:71 — the redaction paragraph promises more than the code does
  • LOW permit/utils/sdk_logger.py:111 — dropped records are still labelled and scrubbed; cost grows with every key seen
  • LOW pyproject.toml:248 — the ban covers loguru but not stdlib logging
  • LOW permit/logger.py:84 — a level such as "²" makes client creation raise (isdigit vs isdecimal)
  • LOW tests/test_fix_logging.py:334 — the warn case passes without the alias; four branches untested
  • LOW tests/test_fix_logging.py:61 — the fixture keeps the set of warned-about levels between tests
  • LOW permit/exceptions.py:8 — multidict imported directly but not declared

Details are in the inline comments on each line.

Comment thread permit/exceptions.py Resolved
Comment thread permit/permit.py Resolved
Comment thread permit/exceptions.py Resolved
Comment thread tests/test_fix_logging.py Resolved
Comment thread permit/config.py Resolved
Comment thread pyproject.toml Resolved
Comment thread permit/logger.py Resolved
Comment thread tests/test_fix_logging.py Resolved
Comment thread tests/test_fix_logging.py Resolved
Comment thread permit/exceptions.py Resolved
EliMoshkovich and others added 4 commits October 29, 2026 13:13
…http error text

- An error response whose Content-Type says JSON but whose body isn't JSON (a
  proxy's error page, a truncated body, one that isn't UTF-8) now raises
  PermitApiError with the scrubbed body as its details, instead of letting a
  JSONDecodeError or UnicodeDecodeError, which keep the raw body, escape. The
  body is scrubbed before it is parsed, and the decode error isn't chained.
- PermitConnectionError replaces the key in its message itself, so every raise
  site is covered, including the ones that build it from an aiohttp error's text.
- The aiohttp error it carries has the key replaced in its message, the response
  headers it keeps and its string arguments, not only in the request headers.
- Tests: a body labelled JSON that isn't JSON; an aiohttp error whose text echoes
  the key, for every enforcer call and the REST API; an echoed key after a followed
  redirect, and in a list of errors and an object key spelled with JSON escapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nnect hook

Permit.__init__ called self._connect(), a new override point: an application
subclass with its own _connect method had it called by the SDK's constructor.
The logging change doesn't need it, so the base constructor builds the four
clients again and the blocking client's constructor builds its own after it, as
before. A test checks that a subclass's own _connect is left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- SdkLogger logs with loguru's lazy option, so the label, the key replacement and
  the message itself are built only for a record loguru passes on. With the SDK's
  logging off (the default) a debug record is dropped without being built or
  scrubbed. An application that turns the permit records on itself still gets
  them in full.
- The enforcer's debug lines, which pformat the request and the response, pass a
  function that builds the message, so the pformat runs only for a record a sink
  receives.
- A log level such as "²", which str.isdigit accepts but int() doesn't, is now an
  unknown level that falls back to INFO with a warning, instead of failing
  client creation.
- Tests: the echoed-key test runs on both clients, at trace, debug, info, error and
  critical, with logging off and with the application turning it on; the level
  sweep runs on both clients, checks the key and that no level falls back to INFO,
  and covers "fatal"; tests for the 12-character boundary, the label on the
  unknown-level path, an enabled client undoing the SDK's own disable only once, and
  a disable the application made for an SDK module before. The fixture resets the
  set of level names already warned about.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s; lint and deps

- README: list where the key is replaced (log messages, exception messages and
  error bodies, the aiohttp objects the exceptions carry) and where it isn't (the
  response's own headers and raw body, frame values in tracebacks), and keep the
  user-name-and-password sentence to the start-up line.
- README and docstrings: undoing the SDK's own disable with logger.enable("permit")
  also drops every disable set for a permit module, before or since, and overrides
  an application-wide logger.disable("") for the SDK's records. A test covers both.
- The repr=False comment on PermitConfig.token now says what it covers: repr() and
  str() of the config, not every value a traceback prints.
- ruff bans the standard logging module in the package, as it bans loguru's logger.
- multidict, imported directly, is a declared dependency, in aiohttp's range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 29, 2026 18:21

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 insufficient remaining quota.

@zeevmoney zeevmoney left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved — no CRITICAL or HIGH issues found.

Non-blocking:

  • MEDIUM permit/enforcement/enforcer.py:64 — a PDP error body that isn't UTF-8 escapes as a raw UnicodeDecodeError
  • MEDIUM permit/exceptions.py:270 — the args scrub has no test
  • LOW permit/exceptions.py:265 — the aiohttp error is scrubbed, but not everything its traceback prints
  • LOW permit/utils/sdk_logger.py:103 — the key is matched case-sensitively, so a lower-cased copy isn't replaced
  • LOW README.md:80 — two items in the "replaced" list are broader than the code
  • LOW permit/exceptions.py:60 — PermitConnectionError now fails on a message that isn't a string
  • LOW pyproject.toml:34 — the floor lanes now test multidict 4.5.0, built as pure Python
  • LOW tests/test_fix_logging.py:1162 — the lazy-record test pins the exact call sequence and covers check() only
  • LOW tests/test_fix_logging.py:1072 — inline copy of CLIENT_CLASSES

All 14 threads from the previous review were checked at bce2118 and replied to. One part of the PermitConnectionError thread is still open: the chained exceptions, covered by the exceptions.py:265 comment above.

Details are in the inline comments on each line.

Comment thread permit/enforcement/enforcer.py Resolved
Comment thread permit/exceptions.py Resolved
Comment thread permit/exceptions.py Resolved
Comment thread permit/utils/sdk_logger.py Resolved
Comment thread README.md Resolved
Comment thread permit/exceptions.py Resolved
Comment thread pyproject.toml Resolved
Comment thread tests/test_fix_logging.py Resolved
Comment thread tests/test_fix_logging.py Resolved
EliMoshkovich and others added 3 commits October 29, 2026 14:27
…er-cased key

- The aiohttp error an SDK exception carries is scrubbed together with every
  exception chained under it, such as the HTTP parser errors aiohttp raises it
  from: their string args, message, line, and the URL an InvalidURL error prints.
  Tested against the whole formatted traceback, for a bad header, a bad status
  line, a redirect to an invalid URL and one to a non-HTTP URL, on the PDP and
  the REST API, with both clients. The non-HTTP redirect keeps the key in args only.
- Each key is also replaced in lower case: aiohttp lower-cases some server text,
  such as a Content-Type, in its error messages. The test key is now mixed case.
- The enforcer reads a PDP error body with errors="replace", so a body that isn't
  valid UTF-8 raises PermitConnectionError instead of a UnicodeDecodeError that
  keeps the raw bytes.
- PermitConnectionError keeps a message that isn't a string as it is, as before.
- README: say what the redaction covers, now including the chained exceptions, and
  that URLs and a redirect response's own headers are not covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test now runs bulk_check, authorized_users and get_user_permissions as well
as check, and with the records on asserts that the message was built and
scrubbed, without pinning how many pformat calls it makes. Two parametrizations
use CLIENT_CLASSES instead of copying it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The multidict floor stays at aiohttp's own, 4.5, so declaring it narrows nothing
users can install. The comment now says that the lowest-direct floor lanes
therefore run 4.5.0, which has no C extension build for Python 3.10+.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 29, 2026 19:28

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 insufficient remaining quota.

@zeevmoney zeevmoney left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Changes requested — 1 HIGH, 2 MEDIUM, 4 LOW.

Blocking:

  • HIGH permit/exceptions.py:282 — the chain walk rewrites the application's own exceptions, and can raise instead of PermitConnectionError

Non-blocking:

  • MEDIUM permit/exceptions.py:298 — messages and arguments that aren't strings are left as they are, though the README says they are covered
  • MEDIUM tests/test_fix_logging.py:1253 — four of the new scrub branches can be removed with every test still passing
  • LOW permit/exceptions.py:274 — _chain's docstring doesn't match what it walks, and its cycle guard has no test
  • LOW permit/exceptions.py:270 — _description never holds server text
  • LOW tests/test_fix_logging.py:1294 — the raw-server test doesn't check that its case still reaches a scrub
  • LOW README.md:82 — "an error's ... URL" reads as contradicting "not in URLs"

All 9 threads from the previous review were checked at 1eea9d8 and replied to; each is fixed.

Details are in the inline comments on each line.

Comment thread permit/exceptions.py Resolved
Comment thread permit/exceptions.py Resolved
Comment thread tests/test_fix_logging.py Resolved
Comment thread permit/exceptions.py Resolved
Comment thread permit/exceptions.py Resolved
Comment thread tests/test_fix_logging.py Resolved
Comment thread README.md Resolved
EliMoshkovich and others added 2 commits October 29, 2026 16:18
…crub

The walk over the exceptions chained under a carried aiohttp error reached, through
__context__, an exception the application was handling when it called the SDK,
and wrote to it. A frozen dataclass exception, read-only args or a headers dict
with a value that isn't a string then made the scrub raise inside
PermitConnectionError.__init__, so the caller got that error instead of a
PermitConnectionError.

- The walk keeps only exceptions raised and caught within the request: every frame
  of their traceback is in aiohttp, the libraries it raises errors from, asyncio or
  the SDK. It doesn't go past any other exception, so the application's exception
  and its own chain are never changed.
- A value is written only when it holds a key, each write is guarded, and
  scrub_request_info never raises: it returns False when a value that holds a key
  can't be replaced. PermitConnectionError then attaches a plain ClientError that
  says so, and every raise site raises from original_error, so the unscrubbed error
  is neither attached nor the cause. handle_api_error doesn't chain to it either.
- Every string and bytes argument and attribute of those exceptions is scrubbed,
  not a fixed list, and so is a yarl URL, which becomes a string when it holds a key.
- Tests: the application's exception (frozen, headers with an int, read-only args)
  is left alone and PermitConnectionError is still raised, on both clients, for the
  PDP and the REST API; a scrub that fails is not attached or the cause, at every
  raise site; an error chained only as the context; a __cause__ cycle, in a
  subprocess; raw answers whose errors keep the key as bytes (a header line too
  long, a NUL in a header) and as a yarl URL; a padded key echoed in lower case.
  assert_holds_no_key also checks every attribute of every chained exception, and
  the raw-server test checks that each case still reaches a key to replace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ceptions alone

The redaction list says which chained exceptions are covered (those aiohttp and its
libraries raised during the request), that string and bytes values and an
invalid-URL error's URL are, the latter becoming a string, and that values that
aren't text, such as a parsed HTTP message, are not. It says the SDK never changes
an exception it didn't raise, and what it attaches when the key can't be removed.
"Request URLs" now names the URLs that are not covered, so it no longer reads as
contradicting the invalid-URL item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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 insufficient remaining quota.

@zeevmoney zeevmoney left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Changes requested: 1 HIGH, 3 MEDIUM, 3 LOW.

Blocking:

  • HIGH permit/exceptions.py:324: the frame check also applies to the error the SDK caught. When that error fails it, nothing is scrubbed and the scrub still reports success.

Non-blocking:

  • MEDIUM permit/exceptions.py:274: the walk now stops at errors raised in packages missing from the list, some of which 1eea9d8 scrubbed.
  • MEDIUM tests/test_fix_logging.py:1466: only 2 of the application-exception test's 12 cases catch a regression.
  • MEDIUM permit/exceptions.py:408: the fail-closed paths outside PermitConnectionError have no test.
  • LOW permit/exceptions.py:538: when the scrub fails, the error that wasn't attached is still the raised exception's __context__.
  • LOW README.md:89: the fail-closed sentence holds only for PermitConnectionError.
  • LOW tests/test_fix_logging.py:1276: the raw-server cases run only on aiohttp's C parser, and the positive control can pass without reaching a chained error.

All 7 threads from the previous review were checked at d2d1f86 and replied to. The previous HIGH, which changed the application's own exceptions, is fixed.

Details are in the inline comments on each line.

Comment thread permit/exceptions.py Resolved
Comment thread permit/exceptions.py Resolved
Comment thread tests/test_fix_logging.py Resolved
Comment thread permit/exceptions.py Resolved
Comment thread permit/exceptions.py Resolved
Comment thread README.md Resolved
Comment thread tests/test_fix_logging.py Resolved
…rub can't reach

- The error the SDK caught is always scrubbed; the frame check applies only to what is
  chained under it.
- The exception the application was handling when it called the SDK is passed in as
  `handled` and is left alone, with everything under it.
- Any other exception left as it is (raised in a package off the list, or with a frame of
  the application's), or text the scrub can't rewrite (a UnicodeError's `object`), makes
  the scrub return False if it still holds a key.
- PermitConnectionError and PermitApiError are raised after the `except` block, so an
  error that wasn't attached isn't their context.
- Tests: the caught error with a foreign frame; an idna error in a redirect host; a
  yarl-raised error; all four PDP calls with the application's exception; a refused
  connection; the frame-check, fail-closed, bytearray and builtin-field paths; and the raw
  answers on aiohttp's pure-Python parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 30, 2026 13:38

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 insufficient remaining quota.

zeevmoney and others added 2 commits October 30, 2026 16:36
A traceback shows every member of an exception group and what is chained
under each, so the scrub now walks them as it walks a cause and a context:
members raised during the request are scrubbed, and a key in one left as
it is fails the scrub. Works with the exceptiongroup backport on 3.10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEQWbhLbnWSwco3H5iNFis
The check for a key the scrub could not reach also catches most write
failures, so several tests passed whether or not the scrub worked. They
now check that the error is attached where the scrub should succeed, and
new cases cover: a write that fails out of sight of that check, an error
without the key that can't be written to, a key held only in an error's
text or only in its args, a cycle among the exceptions the scrub leaves
as they are, and the exception the application was handling, under one
left as it is, with frames only in the request's packages, and while an
error body that isn't JSON is read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEQWbhLbnWSwco3H5iNFis
Copilot AI balanced review requested due to automatic review settings October 30, 2026 15:06

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 insufficient remaining quota.

@EliMoshkovich
EliMoshkovich merged commit a7cd148 into main Oct 30, 2026
29 of 31 checks passed
@EliMoshkovich
EliMoshkovich deleted the eli/per-16680-sdk-logging branch October 30, 2026 18:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants