Skip to content

fix(logging): stop a hostile metaclass costing the record - #1516

Merged
groupthinking merged 1 commit into
mainfrom
claude/clever-heisenberg-vk1msh
Aug 29, 2026
Merged

fix(logging): stop a hostile metaclass costing the record#1516
groupthinking merged 1 commit into
mainfrom
claude/clever-heisenberg-vk1msh

Conversation

@groupthinking

@groupthinking groupthinking commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1576

Rescoped. This PR previously carried a five-hole consolidation against the earlier logging issue, opened as a backup while #1515 was in flight. #1515 merged on 13 Aug (5473bcc) and closed four of those five. The branch has been rebuilt from current main and reduced to the one hole that remains. It is no longer subordinate to anything, and no longer overlaps any merged or open PR.

Outcome

_describe_exception can still cost the record, on the one branch its own docstring calls safe:

"""...The type name is a plain attribute lookup and is always safe."""
try:
    return f"{type(exc).__name__}: {exc}"
except Exception:
    return type(exc).__name__          # <- this can raise too

__name__ on a class is looked up on its metaclass, so a metaclass defining __name__ as a raising property defeats the except branch. The second raise is outside any guard, so it propagates past the _JSON_UNSERIALIZABLE_RECORD tier and out of _format_json entirely; Handler.handleError then drops the record. The constant-record tier does not catch it.

Measured on 5473bcc, healthy → poisoned → healthy through a real StreamHandler: 2 of 3 records reach the sink.

This is the shape the previous issue was filed about, one level further down — the recovery step for a failure is itself able to fail.

The obvious fix does not work

CodeRabbit recommended object.__getattribute__(type(exc), "__name__") for this on an earlier PR in this series. It still routes through the metaclass descriptor:

type(exc).__name__                             -> RAISES RuntimeError
object.__getattribute__(type(exc),"__name__")  -> RAISES RuntimeError
type.__dict__["__name__"].__get__(type(exc))   -> OK: 'NamelessExc'

Binding the descriptor from type.__dict__ bypasses an override and returns the ordinary name for ordinary classes (ValueError'ValueError'), with a constant as the final floor.

Scope

  • Included: logging_config.py — one added tier in _describe_exception (6 lines of behaviour) and its corrected docstring. tests/unit/test_logging_config_crlf.py — 3 tests.
  • Excluded: everything fix(logging): close three ways the JSON fallback still lost the record #1515 already landed. This branch is built on 5473bcc; the scalar filter, int bound, allow_nan=False and constant tier are untouched as merged.
  • Excluded: payload construction. record.getMessage() on mismatched %-args raises above the try and fails the line-oriented path identically. The guarantee is "serialization never loses a record", not "no record is ever lost".

Risk

  • Risk level: low
  • Failure mode: the added tier is only reached when the existing branch would have raised, i.e. where the alternative today is no record. On every ordinary exception the output is byte-identical to main's — pinned by test_describe_exception_is_unchanged_for_ordinary_exceptions, and by the pre-existing fallback tests that assert exact strings.
  • Rollback: git revert. No migration, config, or schema change.

Verification

Head f35b5c2, on 5473bcc. Measured, not inferred.

  • Focused teststests/unit/test_logging_config_crlf.py: 36 passed. Collection goes 33 → 36, so every pre-existing test, including all of fix(logging): close three ways the JSON fallback still lost the record #1515's, is unchanged and still passes.
  • Non-vacuous, and explicit about which test proves what. Reverting only logging_config.py: 2 failed, 34 passedtest_describe_exception_survives_a_hostile_metaclass and test_hostile_metaclass_exception_does_not_cost_the_record. The third new test passes on 5473bcc by design: it guards the normal path against regression rather than pinning the fix, so it is excluded from the non-vacuity claim rather than padding it.
  • Lintruff check clean on both changed files.
  • Type checkingmypy reports 17 errors on 5473bcc and 17 on this head, the same pre-existing set at lines this PR does not touch.
  • Blast radius_describe_exception is module-private with exactly one caller, on the fallback path of _format_json, reached only via StructuredFormatter.format when json_output is set. test_logging_config_crlf.py is the only test file importing the module.
  • Required CI — pending on this head.

Not verified in this sandbox: the wider suite. pytest tests/unit hits collection errors from third-party deps absent from this container (fastapi, aiohttp, yaml, sqlalchemy, pydantic), unrelated to this change.

Production evidence

Not applicable as a preview: backend logging, no apps/web/** surface, which is what gate 4 of MERGE_POLICY.md scopes previews to. The runtime evidence is the reproduction itself, run against the real formatter through a real StreamHandler in both directions. production_config.py:72 defaults JSON_LOGGING to "true", so the JSON path is live.

Severity: low, and narrower than the previous issue's. Reaching it needs a call site to pass a value whose __str__ raises an exception whose class also carries a hostile metaclass. No current call site passes a non-scalar. This is a correctness gap in a safety net, not an exploitable path — worth closing because the guarantee is stated unconditionally in the code and is not unconditional.

A note on the red PR Governance attempts in this PR's history

Recorded because it will otherwise look like this PR failed the gate on its merits, and because it is a reusable gotcha for this repo.

pr-governance.yml reads the PR body from the frozen webhook payload (const pr = context.payload.pull_request), not from a fresh API fetch. Two consequences:

  1. The gate that ran on the force-push evaluated the body as it was before the rescope, so it reported the old canonical issue as closed. Editing the body fired an edited event whose run passed.
  2. rerun_failed_jobs cannot green this gate, because a re-run replays the original payload. I re-ran it once and it re-published a failure over an already-passing check — my error, and the reason there is a red attempt timestamped after a green one.

The way to re-evaluate this gate is a new pull_request_target event (edit the body, or push), never a job re-run. Worth considering pulls.get instead of the payload so the gate reflects current state.

Agent handoff

Produced by a scheduled, unattended PR-remediation routine. Halts at the human gate; no auto-merge to protected main is requested or performed.

@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v0-uvai Ready Ready Preview, v0 Aug 29, 2026 6:28am

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 47e459b7-39f1-4e2e-a065-1e5f1c74d663


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Snapshot Warnings

⚠️: No snapshots were found for the head SHA f35b5c2.
Ensure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice.

Scanned Files

None

#1515 closed #1525's three residual holes and is on main. One path in the
same function is still reachable, and it is the one its docstring asserts is
safe: "The type name is a plain attribute lookup and is always safe."

It is not. `__name__` on a class is looked up on its *metaclass*, so a
metaclass defining `__name__` as a raising property defeats the `except`
branch. That second raise happens outside any guard, so it propagates past
the `_JSON_UNSERIALIZABLE_RECORD` tier and out of `_format_json` entirely,
and `Handler.handleError` drops the record. The constant-record tier does
not catch it. Measured on 5473bcc: 2 of 3 records reach the sink.

`object.__getattribute__(type(exc), "__name__")` does not fix this -- it
still routes through the metaclass descriptor:

  type(exc).__name__                             -> RAISES
  object.__getattribute__(type(exc),"__name__")  -> RAISES
  type.__dict__["__name__"].__get__(type(exc))   -> OK

Binding the descriptor from `type.__dict__` bypasses an override and returns
the ordinary name for ordinary classes, with a constant as the final floor.
The docstring is corrected to state the guarantee the code provides.

This is the same failure shape #1525 was filed about, one level down: the
recovery step for a failure is itself able to fail.

Verification on this head: 36 passed in tests/unit/test_logging_config_crlf.py
(33 pre-existing, unchanged). Reverting only logging_config.py: 2 failed,
34 passed. The third new test,
test_describe_exception_is_unchanged_for_ordinary_exceptions, passes on
5473bcc by design -- it guards the normal path against regression rather than
pinning the fix, so it is excluded from the non-vacuity claim.
ruff clean; mypy reports the same 17 pre-existing errors on both heads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msg6kqkhiuW1ZiDv66sr4N
@groupthinking
groupthinking force-pushed the claude/clever-heisenberg-vk1msh branch from b7eb515 to f35b5c2 Compare August 29, 2026 06:27
@groupthinking groupthinking changed the title fix(logging): make the JSON serialization fallback unable to fail fix(logging): stop a hostile metaclass costing the record Aug 29, 2026
@groupthinking
groupthinking merged commit c23009b into main Aug 29, 2026
31 of 33 checks passed
@groupthinking
groupthinking deleted the claude/clever-heisenberg-vk1msh branch August 29, 2026 06:36
@linear-code

linear-code Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

GRV-441

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_describe_exception can still drop the record — type(exc).__name__ resolves through the metaclass

2 participants