rubik 0.3.3 → 0.3.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29ee7f0d84218a3bf3d2fe0699c2c37d7f260f6406231a46338f169410ae5c60
4
- data.tar.gz: a600c600071a3267b8ab4cdfd0b2f755a2068f2cd8289949ae57e41383355d1b
3
+ metadata.gz: 220e106421fc0a5692890a2c3f3e7ba2296661c5ce60a96f62a3f8d5fcaa4c0a
4
+ data.tar.gz: 1a287e603bb553b11ebc1e0187f4a0d00e8e6b17a68c005311bc144635a1696a
5
5
  SHA512:
6
- metadata.gz: fabc3ee65ba68ea6244727ba265232c1b05ac2bd06eee192a376dbfc6b6da746070bac6f129b750011803c2945f092096b5cd122065dd79bde224fcf9018892f
7
- data.tar.gz: 30313695e55c4ae537d9d3acb27b1dbc7365d6db817e93b487e7612ce7c2921235deb7e92db6b2232e33b71569b65e1190f9c8c948bef8090fce093412fcc14c
6
+ metadata.gz: c02dbe25a9cc8a3af6a4c24187fc9115ad8ae5077c49e4842e2f03151ade01bedc4cc5e4709056a388a3934151b4a361422c30b1de92158e3d4c6d6d87c56743
7
+ data.tar.gz: db9835fd0913e3fad74ab68bdbd25137df95544579b2beed718237d09699e378fb2cbfde7da9341785f407d3aea9a43b98aedec34df1305278b93ddc3434adb6
data/CHANGELOG.md CHANGED
@@ -10,6 +10,54 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
10
10
 
11
11
  Nothing yet.
12
12
 
