Repository navigation
Cap logged structured error details at 256K characters and mask credential-shaped fields in them - #2939
Merged
kriszyp merged 11 commits intoOct 12, 2026
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request refactors inspectForLog in harper_logger.ts to enforce a cumulative rendering budget (MAX_LOG_RENDER_LENGTH) across the entire object structure, preventing excessive log sizes. It also unifies credential masking using a shared CREDENTIAL_KEY_PATTERN and improves error logging by avoiding large stack materialization when the budget is exceeded. The feedback points out a potential TypeError in renderErrorLine when accessing error.constructor.name on objects with a null prototype, suggesting the use of optional chaining to safely fall back to a default name.
This was referenced Oct 4, 2026
This was referenced Oct 5, 2026
This was referenced Oct 6, 2026
kriszyp
marked this pull request as ready for review
October 12, 2026 15:54
inspectForLog renders caller-supplied diagnostic structures at raised inspect limits (the operation caller logs http_resp_msg at depth 8 with maxStringLength 20000). Its sanitize walk capped depth, entries per container and total nodes, but those limits multiply: 50k string leaves rendered ~134M characters into one log entry. And only Error properties were filtered, so a nested plain object's password or authorization header rendered verbatim once depth 8 exposed it. - The walk now charges an estimated rendered length per entry and stops each open container with one omitted-entries marker once it is spent. Nothing renders uncharged: Errors, opaque built-ins and functions are rendered to text in the walk and charged exactly, repeat references to a walked container are charged again, and preserved custom inspect hooks run against the same budget. - The rendered string is cut to MAX_LOG_RENDER_LENGTH (256 Ki characters) on every path, including the failure text, since inspect's layout and escaping are not predictable from the walk. - Values under keys matching CREDENTIAL_KEY_PATTERN are masked on walked objects, class instances and Maps. The pattern is the MCP audit log's, moved into harper_logger.ts and shared, not a third list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rVw4rPvxkKS6HkrR5z3xy Dispatch-Task: harper-inspectforlog-byte-budget-and-key-redaction
… prints Review follow-up to the render budget: - Custom inspect hooks, own or inherited, are called during the walk where util.inspect would call them, and their output is walked, masked and charged in order. Inherited hooks were previously run by util.inspect on the clone, unsanitized and uncharged, and own hooks ran at render time after later fields had already spent the budget. - Nothing below the level where util.inspect collapses a container to [Object] is walked, rendered or charged, so deep content no longer starves shallow fields; a clone is reused only at its own depth or deeper. - A property name longer than the remaining budget stops its container, since util.inspect prints keys whole. - An Error's cause chain stops growing once it passes the budget. - Symbol keys are masked by description; the shared pattern also covers cookie, private_key and passphrase. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rVw4rPvxkKS6HkrR5z3xy Dispatch-Task: harper-inspectforlog-byte-budget-and-key-redaction
Second review follow-up: - Opaque built-ins and functions render with customInspect off (Buffer's built-in hook aside), so a hook they carry cannot print unwalked, unmasked output. - A container at the level where util.inspect collapses it to [ClassName] is no longer enumerated, so deep wide structures spend neither the node nor the length budget on fields never printed. - Arrays, Maps and Sets are walked to the maxArrayLength util.inspect prints (100 by default) rather than 1000, which over-charged the HdbError message path about tenfold. - An Error whose message alone exceeds the remaining budget is rendered from its message without materializing its stack. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rVw4rPvxkKS6HkrR5z3xy Dispatch-Task: harper-inspectforlog-byte-budget-and-key-redaction
… own hook Third review follow-up: - The properties walked for an inherited inspect hook's `this` stay charged when the hook's output replaces them. Refunding them let every such sibling format another budget's worth of leaves (250 instances each formatted a 200 KB stack). - Only Node's own Buffer inspect hook, captured at load, is left to run; a replaced one renders natively instead. - A spent budget no longer slices a huge Error message from the end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rVw4rPvxkKS6HkrR5z3xy Dispatch-Task: harper-inspectforlog-byte-budget-and-key-redaction
… are large Charging the properties walked for an inherited hook's `this` against the render budget made a class with a short summary inspector and large fields print an omission marker, and dropped the fields after it. They are refunded to the render budget again and counted against a separate cap of four budgets instead, which still bounds the work across siblings (250 such objects read 8 stacks, not 500). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rVw4rPvxkKS6HkrR5z3xy Dispatch-Task: harper-inspectforlog-byte-budget-and-key-redaction
…walks Fifth review follow-up: - A hook on an opaque built-in or function is resolved like an object's and its output walked, instead of being switched off: a hook that hides data (a Uint8Array subclass printing `SecretKey(***)`) keeps hiding it, and one returning credentials is masked. Node's own Buffer hook still renders natively. - Reaching the cap on discarded inherited-hook work now omits only further inherited-hook objects; plain fields after them keep rendering. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rVw4rPvxkKS6HkrR5z3xy Dispatch-Task: harper-inspectforlog-byte-budget-and-key-redaction
Sixth review nits: the fresh built-in that stands in for one carrying an expando goes through the walk, so a hook patched onto its prototype is resolved and masked like any other; a string that fits is no longer suffixed with "0 more characters omitted"; and the omission label for a capped inherited-hook object is charged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rVw4rPvxkKS6HkrR5z3xy Dispatch-Task: harper-inspectforlog-byte-budget-and-key-redaction
…its marker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rVw4rPvxkKS6HkrR5z3xy Dispatch-Task: harper-inspectforlog-byte-budget-and-key-redaction
…s message Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rVw4rPvxkKS6HkrR5z3xy Dispatch-Task: harper-inspectforlog-byte-budget-and-key-redaction
kriszyp
force-pushed
the
fix/inspectforlog-size-budget-and-credential-redaction
branch
from
October 12, 2026 16:14
3638916 to
efce249
Compare
…IGN.md overclaim callInspectHook always computed budget.renderDepth - depth, so an unlimited caller depth (Infinity internally) reached a hook as Infinity instead of the null util.inspect itself would pass - matters only to a hook that tests depth === null; no current caller passes depth: null. The design note claimed every custom inspect hook is called in the walk; an Array/Map/Set's own hook, a non-enumerable own hook, and an inherited hook behind an accessor-bearing prototype are not resolved (pre-existing, not introduced by this change) - narrowed the claim to leaves and plain objects and noted the gap. Dispatch-Task: pr-maint-617e5e646dd20258dbfbe4a5d0960996 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The prior wording said a non-enumerable own hook and an accessor-guarded inherited hook are "never resolved" without qualifying that leafInspectHook (opaque built-ins, functions) has neither restriction - only ownInspectHook and the plain-object inherited-hook path do. Also credits Array/Map/Set's inherited hook, not just its own, as unresolved, since those branches clone before any hook lookup at all. Dispatch-Task: pr-maint-617e5e646dd20258dbfbe4a5d0960996 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kriszyp
deleted the
fix/inspectforlog-size-budget-and-credential-redaction
branch
October 12, 2026 17:57
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
When an operation fails with a structured
http_resp_msg, Harper logs it throughinspectForLogat raised inspect limits (depth 8, 250 entries, 20,000-character strings). This PR bounds that render to 256 Ki characters in total — one log entry previously reached ~134M characters — and masks values stored under credential-shaped keys (password,authorization,*_secret_access_key,token,api_key,cookie,private_key, …) at any depth. The same renderer derivesHdbError.messagefor structured non-report messages, so those are capped and masked too.Not covered, by design: masking is structural, so a custom inspect hook that returns a secret as a plain string, a non-string Map key, and
rawHeaders-style[name, value]arrays are not masked;HdbError's report flattening stays unmasked (entry 4).For the human reviewer
main: a render of 199 objects × 249 fields of one shared 20 KB string produced 134,554,518 characters in ~850 ms, stopped only byutil.inspect's own internal ~2^27-character limit, and a nested{ headers: { Authorization } },aws_secret_access_keyorpasswordrendered verbatim. One deviation from the task text: the budget counts characters, not bytes, because string length is what can be measured without an encoding pass; a capped entry is at most 768 KiB as UTF-8. No other in-flight work supersedes this.HdbErrorderivesmessagethroughinspectForLog, a structured non-report error message now shows[redacted]and is capped in what API clients receive (errorToString, HTTP error bodies,get_job). The alternative was masking only atOperationFunctionCaller; chosen because that samemessageis also what gets logged. Reversible by moving masking to the call site.CREDENTIAL_KEY_PATTERNand gainedcookie,private_keyandpassphrase, which also masks those in MCP audit records (anadd_certificate-styleprivate_keyargument was previously logged). It over-masks by design:author,max_tokensand similar substrings are masked, numbers included; onlynull,undefinedand booleans pass through, sotoken: undefinedstill shows. Rejected:UNLOGGABLE_OPERATION_FIELDS(exact names, including genericvalue/payloadthat a deep walk would erase) anddecisionStore's hash-exclusion regex (its.*keymatchesprimaryKey). A one-line change either way.HdbErrorreport reasons stay unmasked.messageTextflattens{ error, ...reasons }beforeinspectForLogsees it, and those values are validation reasons — often under a field literally namedpassword("Password must be at least 8 characters"). Masking there would erase them; the cost is that a caller putting a real secret into a report sends it toerr.message. Pinned by a test; a follow-up issue if report objects can carry secrets.util.inspectwould call them — own hooks with the original asthis, inherited ones with the sanitized clone, both as before — and their output is walked, masked and charged like any value. This closes a pre-existing gap: a prototype hook's output was printed raw. Hooks carried by opaque built-ins and functions (a Date carrying a hook, a class'sstatichook, aUint8Arraysubclass) are resolved the same way, so a hook that hides data keeps hiding it and one returning credentials is masked; Node's own Buffer hook renders natively. The alternative was never running application hooks on the log path, which would print class instances by their properties only.util.inspect's layout. For the estimate to bound work, the walk charges per-entry overhead, charges repeat references again, walks only to thedepthandmaxArrayLengthinspect prints, and does not enumerate a container inspect collapses to[ClassName]— so an empty object at that level now prints[Object]instead of{}. Properties walked only to serve as an inherited hook'sthisare refunded when its output replaces them, against a separate cap of four budgets; past that cap, further such objects print as an omission while other fields keep rendering. All of this assumesutil.inspect's rendering rules; if Node changes them the estimate drifts, while the exact final cut still holds the 256 Ki cap. Simpler alternative rejected: charge only leaf lengths and rely on the final cut, which leaves the work unbounded (the 134M-character intermediate still gets built).Framing-Verdict: chosen-approach-sound(plan round, Codex). The 256 Ki cap is a constant rather than configuration; the largest known legitimate payload (deployComponent'sinstall_output) is capped at 16 KB.Product and architecture tour
Two-part bound on one log entry
Why both an estimate in the walk and an exact cut at the end?
Authorizationprinted verbatim.[N more … omitted (sanitize budget)]marker, credential-keyed values print[redacted].From payload to log line
Every value is sanitized and charged in the walk, in the order inspect prints it; inspect then formats only sanitized clones and pre-rendered text, and the result is cut to the cap.
flowchart LR A[Structured payload] --> B[Sanitize walk] B -->|charge per entry| C[Render budget] B -->|call hooks, walk output| B B --> D[util.inspect] D --> E[Exact cut to 256 Ki] E --> F[Log line]What the walk treats as rendered
Which values are charged, and which are never walked?
Errors, opaque built-ins and functions are rendered to text in the walk and charged exactly. Custom inspect hooks, own or inherited, run in the walk and their output is walked like any value. Containers below the caller's
depth+ 1 are never walked, and a container at that level prints[ClassName]without being enumerated. Repeat references are charged again because inspect prints a shared sub-object at every occurrence.Changes
utility/logging/harper_logger.ts: the cap (MAX_LOG_RENDER_LENGTH) is applied on every path ininspectForLog, whose budget walks arrays only to the printedmaxArrayLength. The walk skips what inspect never prints (belowdepth+ 1, collapsed containers), charges repeat references, and stops before an oversized key. Hooks are resolved in the walk for objects (own, inherited) and leaves; properties walked for an inherited hook are refunded but capped byMAX_DISCARDED_RENDER. Masking isisCredentialEntry, applied to object keys and Map keys with the sharedCREDENTIAL_KEY_PATTERN.errorToLogStringskips the stack when the message alone exceeds the budget and stops appending causes past it. Look hardest at the hook resolution: it decides what application code runs during logging.components/mcp/audit.ts: imports the shared pattern and uses it in place of its local copy.utility/DESIGN.md— the invariant note;DESIGN.md— its index line.Verification
unitTests/utility/logging/harper_logger.test.js— 24 new tests in theinspectForLogblock: the 134M-character shape renders within 256 Ki and ends as a whole structure (walk-truncated, not cut); escape-heavy output still meets the exact cap; hooks share one budget, including hooks nested in hook output, and resolve in walk order; inherited hook output is masked and charged, and a short summary survives large replaced properties; replaced properties across 250 siblings read 8 stacks, not 500, and fields after the cap still render; a hook on a built-in leaf is walked — masked when it returns credentials, kept when it hides data (SecretKey(***)), including a replaced Buffer hook; containers collapsed at inspect depth are not walked; arrays are walked only to the printedmaxArrayLength; a huge-message Error's stack is never read; oversized keys stop their container; repeat references, Errors, typed arrays and Map keys are charged; masking at depth ≥ 6 in plain objects, class instances, Maps and symbol keys, withtoken: undefinedandauthorized: falsestill shown.unitTests/utility/OperationFunctionCaller.test.js— the real operation-caller log path emits a bounded, masked payload that keepsdeployment_id.unitTests/utility/errors/hdbError.test.js— a non-report structured message is masked; a report reason underpasswordis kept.origin/mainbuild; the 3 that pass there guard behavior that must not change (report reasons, below-depth rendering, shared-object reuse). The ones added during review each target a finding and failed on the commit before its fix.test:unit:main6322 passing, 0 failing (dfc88e3, run with the dispatch worker'sGIT_CONFIG_GLOBAL/GIT_EDITOR/GIT_SSH_COMMANDunset — they failgitCredentials.test.jslocally only);test:unit:resources3798 passing, 0 failing (133c30d; later commits touch only the logger);test:integration:all2283 pass, 0 fail, 6 cancelled — all six theOllamaBackendsuite, which needs models this machine lacks (0601c0b). Format,oxlint --deny-warningson changed files, andcheck:design-docsclean.util.inspect(inspectForLog(…)), which the unit tests call directly and throughcallOperationFunctionAsAwait; no separate integration test.Signed: Claude Opus 5.5
🤖 Generated with Claude Code
https://claude.ai/code/session_013rVw4rPvxkKS6HkrR5z3xy
Release target: main — no backport evidence (8 changed lines predate this shallow clone's history) and no linked issue to carry a milestone from
Related PRs: #562 overlaps (its cached diff touches
harper_logger.ts's export surface, which now carriesCREDENTIAL_KEY_PATTERN), #3064 overlaps (addsagent.maxTokens, which this PR's substring pattern masks), #3078 overlaps (sameutility/DESIGN.md"Read this when" line), 15 others independentComplexity: complicated
Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi,cursor-muse; rounds=12; full=3 @ e90c7d6
Review-Attention: read ~6m (decisions: document-hook-gaps, substring-credential-match, shared-credential-list, mask-type-exemptions, hooks-in-walk, render-cap-size, do-less-alternative) @ e90c7d6