little_ghost 0.1.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/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- metadata +225 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "monitor"
|
|
5
|
+
|
|
6
|
+
module LittleGhost
|
|
7
|
+
module Support
|
|
8
|
+
# Loader finds agents and tools from a conventional application layout. Ruby
|
|
9
|
+
# files map to constants by path, so applications can add extension classes
|
|
10
|
+
# without maintaining a manual require list.
|
|
11
|
+
#
|
|
12
|
+
# loader = LittleGhost::Support::Loader.new(root: Dir.pwd)
|
|
13
|
+
# loader.setup.eager_load
|
|
14
|
+
#
|
|
15
|
+
# Setup is process-serialized, collisions are rejected, and real paths are
|
|
16
|
+
# checked for symbolic-link escapes. Application load roots are trusted code,
|
|
17
|
+
# not a sandbox for untrusted files.
|
|
18
|
+
class Loader
|
|
19
|
+
DEFAULT_DIRECTORIES = %w[app/agents app/tools].freeze # :nodoc:
|
|
20
|
+
PROCESS_LOCK = Monitor.new # :nodoc:
|
|
21
|
+
|
|
22
|
+
# Application root and configured relative load directories.
|
|
23
|
+
attr_reader :root, :directories
|
|
24
|
+
|
|
25
|
+
# Uses explicit +paths+ or conventional directories beneath +root+.
|
|
26
|
+
def initialize(paths: nil, root: nil, directories: DEFAULT_DIRECTORIES)
|
|
27
|
+
@root = root && File.expand_path(root)
|
|
28
|
+
@directories = Array(directories).map(&:to_s).freeze
|
|
29
|
+
if @root && @directories.any? { |directory| Pathname.new(directory).absolute? || directory.split(File::SEPARATOR).include?("..") }
|
|
30
|
+
raise ArgumentError, "application load directories must stay within root"
|
|
31
|
+
end
|
|
32
|
+
roots = paths || @directories.map { |directory| File.join(@root || Dir.pwd, directory) }
|
|
33
|
+
@paths = Array(roots).map { |path| File.expand_path(path) }.uniq.freeze
|
|
34
|
+
@registry = nil
|
|
35
|
+
@loaded_constants = {}
|
|
36
|
+
@setup = false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Installs autoloads for the current registry and returns self.
|
|
40
|
+
def setup
|
|
41
|
+
PROCESS_LOCK.synchronize do
|
|
42
|
+
return self if @setup
|
|
43
|
+
|
|
44
|
+
registry.each do |constant_name, path|
|
|
45
|
+
namespace, name = namespace_for(constant_name)
|
|
46
|
+
if namespace.const_defined?(name, false) || namespace.autoload?(name)
|
|
47
|
+
existing = namespace.autoload?(name)
|
|
48
|
+
next if existing && File.expand_path(existing) == path
|
|
49
|
+
next if !existing && constant_source_location(constant_name)&.then { |location| File.realpath(location.first) == path }
|
|
50
|
+
raise ConflictError, "#{constant_name} is already defined"
|
|
51
|
+
end
|
|
52
|
+
namespace.autoload(name, path)
|
|
53
|
+
end
|
|
54
|
+
@setup = true
|
|
55
|
+
end
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Loads every registered constant and verifies its defining file.
|
|
60
|
+
def eager_load
|
|
61
|
+
PROCESS_LOCK.synchronize do
|
|
62
|
+
setup
|
|
63
|
+
registry.each_key do |constant_name|
|
|
64
|
+
validate_registered_path!(constant_name)
|
|
65
|
+
constantize(constant_name)
|
|
66
|
+
@loaded_constants[constant_name] = registry.fetch(constant_name)
|
|
67
|
+
rescue NameError => error
|
|
68
|
+
raise unless expected_constant_missing?(error, constant_name)
|
|
69
|
+
raise ExpectedConstantError, "#{registry.fetch(constant_name)} must define #{constant_name}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Finds a relative file beneath configured paths, returning its resolved
|
|
76
|
+
# path or nil.
|
|
77
|
+
def find(relative_path)
|
|
78
|
+
clean = clean_path(relative_path)
|
|
79
|
+
@paths.each do |path_root|
|
|
80
|
+
candidate = File.expand_path(clean, path_root)
|
|
81
|
+
next unless inside?(candidate, path_root)
|
|
82
|
+
next unless File.file?(candidate)
|
|
83
|
+
real_candidate = File.realpath(candidate)
|
|
84
|
+
return real_candidate if inside?(real_candidate, real_path_root(path_root))
|
|
85
|
+
end
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Finds a relative file or raises LoadError.
|
|
90
|
+
def fetch(relative_path)
|
|
91
|
+
find(relative_path) || raise(LoadError, "Could not find #{relative_path} in configured paths")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Reads a relative file using +encoding+.
|
|
95
|
+
def read(relative_path, encoding: "UTF-8")
|
|
96
|
+
File.read(fetch(relative_path), encoding:)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Finds resolved paths matching a relative glob without root escapes.
|
|
100
|
+
def glob(pattern)
|
|
101
|
+
clean = clean_path(pattern)
|
|
102
|
+
@paths.flat_map do |path_root|
|
|
103
|
+
next [] unless Dir.exist?(path_root)
|
|
104
|
+
real_root = real_path_root(path_root)
|
|
105
|
+
Dir.glob(File.join(path_root, clean)).filter_map do |candidate|
|
|
106
|
+
expanded = File.expand_path(candidate)
|
|
107
|
+
next unless inside?(expanded, path_root)
|
|
108
|
+
real_candidate = File.realpath(candidate)
|
|
109
|
+
real_candidate if inside?(real_candidate, real_root)
|
|
110
|
+
rescue Errno::ENOENT
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
end.uniq.sort
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Loads an application constant after installing autoloads.
|
|
117
|
+
def constant(name)
|
|
118
|
+
PROCESS_LOCK.synchronize do
|
|
119
|
+
setup
|
|
120
|
+
constantize(name.to_s)
|
|
121
|
+
end
|
|
122
|
+
rescue NameError => error
|
|
123
|
+
raise unless expected_constant_missing?(error, name.to_s)
|
|
124
|
+
raise ExpectedConstantError, "Unknown application constant: #{name}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Copies registered constant names and resolved paths.
|
|
128
|
+
def registered_constants
|
|
129
|
+
registry.dup.freeze
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Checks whether +name+ was loaded from its registered source path.
|
|
133
|
+
def loaded_constant?(name)
|
|
134
|
+
PROCESS_LOCK.synchronize do
|
|
135
|
+
path = registry[name.to_s]
|
|
136
|
+
return false unless path
|
|
137
|
+
|
|
138
|
+
names = name.to_s.split("::")
|
|
139
|
+
leaf = names.pop
|
|
140
|
+
namespace = names.inject(Object) do |parent, child|
|
|
141
|
+
break unless parent.const_defined?(child, false)
|
|
142
|
+
|
|
143
|
+
parent.const_get(child, false)
|
|
144
|
+
end
|
|
145
|
+
autoload_path = namespace&.autoload?(leaf)
|
|
146
|
+
return File.realpath(autoload_path) == path if autoload_path
|
|
147
|
+
|
|
148
|
+
location = constant_source_location(name.to_s)
|
|
149
|
+
location && File.realpath(location.first) == path
|
|
150
|
+
end
|
|
151
|
+
rescue Errno::ENOENT
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def registry
|
|
158
|
+
@registry ||= @paths.each_with_object({}) do |path_root, index|
|
|
159
|
+
next unless Dir.exist?(path_root)
|
|
160
|
+
|
|
161
|
+
Dir.glob(File.join(path_root, "**/*.rb")).sort.each do |path|
|
|
162
|
+
relative = path.delete_prefix("#{path_root}#{File::SEPARATOR}").delete_suffix(".rb")
|
|
163
|
+
constant_name = relative.split(File::SEPARATOR).map { |part| camelize(part) }.join("::")
|
|
164
|
+
expanded = File.realpath(path)
|
|
165
|
+
unless inside?(expanded, real_path_root(path_root))
|
|
166
|
+
raise ConflictError, "Autoload path escapes configured root: #{path}"
|
|
167
|
+
end
|
|
168
|
+
if index.key?(constant_name) && index[constant_name] != expanded
|
|
169
|
+
raise ConflictError, "Multiple files map to #{constant_name}: #{index[constant_name]} and #{expanded}"
|
|
170
|
+
end
|
|
171
|
+
index[constant_name] = expanded
|
|
172
|
+
end
|
|
173
|
+
end.freeze
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def namespace_for(constant_name)
|
|
177
|
+
names = constant_name.split("::")
|
|
178
|
+
name = names.pop
|
|
179
|
+
namespace = names.inject(Object) do |parent, child|
|
|
180
|
+
if parent.const_defined?(child, false)
|
|
181
|
+
value = parent.const_get(child, false)
|
|
182
|
+
raise ConflictError, "#{parent}::#{child} is not a namespace" unless value.is_a?(Module)
|
|
183
|
+
value
|
|
184
|
+
else
|
|
185
|
+
parent.const_set(child, Module.new)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
[namespace, name]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def constantize(name)
|
|
192
|
+
name.split("::").inject(Object) { |parent, child| parent.const_get(child, false) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def constant_source_location(name)
|
|
196
|
+
names = name.split("::")
|
|
197
|
+
leaf = names.pop
|
|
198
|
+
namespace = Object
|
|
199
|
+
until names.empty?
|
|
200
|
+
child = names.shift
|
|
201
|
+
return if namespace.autoload?(child) || !namespace.const_defined?(child, false)
|
|
202
|
+
|
|
203
|
+
namespace = namespace.const_get(child, false)
|
|
204
|
+
end
|
|
205
|
+
return if namespace.autoload?(leaf) || !namespace.const_defined?(leaf, false)
|
|
206
|
+
|
|
207
|
+
namespace.const_source_location(leaf, false)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def camelize(value)
|
|
211
|
+
value.split("_").map do |part|
|
|
212
|
+
raise ConflictError, "Invalid autoload path component: #{value}" unless part.match?(/\A[a-z][a-z0-9]*\z/)
|
|
213
|
+
part[0].upcase + part[1..]
|
|
214
|
+
end.join
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def clean_path(value)
|
|
218
|
+
path = value.to_s
|
|
219
|
+
raise ArgumentError, "path must be relative" if path.empty? || Pathname.new(path).absolute?
|
|
220
|
+
raise ArgumentError, "path cannot escape configured roots" if path.split(File::SEPARATOR).include?("..")
|
|
221
|
+
path
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def inside?(candidate, path_root)
|
|
225
|
+
candidate == path_root || candidate.start_with?("#{path_root}#{File::SEPARATOR}")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def real_path_root(path_root)
|
|
229
|
+
resolved = File.realpath(path_root)
|
|
230
|
+
if @root && !inside?(resolved, File.realpath(@root))
|
|
231
|
+
raise ConflictError, "Load directory escapes application root: #{path_root}"
|
|
232
|
+
end
|
|
233
|
+
resolved
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def expected_constant_missing?(error, constant_name)
|
|
237
|
+
names = constant_name.split("::")
|
|
238
|
+
return false unless error.name.to_s == names.last
|
|
239
|
+
|
|
240
|
+
expected_receiver = names[0...-1].inject(Object) { |parent, child| parent.const_get(child, false) }
|
|
241
|
+
error.receiver.equal?(expected_receiver)
|
|
242
|
+
rescue ArgumentError, NameError
|
|
243
|
+
false
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def validate_registered_path!(constant_name)
|
|
247
|
+
path = registry.fetch(constant_name)
|
|
248
|
+
current = File.realpath(path)
|
|
249
|
+
raise ConflictError, "Autoload path changed after registration: #{path}" unless current == path
|
|
250
|
+
rescue Errno::ENOENT
|
|
251
|
+
raise ConflictError, "Autoload path disappeared after registration: #{path}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Raised when a conventional path collides with an existing constant or
|
|
255
|
+
# autoload.
|
|
256
|
+
class ConflictError < Error; end
|
|
257
|
+
|
|
258
|
+
# Raised when a loaded file does not define the constant implied by its
|
|
259
|
+
# path.
|
|
260
|
+
class ExpectedConstantError < Error; end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
module Support
|
|
5
|
+
# OutputTruncation keeps large tool results within a predictable context
|
|
6
|
+
# budget without breaking UTF-8. Its byte-to-token estimate is deliberately
|
|
7
|
+
# approximate; use a provider tokenizer when exact accounting is required.
|
|
8
|
+
module OutputTruncation
|
|
9
|
+
# Byte estimate used when no provider tokenizer is available.
|
|
10
|
+
APPROX_BYTES_PER_TOKEN = 4
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Estimates tokens from the UTF-8 byte length of +text+.
|
|
15
|
+
def approx_token_count(text)
|
|
16
|
+
approx_tokens_from_byte_count(String(text).bytesize)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Converts a token budget to its approximate byte budget.
|
|
20
|
+
def approx_bytes_for_tokens(tokens)
|
|
21
|
+
Integer(tokens) * APPROX_BYTES_PER_TOKEN
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Converts bytes to an approximate token count, rounded up.
|
|
25
|
+
def approx_tokens_from_byte_count(bytes)
|
|
26
|
+
(Integer(bytes) + APPROX_BYTES_PER_TOKEN - 1) / APPROX_BYTES_PER_TOKEN
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Keeps text within budget or produces a middle-truncated
|
|
30
|
+
# UTF-8 string and the original approximate token count.
|
|
31
|
+
def truncate_middle_with_token_budget(text, max_tokens)
|
|
32
|
+
content = utf8_content(text)
|
|
33
|
+
max_tokens = Integer(max_tokens)
|
|
34
|
+
max_bytes = approx_bytes_for_tokens(max_tokens)
|
|
35
|
+
return [content, nil] if max_tokens.positive? && content.bytesize <= max_bytes
|
|
36
|
+
|
|
37
|
+
prefix, suffix = split_string(content, max_bytes / 2, max_bytes - (max_bytes / 2))
|
|
38
|
+
removed_tokens = approx_tokens_from_byte_count([content.bytesize - max_bytes, 0].max)
|
|
39
|
+
truncated = "#{prefix}…#{removed_tokens} tokens truncated…#{suffix}"
|
|
40
|
+
[truncated, approx_token_count(content)]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def split_string(content, beginning_bytes, end_bytes) # :nodoc:
|
|
44
|
+
total_bytes = content.bytesize
|
|
45
|
+
prefix_end = [beginning_bytes, total_bytes].min
|
|
46
|
+
prefix_end -= 1 while prefix_end.positive? && continuation_byte?(content.getbyte(prefix_end))
|
|
47
|
+
suffix_start = [total_bytes - end_bytes, 0].max
|
|
48
|
+
suffix_start += 1 while suffix_start < total_bytes && continuation_byte?(content.getbyte(suffix_start))
|
|
49
|
+
[content.byteslice(0, prefix_end), content.byteslice(suffix_start, total_bytes - suffix_start)]
|
|
50
|
+
end
|
|
51
|
+
private_class_method :split_string
|
|
52
|
+
|
|
53
|
+
def continuation_byte?(byte) # :nodoc:
|
|
54
|
+
byte && (byte & 0xc0) == 0x80
|
|
55
|
+
end
|
|
56
|
+
private_class_method :continuation_byte?
|
|
57
|
+
|
|
58
|
+
def utf8_content(text) # :nodoc:
|
|
59
|
+
content = String(text)
|
|
60
|
+
return content if content.encoding == Encoding::UTF_8 && content.valid_encoding?
|
|
61
|
+
if content.encoding == Encoding::ASCII_8BIT
|
|
62
|
+
utf8 = content.dup.force_encoding(Encoding::UTF_8)
|
|
63
|
+
return utf8 if utf8.valid_encoding?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
content.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
67
|
+
end
|
|
68
|
+
private_class_method :utf8_content
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
module Support
|
|
5
|
+
# Redactor removes common credential keys, known secret values, and
|
|
6
|
+
# secret-shaped strings from nested diagnostic data. It returns a copy and
|
|
7
|
+
# leaves the caller's value unchanged.
|
|
8
|
+
#
|
|
9
|
+
# Redaction is a defense-in-depth aid, not proof that arbitrary
|
|
10
|
+
# sensitive content is safe to export. Applications should add known secret
|
|
11
|
+
# values and keep telemetry within an appropriate trust boundary.
|
|
12
|
+
class Redactor
|
|
13
|
+
SENSITIVE_KEY = /(authorization|api[_-]?key|credential|password|secret|(?:^|[_-])token(?:$|[_-])|cookie|private[_-]?key)/i # :nodoc:
|
|
14
|
+
SECRET_PATTERNS = [
|
|
15
|
+
/\bBearer\s+[A-Za-z0-9._~+\/-]+=*/i,
|
|
16
|
+
/\b(?:gh[opusr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/,
|
|
17
|
+
/\bAKIA[A-Z0-9]{16}\b/,
|
|
18
|
+
/\b(?:sk|pk)-[A-Za-z0-9_-]{20,}\b/
|
|
19
|
+
].freeze # :nodoc:
|
|
20
|
+
|
|
21
|
+
# Adds literal secrets to the built-in patterns. Values shorter than
|
|
22
|
+
# eight characters are ignored to avoid over-redaction.
|
|
23
|
+
def initialize(redactions: [], stringify_keys: false)
|
|
24
|
+
@redactions = Array(redactions).map(&:to_s).reject { |value| value.length < 8 }.uniq.freeze
|
|
25
|
+
@stringify_keys = stringify_keys
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Produces a redacted copy of +value+.
|
|
29
|
+
def call(value, key: nil)
|
|
30
|
+
return "[REDACTED]" if key && sensitive_key?(key)
|
|
31
|
+
|
|
32
|
+
case value
|
|
33
|
+
when Hash
|
|
34
|
+
value.to_h do |child_key, child|
|
|
35
|
+
output_key = @stringify_keys ? child_key.to_s : child_key
|
|
36
|
+
[output_key, call(child, key: child_key)]
|
|
37
|
+
end
|
|
38
|
+
when Array
|
|
39
|
+
value.map { |child| call(child) }
|
|
40
|
+
when String
|
|
41
|
+
scrub_string(value)
|
|
42
|
+
else
|
|
43
|
+
value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Checks whether +key+ matches a credential-like name.
|
|
48
|
+
def sensitive_key?(key)
|
|
49
|
+
SENSITIVE_KEY.match?(normalize_key(key))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Normalizes invalid UTF-8 and replaces configured and common secrets.
|
|
53
|
+
def scrub_string(value)
|
|
54
|
+
normalized = value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
|
|
55
|
+
text = @redactions.reduce(normalized) { |current, secret| current.gsub(secret, "[REDACTED]") }
|
|
56
|
+
SECRET_PATTERNS.reduce(text) { |current, pattern| current.gsub(pattern, "[REDACTED]") }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def normalize_key(key)
|
|
62
|
+
key.to_s.gsub(/([a-z\d])([A-Z])/, "\\1_\\2").downcase
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# Support collects small building blocks for LittleGhost extensions. They are
|
|
5
|
+
# available to applications that need the same cancellation, loading,
|
|
6
|
+
# callback, execution, and diagnostic behavior as the framework.
|
|
7
|
+
module Support
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Recursively duplicates hashes, arrays, and strings while preserving other
|
|
11
|
+
# values. Cyclic containers are not supported.
|
|
12
|
+
def deep_dup(value)
|
|
13
|
+
case value
|
|
14
|
+
when Hash
|
|
15
|
+
value.each_with_object({}) { |(key, child), copy| copy[deep_dup(key)] = deep_dup(child) }
|
|
16
|
+
when Array
|
|
17
|
+
value.map { |child| deep_dup(child) }
|
|
18
|
+
when String
|
|
19
|
+
value.dup
|
|
20
|
+
else
|
|
21
|
+
value
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
require_relative "support/callbacks"
|
|
28
|
+
require_relative "support/cancellation_token"
|
|
29
|
+
require_relative "support/class_attributes"
|
|
30
|
+
require_relative "support/redactor"
|
|
31
|
+
require_relative "support/content_capture"
|
|
32
|
+
require_relative "support/executor"
|
|
33
|
+
require_relative "support/interruptible_stream"
|
|
34
|
+
require_relative "support/loader"
|