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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +5 -1
  3. data/CHANGELOG.md +169 -0
  4. data/README.md +137 -255
  5. data/bin/rubik +9 -0
  6. data/lib/rubik/ast/extraction_result.rb +37 -0
  7. data/lib/rubik/ast/extractor.rb +35 -9
  8. data/lib/rubik/ast/graph.rb +27 -1
  9. data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
  10. data/lib/rubik/ast/treesitter_extractor.rb +45 -17
  11. data/lib/rubik/buffer/apply_contract.rb +78 -0
  12. data/lib/rubik/buffer/manager.rb +61 -5
  13. data/lib/rubik/cli/workspace_trust.rb +78 -0
  14. data/lib/rubik/concurrency/event_loop.rb +56 -44
  15. data/lib/rubik/concurrency/scheduler.rb +7 -24
  16. data/lib/rubik/context/git_lister.rb +6 -1
  17. data/lib/rubik/context/scanner.rb +15 -3
  18. data/lib/rubik/kernel/init.rb +15 -0
  19. data/lib/rubik/mcp/transport/stdio.rb +14 -11
  20. data/lib/rubik/orchestrator/auto_apply.rb +1 -1
  21. data/lib/rubik/orchestrator/subagent/activation.rb +29 -4
  22. data/lib/rubik/orchestrator/subagent/builtin_types.rb +117 -0
  23. data/lib/rubik/orchestrator/subagent/dynamic_store.rb +119 -0
  24. data/lib/rubik/orchestrator/subagent/instance.rb +8 -36
  25. data/lib/rubik/orchestrator/subagent/journal.rb +77 -4
  26. data/lib/rubik/orchestrator/subagent/profile_contract.rb +187 -0
  27. data/lib/rubik/orchestrator/subagent/profile_snapshot.rb +72 -0
  28. data/lib/rubik/orchestrator/subagent/reuse.rb +2 -1
  29. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +2 -0
  30. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +170 -0
  31. data/lib/rubik/orchestrator/subagent/start_gate.rb +98 -0
  32. data/lib/rubik/orchestrator/subagent/supervisor.rb +4 -67
  33. data/lib/rubik/orchestrator/subagent/type_definition.rb +84 -0
  34. data/lib/rubik/orchestrator/subagent/type_registry.rb +139 -159
  35. data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +4 -2
  36. data/lib/rubik/platform/process.rb +152 -3
  37. data/lib/rubik/plugin/manager.rb +148 -71
  38. data/lib/rubik/plugin/package_inventory.rb +84 -0
  39. data/lib/rubik/plugin/requires_dag.rb +40 -0
  40. data/lib/rubik/plugin/storage.rb +100 -14
  41. data/lib/rubik/prompt/context_builder.rb +54 -20
  42. data/lib/rubik/prompt/doctrine.rb +158 -22
  43. data/lib/rubik/prompt/token_estimator.rb +5 -3
  44. data/lib/rubik/prompt/tool_teachings.rb +19 -12
  45. data/lib/rubik/repl/commands/buffer.rb +28 -18
  46. data/lib/rubik/runtime/repair_probe.rb +98 -0
  47. data/lib/rubik/sandbox/driver/bubblewrap.rb +13 -59
  48. data/lib/rubik/sandbox/driver/docker.rb +14 -60
  49. data/lib/rubik/sandbox/driver/podman.rb +13 -56
  50. data/lib/rubik/sandbox/driver/process.rb +12 -64
  51. data/lib/rubik/security/blocked_guidance.rb +19 -0
  52. data/lib/rubik/security/guard.rb +116 -7
  53. data/lib/rubik/storage/store.rb +52 -11
  54. data/lib/rubik/tools/builtins/define_subagent.rb +69 -0
  55. data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
  56. data/lib/rubik/tools/builtins/storage.rb +8 -5
  57. data/lib/rubik/tools/builtins/subagent.rb +20 -53
  58. data/lib/rubik/tools/builtins/synthesis.rb +6 -1
  59. data/lib/rubik/tools/builtins.rb +1 -0
  60. data/lib/rubik/tools/diagnostic_reporter.rb +2 -1
  61. data/lib/rubik/version.rb +1 -1
  62. 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
- stdout_str = ""
46
- stderr_str = ""
47
- exit_code = 127
48
- pid = nil
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: exit_code.zero?,
100
- exit_code: exit_code,
101
- stdout: stdout_str,
102
- stderr: stderr_str,
103
- output: [stdout_str, stderr_str].reject(&:empty?).join("\n"),
104
- duration: duration.round(4),
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
- stdout_str = ""
44
- stderr_str = ""
45
- exit_code = 127
46
- wait_thr_ref = nil
47
-
48
- begin
49
- Timeout.timeout(timeout) do
50
- Open3.popen3(*docker_args) do |stdin, stdout, stderr, wait_thr|
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: exit_code.zero?,
102
- exit_code: exit_code,
103
- stdout: stdout_str,
104
- stderr: stderr_str,
105
- output: [stdout_str, stderr_str].reject(&:empty?).join("\n"),
106
- duration: duration.round(4),
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
- stdout_str = ""
36
- stderr_str = ""
37
- exit_code = 127
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: exit_code.zero?,
87
- exit_code: exit_code,
88
- stdout: stdout_str,
89
- stderr: stderr_str,
90
- output: [stdout_str, stderr_str].reject(&:empty?).join("\n"),
91
- duration: duration.round(4),
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
- pid = nil
33
- begin
34
- Timeout.timeout(timeout) do
35
- Open3.popen3(env, *spawn_args_for(resolved, cmd), chdir: chdir) do |stdin, stdout, stderr, wait_thr|
36
- pid = wait_thr.pid
37
- begin
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
- duration = Time.now - start_time
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(stdout_str)
30
+ clean_stdout = Rubik::Security::Redactor.redact(res[:stdout])
84
31
  clean_stderr = Rubik::Security::Redactor.redact(stderr_out)
32
+
85
33
  {
86
- ok: exit_code.zero?,
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.round(4),
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.
@@ -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 downgrades
494
- # this verification to the narrow fallback regex, silently skipping
495
- # allowlist/probe logic — a security-observable downgrade nobody
496
- # should need RUBIK_DEBUG to notice.
497
- warn "[WARN] AST classification error, falling back to regex gate: #{e.message}"
498
- log_audit("classifier_error", command: cmd_str, reason: "#{e.class}: #{e.message}")
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 (ARCHITECTURE.md §2 "single declaration point" doctrine; §8.3
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: