spurline-docs 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 +160 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Spurline
|
|
5
|
+
module Tools
|
|
6
|
+
# Executes tool calls with permission checking, confirmation, and result wrapping.
|
|
7
|
+
# Tool results always enter the pipeline as Content objects with trust: :external.
|
|
8
|
+
class Runner
|
|
9
|
+
attr_reader :registry
|
|
10
|
+
|
|
11
|
+
def initialize(registry:, guardrails: {}, permissions: {}, secret_resolver: nil, idempotency_configs: {})
|
|
12
|
+
@registry = registry
|
|
13
|
+
@guardrails = guardrails
|
|
14
|
+
@permissions = permissions
|
|
15
|
+
@secret_resolver = secret_resolver
|
|
16
|
+
@idempotency_configs = normalize_idempotency_configs(idempotency_configs)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# ASYNC-READY: scheduler param is the async entry point
|
|
20
|
+
def execute(
|
|
21
|
+
tool_call,
|
|
22
|
+
session:,
|
|
23
|
+
scheduler: Spurline::Adapters::Scheduler::Sync.new,
|
|
24
|
+
scope: nil,
|
|
25
|
+
idempotency_ledger: nil,
|
|
26
|
+
&confirmation_handler
|
|
27
|
+
)
|
|
28
|
+
tool_name = tool_call[:name].to_s
|
|
29
|
+
registered_tool = @registry.fetch(tool_name)
|
|
30
|
+
tool_class = registered_tool.is_a?(Class) ? registered_tool : registered_tool.class
|
|
31
|
+
tool = registered_tool.is_a?(Class) ? registered_tool.new : registered_tool
|
|
32
|
+
|
|
33
|
+
permission_check!(tool_name, session)
|
|
34
|
+
confirmation_check!(tool_name, tool_class, tool_call, &confirmation_handler)
|
|
35
|
+
|
|
36
|
+
started_at = Time.now
|
|
37
|
+
args = symbolize_keys(tool_call[:arguments])
|
|
38
|
+
if tool_class.respond_to?(:validate_arguments!)
|
|
39
|
+
tool_class.validate_arguments!(args)
|
|
40
|
+
end
|
|
41
|
+
scoped_tool = tool_class.respond_to?(:scoped?) && tool_class.scoped?
|
|
42
|
+
if scoped_tool && scope.nil?
|
|
43
|
+
raise Spurline::ScopeViolationError,
|
|
44
|
+
"Tool '#{tool_name}' is scoped and requires a scope, but none was provided."
|
|
45
|
+
end
|
|
46
|
+
scope_id = scoped_tool && scope.respond_to?(:id) ? scope.id.to_s : nil
|
|
47
|
+
|
|
48
|
+
idempotency = build_idempotency_context(
|
|
49
|
+
tool_name: tool_name,
|
|
50
|
+
tool_class: tool_class,
|
|
51
|
+
args: args,
|
|
52
|
+
scoped_tool: scoped_tool,
|
|
53
|
+
scope_id: scope_id,
|
|
54
|
+
idempotency_ledger: idempotency_ledger || (session.metadata[:idempotency_ledger] ||= {})
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
was_cached = false
|
|
58
|
+
cache_age_ms = nil
|
|
59
|
+
raw_result = nil
|
|
60
|
+
|
|
61
|
+
if idempotency[:enabled]
|
|
62
|
+
idempotency[:ledger].cached?(idempotency[:key], ttl: idempotency[:ttl])
|
|
63
|
+
if idempotency[:ledger].conflict?(idempotency[:key], idempotency[:args_hash])
|
|
64
|
+
raise Spurline::IdempotencyKeyConflictError,
|
|
65
|
+
"Tool '#{tool_name}' generated idempotency key '#{idempotency[:key]}' " \
|
|
66
|
+
"for different arguments in the same session."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
cached_result = idempotency[:ledger].fetch(idempotency[:key], ttl: idempotency[:ttl])
|
|
70
|
+
unless cached_result.nil?
|
|
71
|
+
raw_result = cached_result
|
|
72
|
+
was_cached = true
|
|
73
|
+
cache_age_ms = idempotency[:ledger].cache_age_ms(idempotency[:key])
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
unless was_cached
|
|
78
|
+
args = inject_secrets(tool_class, args)
|
|
79
|
+
args = inject_scope(args, scope: scope) if scoped_tool
|
|
80
|
+
raw_result = scheduler.run { tool.call(**args) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
serialized_result = was_cached ? raw_result.to_s : serialize_result(raw_result)
|
|
84
|
+
if idempotency[:enabled] && !was_cached
|
|
85
|
+
idempotency[:ledger].store!(
|
|
86
|
+
idempotency[:key],
|
|
87
|
+
result: serialized_result,
|
|
88
|
+
args_hash: idempotency[:args_hash],
|
|
89
|
+
ttl: idempotency[:ttl]
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
duration_ms = ((Time.now - started_at) * 1000).round
|
|
94
|
+
arguments_for_audit = args.dup
|
|
95
|
+
arguments_for_audit.delete(:_scope)
|
|
96
|
+
arguments_for_audit[:_scope_id] = scope_id if scoped_tool && scope_id
|
|
97
|
+
filtered_arguments = Audit::SecretFilter.filter(
|
|
98
|
+
arguments_for_audit,
|
|
99
|
+
tool_name: tool_name,
|
|
100
|
+
registry: @registry
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
session.current_turn&.record_tool_call(
|
|
104
|
+
name: tool_name,
|
|
105
|
+
arguments: filtered_arguments,
|
|
106
|
+
result: raw_result,
|
|
107
|
+
duration_ms: duration_ms,
|
|
108
|
+
scope_id: scope_id,
|
|
109
|
+
idempotency_key: idempotency[:enabled] ? idempotency[:key] : nil,
|
|
110
|
+
was_cached: idempotency[:enabled] ? was_cached : nil,
|
|
111
|
+
cache_age_ms: cache_age_ms
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
Security::Gates::ToolResult.wrap(
|
|
115
|
+
serialized_result,
|
|
116
|
+
tool_name: tool_name
|
|
117
|
+
)
|
|
118
|
+
rescue ArgumentError => e
|
|
119
|
+
raise unless missing_keyword_argument_error?(e)
|
|
120
|
+
|
|
121
|
+
raise Spurline::ConfigurationError.new(
|
|
122
|
+
"Tool '#{tool_name}' received invalid arguments #{tool_call[:arguments].inspect}: #{e.message}"
|
|
123
|
+
), cause: nil
|
|
124
|
+
rescue Spurline::ConfigurationError => e
|
|
125
|
+
raise Spurline::ConfigurationError.new(
|
|
126
|
+
"Invalid tool call for '#{tool_name}' with arguments #{tool_call[:arguments].inspect}: #{e.message}"
|
|
127
|
+
), cause: nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def permission_check!(tool_name, session)
|
|
133
|
+
tool_perms = @permissions[tool_name.to_sym] || @permissions[tool_name.to_s]
|
|
134
|
+
return unless tool_perms
|
|
135
|
+
|
|
136
|
+
if tool_perms[:denied]
|
|
137
|
+
raise Spurline::PermissionDeniedError,
|
|
138
|
+
"Tool '#{tool_name}' is denied by the permission configuration. " \
|
|
139
|
+
"Check config/permissions.yml or the agent's permission settings."
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if tool_perms[:allowed_users] && session.user
|
|
143
|
+
unless tool_perms[:allowed_users].include?(session.user)
|
|
144
|
+
raise Spurline::PermissionDeniedError,
|
|
145
|
+
"Tool '#{tool_name}' is not permitted for user '#{session.user}'. " \
|
|
146
|
+
"Allowed users: #{tool_perms[:allowed_users].join(", ")}."
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def confirmation_check!(tool_name, tool_class, tool_call, &confirmation_handler)
|
|
152
|
+
needs_confirmation = tool_class.respond_to?(:requires_confirmation?) && tool_class.requires_confirmation?
|
|
153
|
+
|
|
154
|
+
# Also check permissions config
|
|
155
|
+
tool_perms = @permissions[tool_name.to_sym] || @permissions[tool_name.to_s]
|
|
156
|
+
needs_confirmation ||= tool_perms&.dig(:requires_confirmation)
|
|
157
|
+
|
|
158
|
+
return unless needs_confirmation
|
|
159
|
+
return unless confirmation_handler
|
|
160
|
+
|
|
161
|
+
confirmed = confirmation_handler.call(
|
|
162
|
+
tool_name: tool_name,
|
|
163
|
+
arguments: tool_call[:arguments]
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
unless confirmed
|
|
167
|
+
raise Spurline::PermissionDeniedError,
|
|
168
|
+
"Tool '#{tool_name}' requires confirmation, but confirmation was denied. " \
|
|
169
|
+
"The user or operator declined to execute this tool."
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def symbolize_keys(hash)
|
|
174
|
+
return {} unless hash
|
|
175
|
+
|
|
176
|
+
hash.transform_keys(&:to_sym)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def inject_secrets(tool_class, args)
|
|
180
|
+
return args unless @secret_resolver
|
|
181
|
+
return args unless tool_class.respond_to?(:declared_secrets)
|
|
182
|
+
|
|
183
|
+
secrets = tool_class.declared_secrets
|
|
184
|
+
return args if secrets.empty?
|
|
185
|
+
|
|
186
|
+
injected = args.dup
|
|
187
|
+
secrets.each do |secret_def|
|
|
188
|
+
name = secret_def[:name]
|
|
189
|
+
next if injected.key?(name)
|
|
190
|
+
|
|
191
|
+
injected[name] = @secret_resolver.resolve!(name)
|
|
192
|
+
end
|
|
193
|
+
injected
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def inject_scope(args, scope:)
|
|
197
|
+
args.merge(_scope: scope)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def build_idempotency_context(tool_name:, tool_class:, args:, scoped_tool:, scope_id:, idempotency_ledger:)
|
|
201
|
+
dsl_options = @idempotency_configs[tool_name.to_sym] || {}
|
|
202
|
+
config = Spurline::Tools::Idempotency::Config.from_dsl(dsl_options, tool_class: tool_class)
|
|
203
|
+
return { enabled: false } unless config.enabled?
|
|
204
|
+
|
|
205
|
+
ledger = if idempotency_ledger.is_a?(Spurline::Tools::Idempotency::Ledger)
|
|
206
|
+
idempotency_ledger
|
|
207
|
+
else
|
|
208
|
+
Spurline::Tools::Idempotency::Ledger.new(idempotency_ledger || {})
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
args_for_hash = args.dup
|
|
212
|
+
args_for_hash[:_scope_id] = scope_id if scoped_tool && scope_id
|
|
213
|
+
|
|
214
|
+
key_tool_name = scoped_tool && scope_id ? "#{tool_name}@#{scope_id}" : tool_name
|
|
215
|
+
key = Spurline::Tools::Idempotency::KeyComputer.compute(
|
|
216
|
+
tool_name: key_tool_name,
|
|
217
|
+
args: args_for_hash,
|
|
218
|
+
key_params: config.key_params,
|
|
219
|
+
key_fn: config.key_fn
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
{
|
|
223
|
+
enabled: true,
|
|
224
|
+
ttl: config.ttl,
|
|
225
|
+
key: key,
|
|
226
|
+
args_hash: Spurline::Tools::Idempotency::KeyComputer.canonical_hash(args_for_hash),
|
|
227
|
+
ledger: ledger,
|
|
228
|
+
}
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def serialize_result(raw_result)
|
|
232
|
+
return raw_result if raw_result.is_a?(String)
|
|
233
|
+
|
|
234
|
+
JSON.generate(raw_result)
|
|
235
|
+
rescue JSON::GeneratorError, TypeError
|
|
236
|
+
raw_result.to_s
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def missing_keyword_argument_error?(error)
|
|
240
|
+
message = error.message.to_s
|
|
241
|
+
message.include?("missing keyword") || message.include?("unknown keyword")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def normalize_idempotency_configs(raw)
|
|
245
|
+
return {} unless raw.is_a?(Hash)
|
|
246
|
+
|
|
247
|
+
raw.each_with_object({}) do |(tool_name, config), normalized|
|
|
248
|
+
next unless config.is_a?(Hash)
|
|
249
|
+
|
|
250
|
+
normalized[tool_name.to_sym] = symbolize_keys(config)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Tools
|
|
5
|
+
class Scope
|
|
6
|
+
TYPES = %i[branch pr repo review_app custom].freeze
|
|
7
|
+
|
|
8
|
+
CONSTRAINT_KEYS = {
|
|
9
|
+
path: :paths,
|
|
10
|
+
branch: :branches,
|
|
11
|
+
repo: :repos,
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :id, :type, :constraints, :metadata
|
|
15
|
+
|
|
16
|
+
# Creates a new scope.
|
|
17
|
+
#
|
|
18
|
+
# @param id [String] Scope identifier (e.g., branch name, PR number)
|
|
19
|
+
# @param type [Symbol] One of TYPES
|
|
20
|
+
# @param constraints [Hash] Resource constraints
|
|
21
|
+
# - paths: [Array<String>] Glob patterns for file paths (e.g., "src/**")
|
|
22
|
+
# - branches: [Array<String>] Glob patterns for branch names (e.g., "feature-*")
|
|
23
|
+
# - repos: [Array<String>] Exact repo identifiers (e.g., "org/repo")
|
|
24
|
+
# @param metadata [Hash] Arbitrary metadata
|
|
25
|
+
def initialize(id:, type: :custom, constraints: {}, metadata: {})
|
|
26
|
+
type = type.to_sym
|
|
27
|
+
validate_type!(type)
|
|
28
|
+
|
|
29
|
+
@id = id.to_s
|
|
30
|
+
@type = type
|
|
31
|
+
@constraints = normalize_constraints(constraints)
|
|
32
|
+
@metadata = deep_copy(metadata || {})
|
|
33
|
+
|
|
34
|
+
deep_freeze(@constraints)
|
|
35
|
+
deep_freeze(@metadata)
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Checks if a resource is within scope constraints.
|
|
40
|
+
#
|
|
41
|
+
# @param resource [String] Resource identifier to check
|
|
42
|
+
# @param type [Symbol, nil] Resource type (:path, :branch, :repo) to narrow which constraints apply
|
|
43
|
+
# @return [Boolean]
|
|
44
|
+
#
|
|
45
|
+
# Matching rules:
|
|
46
|
+
# - Empty constraints → everything permitted (open scope)
|
|
47
|
+
# - Glob patterns matched via File.fnmatch (supports *, **, ?, [])
|
|
48
|
+
# - Repos matched via exact string match or prefix match (org/repo)
|
|
49
|
+
# - When type is specified, only that constraint category is checked
|
|
50
|
+
# - When type is nil, all constraint categories are checked (any match = permit)
|
|
51
|
+
def permits?(resource, type: nil)
|
|
52
|
+
return true if constraints.empty?
|
|
53
|
+
|
|
54
|
+
resource = resource.to_s
|
|
55
|
+
|
|
56
|
+
if type
|
|
57
|
+
category = CONSTRAINT_KEYS.fetch(type.to_sym) do
|
|
58
|
+
raise Spurline::ConfigurationError,
|
|
59
|
+
"Invalid scope resource type: #{type.inspect}. " \
|
|
60
|
+
"Must be one of: #{CONSTRAINT_KEYS.keys.map(&:inspect).join(', ')}."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return true unless constraints.key?(category)
|
|
64
|
+
|
|
65
|
+
patterns = constraints.fetch(category)
|
|
66
|
+
return false if patterns.empty?
|
|
67
|
+
|
|
68
|
+
return matches_constraint?(resource, patterns, type.to_sym)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
constrained_categories = constraints.keys
|
|
72
|
+
return true if constrained_categories.empty?
|
|
73
|
+
|
|
74
|
+
constrained_categories.any? do |category|
|
|
75
|
+
patterns = constraints.fetch(category)
|
|
76
|
+
next false if patterns.empty?
|
|
77
|
+
|
|
78
|
+
match_type = CONSTRAINT_KEYS.key(category)
|
|
79
|
+
matches_constraint?(resource, patterns, match_type)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Raises ScopeViolationError if resource is out of bounds.
|
|
84
|
+
#
|
|
85
|
+
# @param resource [String] Resource to check
|
|
86
|
+
# @param type [Symbol, nil] Resource type
|
|
87
|
+
# @raise [ScopeViolationError] with actionable message including scope id and resource
|
|
88
|
+
def enforce!(resource, type: nil)
|
|
89
|
+
return nil if permits?(resource, type: type)
|
|
90
|
+
|
|
91
|
+
raise_scope_violation!(resource, type)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns a new scope with additional constraints applied (intersection).
|
|
95
|
+
# The result is always equal or narrower than self.
|
|
96
|
+
#
|
|
97
|
+
# @param additional_constraints [Hash] Constraints to intersect with current
|
|
98
|
+
# @return [Scope] New scope (narrower or equal)
|
|
99
|
+
#
|
|
100
|
+
# Intersection rules:
|
|
101
|
+
# - If both have a category, result is the intersection of patterns
|
|
102
|
+
# - If only parent has a category, it carries through
|
|
103
|
+
# - If only child has a category, it's added
|
|
104
|
+
def narrow(additional_constraints)
|
|
105
|
+
additional = normalize_constraints(additional_constraints || {})
|
|
106
|
+
merged = {}
|
|
107
|
+
|
|
108
|
+
(constraints.keys | additional.keys).each do |category|
|
|
109
|
+
parent_patterns = constraints[category]
|
|
110
|
+
child_patterns = additional[category]
|
|
111
|
+
|
|
112
|
+
if parent_patterns && child_patterns
|
|
113
|
+
match_type = CONSTRAINT_KEYS.key(category)
|
|
114
|
+
merged[category] = intersect_patterns(parent_patterns, child_patterns, match_type)
|
|
115
|
+
elsif parent_patterns
|
|
116
|
+
merged[category] = deep_copy(parent_patterns)
|
|
117
|
+
elsif child_patterns
|
|
118
|
+
merged[category] = deep_copy(child_patterns)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
self.class.new(id: id, type: type, constraints: merged, metadata: metadata)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Validates that this scope is a subset of (equal or narrower than) another.
|
|
126
|
+
#
|
|
127
|
+
# @param other [Scope] Parent scope to compare against
|
|
128
|
+
# @return [Boolean]
|
|
129
|
+
#
|
|
130
|
+
# A scope is a subset if for every constraint category:
|
|
131
|
+
# - Parent has no constraint on that category (child is free), OR
|
|
132
|
+
# - Every child pattern matches at least one parent pattern
|
|
133
|
+
def subset_of?(other)
|
|
134
|
+
return false unless other.is_a?(self.class)
|
|
135
|
+
|
|
136
|
+
CONSTRAINT_KEYS.values.all? do |category|
|
|
137
|
+
parent_has_category = other.constraints.key?(category)
|
|
138
|
+
child_has_category = constraints.key?(category)
|
|
139
|
+
|
|
140
|
+
next true unless parent_has_category
|
|
141
|
+
next false unless child_has_category
|
|
142
|
+
|
|
143
|
+
child_patterns = constraints.fetch(category)
|
|
144
|
+
parent_patterns = other.constraints.fetch(category)
|
|
145
|
+
|
|
146
|
+
child_patterns.all? do |pattern|
|
|
147
|
+
match_type = CONSTRAINT_KEYS.key(category)
|
|
148
|
+
matches_constraint?(pattern, parent_patterns, match_type)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Serialization
|
|
154
|
+
def to_h
|
|
155
|
+
{
|
|
156
|
+
id: id,
|
|
157
|
+
type: type,
|
|
158
|
+
constraints: deep_copy(constraints),
|
|
159
|
+
metadata: deep_copy(metadata),
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def self.from_h(data)
|
|
164
|
+
hash = deep_symbolize(data || {})
|
|
165
|
+
|
|
166
|
+
new(
|
|
167
|
+
id: hash.fetch(:id),
|
|
168
|
+
type: hash.fetch(:type, :custom),
|
|
169
|
+
constraints: hash.fetch(:constraints, {}),
|
|
170
|
+
metadata: hash.fetch(:metadata, {})
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def validate_type!(type)
|
|
177
|
+
return if TYPES.include?(type)
|
|
178
|
+
|
|
179
|
+
raise Spurline::ConfigurationError,
|
|
180
|
+
"Invalid scope type: #{type.inspect}. " \
|
|
181
|
+
"Must be one of: #{TYPES.map(&:inspect).join(', ')}."
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def normalize_constraints(raw)
|
|
185
|
+
source = self.class.send(:deep_symbolize, raw || {})
|
|
186
|
+
|
|
187
|
+
source.each_with_object({}) do |(key, value), normalized|
|
|
188
|
+
unless CONSTRAINT_KEYS.value?(key)
|
|
189
|
+
raise Spurline::ConfigurationError,
|
|
190
|
+
"Invalid scope constraint category: #{key.inspect}. " \
|
|
191
|
+
"Must be one of: #{CONSTRAINT_KEYS.values.map(&:inspect).join(', ')}."
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
normalized[key] = Array(value).compact.map(&:to_s)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def deep_copy(obj)
|
|
199
|
+
case obj
|
|
200
|
+
when Hash
|
|
201
|
+
obj.each_with_object({}) do |(key, value), copy|
|
|
202
|
+
copy[deep_copy(key)] = deep_copy(value)
|
|
203
|
+
end
|
|
204
|
+
when Array
|
|
205
|
+
obj.map { |value| deep_copy(value) }
|
|
206
|
+
when String
|
|
207
|
+
obj.dup
|
|
208
|
+
else
|
|
209
|
+
obj
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def deep_freeze(obj)
|
|
214
|
+
case obj
|
|
215
|
+
when Hash
|
|
216
|
+
obj.each do |key, value|
|
|
217
|
+
deep_freeze(key)
|
|
218
|
+
deep_freeze(value)
|
|
219
|
+
end
|
|
220
|
+
when Array
|
|
221
|
+
obj.each { |value| deep_freeze(value) }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
obj.freeze
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def matches_constraint?(resource, patterns, match_type)
|
|
228
|
+
patterns.any? do |pattern|
|
|
229
|
+
case match_type
|
|
230
|
+
when :repo
|
|
231
|
+
resource == pattern || resource.start_with?("#{pattern}/")
|
|
232
|
+
when :path, :branch
|
|
233
|
+
glob_match?(pattern, resource)
|
|
234
|
+
else
|
|
235
|
+
false
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def glob_match?(pattern, value)
|
|
241
|
+
if pattern.include?("**")
|
|
242
|
+
match_segments_with_double_star?(pattern.split("/"), value.split("/"))
|
|
243
|
+
else
|
|
244
|
+
File.fnmatch(pattern, value, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def match_segments_with_double_star?(pattern_segments, value_segments)
|
|
249
|
+
if pattern_segments.empty?
|
|
250
|
+
return value_segments.empty?
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
current = pattern_segments.first
|
|
254
|
+
|
|
255
|
+
if current == "**"
|
|
256
|
+
return true if pattern_segments.length == 1
|
|
257
|
+
|
|
258
|
+
tail = pattern_segments.drop(1)
|
|
259
|
+
(0..value_segments.length).any? do |offset|
|
|
260
|
+
match_segments_with_double_star?(tail, value_segments.drop(offset))
|
|
261
|
+
end
|
|
262
|
+
else
|
|
263
|
+
return false if value_segments.empty?
|
|
264
|
+
return false unless File.fnmatch(current, value_segments.first, File::FNM_EXTGLOB | File::FNM_DOTMATCH)
|
|
265
|
+
|
|
266
|
+
match_segments_with_double_star?(pattern_segments.drop(1), value_segments.drop(1))
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def intersect_patterns(parent_patterns, child_patterns, match_type)
|
|
271
|
+
intersection = []
|
|
272
|
+
|
|
273
|
+
child_patterns.each do |child_pattern|
|
|
274
|
+
intersection << child_pattern if matches_constraint?(child_pattern, parent_patterns, match_type)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
parent_patterns.each do |parent_pattern|
|
|
278
|
+
intersection << parent_pattern if matches_constraint?(parent_pattern, child_patterns, match_type)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
intersection.uniq
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def raise_scope_violation!(resource, type)
|
|
285
|
+
type_suffix = type ? " (resource type: #{type})" : ""
|
|
286
|
+
|
|
287
|
+
raise Spurline::ScopeViolationError,
|
|
288
|
+
"Scope '#{id}' (#{self.type}) does not permit resource '#{resource}'#{type_suffix}."
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
class << self
|
|
292
|
+
private
|
|
293
|
+
|
|
294
|
+
def deep_symbolize(value)
|
|
295
|
+
case value
|
|
296
|
+
when Hash
|
|
297
|
+
value.each_with_object({}) do |(key, item), result|
|
|
298
|
+
result[key.to_sym] = deep_symbolize(item)
|
|
299
|
+
end
|
|
300
|
+
when Array
|
|
301
|
+
value.map { |item| deep_symbolize(item) }
|
|
302
|
+
else
|
|
303
|
+
value
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Tools
|
|
5
|
+
# Registry for named toolkits. Toolkits register here and are
|
|
6
|
+
# looked up by name when an agent declares `toolkits :name`.
|
|
7
|
+
#
|
|
8
|
+
# When a tool_registry is provided, registering a toolkit also
|
|
9
|
+
# registers all its owned tools into the tool registry — so they're
|
|
10
|
+
# available for standalone `tools :name` references too.
|
|
11
|
+
class ToolkitRegistry
|
|
12
|
+
def initialize(tool_registry: nil)
|
|
13
|
+
@toolkits = {}
|
|
14
|
+
@tool_registry = tool_registry
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def register(name, toolkit_class)
|
|
18
|
+
name = name.to_sym
|
|
19
|
+
@toolkits[name] = toolkit_class
|
|
20
|
+
register_toolkit_tools!(toolkit_class) if @tool_registry
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fetch(name)
|
|
24
|
+
name = name.to_sym
|
|
25
|
+
@toolkits.fetch(name) do
|
|
26
|
+
raise Spurline::ToolkitNotFoundError,
|
|
27
|
+
"Toolkit :#{name} not found. Available toolkits: #{names.join(', ')}. " \
|
|
28
|
+
"Define a toolkit class inheriting from Spurline::Toolkit and declare " \
|
|
29
|
+
"`toolkit_name :#{name}`."
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def registered?(name)
|
|
34
|
+
@toolkits.key?(name.to_sym)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns the tool names that a toolkit expands to.
|
|
38
|
+
def expand(name)
|
|
39
|
+
fetch(name).tools
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def all
|
|
43
|
+
@toolkits.dup
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def names
|
|
47
|
+
@toolkits.keys
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def clear!
|
|
51
|
+
@toolkits.clear
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def register_toolkit_tools!(toolkit_class)
|
|
57
|
+
toolkit_class.tool_classes.each do |tool_name, tool_class|
|
|
58
|
+
@tool_registry.register(tool_name, tool_class) unless @tool_registry.registered?(tool_name)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/spurline.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zeitwerk"
|
|
4
|
+
require_relative "spurline/errors"
|
|
5
|
+
|
|
6
|
+
module Spurline
|
|
7
|
+
class << self
|
|
8
|
+
def configure(&block)
|
|
9
|
+
Configuration.configure(&block)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def config
|
|
13
|
+
Configuration.config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def analyze_repo(path)
|
|
17
|
+
Cartographer::Runner.new.analyze(repo_path: path)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def credentials
|
|
21
|
+
@credentials ||= CLI::Credentials.new(project_root: Dir.pwd).read
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reset_credentials!
|
|
25
|
+
@credentials = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def loader
|
|
29
|
+
@loader ||= begin
|
|
30
|
+
loader = Zeitwerk::Loader.for_gem
|
|
31
|
+
loader.inflector.inflect("dsl" => "DSL")
|
|
32
|
+
loader.inflector.inflect("pii_filter" => "PIIFilter")
|
|
33
|
+
loader.inflector.inflect("cli" => "CLI")
|
|
34
|
+
loader.inflector.inflect("ci_config" => "CIConfig")
|
|
35
|
+
loader.inflector.inflect("sqlite" => "SQLite")
|
|
36
|
+
loader.inflector.inflect("open_ai" => "OpenAI")
|
|
37
|
+
loader.inflector.inflect("github" => "GitHub")
|
|
38
|
+
loader.ignore("#{__dir__}/spurline/errors.rb")
|
|
39
|
+
loader
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Spurline.loader.setup
|
|
46
|
+
|
|
47
|
+
# Auto-discover and load spur gems from the bundle.
|
|
48
|
+
if defined?(Bundler)
|
|
49
|
+
Bundler.load.current_dependencies.each do |dep|
|
|
50
|
+
next unless dep.name.start_with?("spurline-") && dep.name != "spurline-core"
|
|
51
|
+
|
|
52
|
+
require dep.name
|
|
53
|
+
rescue LoadError
|
|
54
|
+
# Spur gem is in Gemfile but not loadable — skip silently.
|
|
55
|
+
end
|
|
56
|
+
end
|