Class: Terret::Redactor

Inherits:
Hames::Service
  • Object
show all
Defined in:
lib/terret/redactor.rb

Overview

ctx — §13's "credentials never enter the session log", in the two layers docs/exec.md §6 describes.

The FIRST is a tools/post_execute listener: the waterfall every tool result already passes through, so a tool that happened to hand back a credential is rewritten before the loop appends it — and before any other post_execute listener, or any in-memory consumer of the Result, reads it.

The SECOND is a Sessions scrubber, and it is the one that makes the promise true rather than likely: a secret can reach the log through a path that never touches a tool result at all — a user's own message, an injected steer, a plugin's durable event — and register_scrubber runs over every String of every append regardless of type. Because that runs INSIDE normalize_payload, the stored bytes and every projection derived from them agree by construction, so the log invariant needs nothing special here.

What this is not: comprehensive. Patterns are regexp sources on this row's config, so it catches shapes a deployment named and nothing else. Catching a secret by its exact bytes rather than by a named shape is ctx's job (plan §6.9, Terret::Credentials): it registers every value it resolves as its own append-boundary scrubber, running alongside this one.

Constant Summary collapse

DEFAULT_REPLACEMENT =
"[REDACTED]"

Instance Method Summary collapse

Instance Method Details

#reconfigure(config) ⇒ Object

Patterns are compiled, so they are the one thing a hot swap has to re-derive; the replacement token is read per call and is already live. An uncompilable pattern raises here, and the loader rolls the row back.



48
49
50
# File 'lib/terret/redactor.rb', line 48

def reconfigure(config)
  @patterns = compile(config[:patterns])
end

#redact(text) ⇒ Object

The redaction itself, and the method the Sessions scrubber calls. A non-String is handed straight back: this is reached from the tools pipeline too, where a result's content may be anything a handler returned.



56
57
58
59
60
61
62
63
64
# File 'lib/terret/redactor.rb', line 56

def redact(text)
  return text unless text.is_a?(String)

  # gsub's BLOCK form, not its string form, which would read `\0`/`\1` in
  # a deployment's replacement token as backreferences — a token
  # containing `\0` would paste the matched secret back in and leave a
  # redactor that silently un-redacts.
  @patterns.reduce(text) { |acc, pattern| acc.gsub(pattern) { replacement } }
end

#replacementObject

Public because a reader downstream has to recognize this mechanism's own mark: the loop refuses to replay a resumed tool call whose stored arguments carry it (Loop#redaction_token).



69
# File 'lib/terret/redactor.rb', line 69

def replacement = config[:replacement] || DEFAULT_REPLACEMENT

#start(ctx) ⇒ Object



35
36
37
38
39
40
41
42
43
# File 'lib/terret/redactor.rb', line 35

def start(ctx)
  @patterns = compile(config[:patterns])

  ctx.on("tools/post_execute") { |result, next_| next_.(redact_result(result)) }
  # Registered through the seam rather than as a second listener: this
  # runs at the append boundary itself (Sessions#register_scrubber), which
  # is the whole reason the backstop is trustworthy.
  ctx[:sessions].register_scrubber(method(:redact))
end