Skip to content

fix(security): drop the never-flipped secure_error_class! switch; log LLM errors via safe_message (#60) - #68

Open
minerva-sky wants to merge 2 commits into
codenamev:mainfrom
minerva-sky:fix/secure-error-mixin-dead-switch
Open

minerva-sky wants to merge 2 commits into
codenamev:mainfrom
minerva-sky:fix/secure-error-mixin-dead-switch

Conversation

@minerva-sky

Copy link
Copy Markdown
Collaborator

Closes #60.

SecureErrorMixin promised "automatic sanitization of sensitive data in error messages" and delivered it through a class-level switch, secure_error_class!, that nothing ever flipped. LlmError includes the mixin and never calls it, so e.message was always raw while the header told the next reader it was clean. That is the worst kind of security control: it exists only in the docstring, and it tells people to stop checking.

This PR picks option 1 from the issue.

What changes

  • secure_error_class!, ClassMethods, the included hook, and the private apply_security_sanitization are gone. The mixin now says what it does: sanitization is opt-in through safe_message, safe_context, safe_response, safe_backtrace, to_secure_hash, and log_securely. #message, #to_s, and #inspect are untouched, and a spec pins that.
  • LlmClient had seven log lines writing error.message raw (provider, timeout, parse, unexpected, models, query_generation_stats) while the retry path carefully used log_securely. The issue counted six; the seventh was in query_generation_stats. All seven now go through safe_message. The returned or raised error keeps the raw message, so nothing a consumer rescues changes.
  • safe_message can no longer raise. The panel found that moving a regex gsub into every rescue block let a provider error body with invalid UTF-8 blow up gsub, escape the rescue, and break fail_on_error: false. Reproduced on this machine: "sk-abc \xFF\xFE".force_encoding("UTF-8") raised ArgumentError: invalid byte sequence out of safe_message. The message is now scrubbed before sanitizing, and if the sanitizer still fails the log line reads [message withheld: sanitizer raised <class>]. Never the raw text.

Semver

The mixin arrived in #7 on 2026-09-02. The last published version is 0.2.0 from 2025-06-05, so secure_error_class! has never shipped and nobody can have called it. CHANGELOG records the removal under Unreleased anyway.

Not in this PR

RetryHandler still logs error.message raw on its retry and give-up lines, and PlanOrchestrator logs failure.message. Same shape as the LlmClient lines, pre-existing, and a separate change; one seat raised it and I'd rather keep this diff to the issue's scope. Worth a follow-up.

Tests

Full suite: 1396 examples, 1 failure, and the failure is event_dispatcher_integration_spec.rb:291, the wall-clock flake that fails identically on untouched main (documented in #29 and #64). Five new examples cover the opt-in contract, the sanitized log line, the invalid-bytes path, and the sanitizer-raises fallback.

Intended labels: loop:self.

Panel

  • DHH: cleared (3/3). Deleting the alias_method/define_method rewrite hook removes abstraction; leaving #message alone is the Ruby contract. Nit noted: seven safe_message call sites is repetitive, but a helper would be the one-use abstraction the charter forbids.
  • Obie Fernandez: objected-then-cleared (3/3 → 3/3). Objection: safe_message inside a rescue could raise on invalid UTF-8 and break fail_on_error: false. Fix: scrub before sanitizing, rescue into a withheld placeholder, specs at both layers. Second pass cleared every criterion.
  • Vladimir Dementyev: cleared (3/3). New specs use the per-example logger stub and reset Security::Config in after; safe_message is memoized and only runs on the error path.
  • The gem consumer: cleared (3/3). Removal is semver-safe because the method never shipped; log text is not a contract; no serialized agent or plan shape touches this code.

🤖 Generated with Claude Code

minerva-sky and others added 2 commits October 15, 2026 21:34
… LLM errors via safe_message

SecureErrorMixin shipped two halves: explicit safe_* accessors that
LlmClient, Task, and TaskFailure call, and a class-level
secure_error_class! switch meant to rewrite #message/#to_s/#inspect at
construction. Nothing ever called the switch, so LlmError#message was
never sanitized despite the mixin's header promising "automatic
sanitization". Remove the dead half and say what the mixin actually
does: sanitization is opt-in through safe_*, to_secure_hash, and
log_securely.

LlmClient had seven log lines writing error.message raw (provider,
timeout, parse, unexpected, models, generation stats) while the retry
path carefully used log_securely. Route all seven through safe_message.

Specs pin the contract both ways: #message stays raw, safe_* redacts,
and a provider error echoing an API key logs as [REDACTED_API_KEY].

Closes codenamev#60

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The panel caught that routing the LlmClient rescue blocks through
safe_message put a regex gsub on provider-supplied text ahead of
handle_error. A provider error body with invalid UTF-8 made gsub raise
ArgumentError, which escaped the rescue and broke fail_on_error: false.
Scrub the message before sanitizing and withhold it (never fall back to
raw) if the sanitizer still fails. Specs cover both layers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@minerva-sky minerva-sky added the loop:self Forward-thinking improvement loop (WORLD.md-aligned) label Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

loop:self Forward-thinking improvement loop (WORLD.md-aligned)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SecureErrorMixin#secure_error_class! has zero callers: LlmError#message and #inspect are never sanitized despite the mixin docs

1 participant