@zalom/plastic 1.0.0-alpha.18 → 1.0.0-alpha.19

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.
@@ -215,6 +215,109 @@ module Bridge
215
215
  "(blocked edit: #{file_abs})"
216
216
  end
217
217
 
218
+ # --- Bash-edit gate (intent 27a) ---
219
+
220
+ # Extract the set of file paths a Bash command writes to. Conservative by
221
+ # design: it is acceptable to miss exotic forms, but it must NOT flag reads
222
+ # or /dev/null. Returns an Array of path strings (possibly relative).
223
+ #
224
+ # Covered write vectors: redirection (>, >>, including heredoc `cat > f <<EOF`),
225
+ # tee / tee -a, sed -i / sed -i.bak, cp/mv (last non-flag arg), dd of=.
226
+ def self.bash_write_targets(command)
227
+ return [] unless command.is_a?(String)
228
+
229
+ targets = []
230
+ targets.concat(bash_redirect_targets(command))
231
+ # Split on command separators for per-segment utility parsing.
232
+ command.split(/[;\n]|&&|\|\||\|/).each do |segment|
233
+ targets.concat(bash_utility_targets(segment))
234
+ end
235
+ targets.uniq
236
+ end
237
+
238
+ # Redirections: `> path` / `>> path`, but not fd dups (`2>&1`) or /dev/null.
239
+ # A leading digit (fd number) before > is fine; `>&` is a dup and excluded.
240
+ def self.bash_redirect_targets(command)
241
+ targets = []
242
+ # Match optional leading fd digits, then > or >>, not followed by & , then path.
243
+ command.scan(/\d*>>?(?!&)\s*([^\s;|&<>]+)/) do |m|
244
+ path = m[0]
245
+ next if path.nil? || path.empty?
246
+ next if dev_null?(path)
247
+ targets << path
248
+ end
249
+ targets
250
+ end
251
+
252
+ def self.bash_utility_targets(segment)
253
+ tokens = segment.strip.split(/\s+/)
254
+ return [] if tokens.empty?
255
+
256
+ # Find the utility name, skipping env-style assignments.
257
+ idx = 0
258
+ idx += 1 while tokens[idx] && tokens[idx].include?("=") && tokens[idx] !~ /^-/ && !tokens[idx].start_with?("of=")
259
+ util = File.basename(tokens[idx].to_s)
260
+ args = tokens[(idx + 1)..] || []
261
+
262
+ case util
263
+ when "tee"
264
+ tee_targets(args)
265
+ when "sed"
266
+ sed_targets(args)
267
+ when "cp", "mv"
268
+ copy_move_targets(args)
269
+ when "dd"
270
+ dd_targets(tokens)
271
+ else
272
+ []
273
+ end
274
+ end
275
+
276
+ def self.tee_targets(args)
277
+ args.reject { |a| a.start_with?("-") || dev_null?(a) }
278
+ end
279
+
280
+ def self.sed_targets(args)
281
+ # In-place only: -i or -i.bak (suffix attached). Otherwise sed reads.
282
+ inplace = args.any? { |a| a == "-i" || a.start_with?("-i") }
283
+ return [] unless inplace
284
+ files = args.reject { |a| a.start_with?("-") }
285
+ # sed args: script then file(s). First non-flag is the script expression
286
+ # unless an -e/-f was used; conservatively treat the LAST non-flag as file.
287
+ files.empty? ? [] : [files.last].reject { |f| dev_null?(f) }
288
+ end
289
+
290
+ def self.copy_move_targets(args)
291
+ files = args.reject { |a| a.start_with?("-") }
292
+ return [] if files.length < 2
293
+ dest = files.last
294
+ dev_null?(dest) ? [] : [dest]
295
+ end
296
+
297
+ def self.dd_targets(tokens)
298
+ tokens.each_with_object([]) do |t, acc|
299
+ next unless t.start_with?("of=")
300
+ path = t.sub("of=", "")
301
+ acc << path unless path.empty? || dev_null?(path)
302
+ end
303
+ end
304
+
305
+ def self.dev_null?(path)
306
+ path == "/dev/null" || path.start_with?("/dev/")
307
+ end
308
+
309
+ # Decide whether a Bash command should be blocked under the auto-mode code
310
+ # gate. Resolves each write target against cwd and applies the SAME policy as
311
+ # code_gate_decision. Returns the first block reason, or nil to allow.
312
+ def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home)
313
+ bash_write_targets(command).each do |target|
314
+ abs = File.absolute_path?(target) ? target : File.join(cwd, target)
315
+ reason = code_gate_decision(bridge_data, abs, home: home)
316
+ return reason if reason
317
+ end
318
+ nil
319
+ end
320
+
218
321
  def self.deep_merge(base, overlay)
219
322
  result = base.dup
220
323
  overlay.each do |key, value|