olyx-guardrails 1.1.1 → 1.2.0

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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -7
  3. data/README.md +3 -2
  4. data/lib/olyx/guardrails/check_result_builder.rb +1 -1
  5. data/lib/olyx/guardrails/check_runner.rb +4 -2
  6. data/lib/olyx/guardrails/check_set.rb +66 -19
  7. data/lib/olyx/guardrails/injection_patterns.rb +1 -2
  8. data/lib/olyx/guardrails/llm_analysis.rb +4 -2
  9. data/lib/olyx/guardrails/message_check_runner.rb +6 -4
  10. data/lib/olyx/guardrails/pii/text_scrubber.rb +15 -1
  11. data/lib/olyx/guardrails/policy/match_collector.rb +7 -1
  12. data/lib/olyx/guardrails/policy/rule_matcher.rb +26 -9
  13. data/lib/olyx/guardrails/policy.rb +37 -24
  14. data/lib/olyx/guardrails/policy_rule.rb +56 -19
  15. data/lib/olyx/guardrails/rails.rb +8 -7
  16. data/lib/olyx/guardrails/redaction/content_result.rb +4 -3
  17. data/lib/olyx/guardrails/risk/deterministic_score.rb +2 -4
  18. data/lib/olyx/guardrails/risk/weights.rb +0 -1
  19. data/lib/olyx/guardrails/risk_scorer.rb +4 -5
  20. data/lib/olyx/guardrails/secret_finding_collector.rb +0 -1
  21. data/lib/olyx/guardrails/secret_scanner.rb +1 -2
  22. data/lib/olyx/guardrails/secrets/pattern_catalog.rb +0 -10
  23. data/lib/olyx/guardrails/secrets/redactor.rb +0 -7
  24. data/lib/olyx/guardrails/secrets/source_set.rb +1 -2
  25. data/lib/olyx/guardrails/text/detection_variants.rb +13 -1
  26. data/lib/olyx/guardrails/version.rb +1 -1
  27. data/lib/olyx/guardrails.rb +0 -1
  28. metadata +1 -21
  29. data/lib/olyx/guardrails/check_pipeline.rb +0 -17
  30. data/lib/olyx/guardrails/checks/injection_check.rb +0 -20
  31. data/lib/olyx/guardrails/checks/injection_result.rb +0 -22
  32. data/lib/olyx/guardrails/checks/length_check.rb +0 -18
  33. data/lib/olyx/guardrails/checks/message_injection_check.rb +0 -19
  34. data/lib/olyx/guardrails/checks/pii_check.rb +0 -19
  35. data/lib/olyx/guardrails/checks/policy_check.rb +0 -26
  36. data/lib/olyx/guardrails/checks/secret_check.rb +0 -30
  37. data/lib/olyx/guardrails/checks/skipped_checks.rb +0 -26
  38. data/lib/olyx/guardrails/message_check_set.rb +0 -20
  39. data/lib/olyx/guardrails/policy/configuration.rb +0 -68
  40. data/lib/olyx/guardrails/policy/normalized_pattern_matcher.rb +0 -34
  41. data/lib/olyx/guardrails/policy/pattern_matcher.rb +0 -19
  42. data/lib/olyx/guardrails/policy_rule/configuration.rb +0 -52
  43. data/lib/olyx/guardrails/policy_rule/description_value.rb +0 -24
  44. data/lib/olyx/guardrails/policy_rule/name_value.rb +0 -22
  45. data/lib/olyx/guardrails/policy_rule/replacement_value.rb +0 -26
  46. data/lib/olyx/guardrails/policy_rule/text_values.rb +0 -23
  47. data/lib/olyx/guardrails/policy_rule/values.rb +0 -33
  48. data/lib/olyx/guardrails/secrets/confidentiality_source.rb +0 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 789eb5e71a301345c3fd0d28e59ffee0b0d9cf67ee9686f0912e5a743ccd093b
4
- data.tar.gz: d89fc57b450761fe6e530fa116e39413eeb17edeb9c73023fbd8247ace98a727
3
+ metadata.gz: d1ee4f7c6297bf8348c6d74feff8bd055ce23b819091893043d02a6695140cd2
4
+ data.tar.gz: e06e7f54f777a8e56da1fcbad45f92d327f30aea934467235a9bc4384dc2229d
5
5
  SHA512:
