little_ghost 0.3.0 → 0.5.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +90 -84
  3. data/docs/guides/assemblies.md +412 -0
  4. data/docs/guides/code_mode.md +275 -0
  5. data/docs/guides/core_concepts.md +150 -239
  6. data/docs/guides/getting_started.md +125 -87
  7. data/docs/guides/integrations.md +217 -0
  8. data/docs/guides/models_and_providers.md +125 -0
  9. data/docs/guides/production.md +253 -0
  10. data/docs/guides/prompt_views.md +139 -0
  11. data/docs/guides/sandboxing.md +282 -0
  12. data/docs/guides/skills.md +141 -0
  13. data/docs/guides/structured_outputs_and_content.md +135 -0
  14. data/docs/guides/tools.md +329 -0
  15. data/lib/little_ghost/ag_ui/adapter.rb +5 -5
  16. data/lib/little_ghost/agent/delegation.rb +3 -3
  17. data/lib/little_ghost/agent/skills.rb +6 -1
  18. data/lib/little_ghost/agent/tool_loop.rb +6 -2
  19. data/lib/little_ghost/agent.rb +411 -214
  20. data/lib/little_ghost/agent_builder.rb +61 -22
  21. data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
  22. data/lib/little_ghost/agent_stream_source.rb +262 -0
  23. data/lib/little_ghost/artifact.rb +182 -0
  24. data/lib/little_ghost/artifacts/presentation_budget.rb +44 -0
  25. data/lib/little_ghost/artifacts/workspace_store.rb +370 -0
  26. data/lib/little_ghost/artifacts.rb +11 -0
  27. data/lib/little_ghost/assembly.rb +110 -30
  28. data/lib/little_ghost/assembly_builder.rb +59 -21
  29. data/lib/little_ghost/assembly_execution.rb +110 -6
  30. data/lib/little_ghost/code_mode/broker.rb +164 -0
  31. data/lib/little_ghost/code_mode/catalog.rb +54 -0
  32. data/lib/little_ghost/code_mode/engine.rb +58 -0
  33. data/lib/little_ghost/code_mode/javascript/catalog.rb +118 -0
  34. data/lib/little_ghost/code_mode/javascript/client.rb +550 -0
  35. data/lib/little_ghost/code_mode/javascript/host.rb +347 -0
  36. data/lib/little_ghost/code_mode/javascript/session.rb +579 -0
  37. data/lib/little_ghost/code_mode/javascript_engine.rb +172 -0
  38. data/lib/little_ghost/code_mode/protocol.rb +78 -0
  39. data/lib/little_ghost/code_mode/ruby/catalog.rb +80 -0
  40. data/lib/little_ghost/code_mode/ruby/host.rb +102 -0
  41. data/lib/little_ghost/code_mode/ruby/session.rb +508 -0
  42. data/lib/little_ghost/code_mode/ruby_engine.rb +86 -0
  43. data/lib/little_ghost/code_mode/runtime.rb +335 -0
  44. data/lib/little_ghost/code_mode/session.rb +61 -0
  45. data/lib/little_ghost/code_mode/types.rb +58 -0
  46. data/lib/little_ghost/code_mode.rb +53 -0
  47. data/lib/little_ghost/configuration.rb +366 -47
  48. data/lib/little_ghost/content.rb +24 -13
  49. data/lib/little_ghost/data_map.rb +209 -0
  50. data/lib/little_ghost/errors.rb +28 -9
  51. data/lib/little_ghost/execution.rb +33 -34
  52. data/lib/little_ghost/graph.rb +376 -197
  53. data/lib/little_ghost/mcp/client.rb +487 -88
  54. data/lib/little_ghost/mcp/toolset.rb +210 -0
  55. data/lib/little_ghost/mcp/types.rb +216 -0
  56. data/lib/little_ghost/mcp.rb +3 -0
  57. data/lib/little_ghost/message.rb +4 -4
  58. data/lib/little_ghost/model_capabilities.rb +9 -6
  59. data/lib/little_ghost/model_request.rb +0 -12
  60. data/lib/little_ghost/model_resolver.rb +15 -5
  61. data/lib/little_ghost/model_response.rb +3 -7
  62. data/lib/little_ghost/network/authorizer_server.rb +162 -0
  63. data/lib/little_ghost/network/certificate_authority.rb +98 -0
  64. data/lib/little_ghost/network/envoy_config.rb +362 -0
  65. data/lib/little_ghost/network/envoy_gateway.rb +409 -0
  66. data/lib/little_ghost/network/external_gateway.rb +68 -0
  67. data/lib/little_ghost/network.rb +96 -0
  68. data/lib/little_ghost/prompt_resolver.rb +9 -7
  69. data/lib/little_ghost/provider_registry.rb +3 -3
  70. data/lib/little_ghost/providers/anthropic.rb +8 -1
  71. data/lib/little_ghost/providers/gemini.rb +10 -1
  72. data/lib/little_ghost/providers/vertex_ai.rb +6 -1
  73. data/lib/little_ghost/run.rb +162 -54
  74. data/lib/little_ghost/run_context.rb +42 -20
  75. data/lib/little_ghost/run_result.rb +0 -7
  76. data/lib/little_ghost/runtime/hook.rb +8 -3
  77. data/lib/little_ghost/runtime/hooks/artifacts.rb +338 -0
  78. data/lib/little_ghost/runtime.rb +160 -52
  79. data/lib/little_ghost/sandbox/capabilities.rb +68 -0
  80. data/lib/little_ghost/sandbox/environment_policy.rb +41 -0
  81. data/lib/little_ghost/sandbox/filesystem.rb +377 -0
  82. data/lib/little_ghost/sandbox/isolated_backend.rb +163 -0
  83. data/lib/little_ghost/sandbox/limits.rb +48 -0
  84. data/lib/little_ghost/sandbox/mount.rb +129 -0
  85. data/lib/little_ghost/sandbox/network_policy.rb +108 -0
  86. data/lib/little_ghost/sandbox/policy.rb +88 -0
  87. data/lib/little_ghost/sandbox/process_runner.rb +103 -0
  88. data/lib/little_ghost/sandbox/process_session.rb +335 -0
  89. data/lib/little_ghost/sandbox/scope.rb +304 -0
  90. data/lib/little_ghost/sandbox.rb +158 -32
  91. data/lib/little_ghost/sandboxes/bubblewrap.rb +351 -0
  92. data/lib/little_ghost/sandboxes/native.rb +51 -0
  93. data/lib/little_ghost/sandboxes/seatbelt.rb +261 -0
  94. data/lib/little_ghost/sandboxes/unrestricted.rb +241 -0
  95. data/lib/little_ghost/session.rb +39 -26
  96. data/lib/little_ghost/session_store.rb +9 -5
  97. data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
  98. data/lib/little_ghost/session_stores/filesystem.rb +261 -0
  99. data/lib/little_ghost/session_stores/memory.rb +7 -0
  100. data/lib/little_ghost/skills/catalog.rb +94 -14
  101. data/lib/little_ghost/skills/resource_root.rb +42 -0
  102. data/lib/little_ghost/skills/skill.rb +0 -3
  103. data/lib/little_ghost/stream_event.rb +8 -13
  104. data/lib/little_ghost/subagents/control_tool.rb +8 -0
  105. data/lib/little_ghost/subagents/manager.rb +53 -50
  106. data/lib/little_ghost/support/callbacks.rb +3 -1
  107. data/lib/little_ghost/support/content_capture.rb +3 -3
  108. data/lib/little_ghost/support/http_client.rb +2 -2
  109. data/lib/little_ghost/support/redactor.rb +1 -1
  110. data/lib/little_ghost/swarm.rb +13 -5
  111. data/lib/little_ghost/tool.rb +157 -64
  112. data/lib/little_ghost/tool_registry.rb +1 -1
  113. data/lib/little_ghost/tools/filesystem.rb +1 -1
  114. data/lib/little_ghost/tools/shell.rb +4 -3
  115. data/lib/little_ghost/tools/write_todos.rb +6 -1
  116. data/lib/little_ghost/tracing/open_telemetry.rb +1 -1
  117. data/lib/little_ghost/version.rb +1 -1
  118. data/lib/little_ghost/workflow.rb +30 -21
  119. data/lib/little_ghost/workspace.rb +222 -8
  120. data/lib/little_ghost.rb +40 -27
  121. metadata +104 -3
  122. data/lib/little_ghost/unrestricted_sandbox.rb +0 -306
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module LittleGhost
6
+ class Sandbox
7
+ # Maps one trusted host directory into the sandbox's virtual filesystem.
8
+ # Process and Filesystem Tool visibility are separate: +tools: false+ keeps the
9
+ # mount available to child processes while denying it to the filesystem
10
+ # broker. +protect_aliases+ preserves restrictive access when the same host
11
+ # files are reachable through a broader mount.
12
+ class Mount # :nodoc:
13
+ ACCESS_MODES = %i[read_only read_write].freeze # :nodoc:
14
+
15
+ # Returns +value+ unchanged or builds a mount from a Hash.
16
+ def self.coerce(value, root: nil)
17
+ return value if value.is_a?(self)
18
+ raise PolicyError, "mount must be a Hash or Sandbox::Mount" unless value.is_a?(Hash)
19
+
20
+ new(**value.transform_keys(&:to_sym), root:)
21
+ end
22
+
23
+ # Maps +source+ at the absolute virtual +target+ with the selected access.
24
+ # +source+ is trusted host configuration, not model input.
25
+ def initialize(source:, target:, access: :read_only, protect_aliases: false, tools: true, root: nil)
26
+ source = File.expand_path(String(source), root)
27
+ target = normalize_target(target)
28
+ access = access.to_sym
29
+ raise PolicyError, "mount access must be :read_only or :read_write" unless ACCESS_MODES.include?(access)
30
+
31
+ @source = source.freeze
32
+ @target = target.freeze
33
+ @access = access
34
+ @protect_aliases = !!protect_aliases
35
+ @tools = !!tools
36
+ freeze
37
+ end
38
+
39
+ # Absolute host directory exposed by this mount.
40
+ attr_reader :source
41
+ # Absolute path presented inside the sandbox.
42
+ attr_reader :target
43
+ # Either +:read_only+ or +:read_write+.
44
+ attr_reader :access
45
+ # Indicates whether the mount's access applies through physical aliases.
46
+ attr_reader :protect_aliases
47
+ # Indicates whether direct filesystem tools may traverse this mount.
48
+ attr_reader :tools
49
+
50
+ # Indicates that the mount does not permit writes.
51
+ def read_only? = access == :read_only
52
+ # Indicates that the mount permits writes.
53
+ def writable? = access == :read_write
54
+ # Indicates that physical aliases retain this mount's access.
55
+ def protect_aliases? = protect_aliases
56
+ # Indicates that direct filesystem tools may traverse this mount.
57
+ def tool_visible? = tools
58
+
59
+ # Indicates whether +path+ is this mount target or one of its descendants.
60
+ def covers?(path)
61
+ path = self.class.send(:normalize_virtual_path, path)
62
+ path == target || path.start_with?("#{target}/")
63
+ end
64
+
65
+ # Returns a copy narrowed to +target+ and +access+. Widening raises
66
+ # CapabilityError.
67
+ def narrow(target: self.target, access: self.access)
68
+ target = self.class.send(:normalize_virtual_path, target)
69
+ raise CapabilityError, "sandbox scope cannot expose a path outside its parent mount" unless covers?(target)
70
+ if read_only? && access.to_sym == :read_write
71
+ raise CapabilityError, "sandbox scope cannot make a read-only mount writable"
72
+ end
73
+
74
+ relative = target.delete_prefix(self.target).delete_prefix("/")
75
+ parent_root = File.realpath(source)
76
+ child_root = relative.empty? ? parent_root : File.realpath(File.join(parent_root, relative))
77
+ unless child_root == parent_root || child_root.start_with?("#{parent_root}#{File::SEPARATOR}")
78
+ raise CapabilityError, "sandbox scope mount source escapes its parent"
79
+ end
80
+ self.class.new(
81
+ source: child_root,
82
+ target:,
83
+ access:,
84
+ protect_aliases: protect_aliases?,
85
+ tools: tool_visible?
86
+ )
87
+ rescue Errno::ENOENT, Errno::EACCES
88
+ raise CapabilityError, "sandbox scope mount source is unavailable"
89
+ end
90
+
91
+ # Mounts compare by their normalized mapping and access semantics.
92
+ def ==(other)
93
+ other.is_a?(self.class) &&
94
+ [comparison_source, target, access, protect_aliases?, tool_visible?] ==
95
+ [other.send(:comparison_source), other.target, other.access, other.protect_aliases?, other.tool_visible?]
96
+ end
97
+
98
+ alias_method :eql?, :==
99
+
100
+ # Hashes the normalized mapping and access semantics.
101
+ def hash = [comparison_source, target, access, protect_aliases?, tool_visible?].hash
102
+
103
+ def self.normalize_virtual_path(path)
104
+ value = String(path)
105
+ raise PolicyError, "mount target contains a null byte" if value.include?("\0")
106
+ raise PolicyError, "mount target must be absolute" unless value.start_with?(File::SEPARATOR)
107
+ raise PolicyError, "mount target cannot contain traversal" if value.split(File::SEPARATOR).include?("..")
108
+
109
+ Pathname.new(value).cleanpath.to_s
110
+ end
111
+
112
+ class << self
113
+ private :normalize_virtual_path
114
+ end
115
+
116
+ private
117
+
118
+ def comparison_source
119
+ File.realpath(source)
120
+ rescue Errno::ENOENT, Errno::EACCES
121
+ source
122
+ end
123
+
124
+ def normalize_target(path)
125
+ self.class.send(:normalize_virtual_path, path)
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ class Sandbox
5
+ # Declares outbound connectivity for sandbox-launched processes. A network
6
+ # policy does not apply to providers or arbitrary Ruby tools in the host.
7
+ class NetworkPolicy
8
+ MODES = %i[inherit none allowlist].freeze # :nodoc:
9
+ INSPECTION_MODES = %i[connect http].freeze # :nodoc:
10
+
11
+ # Returns +value+ unchanged or converts a mode or Hash to a policy.
12
+ def self.coerce(value)
13
+ return nil if value.nil?
14
+ return value if value.is_a?(self)
15
+ return new(mode: value) if value.is_a?(Symbol) || value.is_a?(String)
16
+ raise PolicyError, "network policy must be a mode, Hash, or Sandbox::NetworkPolicy" unless value.is_a?(Hash)
17
+
18
+ new(**value.transform_keys(&:to_sym))
19
+ end
20
+
21
+ # Builds an outbound policy. Enforcement remains the configured gateway's
22
+ # responsibility.
23
+ def initialize(mode:, allow: [], inspection: :connect, gateway: nil, authorizer: nil, forward_headers: [], mutation_headers: [])
24
+ mode = mode.to_sym
25
+ inspection = inspection.to_sym
26
+ raise PolicyError, "network mode must be :inherit, :none, or :allowlist" unless MODES.include?(mode)
27
+ unless INSPECTION_MODES.include?(inspection)
28
+ raise PolicyError, "network inspection must be :connect or :http"
29
+ end
30
+ if mode != :allowlist && (!Array(allow).empty? || gateway || authorizer || !Array(forward_headers).empty? || !Array(mutation_headers).empty?)
31
+ raise PolicyError, "network allow, gateway, authorizer, and header policy require mode :allowlist"
32
+ end
33
+ if inspection == :http && mode != :allowlist
34
+ raise PolicyError, "HTTP inspection requires mode :allowlist"
35
+ end
36
+ if inspection == :http && !authorizer
37
+ raise PolicyError, "HTTP inspection requires an authorizer"
38
+ end
39
+ if mode == :allowlist && Array(allow).empty?
40
+ raise PolicyError, "network mode :allowlist requires at least one exact destination"
41
+ end
42
+
43
+ @mode = mode
44
+ @allow = Array(allow).map { |endpoint| normalize_endpoint(endpoint) }.uniq.freeze
45
+ @inspection = inspection
46
+ @gateway = gateway
47
+ @authorizer = authorizer
48
+ @forward_headers = Array(forward_headers).map { |name| normalize_header_name(name) }.uniq.freeze
49
+ @mutation_headers = Array(mutation_headers).map { |name| normalize_header_name(name) }.uniq.freeze
50
+ freeze
51
+ end
52
+
53
+ # Connectivity mode: +:inherit+, +:none+, or +:allowlist+.
54
+ attr_reader :mode
55
+ # Normalized endpoints accepted by an allowlist gateway.
56
+ attr_reader :allow
57
+ # Inspection level requested from the gateway.
58
+ attr_reader :inspection
59
+ # Explicit gateway declaration, when supplied.
60
+ attr_reader :gateway
61
+ # Trusted request authorizer used by HTTP inspection, when supplied.
62
+ attr_reader :authorizer
63
+ # Header names the gateway may pass to an HTTP authorizer.
64
+ attr_reader :forward_headers
65
+ # Header names an HTTP authorizer may set on an upstream request.
66
+ attr_reader :mutation_headers
67
+
68
+ # Indicates unrestricted backend-provided connectivity.
69
+ def inherit? = mode == :inherit
70
+ # Indicates that outbound connectivity must be disabled.
71
+ def none? = mode == :none
72
+ # Indicates that outbound traffic must pass an allowlist gateway.
73
+ def allowlist? = mode == :allowlist
74
+
75
+ # Policies compare by their normalized enforcement declaration.
76
+ def ==(other)
77
+ other.is_a?(self.class) &&
78
+ [mode, allow, inspection, gateway, authorizer, forward_headers, mutation_headers] ==
79
+ [other.mode, other.allow, other.inspection, other.gateway, other.authorizer,
80
+ other.forward_headers, other.mutation_headers]
81
+ end
82
+
83
+ alias_method :eql?, :==
84
+
85
+ # Hashes the normalized enforcement declaration.
86
+ def hash = [mode, allow, inspection, gateway, authorizer, forward_headers, mutation_headers].hash
87
+
88
+ private
89
+
90
+ def normalize_endpoint(endpoint)
91
+ value = String(endpoint).strip.downcase
92
+ raise PolicyError, "network allowlist entries cannot be empty" if value.empty?
93
+ raise PolicyError, "network allowlist entries cannot contain whitespace" if value.match?(/\s/)
94
+
95
+ value.freeze
96
+ end
97
+
98
+ def normalize_header_name(name)
99
+ value = String(name).downcase
100
+ unless value.match?(/\A[a-z0-9][a-z0-9-]*\z/)
101
+ raise PolicyError, "forwarded network header names must be lowercase HTTP tokens"
102
+ end
103
+
104
+ value.freeze
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ class Sandbox
5
+ # Normalizes requested filesystem, process, environment, and child-network
6
+ # controls into one immutable policy. Policy is a declaration, not proof of
7
+ # isolation; the selected backend exposes #effective_policy and rejects
8
+ # controls it cannot enforce.
9
+ class Policy
10
+ COMMON_KEYS = %i[
11
+ files runtime_paths root_filesystem environment network
12
+ ].freeze # :nodoc:
13
+ ACCESS_MODES = %i[read_only read_write].freeze # :nodoc:
14
+ ROOT_FILESYSTEM_MODES = %i[isolated read_only read_write].freeze # :nodoc:
15
+ # Returns an existing policy or builds one from a Hash and keyword options.
16
+ def self.coerce(value = nil, **options)
17
+ return value if value.is_a?(self) && options.empty?
18
+ values = value.nil? ? {} : value
19
+ raise PolicyError, "sandbox policy must be a Hash or Sandbox::Policy" unless values.is_a?(Hash)
20
+
21
+ new(**values.transform_keys(&:to_sym).merge(options))
22
+ end
23
+
24
+ # Builds a backend-independent policy from named Workspace paths.
25
+ def initialize(
26
+ files: {root: :read_only},
27
+ runtime_paths: {},
28
+ root_filesystem: :isolated,
29
+ environment: {},
30
+ network: nil
31
+ )
32
+ @files = normalize_paths(files, "files")
33
+ @runtime_paths = normalize_paths(runtime_paths, "runtime_paths")
34
+ @root_filesystem = enum!(root_filesystem, ROOT_FILESYSTEM_MODES, "root filesystem")
35
+ @environment = EnvironmentPolicy.coerce(environment)
36
+ @network = NetworkPolicy.coerce(network)
37
+ freeze
38
+ end
39
+
40
+ # Named Workspace paths visible to tools and child processes.
41
+ attr_reader :files
42
+ # Named workspace paths visible only to sandboxed processes.
43
+ attr_reader :runtime_paths
44
+ # Requested host-root access: +:isolated+, +:read_only+, or +:read_write+.
45
+ attr_reader :root_filesystem
46
+ # Environment inheritance and explicit values.
47
+ attr_reader :environment
48
+ # Network policy, or +nil+ for a backend-specific secure default.
49
+ attr_reader :network
50
+ # Whether the +:root+ entry in +files+ requests +:read_write+ access.
51
+ def workspace_writable? = files.fetch(:root, :read_only) == :read_write
52
+
53
+ # Builds internal identity grants for a concrete Workspace.
54
+ def process_grants(workspace) # :nodoc:
55
+ file_mounts = files.map do |name, access|
56
+ source = workspace_directory(workspace, name)
57
+ Mount.new(source:, target: source, access:, protect_aliases: true)
58
+ end
59
+ process_mounts = runtime_paths.map do |name, access|
60
+ source = workspace_directory(workspace, name)
61
+ Mount.new(source:, target: source, access:, protect_aliases: true, tools: false)
62
+ end
63
+ (file_mounts + process_mounts).sort_by { |mount| -mount.target.length }.freeze
64
+ end
65
+
66
+ private
67
+
68
+ def enum!(value, choices, label)
69
+ value = value.to_sym
70
+ raise PolicyError, "#{label} must be one of #{choices.map { |choice| ":#{choice}" }.join(", ")}" unless choices.include?(value)
71
+
72
+ value
73
+ end
74
+
75
+ def normalize_paths(value, label)
76
+ raise PolicyError, "sandbox #{label} must be a Hash" unless value.respond_to?(:each_pair)
77
+
78
+ value.each_pair.to_h do |name, access|
79
+ [name.to_sym, enum!(access, ACCESS_MODES, "#{label} access")]
80
+ end.freeze
81
+ end
82
+
83
+ def workspace_directory(workspace, name)
84
+ (name.to_sym == :root) ? workspace.root : workspace.path(name)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module LittleGhost
6
+ class Sandbox
7
+ # Runs one bounded child process and terminates its entire process group when
8
+ # the Run is cancelled or the command exceeds its timeout.
9
+ class ProcessRunner # :nodoc:
10
+ POLL_INTERVAL = 0.01
11
+ TERMINATION_GRACE = 0.5
12
+
13
+ def self.run(...) = new(...).run
14
+
15
+ def initialize(command:, timeout:, context: nil, max_output_bytes: 1_000_000,
16
+ environment: {}, inherit_environment: false, chdir: nil)
17
+ @command = Array(command).map(&:to_s)
18
+ @timeout = Float(timeout)
19
+ @context = context
20
+ @max_output_bytes = Integer(max_output_bytes)
21
+ @environment = environment.transform_keys(&:to_s).transform_values(&:to_s)
22
+ @inherit_environment = inherit_environment
23
+ @chdir = chdir
24
+
25
+ raise ToolError, "Command must contain an executable" if @command.empty? || @command.first.empty?
26
+ raise ArgumentError, "timeout must be positive" unless @timeout.positive? && @timeout.finite?
27
+ raise ArgumentError, "max_output_bytes must be positive" unless @max_output_bytes.positive?
28
+ end
29
+
30
+ def run
31
+ result = nil
32
+ options = {pgroup: true, unsetenv_others: !@inherit_environment}
33
+ options[:chdir] = @chdir if @chdir
34
+ Open3.popen3(@environment, *@command, **options) do |stdin, stdout, stderr, wait_thread|
35
+ stdin.close
36
+ stdout_reader = Thread.new { drain(stdout) }
37
+ stderr_reader = Thread.new { drain(stderr) }
38
+ wait_for(wait_thread, [stdout_reader, stderr_reader])
39
+ result = Execution.new(
40
+ stdout: stdout_reader.value,
41
+ stderr: stderr_reader.value,
42
+ exit_code: wait_thread.value.exitstatus
43
+ )
44
+ ensure
45
+ stdout_reader&.kill
46
+ stderr_reader&.kill
47
+ end
48
+ result
49
+ rescue Errno::ENOENT
50
+ Execution.new(stdout: "", stderr: "#{@command.first}: command not found\n", exit_code: 127)
51
+ end
52
+
53
+ private
54
+
55
+ def wait_for(wait_thread, readers)
56
+ deadline = monotonic_time + @timeout
57
+ until !wait_thread.alive? && readers.none?(&:alive?)
58
+ @context&.check!
59
+ raise ToolError, "Command timed out after #{@timeout} seconds" if monotonic_time >= deadline
60
+
61
+ wait_thread.join(POLL_INTERVAL)
62
+ end
63
+ rescue
64
+ terminate(wait_thread.pid)
65
+ raise
66
+ end
67
+
68
+ def terminate(pid)
69
+ Process.kill("TERM", -pid)
70
+ deadline = monotonic_time + TERMINATION_GRACE
71
+ Thread.pass while monotonic_time < deadline && process_group_alive?(pid)
72
+ Process.kill("KILL", -pid) if process_group_alive?(pid)
73
+ rescue Errno::ESRCH, Errno::EPERM
74
+ nil
75
+ end
76
+
77
+ def process_group_alive?(pid)
78
+ Process.kill(0, -pid)
79
+ true
80
+ rescue Errno::ESRCH
81
+ false
82
+ rescue Errno::EPERM
83
+ true
84
+ end
85
+
86
+ def drain(io)
87
+ captured = String.new(encoding: Encoding::BINARY)
88
+ truncated = false
89
+ while (chunk = io.read(16_384))
90
+ remaining = @max_output_bytes - captured.bytesize
91
+ captured << chunk.byteslice(0, remaining) if remaining.positive?
92
+ truncated ||= chunk.bytesize > remaining
93
+ end
94
+ captured = captured.force_encoding(Encoding::UTF_8).scrub
95
+ truncated ? "#{captured}\n[output truncated]" : captured
96
+ end
97
+
98
+ def monotonic_time
99
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
100
+ end
101
+ end
102
+ end
103
+ end