Repository navigation
fix(logging): add payload-safe helpers for exceptions, URLs, ids and repeat warnings - #4138
chopratejas wants to merge 16 commits into
Conversation
…repeat warnings Log records are kept and shared, so no level may carry request content, tool output or credentials. headroom/log_safety.py describes an exception by its types and code locations without the message (full traceback only with HEADROOM_DEBUG_DUMP=full), redacts URL userinfo and query, bounds ids, and gives a warn-once guard that stays bounded instead of resetting. The request debug dump now uses the shared URL redactor and opt-in check. Refs #4136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR governanceThis PR does not yet satisfy the required template fields:
Please update the PR body, or move the PR back to draft while it is still in progress. |
JerrettDavis
left a comment
There was a problem hiding this comment.
The shared helper direction is useful, and all 14 helper tests pass. Two safety blockers remain with content logging disabled; I independently reproduced both and considered the existing inline findings.
-
_describe_onecopiesOSError.strerrorinto its result. That field is caller-supplied text:describe_exception(OSError(5, 'backend rejected PRIVATE_CREDENTIAL_CANARY\nFORGED_LOG_ENTRY'))includes both the credential canary and the actual newline when the returned description is formatted into a DEBUG record. Truncating to 80 characters does not sanitize it. Please retain the exception class and a validated numeric errno; omit arbitrary strerror, or derive a trusted canonical description from a validated errno rather than the exception's string. Add a regression formatting a real LogRecord with content opt-in unset and assert both the canary and injected newline are absent, including chained errors. -
redact_urldeliberately retains paths, but the newly changed upstream error-dump call site passes the complete upstream URL without establishing that its path is safe.https://upstream.example/botPRIVATE_CREDENTIAL_CANARY/v1/messagesretains the credential after redaction. Please make the dump safe for credential-bearing paths by omitting them or logging only an explicitly known safe route; test arbitrary and percent-encoded path secrets in default/error mode. A helper docstring warning does not protect this production call site.
These are new maintainer findings on exact commit 88f934b; no prior maintainer review was present. Current main is an ancestor, diff --check is clean, and GitHub reports MERGEABLE. Qualification stopped at the concrete blockers; no green-CI or merge-readiness claim. No contributor code was changed.
… overflow describe_exception printed OSError.strerror, which some subclasses fill with free text; it now prints os.strerror(errno). After its overflow notice, WarnOnce no longer lets forget() reopen the cap for new keys. Refs #4136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…no values After its overflow notice, WarnOnce stayed quiet for the life of the process, so a newly broken hook in a long-running proxy never warned. It now stays quiet for one window (an hour by default), then starts over: at most limit + 1 warnings per window. os.strerror raises on an errno that does not fit a C int; the description then says "unknown error". Refs #4136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Found while picking this up in #4111: at 189387a, |
JerrettDavis
left a comment
There was a problem hiding this comment.
Re-reviewed changed head cd50ad3, including the discussion and inline replies. The earlier OSError.strerror blocker is fixed: the helper uses system errno text, and my credential/newline reproduction no longer leaks. All 18 helper tests pass.
The URL blocker remains at the production call site. With HEADROOM_DEBUG_DUMP=redacted (no full-content opt-in), I called write_upstream_error_dump with an upstream URL containing a path credential. The actual saved JSON retains https://upstream.example/botPRIVATE_CREDENTIAL_CANARY/v1/messages. A second call with the percent-encoded path /bot%50RIVATE_CREDENTIAL_CANARY/v1/messages also persists the secret. This reproduction exercises the writer, not just redact_url.
I read the reply that route names cannot reliably be distinguished from secrets. That is precisely why an arbitrary upstream path should not be treated as safe. The helper's docstring and PR-body restriction are not enforced by write_upstream_error_dump, which accepts the complete URL. Please omit arbitrary paths from redacted diagnostics, or substitute an explicitly known safe route, and add production-writer regressions for plain and encoded path credentials. Full-content mode can retain its explicit opt-in behavior.
Qualification stops at this reproduced blocker. No contributor source changes or merge were performed.
… logs Local carry of upstream headroomlabs-ai#4137 (open, unmerged), applied as its net diff against main (git diff origin/main...pr-4137), the same change a squash-merge lands. Includes the headroomlabs-ai#4138 log-safety helpers it merges in. Drop this commit once headroomlabs-ai#4137 is on main. (cherry picked from PR headroomlabs-ai#4137 head e6d0913)
…set WarnOnce hourly A path segment can carry a credential (/bot<token>/, webhook URLs), so redact_url keeps only scheme, host and port unless HEADROOM_DEBUG_DUMP=full; userinfo and query are always dropped. An errno outside the C int range is not formatted, since int-to-text can raise. WarnOnce now resets every window for all keys, so a recurring failure warns again once an hour. Refs #4136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@JerrettDavis thanks, you're right that the docstring wasn't an enforcement.
|
…llback With DEBUG on, compact_lossless must return the original content when a fold raises OSError with an errno past the C int range: 2**31 (os.strerror would overflow) and 10**10000 (str() hits the integer digit limit). The DEBUG line must stay bounded with the digit limit disabled too. Fails with the earlier log_safety helper (ValueError, and a 10000-digit line); passes on #4138's tip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assisted-by: claude-code:claude-opus-5-5 Headline-Session: 6e77a807-8dd4-4a77-a2fa-ddce6dc2515e Headline-AI-Share: 100% strict / 100% weighted
…ypass the cap The cap compared the number of keys held, and forget() removes keys, so a cycle of forget plus new keys warned without limit. The guard now counts warnings issued in the window; forget() re-arms a key but does not refund its warning. Refs #4136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Further keys are not logged at WARNING until the window ends; the notice said they went to debug, which no caller does. Refs #4136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gs in the overflow notice exec/compile code chooses its own file and function names, which can quote request data, so such frames show as <dynamic code>. The overflow notice now counts warnings, matching what WarnOnce limits, instead of distinct keys. Refs #4136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d module Runtime-compiled code can claim a real file name, so checking the file on disk let its chosen function name through, and it hid every frame of a .pyc-only or zip install. A frame is now logged only when its globals are the namespace of the module sys.modules holds under that name; the location comes from the module, so sourceless and zip installs keep theirs. Refs #4136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… logs Local carry of upstream headroomlabs-ai#4137 (open, unmerged), applied as its net diff against main (git diff origin/main...pr-4137), the same change a squash-merge lands. Includes the headroomlabs-ai#4138 log-safety helpers it merges in. Drop this commit once headroomlabs-ai#4137 is on main. (cherry picked from PR headroomlabs-ai#4137 head e6d0913)
…pers - redact_url: a string with no "//" (a pasted "sk-key:secret") is not logged at all; unknown schemes and hosts with line breaks are replaced. - describe_exception: a class name that is not printable is replaced; an int-subclass errno is not formatted; frame data is read from the module namespace with dict's own methods; the helper returns a fixed token rather than raise. - safe_id never raises; WarnOnce rejects an infinite or NaN window. Refs #4136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
describe_exception no longer needs a catch-all: frame data is read through dict's own methods with exact type checks, so the odd inputs it guarded against cannot raise (their tests still pass). safe_id catches only the errors repr raises in practice. Refs #4136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… in URLs Every text a frame carries (module, file, function name) can be set by code compiled at runtime, so describe_exception now names a frame only when it is Headroom's own code: registered module namespace, plain headroom.* name, the module's own file, and an identifier function name. Library frames are skipped and only the innermost three Headroom frames are kept, in bounded memory. redact_url accepts hostless file/sqlite/unix URLs and a host only when it is a DNS name or IP address. Refs #4136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| frames = _innermost_headroom_frames(exc.__traceback__) | ||
| if frames: | ||
| text += " at " + " <- ".join(reversed(frames)) |
There was a problem hiding this comment.
🔍 Exception diagnostics differ from described behavior
The PR description promises <dynamic code> for runtime frames and locations for imported libraries. describe_exception now omits both, leaving only exception types when no Headroom frame qualifies.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed: the PR description now matches the code. Only Headroom's own frames are named and other frames are skipped (no <dynamic code> token); the redact_url and safe_id rules are written out in full.
describe_exception reads __cause__, __context__, __traceback__, errno and the class name through the base classes' own descriptors, so a subclass property that raises never runs. safe_id uses the built-in repr of str, bytes and numbers and shows any other type by name, so no __repr__ of the caller's object runs. No blind except is needed for either. Refs #4136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n safe_id - redact_url drops an IPv6 zone id (free text), accepts "_" in host labels (container service names) and gives non-ASCII hosts in IDNA form; a hostless URL must have ":///" right after its scheme. - safe_id checks the real type, not a fakeable __class__, and respects the process's int-to-text digit limit. - Class names are logged only when shaped like source identifiers. Refs #4136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A host that is not an IP address or a strict DNS name (no "_") may be a pasted key, so it is shown as <host>; scheme and port are kept. - The int digit-limit getter only exists from 3.10.7; earlier releases have no limit, so safe_id no longer raises AttributeError there. - The hostless check cleans the URL the same way urlsplit does. Refs #4136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… logs Local carry of upstream headroomlabs-ai#4137 (open, unmerged), applied as its net diff against main (git diff origin/main...pr-4137), the same change a squash-merge lands. Includes the headroomlabs-ai#4138 log-safety helpers it merges in. Drop this commit once headroomlabs-ai#4137 is on main. (cherry picked from PR headroomlabs-ai#4137 head e6d0913)
Description
Log records are kept, rotated, attached to bug reports and shipped to log collectors, so no level, DEBUG included, may carry request content, tool output or credentials. Review of the cleanup PRs (#4136) found that moving exception detail to
logger.debug(..., exc_info=True)still leaks it: a compressor, hook or storage backend can quote the payload or a connection URL in its exception message, and the rendered traceback prints that message.This PR adds one small shared module,
headroom/log_safety.py, so every log line describes a failure the same safe way. The open cleanup PRs that log failures will use it instead of each inventing their own redaction.Refs #4136
Type of Change
Changes Made
headroom/log_safety.py:describe_exception(exc): each exception in the cause chain as its type plus the innermost Headroom code locations (at most 3), never the message. Only Headroom's own frames are named: the frame's globals must be the namespace of the module registered under that name, the name must be a plainheadroom.*identifier, the code's file must be the module's own file, and the function name a plain identifier. Library, stdlib and runtime-compiled frames are skipped, since any text they carry can be chosen at runtime. Locations come from the module name, so.pyc-only and zip installs keep theirs. A class name not shaped like source identifiers becomes<exception>; an OSError keeps a plain-int errno and the system's text for it. WithHEADROOM_DEBUG_DUMP=fullit returns the full traceback.content_logging_enabled(): one reading of that opt-in.redact_url(url): keeps only scheme, host and port. Userinfo, query, fragment and the path (shown as/<path>) are dropped; the path is kept only withHEADROOM_DEBUG_DUMP=full. The host is kept only when it is an IP address (an IPv6 zone id is dropped) or a strict DNS name (letters, digits and-; non-ASCII names in IDNA form). Any other host, including one with_(key-shaped, e.g.sk_live_…), is shown as<host>, with scheme and port kept. A URL with no host is accepted only forfile:///,sqlite:///andunix:///. Anything else, for example a pastedsk-key:secret, gives<unparseable url>. A scheme outside a short known list is shown as<scheme>.safe_id(value): the built-in repr of astr,bytesor number, capped at 80 characters, so an id can neither forge a log line nor flood the log. No__repr__of the caller's object runs: any other type is shown as<TypeName>, and an int too long to print under the process's digit limit as<int of N bits>.WarnOnce(limit, what, window_seconds=3600): inside each window (an hour by default), each key warns once, for at mostlimitkeys. Past that it logs one overflow notice and stays quiet until the window ends, so a failure across many keys gives at mostlimit + 1warnings an hour. When the window ends it starts over, so a failure that keeps recurring, or a newly broken hook, warns again once an hour. A key is used up only while WARNING is enabled.forget(key)re-arms a key once its failure clears, but cannot reopen the cap after the overflow notice. The window must be positive.headroom/proxy/handlers/_debug_dump.py:redact_urlinstead of its own query-only copy, so a redacted dump's URL loses userinfo and the path as well as the query (afulldump keeps the path, never the query);content_logging_enabled(), so the opt-in values are defined in one place.tests/test_log_safety.py:from None;WarnOnce: bounded across three passes over 65 keys; not used up while WARNING is disabled; re-armed byforget; not reopened byforgetinside a window; starting over after the window.Testing
pytest)ruff check .)mypy headroom)Test Output
Real Behavior Proof
.venv, this branch's worktree. TempHOMEandHEADROOM_WORKSPACE_DIR.bash smoke.sh <this worktree>, does these steps in order:python -m headroom.cli proxy --port <random>from this checkout, withHOMEandHEADROOM_WORKSPACE_DIRin a temp dir,HEADROOM_OFFLINE=1, andANTHROPIC_TARGET_API_URL/OPENAI_TARGET_API_URLpointing at a local fake upstream;/health;/v1/messagesand one/v1/chat/completionsrequest, each with a 400-row JSON tool result;/statssavings, and the proxy.log WARNING/ERROR lines.Runtime Rollout Safety
HEADROOM_DEBUG_DUMP=1), whose URL now also drops userinfo and the path. The new module has no callers yet.Review Readiness
Checklist
CHANGELOG.md— it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this)Screenshots (if applicable)
Not applicable.
Additional Notes
Part of maintainer-requested cleanup #4136. Please merge this one early: the logging PRs flagged in review (#4092, #4094, #4105, #4111, #4124, #4127) pick it up so they can stop logging exception text at DEBUG. Until it merges, those PRs merge this branch in, so its files show in their diffs.
This PR is a little over the 300-line guideline; about half of that is tests.
Not done here: converting the existing
exc_info=Truelines already onmaintodescribe_exception. That is a separate sweep PR after the cleanup PRs land.🤖 Generated with Claude Code