6
- metadata.gz: 17390bf8cbd4961a4e4a669a5ca0b8f80afcf2663fceaea85e5fe31050e789252b5952ef8063ef80d8ffaa5b78f7e69d4a4c96dba8ce1724fcfe090ccb5f3e50
7
- data.tar.gz: d3e28269e0760c9c09c83f25f8499b74391ee1d7ebf5d3d9ab1a4fe9236d56c2f90b63daa6c603068f6a40fa2398577d08a2f8631d1c05c37e01a478d9ec3a2a
6
+ metadata.gz: 1d85a8e9c03dc058fd188132a2754c4738063ddc79039eb5bd88c5dcf25555f3b266d6d4b29ee37503b7c25fe0beabc74d0cfe895f82ad16c82e93ff2351955b
7
+ data.tar.gz: 6e04d4fda8672b775475e0741b621ee850ab178656283a20c4fc1f27aa324112cb2ee5c883890dec97b53d423b7f65e6daa88006269574481c36e69128542afd
data/CHANGELOG.md CHANGED
@@ -3,6 +3,39 @@
3
3
  All notable changes to olyx-guardrails are documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [Unreleased]
7
+
8
+ ## [1.2.0] - 2026-10-02
9
+
10
+ ### Changed
11
+
12
+ - Prompt-injection normalization now scans long inputs in overlapping bounded
13
+ windows instead of ignoring content after the first 20,000 characters.
14
+ - Narrowed role-play matching so ordinary agent prompts do not block by
15
+ default.
16
+ - Provider exceptions return a stable public error instead of exposing the
17
+ provider's exception message.
18
+ - Confidentiality labels are no longer classified as credentials or used to
19
+ redact an entire input. Applications can enforce those labels with policy
20
+ rules when required.
21
+ - Risk scores now describe findings independently from the policy's blocking
22
+ configuration.
23
+ - Removed redundant scanner passes and consolidated internal configuration and
24
+ matching layers.
25
+ - Simplified the public quality gate to tests, documentation, and RuboCop.
26
+
27
+ ## [1.1.3] - 2026-08-07
28
+
29
+ ### Changed
30
+
31
+ - Added the Bundler release task expected by the trusted publishing workflow.
32
+
33
+ ## [1.1.2] - 2026-08-07
34
+
35
+ ### Changed
36
+
37
+ - Added a tag-based RubyGems release workflow using trusted publishing.
38
+
6
39
  ## [1.1.1] - 2026-07-24
7
40
 
8
41
  ### Changed
@@ -27,7 +60,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
27
60
  decision has zero risk.
28
61
  - The Rails generator provides copyable policy customization instructions and
29
62
  tests the customized YAML path.
30
- - Contributor setup, complete local quality validation, and maintainer release
63
+ - Contributor setup, local validation, and maintainer release
31
64
  verification have canonical commands and documentation.
32
65
  - Closely coupled proxy objects were folded into their owning runtime,
33
66
  configuration, result-building, and notification components.
@@ -35,7 +68,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
35
68
  ignored instead of being coerced across the untrusted provider boundary.
36
69
  - Contributor and Rails appraisal locks use the current `net-imap` patch.
37
70
  - CI refreshes the Ruby advisory database and blocks known-vulnerable locked
38
- dependencies without making the offline local quality gate network-dependent.
71
+ dependencies without making offline local validation network-dependent.
39
72
 
40
73
  ## [1.0.0] - 2026-07-21
41
74
 
@@ -143,11 +176,8 @@ Initial public release.
143
176
 
144
177
  - The core has one lightweight runtime dependency, Ruby's `base64` bundled gem.
145
178
  Rails remains optional and is loaded only when used.
146
- - CI enforces RuboCop — including calibrated structural-complexity cops
147
- (`Metrics/AbcSize`, `CyclomaticComplexity`, `PerceivedComplexity`,
148
- `ClassLength`) in place of a separate complexity tool — and a RubyCritic
149
- maintainability gate, alongside the Appraisal matrix across Rails 8.0
150
- and 8.1.
179
+ - CI enforces tests, documentation checks, RuboCop, and the Appraisal matrix
180
+ across Rails 8.0 and 8.1.
151
181
  - Native RDoc covers every supported public class, module, constant, attribute,
