spurline-deploy 0.3.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 (109) hide show
  1. checksums.yaml +7 -0
  2. data/lib/spurline/adapters/base.rb +17 -0
  3. data/lib/spurline/adapters/claude.rb +208 -0
  4. data/lib/spurline/adapters/open_ai.rb +213 -0
  5. data/lib/spurline/adapters/registry.rb +33 -0
  6. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  7. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  8. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  9. data/lib/spurline/agent.rb +433 -0
  10. data/lib/spurline/audit/log.rb +156 -0
  11. data/lib/spurline/audit/secret_filter.rb +121 -0
  12. data/lib/spurline/base.rb +130 -0
  13. data/lib/spurline/cartographer/analyzer.rb +71 -0
  14. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  15. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  16. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  17. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  18. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  19. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  20. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  21. data/lib/spurline/cartographer/runner.rb +88 -0
  22. data/lib/spurline/cartographer.rb +6 -0
  23. data/lib/spurline/channels/base.rb +41 -0
  24. data/lib/spurline/channels/event.rb +136 -0
  25. data/lib/spurline/channels/github.rb +205 -0
  26. data/lib/spurline/channels/router.rb +103 -0
  27. data/lib/spurline/cli/check.rb +88 -0
  28. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  29. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  30. data/lib/spurline/cli/checks/base.rb +35 -0
  31. data/lib/spurline/cli/checks/credentials.rb +43 -0
  32. data/lib/spurline/cli/checks/permissions.rb +22 -0
  33. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  34. data/lib/spurline/cli/checks/session_store.rb +97 -0
  35. data/lib/spurline/cli/console.rb +73 -0
  36. data/lib/spurline/cli/credentials.rb +181 -0
  37. data/lib/spurline/cli/generators/agent.rb +123 -0
  38. data/lib/spurline/cli/generators/migration.rb +62 -0
  39. data/lib/spurline/cli/generators/project.rb +331 -0
  40. data/lib/spurline/cli/generators/tool.rb +98 -0
  41. data/lib/spurline/cli/router.rb +121 -0
  42. data/lib/spurline/configuration.rb +23 -0
  43. data/lib/spurline/dsl/guardrails.rb +108 -0
  44. data/lib/spurline/dsl/hooks.rb +51 -0
  45. data/lib/spurline/dsl/memory.rb +39 -0
  46. data/lib/spurline/dsl/model.rb +23 -0
  47. data/lib/spurline/dsl/persona.rb +74 -0
  48. data/lib/spurline/dsl/suspend_until.rb +53 -0
  49. data/lib/spurline/dsl/tools.rb +176 -0
  50. data/lib/spurline/errors.rb +109 -0
  51. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  52. data/lib/spurline/lifecycle/runner.rb +456 -0
  53. data/lib/spurline/lifecycle/states.rb +47 -0
  54. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  55. data/lib/spurline/memory/context_assembler.rb +100 -0
  56. data/lib/spurline/memory/embedder/base.rb +17 -0
  57. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  58. data/lib/spurline/memory/episode.rb +56 -0
  59. data/lib/spurline/memory/episodic_store.rb +147 -0
  60. data/lib/spurline/memory/long_term/base.rb +22 -0
  61. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  62. data/lib/spurline/memory/manager.rb +147 -0
  63. data/lib/spurline/memory/short_term.rb +57 -0
  64. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  65. data/lib/spurline/orchestration/judge.rb +109 -0
  66. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  67. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  68. data/lib/spurline/orchestration/ledger.rb +339 -0
  69. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  70. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  71. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  72. data/lib/spurline/persona/base.rb +42 -0
  73. data/lib/spurline/persona/registry.rb +42 -0
  74. data/lib/spurline/secrets/resolver.rb +65 -0
  75. data/lib/spurline/secrets/vault.rb +42 -0
  76. data/lib/spurline/security/content.rb +76 -0
  77. data/lib/spurline/security/context_pipeline.rb +58 -0
  78. data/lib/spurline/security/gates/base.rb +36 -0
  79. data/lib/spurline/security/gates/operator_config.rb +22 -0
  80. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  81. data/lib/spurline/security/gates/tool_result.rb +23 -0
  82. data/lib/spurline/security/gates/user_input.rb +22 -0
  83. data/lib/spurline/security/injection_scanner.rb +109 -0
  84. data/lib/spurline/security/pii_filter.rb +104 -0
  85. data/lib/spurline/session/resumption.rb +36 -0
  86. data/lib/spurline/session/serializer.rb +169 -0
  87. data/lib/spurline/session/session.rb +154 -0
  88. data/lib/spurline/session/store/base.rb +27 -0
  89. data/lib/spurline/session/store/memory.rb +45 -0
  90. data/lib/spurline/session/store/postgres.rb +123 -0
  91. data/lib/spurline/session/store/sqlite.rb +139 -0
  92. data/lib/spurline/session/suspension.rb +93 -0
  93. data/lib/spurline/session/turn.rb +98 -0
  94. data/lib/spurline/spur.rb +213 -0
  95. data/lib/spurline/streaming/buffer.rb +77 -0
  96. data/lib/spurline/streaming/chunk.rb +62 -0
  97. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  98. data/lib/spurline/testing.rb +245 -0
  99. data/lib/spurline/toolkit.rb +110 -0
  100. data/lib/spurline/tools/base.rb +209 -0
  101. data/lib/spurline/tools/idempotency.rb +220 -0
  102. data/lib/spurline/tools/permissions.rb +44 -0
  103. data/lib/spurline/tools/registry.rb +43 -0
  104. data/lib/spurline/tools/runner.rb +255 -0
  105. data/lib/spurline/tools/scope.rb +309 -0
  106. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  107. data/lib/spurline/version.rb +5 -0
  108. data/lib/spurline.rb +56 -0
  109. metadata +161 -0
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Spurline
6
+ module Orchestration
7
+ # Immutable work unit for worker execution.
8
+ class TaskEnvelope
9
+ CURRENT_VERSION = "1.0"
10
+
11
+ attr_reader :task_id, :version, :instruction, :input_files,
12
+ :constraints, :acceptance_criteria, :output_spec,
13
+ :scoped_context, :parent_session_id,
14
+ :max_turns, :max_tool_calls, :metadata
15
+
16
+ # @param instruction [String] Natural language task description (required)
17
+ # @param acceptance_criteria [Array<String>] What the output must contain (required)
18
+ # @param task_id [String] UUID (auto-generated)
19
+ # @param version [String] Schema version
20
+ # @param input_files [Array<Hash>] Files the worker needs { path:, content: }
21
+ # @param constraints [Hash] Behavioral limits { no_modify: [...], read_only: true, ... }
22
+ # @param output_spec [Hash] Expected output format { type: :patch|:file|:answer, ... }
23
+ # @param scoped_context [Object,nil] Optional execution scope (M2.3)
24
+ # @param parent_session_id [String,nil] For audit correlation
25
+ # @param max_turns [Integer] Safety limit (default 10)
26
+ # @param max_tool_calls [Integer] Safety limit (default 20)
27
+ # @param metadata [Hash] Arbitrary extra data
28
+ def initialize(
29
+ instruction:,
30
+ acceptance_criteria:,
31
+ task_id: SecureRandom.uuid,
32
+ version: CURRENT_VERSION,
33
+ input_files: [],
34
+ constraints: {},
35
+ output_spec: {},
36
+ scoped_context: nil,
37
+ parent_session_id: nil,
38
+ max_turns: 10,
39
+ max_tool_calls: 20,
40
+ metadata: {}
41
+ )
42
+ validate_instruction!(instruction)
43
+ validate_acceptance_criteria!(acceptance_criteria)
44
+ validate_limit!(max_turns, name: "max_turns")
45
+ validate_limit!(max_tool_calls, name: "max_tool_calls")
46
+
47
+ @task_id = task_id.to_s
48
+ @version = version.to_s
49
+ @instruction = instruction.to_s
50
+ @input_files = deep_copy(input_files || [])
51
+ @constraints = deep_copy(constraints || {})
52
+ @acceptance_criteria = acceptance_criteria.map(&:to_s)
53
+ @output_spec = deep_copy(output_spec || {})
54
+ @scoped_context = normalize_scoped_context(scoped_context)
55
+ @parent_session_id = parent_session_id&.to_s
56
+ @max_turns = max_turns
57
+ @max_tool_calls = max_tool_calls
58
+ @metadata = deep_copy(metadata || {})
59
+
60
+ deep_freeze(@input_files)
61
+ deep_freeze(@constraints)
62
+ deep_freeze(@acceptance_criteria)
63
+ deep_freeze(@output_spec)
64
+ deep_freeze(@metadata)
65
+ if @scoped_context.is_a?(Hash) || @scoped_context.is_a?(Array)
66
+ deep_freeze(@scoped_context)
67
+ elsif @scoped_context.respond_to?(:freeze)
68
+ @scoped_context.freeze
69
+ end
70
+ freeze
71
+ end
72
+
73
+ def to_h
74
+ {
75
+ task_id: task_id,
76
+ version: version,
77
+ instruction: instruction,
78
+ input_files: deep_copy(input_files),
79
+ constraints: deep_copy(constraints),
80
+ acceptance_criteria: deep_copy(acceptance_criteria),
81
+ output_spec: deep_copy(output_spec),
82
+ scoped_context: serialize_scoped_context(scoped_context),
83
+ parent_session_id: parent_session_id,
84
+ max_turns: max_turns,
85
+ max_tool_calls: max_tool_calls,
86
+ metadata: deep_copy(metadata),
87
+ }
88
+ end
89
+
90
+ def self.from_h(data)
91
+ hash = deep_symbolize(data || {})
92
+ new(
93
+ task_id: hash[:task_id] || SecureRandom.uuid,
94
+ version: hash[:version] || CURRENT_VERSION,
95
+ instruction: hash.fetch(:instruction),
96
+ input_files: hash[:input_files] || [],
97
+ constraints: hash[:constraints] || {},
98
+ acceptance_criteria: hash.fetch(:acceptance_criteria),
99
+ output_spec: hash[:output_spec] || {},
100
+ scoped_context: hash[:scoped_context],
101
+ parent_session_id: hash[:parent_session_id],
102
+ max_turns: hash[:max_turns] || 10,
103
+ max_tool_calls: hash[:max_tool_calls] || 20,
104
+ metadata: hash[:metadata] || {}
105
+ )
106
+ end
107
+
108
+ private
109
+
110
+ def validate_instruction!(value)
111
+ return if value.to_s.strip != ""
112
+
113
+ raise Spurline::TaskEnvelopeError, "instruction is required"
114
+ end
115
+
116
+ def validate_acceptance_criteria!(value)
117
+ unless value.is_a?(Array) && !value.empty?
118
+ raise Spurline::TaskEnvelopeError, "acceptance_criteria must be a non-empty array"
119
+ end
120
+
121
+ if value.any? { |criterion| criterion.to_s.strip.empty? }
122
+ raise Spurline::TaskEnvelopeError, "acceptance_criteria entries must be non-empty"
123
+ end
124
+ end
125
+
126
+ def validate_limit!(value, name:)
127
+ unless value.is_a?(Integer) && value.positive?
128
+ raise Spurline::TaskEnvelopeError, "#{name} must be a positive integer"
129
+ end
130
+ end
131
+
132
+ def normalize_scoped_context(value)
133
+ return nil if value.nil?
134
+
135
+ if value.is_a?(Hash) || value.is_a?(Array)
136
+ deep_copy(value)
137
+ elsif value.respond_to?(:to_h)
138
+ deep_copy(value.to_h)
139
+ else
140
+ value
141
+ end
142
+ end
143
+
144
+ def serialize_scoped_context(value)
145
+ return nil if value.nil?
146
+
147
+ if value.is_a?(Hash) || value.is_a?(Array)
148
+ deep_copy(value)
149
+ elsif value.respond_to?(:to_h)
150
+ deep_copy(value.to_h)
151
+ else
152
+ value
153
+ end
154
+ end
155
+
156
+ def deep_copy(value)
157
+ case value
158
+ when Hash
159
+ value.each_with_object({}) do |(key, item), copy|
160
+ copy[key] = deep_copy(item)
161
+ end
162
+ when Array
163
+ value.map { |item| deep_copy(item) }
164
+ else
165
+ value
166
+ end
167
+ end
168
+
169
+ def deep_freeze(value)
170
+ case value
171
+ when Hash
172
+ value.each do |key, item|
173
+ deep_freeze(key)
174
+ deep_freeze(item)
175
+ end
176
+ when Array
177
+ value.each { |item| deep_freeze(item) }
178
+ end
179
+
180
+ value.freeze
181
+ end
182
+
183
+ class << self
184
+ private
185
+
186
+ def deep_symbolize(value)
187
+ case value
188
+ when Hash
189
+ value.each_with_object({}) do |(key, item), result|
190
+ result[key.to_sym] = deep_symbolize(item)
191
+ end
192
+ when Array
193
+ value.map { |item| deep_symbolize(item) }
194
+ else
195
+ value
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Persona
5
+ # A compiled persona. Holds the system prompt as a Content object with
6
+ # trust: :system. Frozen after compilation — cannot be modified at runtime.
7
+ class Base
8
+ attr_reader :name, :content, :injection_config
9
+
10
+ def initialize(name:, system_prompt:, injection_config: {})
11
+ @name = name.to_sym
12
+ @content = Security::Gates::SystemPrompt.wrap(
13
+ system_prompt,
14
+ persona: name.to_s
15
+ )
16
+ @injection_config = injection_config.freeze
17
+ freeze
18
+ end
19
+
20
+ # Returns the system prompt as a Content object.
21
+ def render
22
+ content
23
+ end
24
+
25
+ def system_prompt_text
26
+ content.text
27
+ end
28
+
29
+ def inject_date?
30
+ injection_config.fetch(:inject_date, false)
31
+ end
32
+
33
+ def inject_user_context?
34
+ injection_config.fetch(:inject_user_context, false)
35
+ end
36
+
37
+ def inject_agent_context?
38
+ injection_config.fetch(:inject_agent_context, false)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Persona
5
+ # Per-class storage of compiled personas. Supports multiple personas
6
+ # per agent class, selectable at instantiation time.
7
+ class Registry
8
+ def initialize
9
+ @personas = {}
10
+ end
11
+
12
+ def register(name, persona)
13
+ @personas[name.to_sym] = persona
14
+ end
15
+
16
+ def fetch(name)
17
+ name = name.to_sym
18
+ @personas.fetch(name) do
19
+ raise Spurline::ConfigurationError,
20
+ "Persona '#{name}' is not defined. Available personas: " \
21
+ "#{@personas.keys.map(&:inspect).join(", ")}."
22
+ end
23
+ end
24
+
25
+ def default
26
+ fetch(:default)
27
+ rescue Spurline::ConfigurationError
28
+ nil
29
+ end
30
+
31
+ def names
32
+ @personas.keys
33
+ end
34
+
35
+ def dup_registry
36
+ new_registry = self.class.new
37
+ @personas.each { |name, persona| new_registry.register(name, persona) }
38
+ new_registry
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Secrets
5
+ class Resolver
6
+ def initialize(vault: nil, overrides: {})
7
+ @vault = vault
8
+ @overrides = overrides || {}
9
+ end
10
+
11
+ # Returns resolved value or nil.
12
+ def resolve(secret_name)
13
+ name = secret_name.to_sym
14
+
15
+ if @overrides.key?(name)
16
+ return resolve_override(@overrides[name])
17
+ end
18
+
19
+ if @vault&.key?(name)
20
+ return @vault.fetch(name)
21
+ end
22
+
23
+ cred_value = Spurline.credentials[name.to_s]
24
+ return cred_value if present?(cred_value)
25
+
26
+ env_value = ENV[name.to_s.upcase]
27
+ return env_value if present?(env_value)
28
+
29
+ nil
30
+ end
31
+
32
+ # Returns resolved value or raises SecretNotFoundError.
33
+ def resolve!(secret_name)
34
+ value = resolve(secret_name)
35
+ return value unless value.nil?
36
+
37
+ raise Spurline::SecretNotFoundError,
38
+ "Secret '#{secret_name}' is required but could not be resolved. " \
39
+ "Provide it via: agent.vault.store(:#{secret_name}, '...'), " \
40
+ "Spurline.credentials['#{secret_name}'] (spur credentials:edit), " \
41
+ "or ENV['#{secret_name.to_s.upcase}']."
42
+ end
43
+
44
+ private
45
+
46
+ def resolve_override(override)
47
+ case override
48
+ when Proc, Method
49
+ override.call
50
+ when Symbol, String
51
+ Spurline.credentials[override.to_s]
52
+ else
53
+ override
54
+ end
55
+ end
56
+
57
+ def present?(value)
58
+ return false if value.nil?
59
+ return !value.strip.empty? if value.respond_to?(:strip)
60
+
61
+ true
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Secrets
5
+ class Vault
6
+ def initialize
7
+ @store = {}
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def store(key, value)
12
+ @mutex.synchronize { @store[key.to_sym] = value }
13
+ end
14
+ alias []= store
15
+
16
+ def fetch(key, default = nil)
17
+ @mutex.synchronize { @store.fetch(key.to_sym, default) }
18
+ end
19
+ alias [] fetch
20
+
21
+ def key?(key)
22
+ @mutex.synchronize { @store.key?(key.to_sym) }
23
+ end
24
+
25
+ def delete(key)
26
+ @mutex.synchronize { @store.delete(key.to_sym) }
27
+ end
28
+
29
+ def clear!
30
+ @mutex.synchronize { @store.clear }
31
+ end
32
+
33
+ def keys
34
+ @mutex.synchronize { @store.keys }
35
+ end
36
+
37
+ def empty?
38
+ @mutex.synchronize { @store.empty? }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ # The cardinal type of the Spurline framework. Every piece of content flowing
6
+ # through the system is a Content object carrying a trust level and source.
7
+ # Raw strings never enter the context pipeline.
8
+ #
9
+ # Content objects are frozen on creation and cannot be mutated.
10
+ class Content
11
+ TRUST_LEVELS = %i[system operator user external untrusted].freeze
12
+
13
+ TAINTED_LEVELS = %i[external untrusted].freeze
14
+
15
+ attr_reader :text, :trust, :source
16
+
17
+ def initialize(text:, trust:, source:)
18
+ validate_trust!(trust)
19
+
20
+ @text = text.dup.freeze
21
+ @trust = trust
22
+ @source = source.dup.freeze
23
+ freeze
24
+ end
25
+
26
+ # Raises TaintedContentError for tainted content. Use #render instead.
27
+ def to_s
28
+ if tainted?
29
+ raise Spurline::TaintedContentError,
30
+ "Cannot convert tainted content (trust: #{trust}, source: #{source}) to string. " \
31
+ "Use Content#render to get a safely fenced string."
32
+ end
33
+
34
+ text
35
+ end
36
+
37
+ # Returns the content as a string, applying XML data fencing for tainted content.
38
+ # This is the ONLY safe way to extract a string from tainted content.
39
+ def render
40
+ return text unless tainted?
41
+
42
+ <<~XML.strip
43
+ <external_data trust="#{trust}" source="#{source}">
44
+ #{text}
45
+ </external_data>
46
+ XML
47
+ end
48
+
49
+ def tainted?
50
+ TAINTED_LEVELS.include?(trust)
51
+ end
52
+
53
+ def ==(other)
54
+ other.is_a?(Content) &&
55
+ text == other.text &&
56
+ trust == other.trust &&
57
+ source == other.source
58
+ end
59
+
60
+ def inspect
61
+ "#<Spurline::Security::Content trust=#{trust} source=#{source.inspect} " \
62
+ "text=#{text[0..50].inspect}#{text.length > 50 ? "..." : ""}>"
63
+ end
64
+
65
+ private
66
+
67
+ def validate_trust!(trust)
68
+ return if TRUST_LEVELS.include?(trust)
69
+
70
+ raise Spurline::ConfigurationError,
71
+ "Invalid trust level: #{trust.inspect}. " \
72
+ "Must be one of: #{TRUST_LEVELS.map(&:inspect).join(", ")}."
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ # The only path content takes to the LLM. Every LLM call assembles context
6
+ # through this pipeline. The stages run in fixed order and cannot be reordered.
7
+ #
8
+ # Pipeline stages:
9
+ # 1. Injection scanning — detect and block prompt injection attempts
10
+ # 2. PII filtering — redact/block/warn on personally identifiable information
11
+ # 3. Data fencing — render tainted content with XML fencing
12
+ #
13
+ # Input: Array of Content objects at various trust levels
14
+ # Output: Array of rendered strings, safe for inclusion in an LLM prompt
15
+ class ContextPipeline
16
+ def initialize(guardrails: {})
17
+ @scanner = InjectionScanner.new(
18
+ level: guardrails.fetch(:injection_filter, :strict)
19
+ )
20
+ @pii_filter = PIIFilter.new(
21
+ mode: guardrails.fetch(:pii_filter, :off)
22
+ )
23
+ end
24
+
25
+ # Processes an array of Content objects through the full security pipeline.
26
+ # Returns an array of safe, rendered strings ready for the LLM.
27
+ #
28
+ # Raises InjectionAttemptError if injection patterns are detected.
29
+ def process(contents)
30
+ contents.map do |content|
31
+ validate_content!(content)
32
+ scan!(content)
33
+ filtered = filter(content)
34
+ filtered.render
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def validate_content!(content)
41
+ return if content.is_a?(Content)
42
+
43
+ raise Spurline::TaintedContentError,
44
+ "ContextPipeline received #{content.class.name} instead of " \
45
+ "Spurline::Security::Content. All content must enter through a Gate. " \
46
+ "Raw strings are never allowed in the pipeline."
47
+ end
48
+
49
+ def scan!(content)
50
+ @scanner.scan!(content)
51
+ end
52
+
53
+ def filter(content)
54
+ @pii_filter.filter(content)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ module Gates
6
+ # Abstract base class for security gates. Each gate wraps raw input
7
+ # into a Content object with the appropriate trust level and source.
8
+ #
9
+ # All external data enters the framework through exactly one of four gates.
10
+ # Nothing bypasses a gate.
11
+ class Base
12
+ class << self
13
+ # Wraps raw text into a Content object with the gate's trust level.
14
+ # Subclasses must implement #trust_level and #source_for.
15
+ def wrap(text, **metadata)
16
+ Content.new(
17
+ text: text,
18
+ trust: trust_level,
19
+ source: source_for(**metadata)
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def trust_level
26
+ raise NotImplementedError, "#{name} must implement .trust_level"
27
+ end
28
+
29
+ def source_for(**_metadata)
30
+ raise NotImplementedError, "#{name} must implement .source_for"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ module Gates
6
+ # Gate for developer-authored configuration. Trust level: :operator.
7
+ class OperatorConfig < Base
8
+ class << self
9
+ private
10
+
11
+ def trust_level
12
+ :operator
13
+ end
14
+
15
+ def source_for(key: "config", **)
16
+ "config:#{key}"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ module Gates
6
+ # Gate for framework and persona prompts. Trust level: :system.
7
+ # System prompts are trusted by definition and bypass the injection scanner.
8
+ class SystemPrompt < Base
9
+ class << self
10
+ private
11
+
12
+ def trust_level
13
+ :system
14
+ end
15
+
16
+ def source_for(persona: "default", **)
17
+ "persona:#{persona}"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ module Gates
6
+ # Gate for tool execution results. Trust level: :external.
7
+ # Tool results are always tainted — they come from outside the trust boundary.
8
+ class ToolResult < Base
9
+ class << self
10
+ private
11
+
12
+ def trust_level
13
+ :external
14
+ end
15
+
16
+ def source_for(tool_name: "unknown", **)
17
+ "tool:#{tool_name}"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ module Gates
6
+ # Gate for live user messages. Trust level: :user.
7
+ class UserInput < Base
8
+ class << self
9
+ private
10
+
11
+ def trust_level
12
+ :user
13
+ end
14
+
15
+ def source_for(user_id: "anonymous", **)
16
+ "user:#{user_id}"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end