rubik 0.1.6 → 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 +4 -4
- data/ATTRIBUTION.md +5 -1
- data/CHANGELOG.md +169 -0
- data/README.md +137 -255
- data/bin/rubik +9 -0
- data/lib/rubik/ast/extraction_result.rb +37 -0
- data/lib/rubik/ast/extractor.rb +35 -9
- data/lib/rubik/ast/graph.rb +27 -1
- data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
- data/lib/rubik/ast/treesitter_extractor.rb +45 -17
- data/lib/rubik/buffer/apply_contract.rb +78 -0
- data/lib/rubik/buffer/manager.rb +61 -5
- data/lib/rubik/cli/workspace_trust.rb +78 -0
- data/lib/rubik/concurrency/event_loop.rb +56 -44
- data/lib/rubik/concurrency/scheduler.rb +7 -24
- data/lib/rubik/context/git_lister.rb +6 -1
- data/lib/rubik/context/scanner.rb +15 -3
- data/lib/rubik/kernel/init.rb +15 -0
- data/lib/rubik/mcp/transport/stdio.rb +14 -11
- data/lib/rubik/orchestrator/auto_apply.rb +1 -1
- data/lib/rubik/orchestrator/subagent/activation.rb +29 -4
- data/lib/rubik/orchestrator/subagent/builtin_types.rb +117 -0
- data/lib/rubik/orchestrator/subagent/dynamic_store.rb +119 -0
- data/lib/rubik/orchestrator/subagent/instance.rb +8 -36
- data/lib/rubik/orchestrator/subagent/journal.rb +77 -4
- data/lib/rubik/orchestrator/subagent/profile_contract.rb +187 -0
- data/lib/rubik/orchestrator/subagent/profile_snapshot.rb +72 -0
- data/lib/rubik/orchestrator/subagent/reuse.rb +2 -1
- data/lib/rubik/orchestrator/subagent/scoped_registry.rb +2 -0
- data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +170 -0
- data/lib/rubik/orchestrator/subagent/start_gate.rb +98 -0
- data/lib/rubik/orchestrator/subagent/supervisor.rb +4 -67
- data/lib/rubik/orchestrator/subagent/type_definition.rb +84 -0
- data/lib/rubik/orchestrator/subagent/type_registry.rb +139 -159
- data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +4 -2
- data/lib/rubik/platform/process.rb +152 -3
- data/lib/rubik/plugin/manager.rb +148 -71
- data/lib/rubik/plugin/package_inventory.rb +84 -0
- data/lib/rubik/plugin/requires_dag.rb +40 -0
- data/lib/rubik/plugin/storage.rb +100 -14
- data/lib/rubik/prompt/context_builder.rb +54 -20
- data/lib/rubik/prompt/doctrine.rb +158 -22
- data/lib/rubik/prompt/token_estimator.rb +5 -3
- data/lib/rubik/prompt/tool_teachings.rb +19 -12
- data/lib/rubik/repl/commands/buffer.rb +28 -18
- data/lib/rubik/runtime/repair_probe.rb +98 -0
- data/lib/rubik/sandbox/driver/bubblewrap.rb +13 -59
- data/lib/rubik/sandbox/driver/docker.rb +14 -60
- data/lib/rubik/sandbox/driver/podman.rb +13 -56
- data/lib/rubik/sandbox/driver/process.rb +12 -64
- data/lib/rubik/security/blocked_guidance.rb +19 -0
- data/lib/rubik/security/guard.rb +116 -7
- data/lib/rubik/storage/store.rb +52 -11
- data/lib/rubik/tools/builtins/define_subagent.rb +69 -0
- data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
- data/lib/rubik/tools/builtins/storage.rb +8 -5
- data/lib/rubik/tools/builtins/subagent.rb +20 -53
- data/lib/rubik/tools/builtins/synthesis.rb +6 -1
- data/lib/rubik/tools/builtins.rb +1 -0
- data/lib/rubik/tools/diagnostic_reporter.rb +2 -1
- data/lib/rubik/version.rb +1 -1
- metadata +15 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require_relative "discovery"
|
|
7
|
+
require_relative "diagnostic_engine"
|
|
8
|
+
require_relative "../lsp/client"
|
|
9
|
+
|
|
10
|
+
module Rubik
|
|
11
|
+
module Runtime
|
|
12
|
+
# Functional checks own their resources through the caller's probe context.
|
|
13
|
+
class RepairProbe
|
|
14
|
+
def initialize(root_dir:)
|
|
15
|
+
@root_dir = root_dir
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
PROBE_TIMEOUT = 5
|
|
19
|
+
# These commands support help without running tests or formatting files.
|
|
20
|
+
HELP_PROBE_BINARIES = ["ruby", "python", "python3", "pytest", "rspec", "rake", "bundle", "rubocop", "standardrb", "ruff", "black", "rustfmt", "cargo", "go", "gofmt",
|
|
21
|
+
"goimports", "clang-format", "zig", "prettier", "biome", "npm", "pnpm", "yarn", "bun", "mix", "dart", "crystal", "echo"].freeze
|
|
22
|
+
|
|
23
|
+
def verify(target, lang, proposed_cmd, sub_ctx, probe_args: nil)
|
|
24
|
+
return probe_failure(proposed_cmd, 1, "Unknown target component type: #{target}") unless ["lsp", "test_runner", "linter", "command"].include?(target)
|
|
25
|
+
unless Rubik::Runtime::Discovery.binary_available?(proposed_cmd, root_dir: @root_dir)
|
|
26
|
+
return probe_failure(proposed_cmd, 127, "Availability check failed: executable not found in PATH")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if target == "lsp"
|
|
30
|
+
client = Rubik::LSP::Client.new(lang, root_dir: @root_dir, server_cmd: proposed_cmd)
|
|
31
|
+
sub_ctx.on_dispose { client.stop }
|
|
32
|
+
begin
|
|
33
|
+
result = Timeout.timeout(PROBE_TIMEOUT) { client.start }
|
|
34
|
+
return probe_failure(proposed_cmd, 1, "Functional probe failed: LSP initialize handshake rejected") unless result && client.alive?
|
|
35
|
+
rescue Timeout::Error
|
|
36
|
+
return probe_failure(proposed_cmd, 124, "LSP initialize handshake timed out")
|
|
37
|
+
end
|
|
38
|
+
return [true, nil]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
argv = Shellwords.split(proposed_cmd)
|
|
42
|
+
if probe_args.nil?
|
|
43
|
+
unless HELP_PROBE_BINARIES.include?(File.basename(argv.first.to_s))
|
|
44
|
+
return probe_failure(proposed_cmd, 1, "Available but unverified: provide non-mutating probe_args for this executable")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
probe_args = ["--help"]
|
|
48
|
+
end
|
|
49
|
+
return probe_failure(proposed_cmd, 1, "probe_args must be a non-empty array of strings") unless probe_args.is_a?(Array) && !probe_args.empty? && probe_args.all?(String)
|
|
50
|
+
|
|
51
|
+
argv.concat(probe_args)
|
|
52
|
+
# Array-form spawn never interprets shell operators. File-backed output
|
|
53
|
+
# avoids pipe-reader threads surviving a timeout or a forked descendant.
|
|
54
|
+
output = Tempfile.new("rubik-repair-probe")
|
|
55
|
+
sub_ctx.on_dispose { output.close! }
|
|
56
|
+
options = { chdir: @root_dir, in: File::NULL, out: output, err: output }
|
|
57
|
+
options[:pgroup] = true unless Rubik::Platform.windows?
|
|
58
|
+
pid = Process.spawn(*argv, **options)
|
|
59
|
+
reaped = false
|
|
60
|
+
sub_ctx.on_dispose do
|
|
61
|
+
if Rubik::Platform.windows?
|
|
62
|
+
Rubik::Platform.kill_tree(pid) unless reaped
|
|
63
|
+
else
|
|
64
|
+
begin
|
|
65
|
+
Process.kill("KILL", -pid)
|
|
66
|
+
rescue Errno::ESRCH
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
Process.waitpid(pid) unless reaped
|
|
71
|
+
rescue Errno::ECHILD
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PROBE_TIMEOUT
|
|
75
|
+
loop do
|
|
76
|
+
waited = Process.waitpid2(pid, Process::WNOHANG)
|
|
77
|
+
if waited
|
|
78
|
+
reaped = true
|
|
79
|
+
status = waited.last
|
|
80
|
+
return [true, nil] if status.success?
|
|
81
|
+
|
|
82
|
+
output.rewind
|
|
83
|
+
return probe_failure(proposed_cmd, status.exitstatus || 1, "Functional probe failed: #{output.read(4096)}")
|
|
84
|
+
end
|
|
85
|
+
return probe_failure(proposed_cmd, 124, "Functional probe timed out") if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
86
|
+
|
|
87
|
+
sleep 0.01
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def probe_failure(command, code, message)
|
|
94
|
+
[false, Rubik::Runtime::DiagnosticEngine.diagnose(cmd: command, exit_code: code, stderr: message)]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -18,7 +18,6 @@ module Rubik
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def run(cmd, timeout: 15, chdir: Dir.pwd, allow_network: false, memory_limit_mb: 512, cpus: "1.0") # rubocop:disable Lint/UnusedMethodArgument
|
|
21
|
-
start_time = Time.now
|
|
22
21
|
workspace = File.expand_path(chdir)
|
|
23
22
|
|
|
24
23
|
bwrap_cmd = [
|
|
@@ -42,66 +41,21 @@ module Rubik
|
|
|
42
41
|
bwrap_cmd.push("--unshare-pid", "--die-with-parent")
|
|
43
42
|
bwrap_cmd.push("sh", "-c", cmd)
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
res = Rubik::Platform.popen_capture(
|
|
45
|
+
*bwrap_cmd,
|
|
46
|
+
timeout: timeout,
|
|
47
|
+
chdir: workspace,
|
|
48
|
+
timeout_message: "Bubblewrap execution timed out after #{timeout} seconds",
|
|
49
|
+
error_message: "Bubblewrap execution error"
|
|
50
|
+
)
|
|
49
51
|
|
|
50
|
-
begin
|
|
51
|
-
Timeout.timeout(timeout) do
|
|
52
|
-
Open3.popen3(*bwrap_cmd) do |stdin, stdout, stderr, wait_thr|
|
|
53
|
-
pid = wait_thr.pid
|
|
54
|
-
begin
|
|
55
|
-
stdin.close
|
|
56
|
-
rescue
|
|
57
|
-
nil
|
|
58
|
-
end
|
|
59
|
-
t_out = Thread.new do
|
|
60
|
-
stdout.read
|
|
61
|
-
rescue
|
|
62
|
-
""
|
|
63
|
-
end
|
|
64
|
-
t_err = Thread.new do
|
|
65
|
-
stderr.read
|
|
66
|
-
rescue
|
|
67
|
-
""
|
|
68
|
-
end
|
|
69
|
-
begin
|
|
70
|
-
stdout_str = t_out.value.to_s
|
|
71
|
-
stderr_str = t_err.value.to_s
|
|
72
|
-
status = wait_thr.value
|
|
73
|
-
exit_code = status.exitstatus || (status.signaled? ? (128 + (status.termsig || 1)) : 1)
|
|
74
|
-
ensure
|
|
75
|
-
begin
|
|
76
|
-
t_out.kill
|
|
77
|
-
rescue
|
|
78
|
-
nil
|
|
79
|
-
end
|
|
80
|
-
begin
|
|
81
|
-
t_err.kill
|
|
82
|
-
rescue
|
|
83
|
-
nil
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
rescue Timeout::Error
|
|
89
|
-
Rubik::Platform.kill_tree(pid) if pid
|
|
90
|
-
stderr_str = "Bubblewrap execution timed out after #{timeout} seconds"
|
|
91
|
-
exit_code = 124
|
|
92
|
-
rescue StandardError => e
|
|
93
|
-
stderr_str = "Bubblewrap execution error: #{e.message}"
|
|
94
|
-
exit_code = 1
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
duration = Time.now - start_time
|
|
98
52
|
{
|
|
99
|
-
ok:
|
|
100
|
-
exit_code: exit_code,
|
|
101
|
-
stdout:
|
|
102
|
-
stderr:
|
|
103
|
-
output: [
|
|
104
|
-
duration: duration
|
|
53
|
+
ok: res[:ok],
|
|
54
|
+
exit_code: res[:exit_code],
|
|
55
|
+
stdout: res[:stdout],
|
|
56
|
+
stderr: res[:stderr],
|
|
57
|
+
output: res[:output],
|
|
58
|
+
duration: res[:duration],
|
|
105
59
|
driver: "bwrap"
|
|
106
60
|
}
|
|
107
61
|
end
|
|
@@ -18,7 +18,6 @@ module Rubik
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def run(cmd, timeout: 15, chdir: Dir.pwd, allow_network: false, memory_limit_mb: 512, cpus: "1.0")
|
|
21
|
-
start_time = Time.now
|
|
22
21
|
image = @options[:image] || DEFAULT_IMAGE
|
|
23
22
|
workspace = File.expand_path(chdir)
|
|
24
23
|
container_name = "rubik-box-#{SecureRandom.hex(6)}"
|
|
@@ -40,70 +39,25 @@ module Rubik
|
|
|
40
39
|
docker_args.push("--network", "none") unless allow_network
|
|
41
40
|
docker_args.push(image, "sh", "-c", cmd)
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
wait_thr_ref = wait_thr
|
|
52
|
-
begin
|
|
53
|
-
stdin.close
|
|
54
|
-
rescue StandardError
|
|
55
|
-
nil
|
|
56
|
-
end
|
|
57
|
-
t_out = Thread.new do
|
|
58
|
-
stdout.read
|
|
59
|
-
rescue StandardError
|
|
60
|
-
""
|
|
61
|
-
end
|
|
62
|
-
t_err = Thread.new do
|
|
63
|
-
stderr.read
|
|
64
|
-
rescue StandardError
|
|
65
|
-
""
|
|
66
|
-
end
|
|
67
|
-
begin
|
|
68
|
-
stdout_str = t_out.value.to_s
|
|
69
|
-
stderr_str = t_err.value.to_s
|
|
70
|
-
status = wait_thr.value
|
|
71
|
-
exit_code = status.exitstatus || (status.signaled? ? (128 + (status.termsig || 1)) : 1)
|
|
72
|
-
ensure
|
|
73
|
-
begin
|
|
74
|
-
t_out.kill
|
|
75
|
-
rescue StandardError
|
|
76
|
-
nil
|
|
77
|
-
end
|
|
78
|
-
begin
|
|
79
|
-
t_err.kill
|
|
80
|
-
rescue StandardError
|
|
81
|
-
nil
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
rescue Timeout::Error
|
|
87
|
-
Rubik::Platform.kill_tree(wait_thr_ref.pid) if wait_thr_ref.respond_to?(:pid) && wait_thr_ref.pid
|
|
88
|
-
system("docker rm -f #{container_name} > #{Rubik::Platform.dev_null} 2>&1")
|
|
89
|
-
stderr_str = "Docker sandbox execution timed out after #{timeout} seconds"
|
|
90
|
-
exit_code = 124
|
|
91
|
-
rescue StandardError => e
|
|
92
|
-
stderr_str = "Docker sandbox error: #{e.message}"
|
|
93
|
-
exit_code = 1
|
|
42
|
+
res = begin
|
|
43
|
+
Rubik::Platform.popen_capture(
|
|
44
|
+
*docker_args,
|
|
45
|
+
timeout: timeout,
|
|
46
|
+
chdir: workspace,
|
|
47
|
+
timeout_message: "Docker sandbox execution timed out after #{timeout} seconds",
|
|
48
|
+
error_message: "Docker sandbox error"
|
|
49
|
+
)
|
|
94
50
|
ensure
|
|
95
|
-
Rubik::Platform.kill_tree(wait_thr_ref.pid) if wait_thr_ref.respond_to?(:pid) && wait_thr_ref.pid
|
|
96
51
|
system("docker rm -f #{container_name} > #{Rubik::Platform.dev_null} 2>&1")
|
|
97
52
|
end
|
|
98
53
|
|
|
99
|
-
duration = Time.now - start_time
|
|
100
54
|
{
|
|
101
|
-
ok:
|
|
102
|
-
exit_code: exit_code,
|
|
103
|
-
stdout:
|
|
104
|
-
stderr:
|
|
105
|
-
output: [
|
|
106
|
-
duration: duration
|
|
55
|
+
ok: res[:ok],
|
|
56
|
+
exit_code: res[:exit_code],
|
|
57
|
+
stdout: res[:stdout],
|
|
58
|
+
stderr: res[:stderr],
|
|
59
|
+
output: res[:output],
|
|
60
|
+
duration: res[:duration],
|
|
107
61
|
driver: "docker"
|
|
108
62
|
}
|
|
109
63
|
end
|
|
@@ -16,7 +16,6 @@ module Rubik
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def run(cmd, timeout: 15, chdir: Dir.pwd, allow_network: false, memory_limit_mb: 512, cpus: "1.0")
|
|
19
|
-
start_time = Time.now
|
|
20
19
|
image = @options[:image] || DEFAULT_IMAGE
|
|
21
20
|
workspace = File.expand_path(chdir)
|
|
22
21
|
|
|
@@ -32,63 +31,21 @@ module Rubik
|
|
|
32
31
|
podman_args.push("--network", "none") unless allow_network
|
|
33
32
|
podman_args.push(image, "sh", "-c", cmd)
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
res = Rubik::Platform.popen_capture(
|
|
35
|
+
*podman_args,
|
|
36
|
+
timeout: timeout,
|
|
37
|
+
chdir: workspace,
|
|
38
|
+
timeout_message: "Podman sandbox execution timed out after #{timeout} seconds",
|
|
39
|
+
error_message: "Podman sandbox error"
|
|
40
|
+
)
|
|
38
41
|
|
|
39
|
-
begin
|
|
40
|
-
Timeout.timeout(timeout) do
|
|
41
|
-
Open3.popen3(*podman_args) do |stdin, stdout, stderr, wait_thr|
|
|
42
|
-
begin
|
|
43
|
-
stdin.close
|
|
44
|
-
rescue
|
|
45
|
-
nil
|
|
46
|
-
end
|
|
47
|
-
t_out = Thread.new do
|
|
48
|
-
stdout.read
|
|
49
|
-
rescue
|
|
50
|
-
""
|
|
51
|
-
end
|
|
52
|
-
t_err = Thread.new do
|
|
53
|
-
stderr.read
|
|
54
|
-
rescue
|
|
55
|
-
""
|
|
56
|
-
end
|
|
57
|
-
begin
|
|
58
|
-
stdout_str = t_out.value.to_s
|
|
59
|
-
stderr_str = t_err.value.to_s
|
|
60
|
-
status = wait_thr.value
|
|
61
|
-
exit_code = status.exitstatus || (status.signaled? ? (128 + (status.termsig || 1)) : 1)
|
|
62
|
-
ensure
|
|
63
|
-
begin
|
|
64
|
-
t_out.kill
|
|
65
|
-
rescue
|
|
66
|
-
nil
|
|
67
|
-
end
|
|
68
|
-
begin
|
|
69
|
-
t_err.kill
|
|
70
|
-
rescue
|
|
71
|
-
nil
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
rescue Timeout::Error
|
|
77
|
-
stderr_str = "Podman sandbox execution timed out after #{timeout} seconds"
|
|
78
|
-
exit_code = 124
|
|
79
|
-
rescue StandardError => e
|
|
80
|
-
stderr_str = "Podman sandbox error: #{e.message}"
|
|
81
|
-
exit_code = 1
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
duration = Time.now - start_time
|
|
85
42
|
{
|
|
86
|
-
ok:
|
|
87
|
-
exit_code: exit_code,
|
|
88
|
-
stdout:
|
|
89
|
-
stderr:
|
|
90
|
-
output: [
|
|
91
|
-
duration: duration
|
|
43
|
+
ok: res[:ok],
|
|
44
|
+
exit_code: res[:exit_code],
|
|
45
|
+
stdout: res[:stdout],
|
|
46
|
+
stderr: res[:stderr],
|
|
47
|
+
output: res[:output],
|
|
48
|
+
duration: res[:duration],
|
|
92
49
|
driver: "podman"
|
|
93
50
|
}
|
|
94
51
|
end
|
|
@@ -15,80 +15,28 @@ module Rubik
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def run(cmd, timeout: 15, chdir: Dir.pwd, allow_network: false, memory_limit_mb: 512, cpus: "1.0") # rubocop:disable Lint/UnusedMethodArgument -- sürücü arayüzü kwarg'leri korunuyor
|
|
18
|
-
start_time = Time.now
|
|
19
|
-
stdout_str = ""
|
|
20
|
-
stderr_str = ""
|
|
21
|
-
exit_code = 127
|
|
22
|
-
|
|
23
|
-
# Explicit-argv spawn (Plan 55 D-1): never leave interpreter choice
|
|
24
|
-
# to Open3's string form — on Windows that silently means cmd.exe.
|
|
25
18
|
resolved = ShellResolver.resolve(cmd, preference: @options[:shell] || "auto")
|
|
26
|
-
|
|
27
19
|
env = {}
|
|
28
|
-
# Direct host execution does NOT inject misleading dead proxy variables (127.0.0.1:9999).
|
|
29
|
-
# Hardware/namespace network isolation is only provided when a true sandbox
|
|
30
|
-
# (Bubblewrap, Docker, Podman) is enabled.
|
|
31
20
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
stdin.close
|
|
39
|
-
rescue
|
|
40
|
-
nil
|
|
41
|
-
end
|
|
42
|
-
t_out = Thread.new do
|
|
43
|
-
stdout.read
|
|
44
|
-
rescue
|
|
45
|
-
""
|
|
46
|
-
end
|
|
47
|
-
t_err = Thread.new do
|
|
48
|
-
stderr.read
|
|
49
|
-
rescue
|
|
50
|
-
""
|
|
51
|
-
end
|
|
52
|
-
begin
|
|
53
|
-
stdout_str = t_out.value.to_s
|
|
54
|
-
stderr_str = t_err.value.to_s
|
|
55
|
-
status = wait_thr.value
|
|
56
|
-
exit_code = status.exitstatus || (status.signaled? ? (128 + (status.termsig || 1)) : 1)
|
|
57
|
-
ensure
|
|
58
|
-
begin
|
|
59
|
-
t_out.kill
|
|
60
|
-
rescue
|
|
61
|
-
nil
|
|
62
|
-
end
|
|
63
|
-
begin
|
|
64
|
-
t_err.kill
|
|
65
|
-
rescue
|
|
66
|
-
nil
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
rescue Timeout::Error
|
|
72
|
-
Rubik::Platform.kill_tree(pid) if pid
|
|
73
|
-
stderr_str = "Execution timed out after #{timeout} seconds"
|
|
74
|
-
exit_code = 124
|
|
75
|
-
rescue StandardError => e
|
|
76
|
-
stderr_str = "Execution error: #{e.message}"
|
|
77
|
-
exit_code = 1
|
|
78
|
-
end
|
|
21
|
+
res = Rubik::Platform.popen_capture(
|
|
22
|
+
env,
|
|
23
|
+
*spawn_args_for(resolved, cmd),
|
|
24
|
+
timeout: timeout,
|
|
25
|
+
chdir: chdir
|
|
26
|
+
)
|
|
79
27
|
|
|
80
|
-
|
|
81
|
-
stderr_out = resolved[:note] ? "#{resolved[:note]}\n#{stderr_str}" : stderr_str
|
|
28
|
+
stderr_out = resolved[:note] ? "#{resolved[:note]}\n#{res[:stderr]}" : res[:stderr]
|
|
82
29
|
require_relative "../../security/redactor" unless defined?(Rubik::Security::Redactor)
|
|
83
|
-
clean_stdout = Rubik::Security::Redactor.redact(
|
|
30
|
+
clean_stdout = Rubik::Security::Redactor.redact(res[:stdout])
|
|
84
31
|
clean_stderr = Rubik::Security::Redactor.redact(stderr_out)
|
|
32
|
+
|
|
85
33
|
{
|
|
86
|
-
ok:
|
|
87
|
-
exit_code: exit_code,
|
|
34
|
+
ok: res[:ok],
|
|
35
|
+
exit_code: res[:exit_code],
|
|
88
36
|
stdout: clean_stdout,
|
|
89
37
|
stderr: clean_stderr,
|
|
90
38
|
output: [clean_stdout, clean_stderr].reject(&:empty?).join("\n"),
|
|
91
|
-
duration: duration
|
|
39
|
+
duration: res[:duration],
|
|
92
40
|
driver: "process",
|
|
93
41
|
shell: resolved[:shell]
|
|
94
42
|
}
|
|
@@ -59,6 +59,25 @@ module Rubik
|
|
|
59
59
|
"If genuinely required and safe, the operator can allowlist it once:",
|
|
60
60
|
" !allow-cmd add #{quoted}"
|
|
61
61
|
]
|
|
62
|
+
elsif kind == :classifier_error
|
|
63
|
+
# AST walk crashed (fail-closed): the command was never
|
|
64
|
+
# classified, so no rule matched — blame-free guidance
|
|
65
|
+
# plus the explicit statement that AST evaluation failed.
|
|
66
|
+
[
|
|
67
|
+
"Security blocked: #{message}",
|
|
68
|
+
"The command AST could not be evaluated (classifier error), so the command was not classified.",
|
|
69
|
+
"If genuinely required and safe, the operator can allowlist the exact simple command once:",
|
|
70
|
+
" !allow-cmd add #{quoted}"
|
|
71
|
+
]
|
|
72
|
+
elsif kind == :parse_ambiguous
|
|
73
|
+
# Tree-sitter reported syntax errors: a partial verdict is
|
|
74
|
+
# not a verdict — the command was blocked unparsed.
|
|
75
|
+
[
|
|
76
|
+
"Security blocked: #{message}",
|
|
77
|
+
"The command AST could not be fully parsed (tree-sitter syntax error), so the command was not classified.",
|
|
78
|
+
"If genuinely required and safe, the operator can allowlist it once:",
|
|
79
|
+
" !allow-cmd add #{quoted}"
|
|
80
|
+
]
|
|
62
81
|
elsif cmd && err.respond_to?(:probe_like) && err.probe_like
|
|
63
82
|
# Probe branch first: `python3 --version` is probe-shaped and
|
|
64
83
|
# must not be hijacked by the text-munging variant below.
|
data/lib/rubik/security/guard.rb
CHANGED
|
@@ -109,6 +109,13 @@ module Rubik
|
|
|
109
109
|
# whose ONLY arguments are these flags is read-only by construction.
|
|
110
110
|
SAFE_PROBE_FLAGS = ["--version", "-v", "--help", "-h"].freeze
|
|
111
111
|
|
|
112
|
+
# Shell metacharacters that make a raw (AST-less) token-prefix allowlist
|
|
113
|
+
# match dishonest: they can join, redirect, substitute, or line-split
|
|
114
|
+
# the string, so "the first tokens match" would not mean "the whole
|
|
115
|
+
# simple command matches". The classifier-crash rescue only accepts a
|
|
116
|
+
# single simple command; anything else fails closed.
|
|
117
|
+
RAW_ALLOWLIST_UNSAFE_PATTERN = /[;&|<>`$(){}\[\]\\\n\r]/
|
|
118
|
+
|
|
112
119
|
attr_reader :audit_log_path
|
|
113
120
|
|
|
114
121
|
# Live-reconfigurable audit destination (ConfigWiring applies config
|
|
@@ -486,16 +493,55 @@ module Rubik
|
|
|
486
493
|
)
|
|
487
494
|
end
|
|
488
495
|
|
|
496
|
+
# Ambiguous parse (tree-sitter syntax errors) is a classification
|
|
497
|
+
# failure, not a pass: the walk cannot vouch for the whole
|
|
498
|
+
# expression, so a safe-looking partial verdict must not fall
|
|
499
|
+
# through to the catastrophic-only regex gate. The allowlist stays
|
|
500
|
+
# the valve because a real AST exists — every segment is checked
|
|
501
|
+
# against the operator entries.
|
|
502
|
+
if !allow_unknown && ast.parse_warnings.to_a.any?
|
|
503
|
+
allowlisted = begin
|
|
504
|
+
command_allowlisted?(ast, cmd_str: cmd_str)
|
|
505
|
+
rescue StandardError => e
|
|
506
|
+
warn "[Guard] allowlist evaluation failed, failing closed: #{e.class}: #{e.message}"
|
|
507
|
+
false
|
|
508
|
+
end
|
|
509
|
+
if allowlisted
|
|
510
|
+
log_audit("allowed_command", command: cmd_str, reason: "operator_allowlist")
|
|
511
|
+
verify_command_fallback_regex!(cmd_str)
|
|
512
|
+
return true
|
|
513
|
+
end
|
|
514
|
+
log_audit("blocked_command", command: cmd_str, reason: "parse_ambiguous",
|
|
515
|
+
parse_warnings: ast.parse_warnings.to_a.size)
|
|
516
|
+
raise BlockedWriteError.new(
|
|
517
|
+
"Command AST could not be fully parsed (tree-sitter syntax error); execution blocked (fail-closed): #{cmd_str}",
|
|
518
|
+
kind: :parse_ambiguous,
|
|
519
|
+
blocked_command: cmd_str,
|
|
520
|
+
rule_name: "parse_ambiguous",
|
|
521
|
+
probe_like: probe_shape?(cmd_str),
|
|
522
|
+
segment_report: nil
|
|
523
|
+
)
|
|
524
|
+
end
|
|
525
|
+
|
|
489
526
|
return true if [:readonly, :local_effects, :external_effects].include?(ast.classification) && ast.parse_warnings.empty?
|
|
490
527
|
rescue BlockedWriteError
|
|
491
528
|
raise
|
|
492
529
|
rescue StandardError => e
|
|
493
|
-
# Unconditional warn + audit trail: a classifier crash
|
|
494
|
-
#
|
|
495
|
-
#
|
|
496
|
-
#
|
|
497
|
-
|
|
498
|
-
|
|
530
|
+
# Unconditional warn + audit trail: a classifier crash is a
|
|
531
|
+
# security-observable downgrade nobody should need RUBIK_DEBUG to
|
|
532
|
+
# notice. The shell gate fails closed — an exception must never
|
|
533
|
+
# mint execution permission; the operator allowlist is the one
|
|
534
|
+
# sanctioned valve. The synthesis seam (allow_unknown) keeps
|
|
535
|
+
# flowing into its own deny layers (DANGEROUS_SYNTHESIS_PATTERNS
|
|
536
|
+
# + RubyVM walk), which run after this gate.
|
|
537
|
+
if allow_unknown
|
|
538
|
+
warn "[WARN] AST classification error on non-shell input, continuing to synthesis deny layers: #{e.message}"
|
|
539
|
+
log_audit("classifier_error", command: cmd_str, reason: "#{e.class}: #{e.message}",
|
|
540
|
+
decision: "synthesis_layers")
|
|
541
|
+
else
|
|
542
|
+
warn "[WARN] AST classification error, failing closed: #{e.class}: #{e.message}"
|
|
543
|
+
block_on_classifier_failure!(cmd_str, e)
|
|
544
|
+
end
|
|
499
545
|
end
|
|
500
546
|
|
|
501
547
|
verify_command_fallback_regex!(cmd_str)
|
|
@@ -685,6 +731,69 @@ module Rubik
|
|
|
685
731
|
report ? report[:segments].size : 0
|
|
686
732
|
end
|
|
687
733
|
|
|
734
|
+
# Classifier-crash gate for the shell path: structured block by
|
|
735
|
+
# default, one sanctioned rescue — an operator allowlist entry
|
|
736
|
+
# covering the exact single-command prefix (the same explicit
|
|
737
|
+
# authorization contract as the unknown-command gate). The
|
|
738
|
+
# catastrophic fallback regex still runs afterwards on the rescue
|
|
739
|
+
# path; on the block path it gets first refusal so a catastrophic
|
|
740
|
+
# command keeps its precise :dangerous_pattern reason.
|
|
741
|
+
def block_on_classifier_failure!(cmd_str, error)
|
|
742
|
+
allowlisted = begin
|
|
743
|
+
command_allowlisted_raw?(cmd_str)
|
|
744
|
+
rescue StandardError => e
|
|
745
|
+
warn "[Guard] raw allowlist evaluation failed, failing closed: #{e.class}: #{e.message}"
|
|
746
|
+
false
|
|
747
|
+
end
|
|
748
|
+
log_audit("classifier_error", command: cmd_str, reason: "#{error.class}: #{error.message}",
|
|
749
|
+
decision: allowlisted ? "operator_allowlist" : "blocked")
|
|
750
|
+
|
|
751
|
+
if allowlisted
|
|
752
|
+
log_audit("allowed_command", command: cmd_str, reason: "operator_allowlist_classifier_error")
|
|
753
|
+
return
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
verify_command_fallback_regex!(cmd_str)
|
|
757
|
+
log_audit("blocked_command", command: cmd_str, reason: "classifier_error")
|
|
758
|
+
raise BlockedWriteError.new(
|
|
759
|
+
"AST evaluation failed, so the command could not be classified; execution blocked (fail-closed): #{cmd_str}",
|
|
760
|
+
kind: :classifier_error,
|
|
761
|
+
blocked_command: cmd_str,
|
|
762
|
+
rule_name: "classifier_error",
|
|
763
|
+
probe_like: probe_shape?(cmd_str),
|
|
764
|
+
segment_report: nil
|
|
765
|
+
)
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# Token-prefix allowlist check for an input whose AST does not exist
|
|
769
|
+
# (classifier crashed). Only a single simple command qualifies: with
|
|
770
|
+
# shell metacharacters present the string may hold several segments
|
|
771
|
+
# and the gate's "every segment must match" contract cannot be
|
|
772
|
+
# honored — fail closed instead of over-promising the prefix match.
|
|
773
|
+
# Quoting is handled by Shellwords; an unparseable string (unbalanced
|
|
774
|
+
# quote) fails closed.
|
|
775
|
+
def command_allowlisted_raw?(cmd_str)
|
|
776
|
+
return false if @command_allowlist.empty?
|
|
777
|
+
return false if cmd_str.match?(RAW_ALLOWLIST_UNSAFE_PATTERN)
|
|
778
|
+
|
|
779
|
+
tokens = nil
|
|
780
|
+
begin
|
|
781
|
+
tokens = Shellwords.split(cmd_str)
|
|
782
|
+
rescue ArgumentError
|
|
783
|
+
return false
|
|
784
|
+
end
|
|
785
|
+
return false if tokens.empty?
|
|
786
|
+
return false if Rubik::Security::Classifier::Matcher::BUILTIN_DANGEROUS_CMDS.include?(tokens[0].to_s)
|
|
787
|
+
|
|
788
|
+
@command_allowlist.any? do |entry|
|
|
789
|
+
next false if entry.length > tokens.length
|
|
790
|
+
|
|
791
|
+
entry.each_with_index.all? do |token, i|
|
|
792
|
+
i.zero? ? binary_allowlist_match?(tokens[i], token) : tokens[i] == token
|
|
793
|
+
end
|
|
794
|
+
end
|
|
795
|
+
end
|
|
796
|
+
|
|
688
797
|
def verify_command_fallback_regex!(cmd_str)
|
|
689
798
|
return true unless DANGEROUS_COMMAND_PATTERNS.any? { |p| cmd_str.match?(p) }
|
|
690
799
|
|
|
@@ -699,7 +808,7 @@ module Rubik
|
|
|
699
808
|
|
|
700
809
|
# ── Synthesis capability table (Candidate 07) ───────────────────────
|
|
701
810
|
# The ONE declaration point for everything the synthesis AST walk
|
|
702
|
-
# denies (
|
|
811
|
+
# denies (docs/explanation/architecture.md §2 "single declaration point" doctrine; §8.3
|
|
703
812
|
# "the seam is enforced where it is declared"). Each row is a capability
|
|
704
813
|
# a synthesized tool must never exercise; layer fields say WHERE the
|
|
705
814
|
# denial bites:
|