Skip to content

Cap logged structured error details at 256K characters and mask credential-shaped fields in them - #2939

Merged
kriszyp merged 11 commits into
mainfrom
fix/inspectforlog-size-budget-and-credential-redaction
Oct 12, 2026
Merged

kriszyp merged 11 commits into
mainfrom
fix/inspectforlog-size-budget-and-credential-redaction

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

When an operation fails with a structured http_resp_msg, Harper logs it through inspectForLog at 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 derives HdbError.message for 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

  1. The requirement. Warranted as specified, confirmed on main: a render of 199 objects × 249 fields of one shared 20 KB string produced 134,554,518 characters in ~850 ms, stopped only by util.inspect's own internal ~2^27-character limit, and a nested { headers: { Authorization } }, aws_secret_access_key or password rendered 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.
  2. Masking lives in the shared renderer, not only at the log call site. Because HdbError derives message through inspectForLog, 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 at OperationFunctionCaller; chosen because that same message is also what gets logged. Reversible by moving masking to the call site.
  3. One credential-key list, shared and extended. Reused the MCP audit log's substring regex rather than adding a third list; it is now exported from the logger as CREDENTIAL_KEY_PATTERN and gained cookie, private_key and passphrase, which also masks those in MCP audit records (an add_certificate-style private_key argument was previously logged). It over-masks by design: author, max_tokens and similar substrings are masked, numbers included; only null, undefined and booleans pass through, so token: undefined still shows. Rejected: UNLOGGABLE_OPERATION_FIELDS (exact names, including generic value/payload that a deep walk would erase) and decisionStore's hash-exclusion regex (its .*key matches primaryKey). A one-line change either way.
  4. HdbError report reasons stay unmasked. messageText flattens { error, ...reasons } before inspectForLog sees it, and those values are validation reasons — often under a field literally named password ("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 to err.message. Pinned by a test; a follow-up issue if report objects can carry secrets.
  5. Custom inspect hooks now run inside the sanitize walk. Own and inherited hooks on objects are called once, in walk order, where util.inspect would call them — own hooks with the original as this, 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's static hook, a Uint8Array subclass) 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.
  6. The walk tracks util.inspect's layout. For the estimate to bound work, the walk charges per-entry overhead, charges repeat references again, walks only to the depth and maxArrayLength inspect 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's this are 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 assumes util.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).
  7. Accepted limit: one oversized leaf per budget — an Error with a short message but a huge stack, a huge function name, or a hook's huge thrown-error text — is built in full before it is cut; that is linear in data the value already holds, and the budget is spent after it. An Error whose message alone exceeds the budget is rendered from its message without reading its stack.
  8. Planning review: Framing-Verdict: chosen-approach-sound (plan round, Codex). The 256 Ki cap is a constant rather than configuration; the largest known legitimate payload (deployComponent's install_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?

Before After
199 objects × 249 fields of one 20 KB string: 134,554,518 characters in one entry, ~850 ms, a nested Authorization printed verbatim. ≤ 262,144 characters, the walk stops each open container with one [N more … omitted (sanitize budget)] marker, credential-keyed values print [redacted].

The walk's estimate bounds the work, because it runs before util.inspect builds anything; the final cut bounds the length exactly, because inspect's layout and escaping cannot be predicted from the walk.

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]
Loading

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

Verification

  • unitTests/utility/logging/harper_logger.test.js — 24 new tests in the inspectForLog block: 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 printed maxArrayLength; 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, with token: undefined and authorized: false still shown.
  • unitTests/utility/OperationFunctionCaller.test.js — the real operation-caller log path emits a bounded, masked payload that keeps deployment_id.
  • unitTests/utility/errors/hdbError.test.js — a non-report structured message is masked; a report reason under password is kept.
  • Fails on base: 19 of the first 22 new tests failed against an origin/main build; 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.
  • Gates: test:unit:main 6322 passing, 0 failing (dfc88e3, run with the dispatch worker's GIT_CONFIG_GLOBAL/GIT_EDITOR/GIT_SSH_COMMAND unset — they fail gitCredentials.test.js locally only); test:unit:resources 3798 passing, 0 failing (133c30d; later commits touch only the logger); test:integration:all 2283 pass, 0 fail, 6 cancelled — all six the OllamaBackend suite, which needs models this machine lacks (0601c0b). Format, oxlint --deny-warnings on changed files, and check:design-docs clean.
  • End-to-end route: the logged line is exactly util.inspect(inspectForLog(…)), which the unit tests call directly and through callOperationFunctionAsAwait; 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 carries CREDENTIAL_KEY_PATTERN), #3064 overlaps (adds agent.maxTokens, which this PR's substring pattern masks), #3078 overlaps (same utility/DESIGN.md "Read this when" line), 15 others independent
Complexity: 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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread utility/logging/harper_logger.ts Outdated
@kriszyp
kriszyp marked this pull request as ready for review October 12, 2026 15:54
kriszyp and others added 3 commits October 12, 2026 10:13
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
kriszyp and others added 6 commits October 12, 2026 10:14
… 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
kriszyp force-pushed the fix/inspectforlog-size-budget-and-credential-redaction branch from 3638916 to efce249 Compare October 12, 2026 16:14
kriszyp and others added 2 commits October 12, 2026 11:05
…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
kriszyp merged commit b7d3f83 into main Oct 12, 2026
53 of 55 checks passed
@kriszyp
kriszyp deleted the fix/inspectforlog-size-budget-and-credential-redaction branch October 12, 2026 17:57
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.

1 participant