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,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Spurline
6
+ module CLI
7
+ module Generators
8
+ # Generates a new Spurline agent project scaffold.
9
+ # Usage: spur new my_agent
10
+ class Project
11
+ attr_reader :name, :root
12
+
13
+ def initialize(name:)
14
+ @name = name
15
+ @root = File.expand_path(name)
16
+ end
17
+
18
+ def generate!
19
+ if Dir.exist?(root)
20
+ $stderr.puts "Directory '#{name}' already exists."
21
+ exit 1
22
+ end
23
+
24
+ puts "Creating new Spurline project: #{name}"
25
+
26
+ create_directories!
27
+ create_gemfile!
28
+ create_rakefile!
29
+ create_initializer!
30
+ create_application_agent!
31
+ create_example_agent!
32
+ create_spec_helper!
33
+ create_example_agent_spec!
34
+ create_permissions!
35
+ create_gitignore!
36
+ create_ruby_version!
37
+ create_env_example!
38
+ create_readme!
39
+
40
+ puts ""
41
+ puts "Project '#{name}' created successfully!"
42
+ puts ""
43
+ puts "Next steps:"
44
+ puts " cd #{name}"
45
+ puts " bundle install"
46
+ puts " bundle exec rspec"
47
+ puts ""
48
+ end
49
+
50
+ private
51
+
52
+ def create_directories!
53
+ dirs = %w[
54
+ app/agents
55
+ app/tools
56
+ config
57
+ spec
58
+ spec/agents
59
+ spec/tools
60
+ ]
61
+ dirs.each { |dir| FileUtils.mkdir_p(File.join(root, dir)) }
62
+ end
63
+
64
+ def create_gemfile!
65
+ write_file("Gemfile", <<~RUBY)
66
+ # frozen_string_literal: true
67
+
68
+ source "https://rubygems.org"
69
+
70
+ gem "spurline-core"
71
+
72
+ # Uncomment to add bundled spurs:
73
+ # gem "spurline-web-search"
74
+
75
+ group :development, :test do
76
+ gem "rspec"
77
+ # gem "webmock" # Useful for testing tools that make HTTP calls
78
+ end
79
+ RUBY
80
+ end
81
+
82
+ def create_rakefile!
83
+ write_file("Rakefile", <<~RUBY)
84
+ # frozen_string_literal: true
85
+
86
+ require "rspec/core/rake_task"
87
+ RSpec::Core::RakeTask.new(:spec)
88
+ task default: :spec
89
+ RUBY
90
+ end
91
+
92
+ def create_initializer!
93
+ write_file("config/spurline.rb", <<~RUBY)
94
+ # frozen_string_literal: true
95
+
96
+ require "spurline"
97
+
98
+ Spurline.configure do |config|
99
+ config.default_model = :claude_sonnet
100
+ config.session_store = :memory
101
+ config.permissions_file = "config/permissions.yml"
102
+
103
+ # Durable sessions (survives process restart):
104
+ # config.session_store = :sqlite
105
+ # config.session_store_path = "tmp/spurline_sessions.db"
106
+ #
107
+ # PostgreSQL sessions (for team deployments):
108
+ # config.session_store = :postgres
109
+ # config.session_store_postgres_url = "postgresql://localhost/my_app_development"
110
+ end
111
+ RUBY
112
+ end
113
+
114
+ def create_application_agent!
115
+ write_file("app/agents/application_agent.rb", <<~RUBY)
116
+ # frozen_string_literal: true
117
+
118
+ require "spurline"
119
+
120
+ # The shared base class for all agents in this project.
121
+ # Configure defaults here -- individual agents inherit and override.
122
+ class ApplicationAgent < Spurline::Agent
123
+ use_model :claude_sonnet
124
+
125
+ guardrails do
126
+ max_tool_calls 10
127
+ injection_filter :strict
128
+ pii_filter :off
129
+ end
130
+
131
+ # Uncomment to add a default persona with date injection:
132
+ # persona(:default) do
133
+ # system_prompt "You are a helpful assistant."
134
+ # inject_date true
135
+ # end
136
+
137
+ # Uncomment to add lifecycle hooks:
138
+ # on_start { |session| puts "Session \#{session.id} started" }
139
+ # on_finish { |session| puts "Session \#{session.id} finished" }
140
+ # on_error { |error| $stderr.puts "Error: \#{error.message}" }
141
+
142
+ # Uncomment for memory window customization:
143
+ # memory :short_term, window: 20
144
+ end
145
+ RUBY
146
+ end
147
+
148
+ def create_example_agent!
149
+ write_file("app/agents/assistant_agent.rb", <<~RUBY)
150
+ # frozen_string_literal: true
151
+
152
+ require_relative "application_agent"
153
+
154
+ class AssistantAgent < ApplicationAgent
155
+ persona(:default) do
156
+ system_prompt "You are a helpful assistant for the #{classify(name)} project."
157
+ inject_date true
158
+ end
159
+
160
+ # Uncomment to register tools:
161
+ # tools :example_tool
162
+
163
+ # Uncomment to override guardrails from ApplicationAgent:
164
+ # guardrails do
165
+ # max_tool_calls 5
166
+ # end
167
+ end
168
+ RUBY
169
+ end
170
+
171
+ def create_spec_helper!
172
+ write_file("spec/spec_helper.rb", <<~RUBY)
173
+ # frozen_string_literal: true
174
+
175
+ require_relative "../config/spurline"
176
+ require "spurline/testing"
177
+
178
+ # Load application files
179
+ Dir[File.join(__dir__, "..", "app", "**", "*.rb")].sort.each { |f| require f }
180
+
181
+ RSpec.configure do |config|
182
+ config.expect_with :rspec do |expectations|
183
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
184
+ end
185
+
186
+ config.mock_with :rspec do |mocks|
187
+ mocks.verify_partial_doubles = true
188
+ end
189
+
190
+ config.order = :random
191
+ Kernel.srand config.seed
192
+ end
193
+ RUBY
194
+ end
195
+
196
+ def create_example_agent_spec!
197
+ write_file("spec/agents/assistant_agent_spec.rb", <<~RUBY)
198
+ # frozen_string_literal: true
199
+
200
+ RSpec.describe AssistantAgent do
201
+ let(:agent) do
202
+ described_class.new.tap do |a|
203
+ a.use_stub_adapter(responses: [stub_text("Hello!")])
204
+ end
205
+ end
206
+
207
+ describe "#run" do
208
+ it "streams a response" do
209
+ chunks = []
210
+ agent.run("Say hello") { |chunk| chunks << chunk }
211
+
212
+ text = chunks.select(&:text?).map(&:text).join
213
+ expect(text).to eq("Hello!")
214
+ end
215
+ end
216
+ end
217
+ RUBY
218
+ end
219
+
220
+ def create_permissions!
221
+ write_file("config/permissions.yml", <<~YAML)
222
+ # Tool permission configuration.
223
+ # See: https://github.com/dylanwilcox/spurline
224
+ #
225
+ # tools:
226
+ # dangerous_tool:
227
+ # denied: true
228
+ # sensitive_tool:
229
+ # requires_confirmation: true
230
+ # allowed_users:
231
+ # - admin
232
+ tools: {}
233
+ YAML
234
+ end
235
+
236
+ def create_gitignore!
237
+ write_file(".gitignore", <<~TEXT)
238
+ /.bundle/
239
+ /vendor/bundle
240
+ /tmp/
241
+ /log/
242
+ config/master.key
243
+ tmp/spurline_sessions.db
244
+ *.gem
245
+ .env
246
+ Gemfile.lock
247
+ TEXT
248
+ end
249
+
250
+ def create_ruby_version!
251
+ write_file(".ruby-version", "3.4.5\n")
252
+ end
253
+
254
+ def create_env_example!
255
+ write_file(".env.example", <<~TEXT)
256
+ # Spurline environment variables.
257
+ # Copy this file to .env and fill in your values.
258
+ # Never commit .env to version control.
259
+
260
+ ANTHROPIC_API_KEY=your_key_here
261
+
262
+ # Uncomment for encrypted credentials support:
263
+ # SPURLINE_MASTER_KEY=your_32_byte_hex_key
264
+ TEXT
265
+ end
266
+
267
+ def create_readme!
268
+ write_file("README.md", <<~MARKDOWN)
269
+ # #{classify(name)}
270
+
271
+ A [Spurline](https://github.com/dylanwilcox/spurline) agent project.
272
+
273
+ ## Setup
274
+
275
+ ```bash
276
+ bundle install
277
+ cp .env.example .env
278
+ # Edit .env with your ANTHROPIC_API_KEY
279
+ ```
280
+
281
+ ## Validate
282
+
283
+ ```bash
284
+ bundle exec spur check
285
+ ```
286
+
287
+ ## Run Tests
288
+
289
+ ```bash
290
+ bundle exec rspec
291
+ ```
292
+
293
+ ## Project Structure
294
+
295
+ ```
296
+ app/
297
+ agents/ # Agent classes (inherit from ApplicationAgent)
298
+ tools/ # Tool classes (inherit from Spurline::Tools::Base)
299
+ config/
300
+ spurline.rb # Framework configuration
301
+ permissions.yml # Tool permission rules
302
+ spec/ # RSpec test files
303
+ ```
304
+
305
+ ## Generators
306
+
307
+ ```bash
308
+ spur generate agent researcher # Creates app/agents/researcher_agent.rb
309
+ spur generate tool web_scraper # Creates app/tools/web_scraper.rb + spec
310
+ ```
311
+ MARKDOWN
312
+ end
313
+
314
+ def write_file(relative_path, content)
315
+ path = File.join(root, relative_path)
316
+ FileUtils.mkdir_p(File.dirname(path))
317
+ File.write(path, content)
318
+ puts " create #{relative_path}"
319
+ end
320
+
321
+ def classify(str)
322
+ str.to_s
323
+ .gsub(/[-_]/, " ")
324
+ .split(" ")
325
+ .map(&:capitalize)
326
+ .join
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Spurline
6
+ module CLI
7
+ module Generators
8
+ # Generates a new tool class file.
9
+ # Usage: spur generate tool web_scraper
10
+ class Tool
11
+ attr_reader :name
12
+
13
+ def initialize(name:)
14
+ @name = name.to_s
15
+ end
16
+
17
+ def generate!
18
+ path = File.join("app", "tools", "#{snake_name}.rb")
19
+
20
+ if File.exist?(path)
21
+ $stderr.puts "File already exists: #{path}"
22
+ exit 1
23
+ end
24
+
25
+ FileUtils.mkdir_p(File.dirname(path))
26
+ File.write(path, tool_template)
27
+ puts " create #{path}"
28
+
29
+ spec_path = File.join("spec", "tools", "#{snake_name}_spec.rb")
30
+ FileUtils.mkdir_p(File.dirname(spec_path))
31
+ File.write(spec_path, spec_template)
32
+ puts " create #{spec_path}"
33
+ end
34
+
35
+ private
36
+
37
+ def tool_template
38
+ <<~RUBY
39
+ # frozen_string_literal: true
40
+
41
+ class #{class_name} < Spurline::Tools::Base
42
+ tool_name :#{snake_name}
43
+ description "TODO: Describe what #{snake_name} does"
44
+ parameters({
45
+ type: "object",
46
+ properties: {
47
+ input: { type: "string", description: "TODO: describe input" },
48
+ },
49
+ required: %w[input],
50
+ })
51
+
52
+ def call(input:)
53
+ # TODO: Implement #{snake_name}
54
+ raise NotImplementedError, "#{class_name}#call not yet implemented"
55
+ end
56
+ end
57
+ RUBY
58
+ end
59
+
60
+ def spec_template
61
+ <<~RUBY
62
+ # frozen_string_literal: true
63
+
64
+ require_relative "../../app/tools/#{snake_name}"
65
+
66
+ RSpec.describe #{class_name} do
67
+ let(:tool) { described_class.new }
68
+
69
+ describe "#call" do
70
+ it "executes the tool" do
71
+ # TODO: Write tests for #{snake_name}
72
+ pending "implement #{class_name}#call first"
73
+ result = tool.call(input: "test")
74
+ expect(result).not_to be_nil
75
+ end
76
+ end
77
+ end
78
+ RUBY
79
+ end
80
+
81
+ def class_name
82
+ name.to_s
83
+ .gsub(/[-_]/, " ")
84
+ .split(" ")
85
+ .map(&:capitalize)
86
+ .join
87
+ end
88
+
89
+ def snake_name
90
+ name.to_s
91
+ .gsub(/([a-z])([A-Z])/, '\1_\2')
92
+ .gsub(/[-\s]/, "_")
93
+ .downcase
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module CLI
5
+ # Routes CLI commands to the appropriate handler.
6
+ # Entry point: Router.run(ARGV)
7
+ class Router
8
+ COMMANDS = {
9
+ "new" => :handle_new,
10
+ "generate" => :handle_generate,
11
+ "check" => :handle_check,
12
+ "console" => :handle_console,
13
+ "credentials:edit" => :handle_credentials_edit,
14
+ "version" => :handle_version,
15
+ "help" => :handle_help,
16
+ }.freeze
17
+
18
+ GENERATE_SUBCOMMANDS = %w[agent tool migration].freeze
19
+
20
+ def self.run(args)
21
+ new(args).dispatch
22
+ end
23
+
24
+ def initialize(args)
25
+ @args = args
26
+ @command = args.first
27
+ @rest = args[1..] || []
28
+ end
29
+
30
+ def dispatch
31
+ if @command.nil? || @command == "help" || @command == "--help" || @command == "-h"
32
+ handle_help
33
+ elsif @command == "version" || @command == "--version" || @command == "-v"
34
+ handle_version
35
+ elsif COMMANDS.key?(@command)
36
+ send(COMMANDS[@command])
37
+ else
38
+ $stderr.puts "Unknown command: #{@command}"
39
+ $stderr.puts "Run 'spur help' for available commands."
40
+ exit 1
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def handle_new
47
+ project_name = @rest.first
48
+ unless project_name
49
+ $stderr.puts "Usage: spur new <project_name>"
50
+ exit 1
51
+ end
52
+
53
+ Generators::Project.new(name: project_name).generate!
54
+ end
55
+
56
+ def handle_generate
57
+ subcommand = @rest.first
58
+ name = @rest[1]
59
+
60
+ unless subcommand && GENERATE_SUBCOMMANDS.include?(subcommand)
61
+ $stderr.puts "Usage: spur generate <#{GENERATE_SUBCOMMANDS.join("|")}> <name>"
62
+ exit 1
63
+ end
64
+
65
+ unless name
66
+ $stderr.puts "Usage: spur generate #{subcommand} <name>"
67
+ exit 1
68
+ end
69
+
70
+ case subcommand
71
+ when "agent"
72
+ Generators::Agent.new(name: name).generate!
73
+ when "tool"
74
+ Generators::Tool.new(name: name).generate!
75
+ when "migration"
76
+ Generators::Migration.new(name: name).generate!
77
+ end
78
+ end
79
+
80
+ def handle_version
81
+ puts "spur #{Spurline::VERSION}"
82
+ end
83
+
84
+ def handle_check
85
+ verbose = @rest.include?("--verbose") || @rest.include?("-v")
86
+ results = Check.new(project_root: Dir.pwd, verbose: verbose).run!
87
+ failures = results.count { |result| result.status == :fail }
88
+ exit(failures.positive? ? 1 : 0)
89
+ end
90
+
91
+ def handle_console
92
+ verbose = @rest.include?("--verbose") || @rest.include?("-v")
93
+ Console.new(project_root: Dir.pwd, verbose: verbose).start!
94
+ end
95
+
96
+ def handle_credentials_edit
97
+ Credentials.new(project_root: Dir.pwd).edit!
98
+ puts "Saved encrypted credentials to config/credentials.enc.yml"
99
+ end
100
+
101
+ def handle_help
102
+ puts <<~HELP
103
+ spur — Spurline CLI
104
+
105
+ Commands:
106
+ spur new <project> Create a new Spurline agent project
107
+ spur generate agent <name> Generate a new agent class
108
+ spur generate tool <name> Generate a new tool class
109
+ spur generate migration <name> Generate a SQL migration (e.g. sessions)
110
+ spur check Validate project configuration
111
+ spur console Interactive REPL with project loaded
112
+ spur credentials:edit Edit encrypted credentials
113
+ spur version Show version
114
+ spur help Show this help
115
+
116
+ https://github.com/dylanwilcox/spurline
117
+ HELP
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-configurable"
4
+
5
+ module Spurline
6
+ class Configuration
7
+ extend Dry::Configurable
8
+
9
+ setting :session_store, default: :memory
10
+ setting :session_store_path, default: "tmp/spurline_sessions.db"
11
+ setting :session_store_postgres_url, default: nil
12
+ setting :default_model, default: :claude_sonnet
13
+ setting :log_level, default: :info
14
+ setting :audit_mode, default: :full
15
+ setting :audit_max_entries, default: nil
16
+ setting :idempotency_default_ttl, default: 86_400
17
+ setting :permissions_file, default: "config/permissions.yml"
18
+ setting :brave_api_key, default: nil
19
+ setting :cartographer_exclude_patterns, default: %w[
20
+ .git node_modules vendor tmp log coverage
21
+ ]
22
+ end
23
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module DSL
5
+ # DSL for configuring security guardrails.
6
+ # Registers configuration at class load time — never executes behavior.
7
+ # Misconfiguration raises ConfigurationError at class load time.
8
+ module Guardrails
9
+ def self.included(base)
10
+ base.extend(ClassMethods)
11
+ end
12
+
13
+ module ClassMethods
14
+ def guardrails(&block)
15
+ @guardrail_config ||= GuardrailConfig.new
16
+ @guardrail_config.instance_eval(&block)
17
+ end
18
+
19
+ def guardrail_config
20
+ own = @guardrail_config
21
+ if own
22
+ own
23
+ elsif superclass.respond_to?(:guardrail_config)
24
+ superclass.guardrail_config
25
+ else
26
+ GuardrailConfig.new
27
+ end
28
+ end
29
+ end
30
+
31
+ class GuardrailConfig
32
+ INJECTION_LEVELS = %i[strict moderate permissive].freeze
33
+ PII_MODES = %i[redact block warn off].freeze
34
+ AUDIT_MODES = %i[full errors_only off].freeze
35
+
36
+ attr_reader :settings
37
+
38
+ def initialize
39
+ @settings = {
40
+ injection_filter: :strict,
41
+ pii_filter: :off,
42
+ max_tool_calls: 10,
43
+ max_turns: 50,
44
+ audit_max_entries: nil,
45
+ denied_domains: [],
46
+ audit: :full,
47
+ }
48
+ end
49
+
50
+ def injection_filter(level)
51
+ validate_inclusion!(:injection_filter, level, INJECTION_LEVELS)
52
+ @settings[:injection_filter] = level
53
+ end
54
+
55
+ def pii_filter(mode)
56
+ validate_inclusion!(:pii_filter, mode, PII_MODES)
57
+ @settings[:pii_filter] = mode
58
+ end
59
+
60
+ def max_tool_calls(n)
61
+ validate_positive_integer!(:max_tool_calls, n)
62
+ @settings[:max_tool_calls] = n
63
+ end
64
+
65
+ def max_turns(n)
66
+ validate_positive_integer!(:max_turns, n)
67
+ @settings[:max_turns] = n
68
+ end
69
+
70
+ def audit_max_entries(n)
71
+ validate_positive_integer!(:audit_max_entries, n)
72
+ @settings[:audit_max_entries] = n
73
+ end
74
+
75
+ def denied_domains(domains)
76
+ @settings[:denied_domains] = Array(domains)
77
+ end
78
+
79
+ def audit(mode)
80
+ validate_inclusion!(:audit, mode, AUDIT_MODES)
81
+ @settings[:audit] = mode
82
+ end
83
+
84
+ def to_h
85
+ @settings.dup
86
+ end
87
+
88
+ private
89
+
90
+ def validate_inclusion!(name, value, valid_values)
91
+ return if valid_values.include?(value)
92
+
93
+ raise Spurline::ConfigurationError,
94
+ "Invalid guardrail value for #{name}: #{value.inspect}. " \
95
+ "Must be one of: #{valid_values.map(&:inspect).join(", ")}."
96
+ end
97
+
98
+ def validate_positive_integer!(name, value)
99
+ return if value.is_a?(Integer) && value.positive?
100
+
101
+ raise Spurline::ConfigurationError,
102
+ "Invalid guardrail value for #{name}: #{value.inspect}. " \
103
+ "Must be a positive integer."
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end