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.
- checksums.yaml +7 -0
- data/lib/spurline/adapters/base.rb +17 -0
- data/lib/spurline/adapters/claude.rb +208 -0
- data/lib/spurline/adapters/open_ai.rb +213 -0
- data/lib/spurline/adapters/registry.rb +33 -0
- data/lib/spurline/adapters/scheduler/base.rb +15 -0
- data/lib/spurline/adapters/scheduler/sync.rb +15 -0
- data/lib/spurline/adapters/stub_adapter.rb +54 -0
- data/lib/spurline/agent.rb +433 -0
- data/lib/spurline/audit/log.rb +156 -0
- data/lib/spurline/audit/secret_filter.rb +121 -0
- data/lib/spurline/base.rb +130 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
- data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
- data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
- data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
- data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
- data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
- data/lib/spurline/cartographer/repo_profile.rb +140 -0
- data/lib/spurline/cartographer/runner.rb +88 -0
- data/lib/spurline/cartographer.rb +6 -0
- data/lib/spurline/channels/base.rb +41 -0
- data/lib/spurline/channels/event.rb +136 -0
- data/lib/spurline/channels/github.rb +205 -0
- data/lib/spurline/channels/router.rb +103 -0
- data/lib/spurline/cli/check.rb +88 -0
- data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
- data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
- data/lib/spurline/cli/checks/base.rb +35 -0
- data/lib/spurline/cli/checks/credentials.rb +43 -0
- data/lib/spurline/cli/checks/permissions.rb +22 -0
- data/lib/spurline/cli/checks/project_structure.rb +48 -0
- data/lib/spurline/cli/checks/session_store.rb +97 -0
- data/lib/spurline/cli/console.rb +73 -0
- data/lib/spurline/cli/credentials.rb +181 -0
- data/lib/spurline/cli/generators/agent.rb +123 -0
- data/lib/spurline/cli/generators/migration.rb +62 -0
- data/lib/spurline/cli/generators/project.rb +331 -0
- data/lib/spurline/cli/generators/tool.rb +98 -0
- data/lib/spurline/cli/router.rb +121 -0
- data/lib/spurline/configuration.rb +23 -0
- data/lib/spurline/dsl/guardrails.rb +108 -0
- data/lib/spurline/dsl/hooks.rb +51 -0
- data/lib/spurline/dsl/memory.rb +39 -0
- data/lib/spurline/dsl/model.rb +23 -0
- data/lib/spurline/dsl/persona.rb +74 -0
- data/lib/spurline/dsl/suspend_until.rb +53 -0
- data/lib/spurline/dsl/tools.rb +176 -0
- data/lib/spurline/errors.rb +109 -0
- data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
- data/lib/spurline/lifecycle/runner.rb +456 -0
- data/lib/spurline/lifecycle/states.rb +47 -0
- data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/base.rb +17 -0
- data/lib/spurline/memory/embedder/open_ai.rb +70 -0
- data/lib/spurline/memory/episode.rb +56 -0
- data/lib/spurline/memory/episodic_store.rb +147 -0
- data/lib/spurline/memory/long_term/base.rb +22 -0
- data/lib/spurline/memory/long_term/postgres.rb +106 -0
- data/lib/spurline/memory/manager.rb +147 -0
- data/lib/spurline/memory/short_term.rb +57 -0
- data/lib/spurline/orchestration/agent_spawner.rb +151 -0
- data/lib/spurline/orchestration/judge.rb +109 -0
- data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
- data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
- data/lib/spurline/orchestration/ledger.rb +339 -0
- data/lib/spurline/orchestration/merge_queue.rb +133 -0
- data/lib/spurline/orchestration/permission_intersection.rb +151 -0
- data/lib/spurline/orchestration/task_envelope.rb +201 -0
- data/lib/spurline/persona/base.rb +42 -0
- data/lib/spurline/persona/registry.rb +42 -0
- data/lib/spurline/secrets/resolver.rb +65 -0
- data/lib/spurline/secrets/vault.rb +42 -0
- data/lib/spurline/security/content.rb +76 -0
- data/lib/spurline/security/context_pipeline.rb +58 -0
- data/lib/spurline/security/gates/base.rb +36 -0
- data/lib/spurline/security/gates/operator_config.rb +22 -0
- data/lib/spurline/security/gates/system_prompt.rb +23 -0
- data/lib/spurline/security/gates/tool_result.rb +23 -0
- data/lib/spurline/security/gates/user_input.rb +22 -0
- data/lib/spurline/security/injection_scanner.rb +109 -0
- data/lib/spurline/security/pii_filter.rb +104 -0
- data/lib/spurline/session/resumption.rb +36 -0
- data/lib/spurline/session/serializer.rb +169 -0
- data/lib/spurline/session/session.rb +154 -0
- data/lib/spurline/session/store/base.rb +27 -0
- data/lib/spurline/session/store/memory.rb +45 -0
- data/lib/spurline/session/store/postgres.rb +123 -0
- data/lib/spurline/session/store/sqlite.rb +139 -0
- data/lib/spurline/session/suspension.rb +93 -0
- data/lib/spurline/session/turn.rb +98 -0
- data/lib/spurline/spur.rb +213 -0
- data/lib/spurline/streaming/buffer.rb +77 -0
- data/lib/spurline/streaming/chunk.rb +62 -0
- data/lib/spurline/streaming/stream_enumerator.rb +29 -0
- data/lib/spurline/testing.rb +245 -0
- data/lib/spurline/toolkit.rb +110 -0
- data/lib/spurline/tools/base.rb +209 -0
- data/lib/spurline/tools/idempotency.rb +220 -0
- data/lib/spurline/tools/permissions.rb +44 -0
- data/lib/spurline/tools/registry.rb +43 -0
- data/lib/spurline/tools/runner.rb +255 -0
- data/lib/spurline/tools/scope.rb +309 -0
- data/lib/spurline/tools/toolkit_registry.rb +63 -0
- data/lib/spurline/version.rb +5 -0
- data/lib/spurline.rb +56 -0
- 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
|