152
182
  and method. CI blocks undocumented additions to the explicit public API
153
183
  manifest while leaving implementation-only constants outside the
data/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # Olyx Guardrails
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/olyx-guardrails.svg)](https://rubygems.org/gems/olyx-guardrails)
3
+ [![RubyGems](https://img.shields.io/gem/v/olyx-guardrails?label=rubygems)](https://rubygems.org/gems/olyx-guardrails)
4
+ [![Release](https://img.shields.io/github/v/release/Olyx-labs/olyx-guardrails?label=release)](https://github.com/Olyx-labs/olyx-guardrails/releases/latest)
4
5
  [![Test](https://github.com/Olyx-labs/olyx-guardrails/actions/workflows/test.yml/badge.svg)](https://github.com/Olyx-labs/olyx-guardrails/actions/workflows/test.yml)
5
6
  [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Olyx-labs/olyx-guardrails/badge)](https://securityscorecards.dev/viewer/?uri=github.com/Olyx-labs/olyx-guardrails)
6
7
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
@@ -28,7 +29,7 @@ discover model calls, or send application content to a third party.
28
29
 
29
30
  Rails is optional. The standalone Ruby API does not load Rails.
30
31
 
31
- The 1.1 release supports Rails 8.0 and 8.1. Only the listed Rails
32
+ The 1.1 release line supports Rails 8.0 and 8.1. Only the listed Rails
32
33
  series are tested and supported. When a series reaches upstream end-of-life,
33
34
  a subsequent gem release may remove it instead of maintaining framework
34
35
  security fixes independently. Every support change is recorded in the
@@ -39,7 +39,7 @@ module Olyx
39
39
  end
40
40
 
41
41
  def risk(checks, ordered, analysis)
42
- { risk_score: RiskScorer.call(checks, ordered, analysis), checks: ordered }
42
+ { risk_score: RiskScorer.call(checks, analysis), checks: ordered }
43
43
  end
44
44
 
45
45
  def analysis_result(analysis)
@@ -3,7 +3,6 @@
3
3
  require_relative 'check_result_builder'
4
4
  require_relative 'check_analyzer'
5
5
  require_relative 'check_set'
6
- require_relative 'check_pipeline'
7
6
  require_relative 'policy'
8
7
  require_relative 'validation'
9
8
 
@@ -24,7 +23,10 @@ module Olyx
24
23
 
25
24
  def call
26
25
  checks = CheckSet.call(@source, policy: @policy)
27
- CheckPipeline.call(@source, checks, policy: @policy, llm_provider: @llm_provider)
26
+ merged, analysis = CheckAnalyzer.call(
27
+ checks, provider: @llm_provider, source: @source, policy: @policy
28
+ )
29
+ CheckResultBuilder.call(merged, analysis, @policy)
28
30
  end
29
31
 
30
32
  private
@@ -1,33 +1,80 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'checks/injection_check'
4
- require_relative 'checks/length_check'
5
- require_relative 'checks/pii_check'
6
- require_relative 'checks/policy_check'
7
- require_relative 'checks/secret_check'
8
- require_relative 'checks/skipped_checks'
3
+ require_relative 'injection_detector'
4
+ require_relative 'pii/text_scrubber'
5
+ require_relative 'policy_scanner'
6
+ require_relative 'secret_scanner'
9
7
 
10
8
  module Olyx
11
9
  module Guardrails
12
10
  # Coordinates independent deterministic checks for one normalized input.
13
11
  class CheckSet
14
- CHECKS = {
15
- pii: Checks::PiiCheck,
16
- injection: Checks::InjectionCheck,
17
- secret: Checks::SecretCheck,
18
- policy: Checks::PolicyCheck
19
- }.freeze
20
-
21
- def self.call(source, policy:)
22
- length = Checks::LengthCheck.call(source, policy)
23
- content = length[:allowed] ? scan(source, policy) : Checks::SkippedChecks.call
12
+ def self.call(source, policy:, messages: nil)
13
+ length = length_check(source, policy)
14
+ content = length[:allowed] ? scan(source, policy, messages) : skipped_checks
24
15
  content.merge(length: length)
25
16
  end
26
17
 
27
- def self.scan(source, policy)
28
- CHECKS.to_h { |name, check| [name, check.call(source, policy)] }
18
+ def self.scan(source, policy, messages)
19
+ {
20
+ pii: pii_check(source, policy),
21
+ injection: injection_check(source, policy, messages),
22
+ secret: secret_check(source, policy),
23
+ policy: policy_check(source, policy)
24
+ }
29
25
  end
30
- private_class_method :scan
26
+
27
+ def self.length_check(source, policy)
28
+ length = source.length
29
+ maximum = policy.max_input_length
30
+ { type: 'length', allowed: length <= maximum, length: length, max_length: maximum }
31
+ end
32
+
33
+ def self.pii_check(source, policy)
34
+ detected = Pii::TextScrubber.detect?(source)
35
+ { type: 'pii', allowed: !detected || !policy.block_pii?, detected: detected }
36
+ end
37
+
38
+ def self.injection_check(source, policy, messages)
39
+ scan = InjectionDetector.scan(messages || [{ 'role' => 'user', 'content' => source }])
40
+ attempt = scan[:injection_attempt]
41
+ {
42
+ type: 'injection',
43
+ allowed: !attempt || !policy.block_injections?,
44
+ injection_attempt: attempt,
45
+ patterns: scan[:patterns]
46
+ }
47
+ end
48
+
49
+ def self.secret_check(source, policy)
50
+ scan = SecretScanner.scan(source, custom_patterns: policy.secret_patterns)
51
+ leaked = scan[:leaked]
52
+ { type: 'secret', allowed: !leaked || !policy.block_secrets?, leaked: leaked, count: scan[:findings].size }
53
+ end
54
+
55
+ def self.policy_check(source, policy)
56
+ scan = PolicyScanner.scan(source, policy: policy)
57
+ findings = scan[:findings]
58
+ {
59
+ type: 'policy', allowed: !scan[:blocked], violated: scan[:violated],
60
+ count: findings.size, findings: findings
61
+ }
62
+ end
63
+
64
+ def self.skipped_checks
65
+ {
66
+ pii: skipped('pii', detected: false),
67
+ injection: skipped('injection', injection_attempt: false, patterns: []),
68
+ secret: skipped('secret', leaked: false, count: 0),
69
+ policy: skipped('policy', violated: false, count: 0, findings: [])
70
+ }
71
+ end
72
+
73
+ def self.skipped(type, **fields)
74
+ { type: type, allowed: true, skipped: true, **fields }
75
+ end
76
+ private_class_method :injection_check, :length_check, :pii_check, :policy_check, :scan,
77
+ :secret_check, :skipped, :skipped_checks
31
78
  end
32
79
  end
33
80
  end
@@ -19,8 +19,7 @@ module Olyx
19
19
  /forget\s+(?:all\s+)?(?:your|previous)\s+instructions?/i,
20
20
  /your\s+(?:new\s+)?(?:instructions?\s+are|rules?\s+are)/i,
21
21
  /(?:override|bypass|ignore)\s+your\s+(?:instructions?|safety|restrictions?|filters?|rules?|training)/i,
22
- /(?:pretend|act)\s+(?:you\s+are|as\s+if\s+you|as\s+a(?:n?\s+AI)?)/i,
23
- /you\s+are\s+now\s+(?:a|an|free|DAN|unrestricted)/i,
22
+ /you\s+are\s+now\s+(?:an?\s+)?(?:free|DAN|unrestricted)(?:\s+AI)?/i,
24
23
  /(?:jailbreak|dan\s+mode|developer\s+mode|god\s+mode|unrestricted\s+mode)/i,
25
24
  /do\s+anything\s+now/i,
26
25
  /you\s+have\s+no\s+(?:restrictions?|limits?|rules?|filters?)/i,
@@ -6,6 +6,8 @@ module Olyx
6
6
  module Guardrails
7
7
  # Normalizes and bounds responses from an untrusted optional LLM provider.
8
8
  class LlmAnalysis
9
+ PROVIDER_ERROR = 'llm_provider failed'
10
+
9
11
  def self.call(provider, text, context)
10
12
  new(provider, text, context).call
11
13
  end
@@ -18,8 +20,8 @@ module Olyx
18
20
 
19
21
  def call
20
22
  Llm::AnalysisPipeline.call(@provider.call(@text, @context))
21
- rescue StandardError => error
22
- { error: error.message.to_s[0..200] }
23
+ rescue StandardError
24
+ { error: PROVIDER_ERROR }
23
25
  end
24
26
  end
25
27
  end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'check_pipeline'
4
- require_relative 'message_check_set'
3
+ require_relative 'check_analyzer'
4
+ require_relative 'check_result_builder'
5
+ require_relative 'check_set'
5
6
  require_relative 'message_source'
6
7
  require_relative 'policy'
7
8
  require_relative 'validation'
@@ -16,8 +17,9 @@ module Olyx
16
17
 
17
18
  Validation.callable_or_nil!(llm_provider, name: 'llm_provider')
18
19
  source = MessageSource.call(messages)
19
- checks = MessageCheckSet.call(source, messages, policy: policy)
20
- CheckPipeline.call(source, checks, policy: policy, llm_provider: llm_provider)
20
+ checks = CheckSet.call(source, policy: policy, messages: messages)
21
+ merged, analysis = CheckAnalyzer.call(checks, provider: llm_provider, source: source, policy: policy)
22
+ CheckResultBuilder.call(merged, analysis, policy)
21
23
  end
22
24
  end
23
25
  end
@@ -15,13 +15,27 @@ module Olyx
15
15
  PatternCatalog::ENTRIES.reduce(text) { |output, entry| replace(output, entry) }
16
16
  end
17
17
 
18
+ def detect?(text)
19
+ return false unless text.is_a?(String)
20
+
21
+ PatternCatalog::ENTRIES.any? { |entry| matches?(text, entry) }
22
+ end
23
+
18
24
  def replace(text, entry)
19
25
  pattern, replacement, validator = entry
20
26
  return text.gsub(pattern, replacement) unless validator
21
27
 
22
28
  text.gsub(pattern) { |match| validator.call(match) ? replacement : match }
23
29
  end
24
- private_class_method :replace
30
+
31
+ def matches?(text, entry)
32
+ pattern, _, validator = entry
33
+ text.to_enum(:scan, pattern).any? do
34
+ match = Regexp.last_match[0]
35
+ !validator || validator.call(match)
36
+ end
37
+ end
38
+ private_class_method :matches?, :replace
25
39
  end
26
40
  end
27
41
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'finding_order'
4
4
  require_relative 'rule_matcher'
5
+ require_relative '../text/mapped_normalization'
5
6
 
6
7
  module Olyx
7
8
  module Guardrails
@@ -11,7 +12,12 @@ module Olyx
11
12
  module_function
12
13
 
13
14
  def call(source, rules)
14
- findings = rules.each_with_index.flat_map { |rule, index| RuleMatcher.call(source, rule, index) }
15
+ return [] if rules.empty?
16
+
17
+ normalized = Text::MappedNormalization.new(source)
18
+ findings = rules.each_with_index.flat_map do |rule, index|
19
+ RuleMatcher.call(source, normalized, rule, index)
20
+ end
15
21
  findings.uniq { |finding| FindingOrder.identity(finding) }.sort_by { |finding| FindingOrder.key(finding) }
16
22
  end
17
23
  end
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'pattern_matcher'
4
- require_relative 'normalized_pattern_matcher'
5
-
6
3
  module Olyx
7
4
  module Guardrails
8
5
  module PolicyComponents
@@ -12,17 +9,37 @@ module Olyx
12
9
 
13
10
  module_function
14
11
 
15
- def call(source, rule, index)
16
- rule.patterns.flat_map { |pattern| matches(source, rule, index, pattern) }
12
+ def call(source, normalized, rule, index)
13
+ rule.patterns.flat_map { |pattern| matches(source, normalized, rule, index, pattern) }
17
14
  rescue TIMEOUT_ERROR
18
15
  raise ArgumentError, "policy rule #{rule.name.inspect} timed out"
19
16
  end
20
17
 
21
- def matches(source, rule, index, pattern)
22
- PatternMatcher.call(source, rule, index, pattern) +
23
- NormalizedPatternMatcher.call(source, rule, index, pattern)
18
+ def matches(source, normalized, rule, index, pattern)
19
+ scan(source, rule, index, pattern) + normalized_matches(source, normalized, rule, index, pattern)
20
+ end
21
+
22
+ def scan(source, rule, index, pattern)
23
+ source.to_enum(:scan, pattern).map do
24
+ match = Regexp.last_match
25
+ finding(rule, index, match[0].to_s, match.begin(0), match.end(0))
26
+ end
27
+ end
28
+
29
+ def normalized_matches(source, normalized, rule, index, pattern)
30
+ return [] unless normalized.changed?
31
+
32
+ normalized.text.to_enum(:scan, pattern).map do
33
+ match = Regexp.last_match
34
+ starting, ending = normalized.original_span(match.begin(0), match.end(0))
35
+ finding(rule, index, source[starting...ending], starting, ending)
36
+ end
37
+ end
38
+
39
+ def finding(rule, index, full, starting, ending)
40
+ { rule: rule, rule_index: index, full: full, start: starting, end: ending }
24
41
  end
25
- private_class_method :matches
42
+ private_class_method :finding, :matches, :normalized_matches, :scan
26
43
  end
27
44
  end
28
45
  end
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'policy/configuration_hash'
4
- require_relative 'policy/configuration'
4
+ require_relative 'policy/name'
5
+ require_relative 'policy/rule_collection'
6
+ require_relative 'policy/secret_pattern_collection'
7
+ require_relative 'enum_value'
8
+ require_relative 'validation'
5
9
 
6
10
  module Olyx # :nodoc:
7
11
  module Guardrails
@@ -21,6 +25,27 @@ module Olyx # :nodoc:
21
25
  #
22
26
  # See docs/POLICIES.md for matching and replacement semantics.
23
27
  class Policy
28
+ LLM_FAILURE_MODE = EnumValue.new(
29
+ allowed: %i[allow block raise],
30
+ error: 'policy llm_failure_mode must be allow, block, or raise'
31
+ )
32
+ private_constant :LLM_FAILURE_MODE
33
+
34
+ # Returns the policy's stable String identifier.
35
+ attr_reader :name
36
+
37
+ # Returns the maximum accepted input length in Ruby characters.
38
+ attr_reader :max_input_length
39
+
40
+ # Returns +:allow+, +:block+, or +:raise+.
41
+ attr_reader :llm_failure_mode
42
+
43
+ # Returns the frozen custom secret regular-expression source Strings.
44
+ attr_reader :secret_patterns
45
+
46
+ # Returns the frozen PolicyRule collection.
47
+ attr_reader :rules
48
+
24
49
  # :call-seq:
25
50
  # Policy.default -> Policy
26
51
  #
@@ -67,37 +92,25 @@ module Olyx # :nodoc:
67
92
  secret_patterns: [],
68
93
  rules: []
69
94
  )
70
- @configuration = PolicyComponents::Configuration.new(
71
- identity: [name, max_input_length],
72
- blocking: [block_pii, block_injections, block_secrets],
73
- restrictions: [llm_failure_mode, secret_patterns, rules]
74
- )
95
+ @name = PolicyComponents::Name.call(name)
96
+ @max_input_length = Validation.non_negative_integer!(max_input_length, name: 'policy max_input_length')
97
+ @block_pii = Validation.boolean!(block_pii, name: 'policy block_pii')
98
+ @block_injections = Validation.boolean!(block_injections, name: 'policy block_injections')
99
+ @block_secrets = Validation.boolean!(block_secrets, name: 'policy block_secrets')
100
+ @llm_failure_mode = LLM_FAILURE_MODE.call(llm_failure_mode)
101
+ @secret_patterns = PolicyComponents::SecretPatternCollection.call(secret_patterns)
102
+ @rules = PolicyComponents::RuleCollection.call(rules)
75
103
  freeze
76
104
  end
77
105
 
78
- # Returns the policy's stable String identifier.
79
- def name = @configuration.identity.name
80
-
81
- # Returns the maximum accepted input length in Ruby characters.
82
- def max_input_length = @configuration.identity.max_input_length
83
-
84
- # Returns +:allow+, +:block+, or +:raise+.
85
- def llm_failure_mode = @configuration.restrictions.llm_failure_mode
86
-
87
- # Returns the frozen custom secret regular-expression source Strings.
88
- def secret_patterns = @configuration.restrictions.secret_patterns
89
-
90
- # Returns the frozen PolicyRule collection.
91
- def rules = @configuration.restrictions.rules
92
-
93
106
  # Returns whether PII findings block a decision.
94
- def block_pii? = @configuration.blocking.pii?
107
+ def block_pii? = @block_pii
95
108
 
96
109
  # Returns whether prompt-injection findings block a decision.
97
- def block_injections? = @configuration.blocking.injections?
110
+ def block_injections? = @block_injections
98
111
 
99
112
  # Returns whether secret findings block a decision.
100
- def block_secrets? = @configuration.blocking.secrets?
113
+ def block_secrets? = @block_secrets
101
114
  end
102
115
  end
103
116
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'policy_rule/configuration'
3
+ require_relative 'policy_rule/pattern_compiler'
4
+ require_relative 'validation'
4
5
 
5
6
  module Olyx # :nodoc:
6
7
  module Guardrails
@@ -10,6 +11,25 @@ module Olyx # :nodoc:
10
11
  # records safe fingerprints and offsets; it does not expose the matched
11
12
  # restricted text in public findings.
12
13
  class PolicyRule
14
+ MATCH_MODES = %i[substring whole_word regexp].freeze
15
+ NAME_FORMAT = /\A[a-z][a-z0-9_.:-]*\z/i
16
+ private_constant :MATCH_MODES, :NAME_FORMAT
17
+
18
+ # Returns the normalized String rule identifier.
19
+ attr_reader :name
20
+
21
+ # Returns the optional description, or +nil+.
22
+ attr_reader :description
23
+
24
+ # Returns +:substring+, +:whole_word+, or +:regexp+.
25
+ attr_reader :match
26
+
27
+ # Returns the frozen compiled Regexp collection.
28
+ attr_reader :patterns
29
+
30
+ # Returns the safe replacement used by redaction.
31
+ attr_reader :replacement
32
+
13
33
  # :call-seq:
14
34
  # PolicyRule.new(name:, patterns: [], terms: [], match: :substring,
15
35
  # block: true, description: nil, replacement: nil)
@@ -28,34 +48,51 @@ module Olyx # :nodoc:
28
48
  # Invalid values and expressions that match empty text raise
29
49
  # ArgumentError.
30
50
  def initialize(name:, patterns: [], terms: [], match: :substring, block: true, description: nil, replacement: nil)
31
- @identity = PolicyRuleComponents::IdentityConfiguration.new(name, description)
32
- @matching = PolicyRuleComponents::MatchConfiguration.new(patterns, terms, match)
33
- @enforcement = PolicyRuleComponents::EnforcementConfiguration.new(block, replacement || default_replacement)
51
+ @name = validate_name(name)
52
+ @description = validate_description(description)
53
+ @match = validate_match(match)
54
+ @patterns = PolicyRuleComponents::PatternCompiler.call(patterns: patterns, terms: terms, match: @match)
55
+ @block = Validation.boolean!(block, name: 'policy rule block')
56
+ @replacement = validate_replacement(replacement || default_replacement)
34
57
  freeze
35
58
  end
36
59
 
37
- # Returns the normalized String rule identifier.
38
- def name = @identity.name
60
+ # Returns whether a match blocks a decision.
61
+ def block? = @block
39
62
 
40
- # Returns the optional description, or +nil+.
41
- def description = @identity.description
63
+ private
42
64
 
43
- # Returns +:substring+, +:whole_word+, or +:regexp+.
44
- def match = @matching.mode
65
+ def default_replacement
66
+ "[RESTRICTED:#{@name.upcase}]"
67
+ end
45
68
 
46
- # Returns the frozen compiled Regexp collection.
47
- def patterns = @matching.patterns
69
+ def validate_name(value)
70
+ normalized = value.to_s
71
+ valid = (value.is_a?(String) || value.is_a?(Symbol)) && normalized.match?(NAME_FORMAT)
72
+ raise ArgumentError, 'policy rule name must be a String or Symbol identifier' unless valid
48
73
 
49
- # Returns the safe replacement used by redaction.
50
- def replacement = @enforcement.replacement
74
+ normalized.dup.freeze
75
+ end
51
76
 
52
- # Returns whether a match blocks a decision.
53
- def block? = @enforcement.block?
77
+ def validate_match(value)
78
+ normalized = value.respond_to?(:to_sym) ? value.to_sym : value
79
+ return normalized if MATCH_MODES.include?(normalized)
54
80
 
55
- private
81
+ raise ArgumentError, 'policy rule match must be substring, whole_word, or regexp'
82
+ end
56
83
 
57
- def default_replacement
58
- "[RESTRICTED:#{@identity.name.upcase}]"
84
+ def validate_description(value)
85
+ return nil if value.nil?
86
+ return value.dup.freeze if value.is_a?(String) && !value.strip.empty? && value.length <= 500
87
+
88
+ raise ArgumentError, 'policy rule description must be a String of 1..500 characters or nil'
89
+ end
90
+
91
+ def validate_replacement(value)
92
+ valid = value.is_a?(String) && !value.empty? && value.length <= 100 && !value.match?(/[\r\n\t]/)
93
+ raise ArgumentError, 'policy rule replacement must be a single-line String of 1..100 characters' unless valid
94
+
95
+ value.dup.freeze
59
96
  end
60
97
  end
61
98
  end
@@ -3,15 +3,8 @@
3
3
  require 'active_support/notifications'
4
4
  require 'forwardable'
5
5
  require_relative '../guardrails'
6
- require_relative 'rails/active_job_handler'
7
- require_relative 'rails/action_cable'
8
6
  require_relative 'rails/active_model_validator'
9
- require_relative 'rails/controller'
10
- require_relative 'rails/enforcer'
11
- require_relative 'rails/graphql'
12
- require_relative 'rails/job'
13
7
  require_relative 'rails/runtime'
14
- require_relative 'rails/upload'
15
8
 
16
9
  module Olyx # :nodoc:
17
10
  module Guardrails
@@ -29,6 +22,14 @@ module Olyx # :nodoc:
29
22
  module Rails
30
23
  extend SingleForwardable
31
24
 
25
+ autoload :ActiveJobHandler, File.join(__dir__, 'rails/active_job_handler')
26
+ autoload :ActionCable, File.join(__dir__, 'rails/action_cable')
27
+ autoload :Controller, File.join(__dir__, 'rails/controller')
28
+ autoload :Enforcer, File.join(__dir__, 'rails/enforcer')
29
+ autoload :GraphQL, File.join(__dir__, 'rails/graphql')
30
+ autoload :Job, File.join(__dir__, 'rails/job')
31
+ autoload :Upload, File.join(__dir__, 'rails/upload')
32
+
32
33
  def self.runtime # :nodoc:
33
34
  @runtime ||= Runtime.new
34
35
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../pii/text_scrubber'
4
+
3
5
  module Olyx
4
6
  module Guardrails
5
7
  module Redaction
@@ -13,13 +15,12 @@ module Olyx
13
15
  end
14
16
 
15
17
  def content(text, findings)
16
- output = '[REDACTED]' if findings.any? { |finding| finding[:category] == 'confidentiality_marker' }
17
- { text: output || text, findings: findings }
18
+ { text: text, findings: findings }
18
19
  end
19
20
 
20
21
  def detection(source, secret_scan, policy_redaction)
21
22
  {
22
- pii_detected: PiiScrubber.scrub(source) != source,
23
+ pii_detected: Pii::TextScrubber.detect?(source),
23
24
  secret_leaked: secret_scan[:leaked],
24
25
  policy_violated: policy_redaction[:violated],
25
26
  policy_findings: policy_redaction[:findings]
@@ -9,10 +9,8 @@ module Olyx
9
9
  module DeterministicScore
10
10
  module_function
11
11
 
12
- def call(checks, ordered_checks)
13
- score = CheckWeights.call(checks)
14
- score += BLOCKED_RISK_WEIGHT if ordered_checks.any? { |check| !check[:allowed] }
15
- score.clamp(0.0, 1.0).round(4)
12
+ def call(checks)
13
+ CheckWeights.call(checks).clamp(0.0, 1.0).round(4)
16
14
  end
17
15
  end
18
16
  end