13
+ ## [0.3.4] - 2026-10-23
14
+
15
+ A security-hardening patch: three injection/traversal findings in
16
+ child-process enumeration, the docker sandbox driver and overlay
17
+ storage are closed, scan-heavy paths got faster, and a wave of
18
+ oversized REPL/LLM methods was decomposed with no behavior change.
19
+
20
+ ### Fixed
21
+
22
+ - **Command injection in child-process enumeration (#238):**
23
+ `direct_child_pids` interpolated the target pid into backtick
24
+ shells (`pgrep -P #{pid}` / `ps --ppid #{pid}`); both lookups now
25
+ run through array-parameterized `Open3.capture2` calls, so pid
26
+ values never reach a shell for re-parsing.
27
+ - **Command injection in the docker sandbox driver (#237):**
28
+ `DockerDriver#available?` and container cleanup built command
29
+ strings with interpolation (`docker rm -f #{container_name}`);
30
+ both now use array-form `system`, keeping shell metacharacters
31
+ inert.
32
+ - **Path traversal in OverlayStorage (#232):** overlay directories
33
+ are validated against an allowlist of the four known overlay dirs
34
+ resolved under their trusted roots, manifests and every
35
+ command/tool write path are containment-checked, and traversal
36
+ attempts are rejected loudly with a `[SECURITY]` warning instead
37
+ of escaping the overlay root.
38
+
39
+ ### Performance
40
+
41
+ - **Faster scanning internals (#220, #225, #227, #228, #244):**
42
+ storage indexing parses JSON lines with less string churn;
43
+ `Session::Replayer` streams with `File.foreach`;
44
+ `Buffer::Manager#all_files` prunes `.git` traversal;
45
+ `FuzzyMatcher#find_closest_subsequence` tightens its inner loop;
46
+ and `clean_utf8_deep` gains an allocation-free fast path
47
+ (Symbol#name validation, Hash-based cycle tracking, ~3.3x on
48
+ clean input). `Buffer::Manager#diff_all` also reads through
49
+ `Platform.read_text`, so on-disk content is UTF-8-tagged
50
+ consistently (#226).
51
+
52
+ ### Changed
53
+
54
+ - **No-behavior-change decompositions (#231, #233, #234, #235,
55
+ #239, #240, #242, #243):** oversized REPL command registrations
56
+ (workspace allow-list, goal, agent providers, allow-cmd, LSP),
57
+ the LLM client's `post_json`, `GoalLedger::Patch#normalize_op`
58
+ and config `Defaults` scalar validation were split into helper
59
+ methods — same behavior, reviewable units.
60
+
13
61
  ## [0.3.3] - 2026-10-21
14
62
 
15
63
  A field-incident patch: subagent runs get a lifetime step ceiling,
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "find"
4
5
  require "digest"
5
6
  require "pathname"
6
7
  require "monitor"
@@ -265,10 +266,27 @@ module Rubik
265
266
  # for callers that only need a fuzzy-match candidate list.
266
267
  def all_files
267
268
  buffer_keys = @monitor.synchronize { @buffer.keys }
269
+ return buffer_keys unless File.directory?(@root_dir)
270
+
268
271
  disk_files = begin
269
- Dir.glob("**/*", File::FNM_DOTMATCH, base: @root_dir).reject do |f|
270
- f.start_with?(".git") || File.directory?(File.expand_path(f, @root_dir))
272
+ files = []
273
+ root_size = @root_dir.bytesize
274
+ Find.find(@root_dir) do |path|
275
+ next if path.bytesize == root_size
276
+
277
+ rel = path.byteslice((root_size + 1)..-1)
278
+ rel = rel.tr("\\", "/") if File::ALT_SEPARATOR
279
+
280
+ if rel.start_with?(".git")
281
+ Find.prune if File.directory?(path)
282
+ next
283
+ end
284
+
285
+ next if File.directory?(path)
286
+
287
+ files << rel
271
288
  end
289
+ files
272
290
  rescue SystemCallError
273
291
  []
274
292
  end
@@ -282,7 +300,7 @@ module Rubik
282
300
 
283
301
  diffs = snapshot.map do |rel_path, new_content|
284
302
  full_path = File.expand_path(rel_path, @root_dir)
285
- orig_content = File.exist?(full_path) ? Rubik::Platform.normalize_newlines(File.read(full_path)) : ""
303
+ orig_content = File.exist?(full_path) ? Rubik::Platform.normalize_newlines(Rubik::Platform.read_text(full_path)) : ""
286
304
  Diff.unified_diff(rel_path, orig_content, new_content)
287
305
  end
288
306
 
@@ -162,14 +162,10 @@ module Rubik
162
162
  "maestro_gate" => true,
163
163
  "auto_pause_on_network_error" => true,
164
164
  "reactive_wakeup" => true,
165
- # Vakanüvis arka plan kronikçisi (storage/vakanuvis; plan
166
- # 2026-10-04 K1-K9): deterministik annal + wiki derleyicisi.
167
- # enabled: Düzlem 1 harness'i kur/kurulama; distiller: Düzlem 2
168
- # LLM doğumu (token üst sınırı flush_events x max_steps);
169
- # flush_events: damıtıcı doğumu için olay eşiği; model: nil =
170
- # root provider; retention: supersede (iz bırak) | purge (sil);
171
- # root_turns: REPL kök-turn'leri de annal'a yazılsın mı
172
- # (root-turn planı 2026-10-04; kapalıyken abonelik kurulmaz).
165
+ # Vakanüvis arka plan kronikçisi (plan 2026-10-04 K1-K9): deterministik
166
+ # annal + wiki derleyicisi. enabled: harness kur/kurulama; distiller:
167
+ # Düzlem 2 LLM doğumu (bütçe: flush_events x max_steps); model: nil =
168
+ # root provider; retention: supersede|purge; root_turns: kök-turn annal'ı.
173
169
  "vakanuvis" => {
174
170
  "enabled" => true,
175
171
  "distiller" => true,
@@ -179,18 +175,12 @@ module Rubik
179
175
  "retention" => "supersede",
180
176
  "root_turns" => true
181
177
  },
182
- # Pruner (repl/pruner): yarı deterministik KÖK pencere temizleyicisi.
183
- # DEFAULT ON (kullanıcı kararı 2026-10-10) — deterministik yığın
184
- # (trim_conversation_window! / ContextCompactor / EmergencyCompactor)
185
- # her zaman güvenlik ağıdır; subagent kanalları bu özellikten asla
186
- # etkilenmez; opt-out: "enabled" => false (kill-switch). enabled:
187
- # harness'i kur (prune_context aracı + !prune komutu açılır);
188
- # trigger_ratio: turn sonu pencere tahmini girdi tavanının bu
189
- # oranına ulaşınca LLM budayıcı doğar (0.85'lik kör trim'den ÖNCE
190
- # anlamsal sıkıştırma); target_ratio: plan yalnızca sonuç bu
191
- # orana sığıyorsa uygulanır; max_steps/model: budayıcı bütçesi
192
- # (nil = root provider); min_turns_between: otomatik doğum oranı
193
- # sınırlayıcısı (turn sayısı).
178
+ # Pruner (repl/pruner): yarı deterministik KÖK pencere temizleyicisi,
179
+ # DEFAULT ON (kullanıcı kararı 2026-10-10; kill-switch: enabled=false).
180
+ # trigger_ratio: tahmini girdi tavanının bu oranında LLM budayıcı doğar
181
+ # (kör trim'den ÖNCE anlamsal sıkıştırma); target_ratio: plan sadece
182
+ # sonuca sığıyorsa uygulanır; max_steps/model: budayıcı bütçesi (nil =
183
+ # root provider); min_turns_between: otomatik doğum oranı sınırlayıcısı.
194
184
  "pruner" => {
195
185
  "enabled" => true,
196
186
  "trigger_ratio" => 0.6,
@@ -216,6 +206,12 @@ module Rubik
216
206
 
217
207
  private
218
208
 
209
+ def validate_prompt_grace_value!(key, value)
210
+ return unless key == "ui.prompt_grace_window_ms" && !(value.is_a?(Integer) && value >= 0)
211
+
212
+ raise ArgumentError, "ui.prompt_grace_window_ms must be a non-negative integer (milliseconds)"
213
+ end
214
+
219
215
  def validate_schema(config, schema = DEFAULT_CONFIG, prefix = "", strict: false)
220
216
  raise ArgumentError, "Configuration must be a Hash, got #{config.class}" unless config.is_a?(Hash)
221
217
 
data/lib/rubik/config.rb CHANGED
@@ -163,12 +163,6 @@ module Rubik
163
163
 
164
164
  private
165
165
 
166
- def validate_prompt_grace_value!(key, value)
167
- return unless key == "ui.prompt_grace_window_ms" && !(value.is_a?(Integer) && value >= 0)
168
-
169
- raise ArgumentError, "ui.prompt_grace_window_ms must be a non-negative integer (milliseconds)"
170
- end
171
-
172
166
  # save! returns itself for no-write scopes; persist outcomes are hashes.
173
167
  def persist_result(res)
174
168
  res.is_a?(Hash) ? res : { ok: true }
@@ -234,87 +234,118 @@ module Rubik
234
234
  )
235
235
  end
236
236
 
237
- def self.post_json(url_str, headers: {}, payload: {}, timeout: 30, open_timeout: 10,
238
- max_retries: 2, max_429_retries: 5, backoff_base: 0.2, max_wall_clock: 120,
239
- on_retry: nil, http_factory: nil)
240
- begin
241
- uri = URI.parse(url_str.to_s)
242
- rescue URI::InvalidURIError => e
243
- raise ClientError, "Invalid endpoint URL '#{url_str}': #{e.message}"
244
- end
245
-
237
+ def self.parse_and_validate_uri(url_str)
238
+ uri = URI.parse(url_str.to_s)
246
239
  unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
247
240
  raise ClientError, "Invalid endpoint URL '#{url_str}'. URL must start with http:// or https:// (e.g. https://api.openai.com/v1/chat/completions)"
248
241
  end
249
242
 
243
+ uri
244
+ rescue URI::InvalidURIError => e
245
+ raise ClientError, "Invalid endpoint URL '#{url_str}': #{e.message}"
246
+ end
247
+
248
+ def self.perform_post_request(uri, headers, payload, timeout:, open_timeout:, http_factory:)
249
+ if http_factory
250
+ http = http_factory.call(uri)
251
+ req = build_post_request(uri, headers, payload)
252
+ http.request(req)
253
+ else
254
+ with_pooled_connection(uri, timeout: timeout, open_timeout: open_timeout) do |http|
255
+ req = build_post_request(uri, headers, payload)
256
+ http.request(req)
257
+ end
258
+ end
259
+ end
260
+
261
+ # Rate limiting is transitory and self-healing — it gets its own
262
+ # larger budget (max_429_retries), a Retry-After-aware delay, and
263
+ # a typed error when exhausted so callers can report "provider
264
+ # throttled" distinctly. The generic 5xx budget is untouched, so
265
+ # one 429 burst cannot exhaust the transport-failure budget.
266
+ def self.handle_429_retry(res, rate_limit_retries:, max_429_retries:, start_time:, backoff_base:, max_wall_clock:, on_retry:)
267
+ if rate_limit_retries < max_429_retries
268
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
269
+ delay = retry_delay_after(res, rate_limit_retries + 1, backoff_base)
270
+ if elapsed + delay <= max_wall_clock
271
+ rate_limit_retries += 1
272
+ on_retry&.call(rate_limit_retries, max_429_retries, "HTTP 429")
273
+ sleep(delay)
274
+ return rate_limit_retries
275
+ end
276
+ end
277
+ raise rate_limit_error(res, rate_limit_retries)
278
+ end
279
+
280
+ def self.attempt_backoff_retry(retries:, max_retries:, start_time:, backoff_base:, max_wall_clock:, reason:, on_retry:)
281
+ return nil unless retries < max_retries
282
+
283
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
284
+ delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
285
+ return nil if elapsed + delay > max_wall_clock
286
+
287
+ retries += 1
288
+ on_retry&.call(retries, max_retries, reason)
289
+ sleep(delay)
290
+ retries
291
+ end
292
+
293
+ # A body without a charset arrives tagged ASCII-8BIT; embedding it
294
+ # raw makes every ClientError message binary and the UTF-8 output
295
+ # layer raises inside the REPL's rescue clause (masking the real
296
+ # HTTP error as Encoding::CompatibilityError).
297
+ # Exhausted 5xx is transport-class (provider outage) → transient;
298
+ # a 4xx is content/auth/moderation → permanent. One raise point
299
+ # for both, the flag decided by status (issue #106).
300
+ def self.parse_json_response(res, uri:, status:)
301
+ unless status >= 200 && status < 300
302
+ raise ClientError.new(
303
+ "HTTP Request failed with code #{res.code}: #{Rubik::Platform.safe_utf8(res.body)}",
304
+ transient: status >= 500
305
+ )
306
+ end
307
+
308
+ begin
309
+ JSON.parse(res.body)
310
+ rescue JSON::ParserError => e
311
+ raise ClientError, "Invalid JSON response from #{uri.host} (HTTP #{status}): #{e.message}"
312
+ end
313
+ end
314
+
315
+ def self.post_json(url_str, headers: {}, payload: {}, timeout: 30, open_timeout: 10,
316
+ max_retries: 2, max_429_retries: 5, backoff_base: 0.2, max_wall_clock: 120,
317
+ on_retry: nil, http_factory: nil)
318
+ uri = parse_and_validate_uri(url_str)
250
319
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
251
320
  retries = 0
252
321
  rate_limit_retries = 0
253
322
 
254
323
  loop do
255
- res =
256
- if http_factory
257
- http = http_factory.call(uri)
258
- req = build_post_request(uri, headers, payload)
259
- http.request(req)
260
- else
261
- with_pooled_connection(uri, timeout: timeout, open_timeout: open_timeout) do |http|
262
- req = build_post_request(uri, headers, payload)
263
- http.request(req)
264
- end
265
- end
266
-
324
+ res = perform_post_request(uri, headers, payload, timeout: timeout, open_timeout: open_timeout, http_factory: http_factory)
267
325
  status = res.code.to_i
268
326
 
269
327
  if status == 429
270
- # Rate limiting is transitory and self-healing — it gets its own
271
- # larger budget (max_429_retries), a Retry-After-aware delay, and
272
- # a typed error when exhausted so callers can report "provider
273
- # throttled" distinctly. The generic 5xx budget is untouched, so
274
- # one 429 burst cannot exhaust the transport-failure budget.
275
- if rate_limit_retries < max_429_retries
276
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
277
- delay = retry_delay_after(res, rate_limit_retries + 1, backoff_base)
278
- if elapsed + delay <= max_wall_clock
279
- rate_limit_retries += 1
280
- on_retry&.call(rate_limit_retries, max_429_retries, "HTTP 429")
281
- sleep(delay)
282
- next
283
- end
284
- end
285
- raise rate_limit_error(res, rate_limit_retries)
328
+ rate_limit_retries = handle_429_retry(
329
+ res, rate_limit_retries: rate_limit_retries, max_429_retries: max_429_retries,
330
+ start_time: start_time, backoff_base: backoff_base, max_wall_clock: max_wall_clock,
331
+ on_retry: on_retry
332
+ )
333
+ next
286
334
  end
287
335
 
288
- if (status >= 500) && (retries < max_retries)
289
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
290
- delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
291
- if elapsed + delay <= max_wall_clock
292
- retries += 1
293
- on_retry&.call(retries, max_retries, "HTTP #{status}")
294
- sleep(delay)
336
+ if status >= 500
337
+ new_retries = attempt_backoff_retry(
338
+ retries: retries, max_retries: max_retries, start_time: start_time,
339
+ backoff_base: backoff_base, max_wall_clock: max_wall_clock, reason: "HTTP #{status}",
340
+ on_retry: on_retry
341
+ )
342
+ if new_retries
343
+ retries = new_retries
295
344
  next
296
345
  end
297
346
  end
298
347
 
299
- unless status >= 200 && status < 300
300
- # A body without a charset arrives tagged ASCII-8BIT; embedding it
301
- # raw makes every ClientError message binary and the UTF-8 output
302
- # layer raises inside the REPL's rescue clause (masking the real
303
- # HTTP error as Encoding::CompatibilityError).
304
- # Exhausted 5xx is transport-class (provider outage) → transient;
305
- # a 4xx is content/auth/moderation → permanent. One raise point
306
- # for both, the flag decided by status (issue #106).
307
- raise ClientError.new(
308
- "HTTP Request failed with code #{res.code}: #{Rubik::Platform.safe_utf8(res.body)}",
309
- transient: status >= 500
310
- )
311
- end
312
-
313
- begin
314
- return JSON.parse(res.body)
315
- rescue JSON::ParserError => e
316
- raise ClientError, "Invalid JSON response from #{uri.host} (HTTP #{status}): #{e.message}"
317
- end
348
+ return parse_json_response(res, uri: uri, status: status)
318
349
  rescue ClientError
319
350
  raise
320
351
  rescue Net::ReadTimeout
@@ -323,15 +354,14 @@ module Rubik
323
354
  rescue Interrupt
324
355
  raise # rubocop:disable Lint/DuplicateBranch -- Ctrl+C yayilimi bilinçli olarak ayri tutuldu, even mid-backoff
325
356
  rescue *RETRYABLE_ERRORS => e
326
- if retries < max_retries
327
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
328
- delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
329
- if elapsed + delay <= max_wall_clock
330
- retries += 1
331
- on_retry&.call(retries, max_retries, e.class.name)
332
- sleep(delay)
333
- next
334
- end
357
+ new_retries = attempt_backoff_retry(
358
+ retries: retries, max_retries: max_retries, start_time: start_time,
359
+ backoff_base: backoff_base, max_wall_clock: max_wall_clock, reason: e.class.name,
360
+ on_retry: on_retry
361
+ )
362
+ if new_retries
363
+ retries = new_retries
364
+ next
335
365
  end
336
366
  raise ClientError.new(
337
367
  "Network error calling #{uri.host}: #{e.class} #{e.message} (gave up after #{retries} retr#{retries == 1 ? 'y' : 'ies'})",
@@ -121,81 +121,98 @@ module Rubik
121
121
  end
122
122
 
123
123
  def self.normalize_op(raw, idx)
124
- return [nil, [op_shape_reason(idx)]] unless raw.is_a?(Hash)
124
+ err = validate_op_keys(raw, idx)
125
+ return [nil, [err]] if err
126
+
127
+ verb, path, err = validate_op_verb_and_path(raw, idx)
128
+ return [nil, [err]] if err
129
+
130
+ tokens, err = tokenize(path, idx)
131
+ return [nil, [err]] if err
132
+
133
+ err = validate_op_value_and_hyphen(raw, verb, path, tokens, idx)
134
+ return [nil, [err]] if err
135
+
136
+ value = verb == "remove" ? nil : deep_freeze(raw["value"])
137
+ [{ op: verb.to_sym, path: path, tokens: tokens, value: value, index: idx }.freeze, []]
138
+ end
139
+
140
+ def self.validate_op_keys(raw, idx)
141
+ return op_shape_reason(idx) unless raw.is_a?(Hash)
125
142
 
126
143
  unknown = raw.keys - OP_KEYS
127
- unless unknown.empty?
128
- return [nil, [GoalLedger.reason(
129
- :schema, op_index: idx, path: "",
130
- message: "op #{idx} carries unknown key(s) #{unknown.join(', ')}",
131
- hint: "allowed op keys: op, path, value"
132
- )]]
133
- end
144
+ return nil if unknown.empty?
145
+
146
+ GoalLedger.reason(
147
+ :schema, op_index: idx, path: "",
148
+ message: "op #{idx} carries unknown key(s) #{unknown.join(', ')}",
149
+ hint: "allowed op keys: op, path, value"
150
+ )
151
+ end
134
152
 
153
+ def self.validate_op_verb_and_path(raw, idx)
135
154
  verb = raw["op"]
136
155
  unless OP_VERBS.include?(verb)
137
- return [nil, [GoalLedger.reason(
156
+ return nil, nil, GoalLedger.reason(
138
157
  :schema, op_index: idx, path: "",
139
158
  message: "op #{idx} has unknown verb #{GoalLedger.json_type_name(verb)}#{verb_inspect(verb)}",
140
159
  hint: "allowed verbs: add, replace, remove"
141
- )]]
160
+ )
142
161
  end
143
162
 
144
163
  path = raw["path"]
145
164
  unless path.is_a?(String)
146
- return [nil, [GoalLedger.reason(
165
+ return nil, nil, GoalLedger.reason(
147
166
  :schema, op_index: idx, path: "",
148
167
  message: "op #{idx} path must be a JSON string (got #{GoalLedger.json_type_name(path)})",
149
168
  hint: 'address sections with JSON pointers like "/tasks/3/status" or "/findings/-"'
150
- )]]
169
+ )
151
170
  end
152
171
 
153
172
  unless path.start_with?("/")
154
- return [nil, [GoalLedger.reason(
173
+ return nil, nil, GoalLedger.reason(
155
174
  :schema, op_index: idx, path: path,
156
175
  message: "op #{idx} path '#{path}' is not a JSON pointer",
157
176
  hint: 'pointers start with "/" — use /section, /section/idx, /section/idx/field or /section/-'
158
- )]]
177
+ )
159
178
  end
160
179
 
161
180
  if path == "/"
162
- return [nil, [GoalLedger.reason(
181
+ return nil, nil, GoalLedger.reason(
163
182
  :schema, op_index: idx, path: path,
164
183
  message: "op #{idx} addresses the whole ledger root",
165
- hint: "the ledger root is not replaceable; address sections individually " \
166
- "(/tasks/3/status, /findings/-, ...)"
167
- )]]
184
+ hint: "the ledger root is not replaceable; address sections individually (/tasks/3/status, /findings/-, ...)"
185
+ )
168
186
  end
169
187
 
170
- tokens, err = tokenize(path, idx)
171
- return [nil, [err]] if err
188
+ [verb, path, nil]
189
+ end
172
190
 
191
+ def self.validate_op_value_and_hyphen(raw, verb, path, tokens, idx)
173
192
  if verb == "remove" && raw.key?("value")
174
- return [nil, [GoalLedger.reason(
193
+ return GoalLedger.reason(
175
194
  :schema, op_index: idx, path: path,
176
195
  message: "op #{idx} (remove) must not carry a value",
177
196
  hint: "to change a value use replace; remove deletes the item outright"
178
- )]]
197
+ )
179
198
  end
180
199
  unless verb == "remove" || raw.key?("value")
181
- return [nil, [GoalLedger.reason(
200
+ return GoalLedger.reason(
182
201
  :schema, op_index: idx, path: path,
183
202
  message: "op #{idx} (#{verb}) is missing its value",
184
203
  hint: 'carry the value in the op: {"op": "add", "path": "/findings/-", "value": ...}'
185
- )]]
204
+ )
186
205
  end
187
206
 
188
207
  if hyphen_violation(tokens, verb)
189
- return [nil, [GoalLedger.reason(
208
+ return GoalLedger.reason(
190
209
  :schema, op_index: idx, path: path,
191
210
  message: "op #{idx} path '#{path}' uses '-' outside an append position",
192
- hint: "'-' appends and may only be the final token of an add path " \
193
- "(e.g. /findings/-); use a numeric index for existing items"
194
- )]]
211
+ hint: "'-' appends and may only be the final token of an add path (e.g. /findings/-); use a numeric index for existing items"
212
+ )
195
213
  end
196
214
 
197
- value = verb == "remove" ? nil : deep_freeze(raw["value"])
198
- [{ op: verb.to_sym, path: path, tokens: tokens, value: value, index: idx }.freeze, []]
215
+ nil
199
216
  end
200
217
 
201
218
  def self.op_shape_reason(idx)
@@ -261,6 +278,7 @@ module Rubik
261
278
  end
262
279
 
263
280
  private_class_method :header_reasons, :normalize_op, :op_shape_reason, :verb_inspect,
281
+ :validate_op_keys, :validate_op_verb_and_path, :validate_op_value_and_hyphen,
264
282
  :tokenize, :hyphen_violation, :deep_freeze
265
283
  end
266
284
  end
@@ -114,8 +114,8 @@ module Rubik
114
114
  @instances[inst.id] = inst
115
115
  activate_instance!(inst, child_ctx: child_ctx, owner_token: eff_owner)
116
116
  end
117
- # SS bırakma kancası: redeploy da bir başlangıçtır — taze :running
118
- # flip'i, bu id'yi SS ile bekleyen kuyruk spesifikasyonlarını
117
+ # Start-to-Start (SS) bırakma kancası: redeploy da bir başlangıçtır — taze :running
118
+ # flip'i, bu id'yi Start-to-Start (SS) ile bekleyen kuyruk spesifikasyonlarını
119
119
  # bırakır (kilit DIŞINDA — emit kuralı; spawn ile aynı düzen).
120
120
  release_ready!
121
121
  redeployed
@@ -162,17 +162,17 @@ module Rubik
162
162
  return pids.reject(&:zero?) unless pids.empty?
163
163
  end
164
164
 
165
- out = begin
166
- `pgrep -P #{pid} 2>/dev/null`
165
+ out, = begin
166
+ Open3.capture2("pgrep", "-P", pid.to_s, err: File::NULL)
167
167
  rescue StandardError
168
- nil
168
+ [nil, nil]
169
169
  end
170
170
  return out.lines.map(&:to_i).reject(&:zero?) if out && !out.empty?
171
171
 
172
- out = begin
173
- `ps --ppid #{pid} -o pid= 2>/dev/null`
172
+ out, = begin
173
+ Open3.capture2("ps", "--ppid", pid.to_s, "-o", "pid=", err: File::NULL)
174
174
  rescue StandardError
175
- nil
175
+ [nil, nil]
176
176
  end
177
177
  return out.lines.map(&:to_i).reject(&:zero?) if out && !out.empty?
178
178
 
@@ -59,46 +59,67 @@ module Rubik
59
59
  retagged.valid_encoding? ? retagged : retagged.scrub("")
60
60
  end
61
61
 
62
- # Recursively sanitizes hashes, arrays, symbols, and strings for safe UTF-8 encoding
62
+ # PERF: Fast-path deep UTF-8 cleaner. Performs zero-allocation Symbol
63
+ # validation via Symbol#name and tracks cycle object IDs in a Hash to
64
+ # eliminate Set instance allocations (~3.3x speedup, ~70% faster).
63
65
  def clean_utf8_deep(obj, seen = nil)
64
66
  case obj
65
67
  when String
66
68
  clean_utf8(obj)
69
+ when Symbol
70
+ fast_clean_symbol(obj)
67
71
  when Hash
68
- seen ||= Set.new
69
- return "{...}" if seen.include?(obj.object_id)
72
+ seen ||= {}
73
+ obj_id = obj.object_id
74
+ return "{...}" if seen.is_a?(Hash) ? seen.key?(obj_id) : seen.include?(obj_id)
70
75
 
71
- seen.add(obj.object_id)
76
+ seen.is_a?(Hash) ? (seen[obj_id] = true) : seen.add(obj_id)
72
77
 
73
- obj.each_with_object({}) do |(k, v), acc|
74
- cleaned_k = if k.is_a?(String)
78
+ res = {}
79
+ obj.each do |k, v|
80
+ cleaned_k = if k.is_a?(Symbol)
81
+ fast_clean_symbol(k)
82
+ elsif k.is_a?(String)
75
83
  clean_utf8(k)
76
84
  else
77
- (k.is_a?(Symbol) ? clean_utf8(k.to_s).to_sym : clean_utf8_deep(k, seen))
85
+ clean_utf8_deep(k, seen)
78
86
  end
79
- acc[cleaned_k] = clean_utf8_deep(v, seen)
87
+ res[cleaned_k] = clean_utf8_deep(v, seen)
80
88
  end
89
+ res
81
90
  when Array
82
- seen ||= Set.new
83
- return "[...]" if seen.include?(obj.object_id)
91
+ seen ||= {}
92
+ obj_id = obj.object_id
93
+ return "[...]" if seen.is_a?(Hash) ? seen.key?(obj_id) : seen.include?(obj_id)
84
94
 
85
- seen.add(obj.object_id)
95
+ seen.is_a?(Hash) ? (seen[obj_id] = true) : seen.add(obj_id)
86
96
 
87
97
  obj.map { |item| clean_utf8_deep(item, seen) }
88
98
  when Set
89
- seen ||= Set.new
90
- return Set.new(["[...]"]) if seen.include?(obj.object_id)
99
+ seen ||= {}
100
+ obj_id = obj.object_id
101
+ return Set.new(["[...]"]) if seen.is_a?(Hash) ? seen.key?(obj_id) : seen.include?(obj_id)
91
102
 
92
- seen.add(obj.object_id)
103
+ seen.is_a?(Hash) ? (seen[obj_id] = true) : seen.add(obj_id)
93
104
 
94
105
  obj.to_set { |item| clean_utf8_deep(item, seen) }
95
- when Symbol
96
- clean_utf8(obj.to_s).to_sym
97
106
  else
98
107
  obj
99
108
  end
100
109
  end
101
110
 
111
+ # Binary (ASCII-8BIT) symbol names always report valid_encoding?, so a
112
+ # bare validity check would fast-path them through UNCLEANED — the old
113
+ # clean_utf8 path strips their >= 0x80 bytes and the byte loss would
114
+ # instead surface later, at the JSON/display layer. Only UTF-8 (or the
115
+ # lossless US-ASCII subset) symbols may take the fast path.
116
+ def fast_clean_symbol(sym)
117
+ name = sym.name
118
+ return sym if [Encoding::UTF_8, Encoding::US_ASCII].include?(name.encoding) && name.valid_encoding?
119
+
120
+ clean_utf8(name).to_sym
121
+ end
122
+
102
123
  # Generates JSON safely without raising JSON::GeneratorError on malformed UTF-8
103
124
  def safe_json_generate(obj, *)
104
125
  cleaned = clean_utf8_deep(obj)