Class: KairosMcp::Daemon::Credentials

Inherits:
Object
  • Object
show all
Defined in:
lib/kairos_mcp/daemon/credentials.rb

Overview

Credentials — Scoped, auto-redacting secret store for daemon-mode tools.

Design (v0.2 §P2.6):

A daemon running unattended needs access to API keys (ANTHROPIC_API_KEY,
GITHUB_TOKEN, ...) but each tool should only see the credentials it
actually needs. secrets.yml declares, per secret, which tool names may
request it; `fetch_for(tool_name)` returns only the matching subset.

Pattern matching uses File.fnmatch so patterns like 'llm_*' or
'safe_http_*' work the same way as in InvocationContext.

Secret sources:

env      — value = ENV[spec['env_var']]
file     — value = File.read(spec['file_path']).strip
keychain — stub; logs a warning and returns nil.

Safety:

• Empty or missing scoped_to means the secret is NOT handed to any tool.
• Missing env var / unreadable file → value is nil, not an exception,
  so a misconfigured secret never leaks a filesystem path or ENV name
  via a backtrace.
• `redact(str)` replaces every known secret *value* with '***REDACTED***'
  so log lines and exception messages can be scrubbed before they
  escape the daemon.

Thread-safety:

`reload!` can race with in-flight `fetch_for` calls from signal
handlers (SIGHUP). A Monitor guards the mutable state.

Constant Summary collapse

REDACTED =
'***REDACTED***'

Instance Method Summary collapse

Constructor Details

#initialize(logger: nil) ⇒ Credentials

Returns a new instance of Credentials.

Parameters:

  • logger (#warn, #info, nil) (defaults to: nil)

    optional logger for keychain stubs



40
41
42
43
44
45
46
# File 'lib/kairos_mcp/daemon/credentials.rb', line 40

def initialize(logger: nil)
  @logger = logger
  @monitor = Monitor.new
  @path = nil
  @specs = []       # Array<Hash> — normalized secret specs
  @values = {}      # { name => String } — resolved values
end

Instance Method Details

#all_patternsArray<String>

Flat list of every scoped_to pattern across all specs, for diagnostics.

Returns:

  • (Array<String>)


113
114
115
116
117
# File 'lib/kairos_mcp/daemon/credentials.rb', line 113

def all_patterns
  @monitor.synchronize do
    @specs.flat_map { |s| Array(s['scoped_to']) }.map(&:to_s).uniq
  end
end

#fetch_for(tool_name) ⇒ Hash{String => String}

Return the subset of secrets whose scoped_to pattern matches ‘tool_name`. Only *resolved, non-nil* values are included.

Parameters:

  • tool_name (String, Symbol)

Returns:

  • (Hash{String => String})


77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/kairos_mcp/daemon/credentials.rb', line 77

def fetch_for(tool_name)
  name = tool_name.to_s
  out = {}
  @monitor.synchronize do
    @specs.each do |spec|
      next unless matches?(spec, name)
      value = @values[spec['name']]
      next if value.nil? || value.empty?
      out[spec['name']] = value
    end
  end
  out
end

#load(secrets_path) ⇒ self

Parse secrets.yml. Non-existent path → empty (no crash).

Parameters:

  • secrets_path (String, Pathname)

Returns:

  • (self)


51
52
53
54
55
56
57
58
# File 'lib/kairos_mcp/daemon/credentials.rb', line 51

def load(secrets_path)
  @monitor.synchronize do
    @path = secrets_path.to_s
    @specs = parse_file(@path)
    @values = resolve_all(@specs)
  end
  self
end

#redact(str) ⇒ String?

Replace every known secret value in ‘str` with REDACTED. Handles nil and empty input without raising so callers can pipe arbitrary log/exception strings through unconditionally.

Parameters:

  • str (String, nil)

Returns:

  • (String, nil)


97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/kairos_mcp/daemon/credentials.rb', line 97

def redact(str)
  return str if str.nil?
  return str if str.empty?
  out = str.dup
  @monitor.synchronize do
    # Replace longer values first so a secret that is a substring of
    # another does not get partially redacted.
    @values.values.compact.reject(&:empty?)
           .sort_by { |v| -v.length }
           .each { |v| out.gsub!(v, REDACTED) }
  end
  out
end

#reload!self

Re-read the previously loaded secrets.yml. No-op if load was never called. Called from the SIGHUP handler.

Returns:

  • (self)


63
64
65
66
67
68
69
70
# File 'lib/kairos_mcp/daemon/credentials.rb', line 63

def reload!
  @monitor.synchronize do
    return self if @path.nil?
    @specs = parse_file(@path)
    @values = resolve_all(@specs)
  end
  self
end

#secret_namesArray<String>

Names of loaded secrets (for diagnostics). Values are NOT exposed.

Returns:

  • (Array<String>)


121
122
123
# File 'lib/kairos_mcp/daemon/credentials.rb', line 121

def secret_names
  @monitor.synchronize { @specs.map { |s| s['name'] }.compact }
end