legion-llm 0.7.8 → 0.7.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95918a13b81cc27203ad7eaccf52b06b7989735cb669febc7e94abd8c8c5452c
4
- data.tar.gz: a7887cd39bbeda75313f37c134ef8461d7d6bd240be811dbf77e91f64343fc30
3
+ metadata.gz: 2e8391f38dbda2f0d629e206bb8844f50768d4200808b85e543f71115d639a65
4
+ data.tar.gz: ae14b4b492a61f8d626ae388ea819851372050df2ee78c7a37fd831e7e4d3fa4
5
5
  SHA512:
6
- metadata.gz: e389df67e9855d573a195948123e6aae6760da6e461d54148edd0cd1faa8535445c744ce02b3512e89fd7603197370f4ea8d84bb9dd82588d3e545dea308bc2b
7
- data.tar.gz: 965932e45bf5905b77ba667d43d24e70380a203b048f01daaa17cc72c835a952fd9ee474a78a868bd8fef538b1dca9843d599f13249cb88d7c67eca6b53fd64b
6
+ metadata.gz: 58253917894e86a8050b7655109a912ebec090cb6eb85607264d5b6a7927d8222bfb5de6e87689e7ce51e0679beb30af7c0e7375a477fca533605564706f6d2e
7
+ data.tar.gz: 3ae513c0ac3ffa347357be2ff50b8ee67ff2170ab416413855ce4f99e3558c17d7627365bf8e6cd4da579dd176d019163533db50ef255ad5f2f8a1504948e453
data/CHANGELOG.md CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.15] - 2026-04-20
6
+
7
+ ### Added
8
+ - PHI cloud provider gate: `compliance.phi_block_cloud` (default: `false`) blocks restricted-classified requests from cloud providers when enabled. Warns on permit when disabled. Cloud provider list configurable via `compliance.cloud_providers`. Fixes #72
9
+
10
+ ## [0.7.14] - 2026-04-20
11
+
12
+ ### Added
13
+ - PII/PHI redaction mode: `compliance.redact_pii` (default: `false`) replaces detected patterns with configurable placeholder token before pipeline continues. Placeholder configurable via `compliance.redaction_placeholder` (default: `[REDACTED]`)
14
+ - `compliance.strict_hipaa` setting (default: `false`): when enabled, scans all 12 HIPAA patterns; when disabled, scans only core 3 (SSN, email, phone) for lighter processing. Closes #73
15
+
16
+ ## [0.7.13] - 2026-04-20
17
+
18
+ ### Fixed
19
+ - Classification step now always runs the PII/PHI scan, even on unclassified requests — defaults to `:public` baseline. Configurable via `compliance.default_level` (default: `:public`) and `compliance.classification_scan` (default: `true`). Fixes #70
20
+
21
+ ## [0.7.12] - 2026-04-20
22
+
23
+ ### Fixed
24
+ - RBAC step now respects `rbac.fail_open` setting (default: `true`) when `Legion::Rbac` is unavailable. Fleet callers are always blocked. Non-fleet callers are permitted with a warning when `fail_open` is true, or blocked with 503 when false. Fixes #69
25
+
26
+ ## [0.7.11] - 2026-04-20
27
+
28
+ ### Fixed
29
+ - RAG faithfulness check now logs a warning when RAG context is present but no `Hooks::RagGuard` is registered, instead of silently skipping. Fixes #71
30
+ - RAG faithfulness failure now logs at warn level in addition to appending to pipeline warnings array
31
+
32
+ ## [0.7.10] - 2026-04-20
33
+
34
+ ### Fixed
35
+ - `configure_anthropic` now passes `base_url` through to RubyLLM (`anthropic_api_base`) when present, enabling custom API gateways and proxies. Fixes #68
36
+ - `configure_openai` now passes `base_url` through to RubyLLM (`openai_api_base`) when present, for consistency with Anthropic and Ollama providers
37
+ - `configure_gemini` now passes `base_url` through to RubyLLM (`gemini_api_base`) when present, for consistency with Anthropic and Ollama providers
38
+
39
+ ## [0.7.9] - 2026-04-18
40
+ ### Added
41
+ - Expanded PII/PHI classification to cover 12 HIPAA Safe Harbor identifier patterns (was 3) and 20 PHI keywords (was 11). Partial fix for #73
42
+
43
+ ### Fixed
44
+ - `web_fetch` client tool now delegates to `Legion::CLI::Chat::WebFetch.fetch` instead of bare `Net::HTTP.get` — gains SSL, redirect following, HTML-to-markdown conversion, and `maxLength` truncation (LegionIO/LegionIO#153)
45
+ - Added `web_search` client tool dispatch via `Legion::CLI::Chat::WebSearch.search` — previously fell through to generic "not executable server-side" error (LegionIO/LegionIO#154)
46
+
5
47
  ## [0.7.8] - 2026-04-17
6
48
  ### Fixed
7
49
  - Guard `Embeddings.generate` against providers that don't support embeddings before calling `RubyLLM.embed` — prevents noisy `NoMethodError: undefined method 'render_embedding_payload'` warns when Bedrock (or Anthropic) is the active provider
@@ -11,27 +11,47 @@ module Legion
11
11
 
12
12
  LEVELS = %i[public internal confidential restricted].freeze
13
13
 
14
- PII_PATTERNS = {
14
+ PII_PATTERNS_CORE = {
15
15
  ssn: /\b\d{3}-\d{2}-\d{4}\b/,
16
16
  email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/,
17
17
  phone: /\b(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b/
18
18
  }.freeze
19
19
 
20
+ PII_PATTERNS_EXTENDED = {
21
+ ip_address: /\b(?:\d{1,3}\.){3}\d{1,3}\b/,
22
+ date_of_birth: %r{\b(?:0[1-9]|1[0-2])[/-](?:0[1-9]|[12]\d|3[01])[/-](?:19|20)\d{2}\b},
23
+ zip_code: /\b\d{5}(?:-\d{4})?\b/,
24
+ mrn: /\b(?:MRN|mrn)[:\s#]?\s?\d{4,12}\b/,
25
+ account_number: /\b(?:account|acct)[:\s#]?\s?\d{6,20}\b/i,
26
+ license_number: /\b[A-Z]\d{3,8}\b/,
27
+ url: %r{\bhttps?://[^\s<>"']+}i,
28
+ vin: /\b[A-HJ-NPR-Z0-9]{17}\b/,
29
+ npi_number: /\b\d{10}\b/
30
+ }.freeze
31
+
32
+ PII_PATTERNS = PII_PATTERNS_CORE.merge(PII_PATTERNS_EXTENDED).freeze
33
+
20
34
  PHI_KEYWORDS = %w[
21
35
  patient diagnosis medication prescription
22
36
  dob date-of-birth mrn medical-record
23
- npi clinical treatment
37
+ npi clinical treatment health-plan
38
+ beneficiary insurance-id group-number
39
+ lab-result radiology pathology
40
+ admission discharge procedure
24
41
  ].freeze
25
42
 
26
43
  def step_classification
27
- return unless @request.classification || compliance_classification_default
44
+ classification = @request.classification || compliance_classification_default || default_classification
45
+ return unless classification_enabled?(classification)
28
46
 
29
- classification = @request.classification || compliance_classification_default
30
47
  declared_level = classification[:level]
31
48
  scan = scan_content_for_sensitive_data
32
49
  effective_level = upgrade_if_needed(declared_level, scan)
33
50
  upgraded = effective_level != declared_level
34
51
 
52
+ redact_sensitive_content(scan)
53
+ enforce_phi_cloud_gate(effective_level)
54
+
35
55
  @enrichments['classification:scan'] = {
36
56
  declared_level: declared_level,
37
57
  effective_level: effective_level,
@@ -67,8 +87,9 @@ module Legion
67
87
  def scan_content_for_sensitive_data
68
88
  text = extract_text_content
69
89
  patterns = []
90
+ active_patterns = strict_hipaa_mode? ? PII_PATTERNS : PII_PATTERNS_CORE
70
91
 
71
- PII_PATTERNS.each do |name, regex|
92
+ active_patterns.each do |name, regex|
72
93
  patterns << name if text.match?(regex)
73
94
  end
74
95
 
@@ -76,12 +97,41 @@ module Legion
76
97
  patterns << :phi_keyword if phi_found
77
98
 
78
99
  {
79
- contains_pii: patterns.intersect?(PII_PATTERNS.keys),
100
+ contains_pii: patterns.intersect?(active_patterns.keys),
80
101
  contains_phi: phi_found,
81
102
  patterns: patterns
82
103
  }
83
104
  end
84
105
 
106
+ def redact_sensitive_content(scan)
107
+ return unless redaction_enabled?
108
+ return unless scan[:contains_pii] || scan[:contains_phi]
109
+
110
+ placeholder = redaction_placeholder
111
+ active_patterns = strict_hipaa_mode? ? PII_PATTERNS : PII_PATTERNS_CORE
112
+
113
+ @request.messages.each do |message|
114
+ next unless message[:content].is_a?(String)
115
+
116
+ active_patterns.each_value do |regex|
117
+ message[:content] = message[:content].gsub(regex, placeholder)
118
+ end
119
+
120
+ next unless scan[:contains_phi]
121
+
122
+ PHI_KEYWORDS.each do |kw|
123
+ message[:content] = message[:content].gsub(/\b#{Regexp.escape(kw)}\b/i, placeholder)
124
+ end
125
+ end
126
+
127
+ @enrichments['classification:redaction'] = {
128
+ redacted: true,
129
+ patterns_redacted: scan[:patterns],
130
+ placeholder: placeholder,
131
+ timestamp: Time.now
132
+ }
133
+ end
134
+
85
135
  def extract_text_content
86
136
  @request.messages.map { |m| m[:content].to_s }.join(' ')
87
137
  end
@@ -97,6 +147,28 @@ module Legion
97
147
  LEVELS[threshold_idx]
98
148
  end
99
149
 
150
+ def default_classification
151
+ { level: default_classification_level }
152
+ end
153
+
154
+ def default_classification_level
155
+ return :public unless defined?(Legion::Settings)
156
+
157
+ level = Legion::Settings.dig(:compliance, :default_level)
158
+ level ? level.to_sym : :public
159
+ rescue StandardError
160
+ :public
161
+ end
162
+
163
+ def classification_enabled?(_classification)
164
+ return true unless defined?(Legion::Settings)
165
+
166
+ enabled = Legion::Settings.dig(:compliance, :classification_scan)
167
+ enabled.nil? || enabled
168
+ rescue StandardError
169
+ true
170
+ end
171
+
100
172
  def compliance_classification_default
101
173
  return nil unless defined?(Legion::Settings)
102
174
 
@@ -108,6 +180,73 @@ module Legion
108
180
  handle_exception(e, level: :warn, operation: 'llm.pipeline.steps.classification.default')
109
181
  nil
110
182
  end
183
+
184
+ def redaction_enabled?
185
+ setting = Legion::Settings.dig(:compliance, :redact_pii)
186
+ setting == true
187
+ rescue StandardError
188
+ false
189
+ end
190
+
191
+ def strict_hipaa_mode?
192
+ setting = Legion::Settings.dig(:compliance, :strict_hipaa)
193
+ setting == true
194
+ rescue StandardError
195
+ false
196
+ end
197
+
198
+ def redaction_placeholder
199
+ Legion::Settings.dig(:compliance, :redaction_placeholder) || '[REDACTED]'
200
+ rescue StandardError
201
+ '[REDACTED]'
202
+ end
203
+
204
+ def enforce_phi_cloud_gate(effective_level)
205
+ return unless effective_level == :restricted
206
+
207
+ provider = resolve_current_provider
208
+ return unless cloud_provider?(provider)
209
+
210
+ if phi_block_cloud?
211
+ raise Legion::LLM::PipelineError.new(
212
+ "Restricted/sensitive content (level=restricted) cannot be sent to cloud provider #{provider}. " \
213
+ 'Set compliance.phi_block_cloud=false to override, or use a local provider.',
214
+ step: :classification
215
+ )
216
+ end
217
+
218
+ log.warn(
219
+ "[classification] Restricted/sensitive content (level=restricted) routing to cloud provider #{provider} — " \
220
+ 'compliance.phi_block_cloud is disabled, permitting'
221
+ )
222
+ @warnings << "Restricted/sensitive content routing to cloud provider #{provider} (phi_block_cloud disabled)"
223
+ end
224
+
225
+ def phi_block_cloud?
226
+ setting = Legion::Settings.dig(:compliance, :phi_block_cloud)
227
+ setting == true
228
+ rescue StandardError
229
+ false
230
+ end
231
+
232
+ def cloud_provider?(provider)
233
+ return false unless provider
234
+
235
+ cloud_providers = Legion::Settings.dig(:compliance, :cloud_providers) ||
236
+ %i[anthropic openai gemini bedrock azure]
237
+ cloud_providers.map(&:to_sym).include?(provider.to_sym)
238
+ rescue StandardError
239
+ false
240
+ end
241
+
242
+ def resolve_current_provider
243
+ routing = @request.respond_to?(:routing) ? @request.routing : nil
244
+ provider = routing[:provider] if routing.is_a?(Hash)
245
+ provider ||= Legion::Settings.dig(:llm, :default_provider)
246
+ provider&.to_sym
247
+ rescue StandardError
248
+ nil
249
+ end
111
250
  end
112
251
  end
113
252
  end
@@ -12,7 +12,11 @@ module Legion
12
12
  def check_rag_faithfulness
13
13
  context = @enrichments.dig('rag:context_retrieval', :data, :entries)
14
14
  return unless context&.any?
15
- return unless defined?(Hooks::RagGuard)
15
+
16
+ unless defined?(Hooks::RagGuard)
17
+ log.warn('[rag_guard] RAG context present but no Hooks::RagGuard registered — faithfulness check skipped')
18
+ return
19
+ end
16
20
 
17
21
  response_text = @raw_response.respond_to?(:content) ? @raw_response.content : @raw_response.to_s
18
22
 
@@ -25,6 +29,7 @@ module Legion
25
29
  return if result.nil? || result[:faithful]
26
30
 
27
31
  detail = result[:details] || result[:reason] || 'faithfulness check failed'
32
+ log.warn("[rag_guard] RAG faithfulness warning: #{detail}")
28
33
  @warnings << "RAG faithfulness warning: #{detail}"
29
34
  @timeline.record(
30
35
  category: :quality, key: 'rag:faithfulness_warning',
@@ -13,18 +13,21 @@ module Legion
13
13
  start_time = Time.now
14
14
 
15
15
  unless defined?(::Legion::Rbac)
16
- if fleet_caller?
17
- msg = 'RBAC unavailable: fleet callers require RBAC enforcement (fail-closed)'
18
- log.error("[llm][rbac] fleet_blocked request_id=#{@request.id} reason=rbac_unavailable")
16
+ if fleet_caller? || !fail_open_permitted?
17
+ msg = '503: RBAC unavailable request denied ' \
18
+ "(fleet=#{fleet_caller?}, fail_open=#{fail_open_permitted?})"
19
+ log.error("[llm][rbac] blocked request_id=#{@request.id} reason=rbac_unavailable " \
20
+ "fleet=#{fleet_caller?} fail_open=#{fail_open_permitted?}")
19
21
  record_rbac_audit(:failure, msg, start_time)
20
22
  record_rbac_timeline("denied: #{msg}")
21
- raise Legion::LLM::PipelineError.new("403 Forbidden: #{msg}", step: :rbac)
23
+ raise Legion::LLM::PipelineError.new(msg, step: :rbac)
22
24
  end
23
25
 
24
- @warnings << 'RBAC unavailable, permitting request without enforcement'
25
- log.info("[llm][rbac] unavailable request_id=#{@request.id} action=permit_without_enforcement")
26
- record_rbac_audit(:success, 'permitted (rbac unavailable)', start_time)
27
- record_rbac_timeline('permitted (rbac unavailable)')
26
+ log.warn('[llm][rbac] RBAC unavailable, permitting request (fail_open enabled) ' \
27
+ "request_id=#{@request.id}")
28
+ @warnings << 'RBAC unavailable, permitting request (fail_open enabled)'
29
+ record_rbac_audit(:success, 'permitted (rbac unavailable, fail_open enabled)', start_time)
30
+ record_rbac_timeline('permitted (rbac unavailable, fail_open enabled)')
28
31
  return
29
32
  end
30
33
 
@@ -54,6 +57,11 @@ module Legion
54
57
 
55
58
  private
56
59
 
60
+ def fail_open_permitted?
61
+ setting = Legion::Settings.dig(:rbac, :fail_open)
62
+ setting.nil? || setting
63
+ end
64
+
57
65
  def build_rbac_principal
58
66
  rb = @request.caller&.fetch(:requested_by, {}) || {}
59
67
  ::Legion::Rbac::Principal.new(
@@ -111,8 +111,9 @@ module Legion
111
111
 
112
112
  RubyLLM.configure do |c|
113
113
  c.anthropic_api_key = api_key
114
+ c.anthropic_api_base = config[:base_url] if config[:base_url]
114
115
  end
115
- log.info 'Configured Anthropic provider'
116
+ log.info "Configured Anthropic provider#{" (#{config[:base_url]})" if config[:base_url]}"
116
117
  end
117
118
 
118
119
  def configure_openai(config)
@@ -121,8 +122,9 @@ module Legion
121
122
 
122
123
  RubyLLM.configure do |c|
123
124
  c.openai_api_key = api_key
125
+ c.openai_api_base = config[:base_url] if config[:base_url]
124
126
  end
125
- log.info 'Configured OpenAI provider'
127
+ log.info "Configured OpenAI provider#{" (#{config[:base_url]})" if config[:base_url]}"
126
128
  end
127
129
 
128
130
  def configure_gemini(config)
@@ -131,8 +133,9 @@ module Legion
131
133
 
132
134
  RubyLLM.configure do |c|
133
135
  c.gemini_api_key = api_key
136
+ c.gemini_api_base = config[:base_url] if config[:base_url]
134
137
  end
135
- log.info 'Configured Gemini provider'
138
+ log.info "Configured Gemini provider#{" (#{config[:base_url]})" if config[:base_url]}"
136
139
  end
137
140
 
138
141
  def configure_azure(config)
@@ -46,7 +46,7 @@ module Legion
46
46
  end
47
47
  end
48
48
 
49
- def dispatch_client_tool(ref, **kwargs)
49
+ def dispatch_client_tool(ref, **kwargs) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
50
50
  case ref
51
51
  when 'sh'
52
52
  cmd = kwargs[:command] || kwargs[:cmd] || kwargs.values.first.to_s
@@ -81,9 +81,27 @@ module Legion
81
81
  Dir.glob(pattern).first(100).join("\n")
82
82
  when 'web_fetch'
83
83
  url = kwargs[:url] || kwargs.values.first.to_s
84
- require 'net/http'
85
- uri = URI(url)
86
- Net::HTTP.get(uri)
84
+ raw_max_length = kwargs[:maxLength] || kwargs[:max_length]
85
+ max_length = raw_max_length.nil? ? nil : [raw_max_length.to_i, 0].max
86
+ begin
87
+ require 'legion/cli/chat/web_fetch'
88
+ content = Legion::CLI::Chat::WebFetch.fetch(url)
89
+ max_length ? content[0, max_length] : content
90
+ rescue LoadError => e
91
+ missing = e.respond_to?(:path) && e.path ? e.path : 'legion/cli/chat/web_fetch'
92
+ "web_fetch is unavailable: missing optional dependency #{missing}"
93
+ end
94
+ when 'web_search'
95
+ query = kwargs[:query] || kwargs.values.first.to_s
96
+ max_results = (kwargs[:max_results] || kwargs[:maxResults] || 5).to_i
97
+ begin
98
+ require 'legion/cli/chat/web_search'
99
+ results = Legion::CLI::Chat::WebSearch.search(query, max_results: max_results, auto_fetch: false)
100
+ results[:results].map { |r| "### #{r[:title]}\n#{r[:url]}\n#{r[:snippet]}" }.join("\n\n")
101
+ rescue LoadError => e
102
+ missing = e.respond_to?(:path) && e.path ? e.path : 'legion/cli/chat/web_search'
103
+ "web_search is unavailable: missing optional dependency #{missing}"
104
+ end
87
105
  else
88
106
  "Tool #{ref} is not executable server-side. Use a legion_ prefixed tool instead."
89
107
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.7.8'
5
+ VERSION = '0.7.15'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.8
4
+ version: 0.7.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity