ace-bundle 0.43.8 → 0.44.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffe6665204245e7b5495e1834ab4ab570cd732986f0a2aa17844769f9858ccbb
4
- data.tar.gz: 40de7e7e422003fe1df4451c37e188c5d72b8f444c55f6e23f40fc11a809a26d
3
+ metadata.gz: 26f6884ec48667065a3bf7bac13098b0fad2e4782cbc71bf1e66a6bdfd872268
4
+ data.tar.gz: 542eed4ca281d655207fb15b9849f194d6666115514434f0bbcb121ebae68f6c
5
5
  SHA512:
6
- metadata.gz: c3783e6af82022acdcb30761a234df06645f4c96a418cbcd15e799159aeab9fe66f07a6573e232318df868a596ed250f4efdefabeb01eceaa866f71f48013763
7
- data.tar.gz: 4cccf1f0e950b4591f5656cefdb43275f28677881481b847d448cee76068896009f505fedfde0348826fe535b497938baf0f2044c5f1be112c8b6472251344b2
6
+ metadata.gz: 65c532db93d1668fb079fc03e733c1153454f2bdc131dc5996bd79fad0842405f7e1364176d885b2254919029b04052688a05434801bf9ea1358799012c5b985
7
+ data.tar.gz: 94830aa2681f89949dba59d6deae8747f38904c8a100eec575e197798e58cdca897f433ed529b7e766d1f57fa092e1adf4d1c1155168b5db13b6ed28c0d75e86
@@ -20,13 +20,6 @@ bundle:
20
20
  TODO: Describe the top-level folders and where core logic lives.
21
21
  Move this to docs/blueprint.md once project docs exist.
22
22
 
23
- quick_status:
24
- title: Quick Status
25
- compressor_mode: exact
26
- commands:
27
- - pwd
28
- - date
29
-
30
23
  starter_guidance:
31
24
  title: Starter Guidance
32
25
  content: |
@@ -20,14 +20,6 @@ bundle:
20
20
  TODO: Add key architecture notes, major components, and decision records.
21
21
  You can later split this into docs/architecture.md, docs/decisions.md, and docs/blueprint.md.
22
22
 
23
- project_status:
24
- title: Project Status
25
- compressor_mode: exact
26
- commands:
27
- - pwd
28
- - date
29
- - ls -1
30
-
31
23
  onboarding_notes:
32
24
  title: Onboarding Notes
33
25
  content: |
data/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog][1], and this project adheres to [Seman
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.44.0] - 2026-09-24
10
+
11
+ ### Changed
12
+
13
+ - Make review-safe bundles reject command and external diff sources before execution, preserve source snapshots for coverage checks, and fail when required context is incomplete.
14
+ - Preserve file and diff bytes in rendered context, including trailing whitespace and final newlines.
15
+ - Include top-level files with base and embedded templates in normal bundle output.
16
+ - Remove volatile status commands from shipped starter presets.
17
+
18
+ ### Fixed
19
+
20
+ - Preserve base-file provenance in rendered bundle metadata so PR review can verify it against the reviewed commit.
21
+ - Include file inputs in merged bundle source metadata.
22
+
23
+ ## [0.43.9] - 2026-09-02
24
+
25
+ ### Technical
26
+ - Included in the coordinated all-package patch release preparation for ACE monorepo review.
9
27
  ## [0.43.8] - 2026-08-12
10
28
 
11
29
  ### Technical
@@ -5,7 +5,7 @@ module Ace
5
5
  module Models
6
6
  # Data model for bundle information
7
7
  class BundleData
8
- attr_accessor :preset_name, :files, :metadata, :content, :commands, :sections
8
+ attr_accessor :preset_name, :files, :metadata, :content, :commands, :sections, :source_files
9
9
 
10
10
  def initialize(preset_name: nil, files: nil, metadata: nil, content: "", commands: nil, sections: nil)
11
11
  @preset_name = preset_name
@@ -14,6 +14,7 @@ module Ace
14
14
  @content = content
15
15
  @commands = commands || []
16
16
  @sections = sections || {}
17
+ @source_files = []
17
18
  end
18
19
 
19
20
  def to_h
@@ -23,7 +24,8 @@ module Ace
23
24
  metadata: metadata,
24
25
  content: content,
25
26
  commands: commands,
26
- sections: sections
27
+ sections: sections,
28
+ source_files: source_files
27
29
  }
28
30
  end
29
31
 
@@ -10,12 +10,14 @@ module Ace
10
10
  module Molecules
11
11
  # Manages context presets from markdown files in .ace/bundle/presets/
12
12
  class PresetManager
13
- attr_reader :presets
13
+ attr_reader :presets, :used_source_files
14
14
 
15
15
  def initialize
16
16
  @section_validator = Atoms::SectionValidator.new
17
+ @source_snapshots = {}
17
18
  @presets = load_presets
18
19
  @preset_cache = {} # Cache for composed presets during single execution
20
+ @used_source_files = []
19
21
  end
20
22
 
21
23
  def list_presets
@@ -24,9 +26,16 @@ module Ace
24
26
 
25
27
  def get_preset(name)
26
28
  preset = @presets[name.to_s]
29
+ @used_source_files << preset[:source_file] if preset && preset[:source_file]
27
30
  preset&.dup
28
31
  end
29
32
 
33
+ def used_source_snapshots
34
+ @used_source_files.uniq.map do |path|
35
+ {path: path, content: @source_snapshots.fetch(path)}
36
+ end
37
+ end
38
+
30
39
  def preset_exists?(name)
31
40
  @presets.key?(name.to_s)
32
41
  end
@@ -264,6 +273,8 @@ module Ace
264
273
 
265
274
  return nil unless frontmatter
266
275
 
276
+ @source_snapshots[file] = content
277
+
267
278
  # Use 'bundle' key for preset configuration
268
279
  bundle_config = frontmatter["bundle"] || {}
269
280
  params = bundle_config["params"] || {}
@@ -123,7 +123,9 @@ module Ace
123
123
  files.each do |file_info|
124
124
  language = detect_language(file_info[:path])
125
125
  output << " <file path=\"#{file_info[:path]}\" language=\"#{language}\">"
126
- output << format_file_content(file_info[:content])
126
+ content = format_file_content(file_info[:content])
127
+ # A review subject must remain byte-exact for the provenance check.
128
+ output << ((section_data[:verbatim_files] || section_data["verbatim_files"]) ? content : format_xml_payload(content))
127
129
  output << " </file>"
128
130
  end
129
131
 
@@ -137,7 +139,7 @@ module Ace
137
139
  commands = section_data[:_processed_commands] || section_data["_processed_commands"] || []
138
140
  commands.each do |command_data|
139
141
  output << " <output command=\"#{command_data[:command]}\">"
140
- output << format_command_output(command_data[:output])
142
+ output << format_xml_payload(format_command_output(command_data[:output]))
141
143
  output << " </output>"
142
144
  end
143
145
 
@@ -152,6 +154,8 @@ module Ace
152
154
  diffs.each do |diff_data|
153
155
  command = diff_command_for(diff_data)
154
156
  output << " <output command=\"#{command}\">"
157
+ # Review diff provenance requires these bytes to remain contiguous.
158
+ # The XML-like tags are presentation markers, not a parsed XML envelope.
155
159
  output << format_diff_output(diff_data[:output])
156
160
  output << " </output>"
157
161
  end
@@ -175,7 +179,7 @@ module Ace
175
179
  # Formats inline content section
176
180
  def format_content_section(section_data)
177
181
  content = section_data[:_processed_content] || section_data["_processed_content"] || ""
178
- format_inline_content(content)
182
+ format_xml_payload(format_inline_content(content))
179
183
  end
180
184
 
181
185
  # Formats full bundle data with sections in markdown format
@@ -472,8 +476,13 @@ module Ace
472
476
  def format_file_content(content)
473
477
  return "" if content.nil? || content.empty?
474
478
 
475
- # Indent content for XML formatting
476
- content.lines.map { |line| " #{line}" }.join.rstrip
479
+ # Keep source bytes intact. Indentation/trim here changes diff context,
480
+ # including significant trailing whitespace and final newlines.
481
+ content
482
+ end
483
+
484
+ def format_xml_payload(content)
485
+ "<![CDATA[#{content.to_s.gsub("]]>", "]]]]><![CDATA[>")}]]>"
477
486
  end
478
487
 
479
488
  def format_command_output(output)
@@ -486,8 +495,7 @@ module Ace
486
495
  def format_diff_output(output)
487
496
  return "" if output.nil? || output.empty?
488
497
 
489
- # Indent diff output for XML formatting
490
- output.lines.map { |line| " #{line}" }.join.rstrip
498
+ output
491
499
  end
492
500
 
493
501
  def format_inline_content(content)
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "pathname"
4
4
  require "date"
5
+ require "json"
6
+ require "yaml"
5
7
  require "ace/core"
6
8
  require_relative "../molecules/bundle_merger"
7
9
  require "ace/core/molecules/file_aggregator"
@@ -27,13 +29,18 @@ module Ace
27
29
  class BundleLoader
28
30
  def initialize(options = {})
29
31
  @options = options
32
+ @review_safe_mode = options[:allow_commands] == false || options["allow_commands"] == false
33
+ @review_allowed_root = options[:allowed_root] || options["allowed_root"] || project_root if @review_safe_mode
34
+ @allowed_protocol_files = []
30
35
  @template_dir = nil
31
36
  @preset_manager = Molecules::PresetManager.new
32
37
  @section_processor = Molecules::SectionProcessor.new
33
38
  @merger = Molecules::BundleMerger.new
34
39
  @file_aggregator = Ace::Core::Molecules::FileAggregator.new(
35
40
  max_size: options[:max_size],
36
- base_dir: options[:base_dir] || project_root
41
+ base_dir: options[:base_dir] || project_root,
42
+ allowed_root: safe_allowed_root,
43
+ allowed_paths: @allowed_protocol_files
37
44
  )
38
45
  @command_executor = Ace::Core::Atoms::CommandExecutor
39
46
  @output_formatter = Ace::Core::Molecules::OutputFormatter.new(
@@ -43,6 +50,7 @@ module Ace
43
50
 
44
51
  def load_preset(preset_name)
45
52
  # Use composition-aware loading
53
+ preset_sources_start = @preset_manager.used_source_files.length
46
54
  preset = @preset_manager.load_preset_with_composition(preset_name)
47
55
 
48
56
  # Handle errors from composition loading
@@ -55,7 +63,7 @@ module Ace
55
63
 
56
64
  # Merge params into options for processing
57
65
  params = preset.dig(:context, :params) || preset.dig(:context, "params") || {}
58
- merged_options = @options.merge(params)
66
+ merged_options = merge_params(params)
59
67
 
60
68
  # Process the preset bundle configuration
61
69
  begin
@@ -78,26 +86,16 @@ module Ace
78
86
  bundle.metadata[:composed_from] = preset[:composed_from]
79
87
  end
80
88
 
81
- # Determine format - respect explicit format requests but default to markdown-xml for embedded sources
82
- # Check for explicit format request in preset or params
83
- explicit_format = preset[:format] || params["format"] || params[:format] || merged_options[:format]
89
+ format_preset_bundle(bundle, preset, params, merged_options)
84
90
 
85
- format = if explicit_format
86
- # Use the explicitly requested format
87
- explicit_format
88
- elsif preset.dig(:context, "embed_document_source")
89
- # Default to markdown-xml format when embed_document_source is true and no explicit format requested
90
- "markdown-xml"
91
- else
92
- # Fallback to markdown
93
- "markdown"
94
- end
95
- format_bundle(bundle, format)
91
+ record_review_preset_sources!(bundle, since: preset_sources_start)
92
+ include_review_section_sources!(bundle)
96
93
 
97
94
  bundle
98
95
  end
99
96
 
100
97
  def load_file(path)
98
+ assert_allowed_file!(path)
101
99
  # Check if it's a template file
102
100
  content = begin
103
101
  File.read(path)
@@ -126,6 +124,7 @@ module Ace
126
124
  # Plain file inputs should emit readable content to stdout.
127
125
  # Keep content as primary payload and record source metadata.
128
126
  bundle.content = result[:content]
127
+ bundle.source_files = [{path: path, content: result[:content]}] if @review_safe_mode
129
128
  bundle.metadata[:raw_content_for_auto_format] = bundle.content
130
129
  bundle.metadata[:source] = path
131
130
  else
@@ -143,11 +142,12 @@ module Ace
143
142
 
144
143
  preset_names.each do |preset_name|
145
144
  # Use composition-aware loading for each preset
145
+ preset_sources_start = @preset_manager.used_source_files.length
146
146
  preset = @preset_manager.load_preset_with_composition(preset_name)
147
147
 
148
148
  if preset[:success]
149
149
  params = preset.dig(:context, :params) || preset.dig(:context, "params") || {}
150
- merged_options = @options.merge(params)
150
+ merged_options = merge_params(params)
151
151
  bundle = load_from_preset_config(preset, merged_options)
152
152
  bundle.metadata[:preset_name] = preset_name
153
153
  bundle.metadata[:output] = preset[:output] # Store preset's output mode
@@ -158,6 +158,9 @@ module Ace
158
158
  bundle.metadata[:composed_from] = preset[:composed_from]
159
159
  end
160
160
 
161
+ record_review_preset_sources!(bundle, since: preset_sources_start)
162
+ format_preset_bundle(bundle, preset, params, merged_options) if @review_safe_mode
163
+
161
164
  bundles << bundle
162
165
  else
163
166
  # Log warning but continue with other presets
@@ -180,6 +183,7 @@ module Ace
180
183
  # Merge all bundles
181
184
  merged = merge_bundles(bundles)
182
185
  merged.metadata[:warnings] = warnings if warnings.any?
186
+ merged.metadata[:errors] = Array(merged.metadata[:errors]) + warnings if @review_safe_mode && warnings.any?
183
187
 
184
188
  merged
185
189
  end
@@ -309,11 +313,12 @@ module Ace
309
313
  # Process presets
310
314
  preset_names.each do |preset_name|
311
315
  # Use composition-aware loading for each preset
316
+ preset_sources_start = @preset_manager.used_source_files.length
312
317
  preset = @preset_manager.load_preset_with_composition(preset_name)
313
318
 
314
319
  if preset[:success]
315
320
  params = preset.dig(:context, :params) || preset.dig(:context, "params") || {}
316
- merged_options = @options.merge(params)
321
+ merged_options = merge_params(params)
317
322
  bundle = load_from_preset_config(preset, merged_options)
318
323
  bundle.metadata[:preset_name] = preset_name
319
324
  bundle.metadata[:source_type] = "preset"
@@ -325,6 +330,9 @@ module Ace
325
330
  bundle.metadata[:composed_from] = preset[:composed_from]
326
331
  end
327
332
 
333
+ record_review_preset_sources!(bundle, since: preset_sources_start)
334
+ format_preset_bundle(bundle, preset, params, merged_options) if @review_safe_mode
335
+
328
336
  bundles << bundle
329
337
  else
330
338
  # Log warning but continue with other inputs
@@ -358,6 +366,7 @@ module Ace
358
366
 
359
367
  # Add warnings to metadata if any
360
368
  merged_bundle.metadata[:warnings] = warnings if warnings.any?
369
+ merged_bundle.metadata[:errors] = Array(merged_bundle.metadata[:errors]) + warnings if @review_safe_mode && warnings.any?
361
370
 
362
371
  merged_bundle
363
372
  end
@@ -416,6 +425,8 @@ module Ace
416
425
  end
417
426
 
418
427
  def load_template(path)
428
+ assert_allowed_file!(path)
429
+ preset_sources_start = @preset_manager.used_source_files.length
419
430
  # Track the template file's directory for resolving ./ relative paths
420
431
  @template_dir = File.dirname(File.expand_path(path))
421
432
 
@@ -454,7 +465,7 @@ module Ace
454
465
  # Merge params into options if present
455
466
  params = config["params"]
456
467
  if params.is_a?(Hash)
457
- @options = @options.merge(params)
468
+ @options = merge_params(params)
458
469
  end
459
470
 
460
471
  # Handle preset/presets keys from frontmatter
@@ -515,41 +526,58 @@ module Ace
515
526
 
516
527
  # Track base resolution before metadata reset (metadata gets replaced below)
517
528
  resolved = bundle.metadata[:base_type] ? bundle.content : nil
529
+ base_path = bundle.metadata[:base_path]
518
530
  base_content_resolved = resolved.to_s.strip.empty? ? nil : resolved
519
531
 
520
532
  # Replace metadata with original frontmatter (keep it unmodified)
521
533
  # Convert string keys to symbols for consistency
534
+ processing_errors = Array(bundle.metadata[:errors])
535
+ if @review_safe_mode
536
+ processing_errors << bundle.metadata[:preset_error] if bundle.metadata[:preset_error]
537
+ processing_errors << bundle.metadata[:base_error] if bundle.metadata[:base_error] && !processing_errors.include?(bundle.metadata[:base_error])
538
+ end
522
539
  bundle.metadata = {}
523
540
  frontmatter.each do |key, value|
524
541
  bundle.metadata[key.to_sym] = value
525
542
  end
543
+ bundle.metadata[:base_path] = base_path if base_path
544
+ bundle.metadata[:errors] = processing_errors if processing_errors.any?
545
+ record_review_preset_sources!(bundle, since: preset_sources_start)
526
546
  # Store original YAML for output formatting
527
547
  bundle.metadata[:frontmatter_yaml] = frontmatter_yaml if frontmatter_yaml
528
548
 
529
549
  # If embed_document_source is true, store original document and keep embedded files separate
530
550
  if config["embed_document_source"]
531
551
  # base replaces the source document for embedding
532
- bundle.content = base_content_resolved || original_content
552
+ bundle.content = if base_content_resolved
553
+ base_content_resolved
554
+ else
555
+ [original_content, (bundle.content if bundle.source_files.any? && bundle.has_sections?)].compact.join("\n\n")
556
+ end
533
557
 
534
558
  # bundle.files already has embedded files from process_template_config
535
559
  # Don't add source to files array - it will be output as raw content
536
560
 
537
561
  # Format and return
538
562
  format = config["format"] || @options[:format] || "markdown-xml"
539
- return format_bundle(bundle, format)
563
+ format_bundle(bundle, format)
564
+ bundle.metadata[:rendered_format] = format
565
+ return record_review_template_source!(bundle, path, original_content)
540
566
  end
541
567
 
542
568
  # Format bundle before returning (same as preset loading)
543
569
  format = config["format"] || @options[:format] || "markdown-xml"
544
570
  format_bundle(bundle, format)
571
+ bundle.metadata[:rendered_format] = format
545
572
 
546
- return bundle
573
+ return record_review_template_source!(bundle, path, original_content)
547
574
  end
548
575
 
549
576
  # Check if this is plain markdown with metadata-only frontmatter
550
577
  # (e.g., workflow files with description/allowed-tools but no context config)
551
578
  if frontmatter.any?
552
- return load_plain_markdown(original_content, frontmatter, path)
579
+ bundle = load_plain_markdown(original_content, frontmatter, path)
580
+ return record_review_template_source!(bundle, path, original_content)
553
581
  end
554
582
 
555
583
  # Otherwise, parse template configuration from body
@@ -558,7 +586,7 @@ module Ace
558
586
  unless parse_result[:success]
559
587
  bundle = Models::BundleData.new
560
588
  bundle.metadata[:error] = parse_result[:error]
561
- return bundle
589
+ return record_review_template_source!(bundle, path, original_content)
562
590
  end
563
591
 
564
592
  config = parse_result[:config]
@@ -572,7 +600,7 @@ module Ace
572
600
  # Add frontmatter to metadata for reference
573
601
  bundle.metadata[:frontmatter] = frontmatter if frontmatter.any?
574
602
 
575
- bundle
603
+ record_review_template_source!(bundle, path, original_content)
576
604
  end
577
605
 
578
606
  def load_from_config(config)
@@ -591,6 +619,8 @@ module Ace
591
619
  aggregator = Ace::Core::Molecules::FileAggregator.new(
592
620
  max_size: @options[:max_size],
593
621
  base_dir: @options[:base_dir] || project_root,
622
+ allowed_root: safe_allowed_root,
623
+ allowed_paths: @allowed_protocol_files,
594
624
  exclude: config[:exclude] || []
595
625
  )
596
626
 
@@ -650,7 +680,7 @@ module Ace
650
680
 
651
681
  # If embed_document_source is true, set content to trigger XML formatting
652
682
  if bundle_config["embed_document_source"] && preset[:body] && !preset[:body].empty?
653
- bundle.content = preset[:body]
683
+ bundle.content = [bundle.content, preset[:body]].reject(&:empty?).join("\n\n")
654
684
  elsif preset[:body] && !preset[:body].empty?
655
685
  # Add preset body to metadata (old behavior for non-embedded)
656
686
  bundle.metadata[:preset_content] = preset[:body]
@@ -837,13 +867,59 @@ module Ace
837
867
  def merge_bundles(bundles)
838
868
  return Models::BundleData.new if bundles.empty?
839
869
 
870
+ if @review_safe_mode
871
+ contents = bundles.map(&:content).reject(&:empty?)
872
+ preset_formats = bundles.map { |bundle| bundle.metadata[:rendered_format] }.compact.uniq
873
+ format = @options[:format] || @options["format"] ||
874
+ ((preset_formats.first if preset_formats.size == 1) || "markdown-xml")
875
+ merged_content = case format
876
+ when "json"
877
+ JSON.pretty_generate("bundles" => contents)
878
+ when "yaml"
879
+ YAML.dump("bundles" => contents)
880
+ when "xml"
881
+ entries = contents.map { |content| "<bundle><![CDATA[#{content.gsub("]]>", "]]]]><![CDATA[>")}]]></bundle>" }
882
+ "<bundles>\n#{entries.join("\n")}\n</bundles>"
883
+ else
884
+ contents.join("\n\n")
885
+ end
886
+ merged_sections = if bundles.size == 1
887
+ bundles.first.sections
888
+ else
889
+ bundles.each_with_index.each_with_object({}) do |(bundle, index), sections|
890
+ bundle.sections.each { |name, section| sections["#{index + 1}:#{name}"] = section }
891
+ end
892
+ end
893
+ result = Models::BundleData.new(
894
+ content: merged_content,
895
+ sections: merged_sections,
896
+ metadata: {
897
+ merged: true,
898
+ total_bundles: bundles.size,
899
+ sources: bundles.map { |bundle| bundle.metadata[:preset_name] || bundle.metadata[:source_path] || bundle.metadata[:source_input] }.compact,
900
+ errors: bundles.flat_map do |bundle|
901
+ Array(bundle.metadata[:errors]) + Array(bundle.metadata[:error]) + Array(bundle.metadata[:warnings])
902
+ end,
903
+ preset_source_files: bundles.flat_map { |bundle| Array(bundle.metadata[:preset_source_files]) }.uniq,
904
+ preset_sources: bundles.flat_map { |bundle| Array(bundle.metadata[:preset_sources]) }.uniq
905
+ }
906
+ )
907
+ result.source_files = bundles.flat_map do |bundle|
908
+ bundle.source_files +
909
+ bundle.sections.values.flat_map { |section| Array(section[:_processed_files]) }
910
+ end.uniq { |file| [file[:path], file[:content]] }
911
+ # Inputs are already formatted. Preserve each complete payload while
912
+ # keeping the requested outer format parseable.
913
+ return result
914
+ end
915
+
840
916
  # Single context with actual processed section content: preserve sections
841
917
  # This handles presets with explicit `sections:` that have real content
842
918
  if bundles.size == 1 && has_processed_section_content?(bundles.first)
843
919
  result = bundles.first
844
920
  result.metadata[:merged] = true
845
921
  result.metadata[:total_bundles] = 1
846
- result.metadata[:sources] = [result.metadata[:preset_name] || result.metadata[:source_path]].compact
922
+ result.metadata[:sources] = [result.metadata[:preset_name] || result.metadata[:source_path] || result.metadata[:source_input]].compact
847
923
  return format_bundle(result, @options[:format] || "markdown-xml")
848
924
  end
849
925
 
@@ -873,6 +949,9 @@ module Ace
873
949
  result.metadata[:total_bundles] = merged[:total_bundles]
874
950
  result.metadata[:sources] = merged[:sources]
875
951
  result.metadata[:errors] = merged[:errors] if merged[:errors]&.any?
952
+ result.metadata[:preset_source_files] = bundles.flat_map { |bundle| Array(bundle.metadata[:preset_source_files]) }.uniq
953
+ result.metadata[:preset_sources] = bundles.flat_map { |bundle| Array(bundle.metadata[:preset_sources]) }.uniq
954
+ result.source_files = bundles.flat_map(&:source_files)
876
955
 
877
956
  format_bundle(result, @options[:format] || "markdown-xml")
878
957
  end
@@ -907,13 +986,25 @@ module Ace
907
986
  # Process files
908
987
  if config["files"] && config["files"].any?
909
988
  # Resolve any protocol references (e.g., wfi://workflow-name)
910
- resolved_files = config["files"].map do |file_ref|
911
- resolve_file_reference(file_ref)
912
- end.compact
989
+ resolved_files = config["files"].filter_map do |file_ref|
990
+ resolved = resolve_file_reference(file_ref)
991
+ if !resolved && @review_safe_mode
992
+ data[:errors] << "Unresolved file source: #{file_ref}"
993
+ end
994
+ resolved
995
+ end
996
+ missing_sources = missing_review_sources(resolved_files, base_dir: @options[:base_dir] || project_root,
997
+ exclude: config["exclude"] || [])
998
+ missing_sources.each do |pattern|
999
+ data[:errors] << "Unmatched required file pattern: #{pattern}"
1000
+ end
1001
+ resolved_files -= missing_sources
913
1002
 
914
1003
  aggregator = Ace::Core::Molecules::FileAggregator.new(
915
1004
  max_size: config["max_size"] || @options[:max_size],
916
1005
  base_dir: @options[:base_dir] || project_root,
1006
+ allowed_root: safe_allowed_root,
1007
+ allowed_paths: @allowed_protocol_files,
917
1008
  exclude: config["exclude"] || []
918
1009
  )
919
1010
 
@@ -932,9 +1023,20 @@ module Ace
932
1023
 
933
1024
  # Process include patterns (similar to files)
934
1025
  if config["include"] && config["include"].any?
1026
+ if @review_safe_mode
1027
+ config["include"].each do |pattern|
1028
+ if Ace::Core::Atoms::GlobExpander.expand_with_exclusions(pattern,
1029
+ base_dir: @options[:base_dir] || project_root,
1030
+ exclude: config["exclude"] || []).empty?
1031
+ data[:errors] << "Unmatched required include pattern: #{pattern}"
1032
+ end
1033
+ end
1034
+ end
935
1035
  aggregator = Ace::Core::Molecules::FileAggregator.new(
936
1036
  max_size: config["max_size"] || @options[:max_size],
937
1037
  base_dir: @options[:base_dir] || project_root,
1038
+ allowed_root: safe_allowed_root,
1039
+ allowed_paths: @allowed_protocol_files,
938
1040
  exclude: config["exclude"] || []
939
1041
  )
940
1042
 
@@ -945,6 +1047,7 @@ module Ace
945
1047
 
946
1048
  # Process commands
947
1049
  if config["commands"] && config["commands"].any?
1050
+ reject_untrusted_commands!
948
1051
  timeout = config["timeout"] || @options[:timeout] || 30
949
1052
  config["commands"].each do |command|
950
1053
  cmd_result = @command_executor.execute(command, timeout: timeout, cwd: project_root)
@@ -959,6 +1062,7 @@ module Ace
959
1062
 
960
1063
  # Process diffs
961
1064
  if config["diffs"] && config["diffs"].any?
1065
+ reject_untrusted_diff_sources!
962
1066
  data[:diffs] ||= []
963
1067
  diff_paths = config["paths"] || config[:paths] || []
964
1068
  config["diffs"].each do |diff_range|
@@ -980,8 +1084,10 @@ module Ace
980
1084
 
981
1085
  # Create bundle with formatted content
982
1086
  bundle = Models::BundleData.new(metadata: data[:metadata])
1087
+ bundle.metadata[:errors] = data[:errors] if data[:errors].any?
983
1088
  bundle.content = formatted_content
984
1089
  bundle.commands = data[:commands]
1090
+ bundle.source_files = data[:files]
985
1091
 
986
1092
  # Store individual files if embed_document_source is true
987
1093
  if config["embed_document_source"]
@@ -1026,6 +1132,14 @@ module Ace
1026
1132
  normalized
1027
1133
  end
1028
1134
 
1135
+ def format_preset_bundle(bundle, preset, params, merged_options)
1136
+ explicit = preset[:format] || params["format"] || params[:format] || merged_options[:format]
1137
+ format = explicit || (preset.dig(:context, "embed_document_source") ? "markdown-xml" : "markdown")
1138
+ format_bundle(bundle, format)
1139
+ bundle.metadata[:rendered_format] = format
1140
+ bundle
1141
+ end
1142
+
1029
1143
  def format_bundle(bundle, format)
1030
1144
  bundle.metadata[:raw_content_for_auto_format] = bundle.content
1031
1145
 
@@ -1066,6 +1180,10 @@ module Ace
1066
1180
  end
1067
1181
 
1068
1182
  def compress_bundle_sections(bundle)
1183
+ # PR review validates file provenance after rendering. Never send
1184
+ # unvalidated review sources to a model-backed compressor here.
1185
+ return if @review_safe_mode
1186
+
1069
1187
  # --compressor off: absolute kill switch
1070
1188
  return if @options[:compressor]&.to_s == "off"
1071
1189
 
@@ -1097,6 +1215,8 @@ module Ace
1097
1215
  # section-level compression was a no-op (no _processed_files to compress).
1098
1216
  # This handles template bundles with command-only/diff-only sections.
1099
1217
  def compress_rendered_bundle(bundle)
1218
+ return if @review_safe_mode
1219
+
1100
1220
  return unless bundle.has_sections?
1101
1221
  return if bundle.metadata[:compressed]
1102
1222
  return if sections_have_processed_files?(bundle)
@@ -1170,9 +1290,15 @@ module Ace
1170
1290
  def resolve_protocol(protocol_ref)
1171
1291
  require "ace/support/nav"
1172
1292
  engine = Ace::Support::Nav::Organisms::NavigationEngine.new
1293
+ if @review_safe_mode && engine.cmd_protocol?(protocol_ref.split("://", 2).first)
1294
+ raise Ace::Bundle::Error, "Command protocol sources are forbidden in review prompts"
1295
+ end
1173
1296
  path = engine.resolve(protocol_ref)
1174
1297
 
1175
- return path if path && File.exist?(path)
1298
+ if path && File.exist?(path)
1299
+ authorize_protocol_path!(protocol_ref, path)
1300
+ return path
1301
+ end
1176
1302
 
1177
1303
  # Fallback: handle cmd-type protocols (e.g., task://) by capturing command output
1178
1304
  protocol = protocol_ref.split("://", 2).first
@@ -1230,13 +1356,27 @@ module Ace
1230
1356
  return unless files.any?
1231
1357
 
1232
1358
  # Resolve any protocol references (e.g., wfi://workflow-name)
1233
- resolved_files = files.map do |file_ref|
1234
- resolve_file_reference(file_ref)
1235
- end.compact
1359
+ resolved_files = files.filter_map do |file_ref|
1360
+ resolved = resolve_file_reference(file_ref)
1361
+ if !resolved && @review_safe_mode
1362
+ bundle.metadata[:errors] ||= []
1363
+ bundle.metadata[:errors] << "Section '#{section_name}': unresolved file source: #{file_ref}"
1364
+ end
1365
+ resolved
1366
+ end
1367
+ missing_sources = missing_review_sources(resolved_files, base_dir: options[:base_dir] || project_root,
1368
+ exclude: section_data[:exclude] || section_data["exclude"] || [])
1369
+ missing_sources.each do |pattern|
1370
+ bundle.metadata[:errors] ||= []
1371
+ bundle.metadata[:errors] << "Section '#{section_name}': unmatched required file pattern: #{pattern}"
1372
+ end
1373
+ resolved_files -= missing_sources
1236
1374
 
1237
1375
  aggregator = Ace::Core::Molecules::FileAggregator.new(
1238
1376
  max_size: options[:max_size] || options["max_size"],
1239
1377
  base_dir: options[:base_dir] || project_root,
1378
+ allowed_root: safe_allowed_root,
1379
+ allowed_paths: @allowed_protocol_files,
1240
1380
  exclude: section_data[:exclude] || section_data["exclude"] || []
1241
1381
  )
1242
1382
 
@@ -1272,6 +1412,8 @@ module Ace
1272
1412
  commands = section_data[:commands] || section_data["commands"] || []
1273
1413
  return unless commands.any?
1274
1414
 
1415
+ reject_untrusted_commands!
1416
+
1275
1417
  timeout = options[:timeout] || options["timeout"] || 30
1276
1418
  processed_commands = []
1277
1419
 
@@ -1298,6 +1440,7 @@ module Ace
1298
1440
  def process_pr_config(bundle, bundle_config, options)
1299
1441
  pr_refs = bundle_config["pr"] || bundle_config[:pr]
1300
1442
  return false unless pr_refs
1443
+ reject_untrusted_diff_sources!
1301
1444
 
1302
1445
  PrBundleLoader.new(options).process(bundle, pr_refs)
1303
1446
  end
@@ -1306,6 +1449,7 @@ module Ace
1306
1449
  def process_diffs_section(bundle, section_name, section_data, options)
1307
1450
  ranges = section_data[:ranges] || section_data["ranges"] || []
1308
1451
  return unless ranges.any?
1452
+ reject_untrusted_diff_sources!
1309
1453
 
1310
1454
  processed_diffs = []
1311
1455
  diff_paths = section_data[:paths] || section_data["paths"] || []
@@ -1344,11 +1488,15 @@ module Ace
1344
1488
  base_ref = bundle_config["base"] || bundle_config[:base]
1345
1489
  return unless base_ref && !base_ref.to_s.strip.empty?
1346
1490
 
1491
+ files_in_content = bundle.source_files.any? &&
1492
+ (!bundle_config["embed_document_source"] || @section_processor.has_sections?({"bundle" => bundle_config}))
1493
+
1347
1494
  # Check if base_ref looks like a file reference (has protocol, slashes, or is a known path pattern)
1348
1495
  # This heuristic helps prioritize file resolution for extension-less files
1349
1496
  has_protocol = base_ref.match?(/^[\w-]+:\/\//)
1350
1497
  has_path_separators = base_ref.match?(/[\/\\]/)
1351
- looks_like_file_ref = has_protocol || has_path_separators
1498
+ looks_like_file_ref = has_protocol || has_path_separators ||
1499
+ (@review_safe_mode && base_ref.match?(/\.(?:md|markdown|txt|json|ya?ml)\z/i))
1352
1500
 
1353
1501
  # Try to resolve as file reference first (handles extension-less files like README, CONTEXT)
1354
1502
  resolved_path = resolve_file_reference(base_ref)
@@ -1356,16 +1504,19 @@ module Ace
1356
1504
  # Check if we successfully resolved to an existing file
1357
1505
  if resolved_path && File.exist?(resolved_path)
1358
1506
  # Load base content from file
1359
- base_content = File.read(resolved_path).strip
1507
+ assert_allowed_file!(resolved_path)
1508
+ base_source = File.read(resolved_path)
1509
+ base_content = base_source.strip
1360
1510
  if base_content.empty?
1361
1511
  warn "Warning: Base file is empty: #{resolved_path}" if options[:debug]
1362
1512
  end
1363
1513
 
1364
1514
  # Store base content as primary content
1365
- bundle.content = base_content
1515
+ bundle.content = [base_content, (bundle.content if files_in_content)].compact.join("\n\n")
1366
1516
  bundle.metadata[:base_path] = resolved_path
1367
1517
  bundle.metadata[:base_ref] = base_ref
1368
1518
  bundle.metadata[:base_type] = "file"
1519
+ bundle.source_files << {path: resolved_path, content: base_source}
1369
1520
  elsif looks_like_file_ref
1370
1521
  # It looks like a file reference but resolution failed - set error
1371
1522
  if !resolved_path
@@ -1375,11 +1526,15 @@ module Ace
1375
1526
  bundle.metadata[:base_error] = "Base file not found: #{resolved_path}"
1376
1527
  warn "Warning: Base file not found: #{resolved_path}" if options[:debug]
1377
1528
  end
1529
+ if @review_safe_mode
1530
+ bundle.metadata[:errors] ||= []
1531
+ bundle.metadata[:errors] << bundle.metadata[:base_error]
1532
+ end
1378
1533
  else
1379
1534
  # Simple string without path indicators - treat as inline content
1380
1535
  # This allows direct definition of base context without requiring separate files
1381
1536
  # Example: base: "System instructions for the task"
1382
- bundle.content = base_ref.to_s.strip
1537
+ bundle.content = [base_ref.to_s.strip, (bundle.content if files_in_content)].compact.join("\n\n")
1383
1538
  bundle.metadata[:base_type] = "inline"
1384
1539
  bundle.metadata[:base_ref] = base_ref
1385
1540
  end
@@ -1481,6 +1636,102 @@ module Ace
1481
1636
  Atoms::TypoDetector.detect_suspicious_keys(frontmatter, path)
1482
1637
  end
1483
1638
 
1639
+ def reject_untrusted_commands!
1640
+ return unless @review_safe_mode
1641
+
1642
+ raise Ace::Bundle::Error, "Command sources are forbidden in review prompts"
1643
+ end
1644
+
1645
+ def reject_untrusted_diff_sources!
1646
+ return unless @review_safe_mode
1647
+
1648
+ raise Ace::Bundle::Error, "Secondary PR and Git diff sources are forbidden in review prompts"
1649
+ end
1650
+
1651
+ def merge_params(params)
1652
+ if @review_safe_mode
1653
+ restricted = %w[allow_commands allowed_root allowed_paths base_dir]
1654
+ supplied = params.keys.map(&:to_s) & restricted
1655
+ unless supplied.empty?
1656
+ raise Ace::Bundle::Error, "Review template cannot override safety options: #{supplied.join(", ")}"
1657
+ end
1658
+ end
1659
+ @options.merge(params)
1660
+ end
1661
+
1662
+ def missing_review_sources(files, base_dir:, exclude:)
1663
+ return [] unless @review_safe_mode
1664
+
1665
+ files.select do |pattern|
1666
+ Ace::Core::Atoms::GlobExpander.expand_with_exclusions(pattern,
1667
+ base_dir: base_dir, exclude: exclude).empty?
1668
+ end
1669
+ end
1670
+
1671
+ def record_review_preset_sources!(bundle, since:)
1672
+ return unless @review_safe_mode
1673
+
1674
+ paths = @preset_manager.used_source_files.drop(since).uniq
1675
+ paths.each { |path| assert_allowed_file!(path) }
1676
+ bundle.metadata[:preset_source_files] = paths if paths.any?
1677
+ if paths.any?
1678
+ bundle.metadata[:preset_sources] = @preset_manager.used_source_snapshots.select { |source| paths.include?(source[:path]) }
1679
+ end
1680
+ end
1681
+
1682
+ def record_review_template_source!(bundle, path, content)
1683
+ return bundle unless @review_safe_mode
1684
+
1685
+ include_review_section_sources!(bundle)
1686
+ bundle.source_files << {path: path, content: content}
1687
+ bundle.source_files.uniq! { |file| [file[:path], file[:content]] }
1688
+ bundle
1689
+ end
1690
+
1691
+ def include_review_section_sources!(bundle)
1692
+ return unless @review_safe_mode
1693
+
1694
+ bundle.source_files += bundle.sections.values.flat_map { |section| Array(section[:_processed_files]) }
1695
+ bundle.source_files.uniq! { |file| [file[:path], file[:content]] }
1696
+ end
1697
+
1698
+ def safe_allowed_root
1699
+ @review_allowed_root if @review_safe_mode
1700
+ end
1701
+
1702
+ def assert_allowed_file!(path)
1703
+ return unless @review_safe_mode
1704
+ return unless File.exist?(path)
1705
+
1706
+ root = File.realpath(safe_allowed_root)
1707
+ actual = File.realpath(path)
1708
+ return if actual == root || actual.start_with?("#{root}#{File::SEPARATOR}")
1709
+ return if @allowed_protocol_files.include?(actual)
1710
+
1711
+ raise Ace::Bundle::Error, "File source outside allowed project root: #{path}"
1712
+ end
1713
+
1714
+ def authorize_protocol_path!(reference, path)
1715
+ return unless @review_safe_mode
1716
+
1717
+ actual = File.realpath(path)
1718
+ root = File.realpath(safe_allowed_root)
1719
+ return if actual == root || actual.start_with?("#{root}#{File::SEPARATOR}")
1720
+
1721
+ unless reference.start_with?("prompt://")
1722
+ raise Ace::Bundle::Error, "Protocol file source outside allowed project root: #{reference}"
1723
+ end
1724
+ spec = Gem.loaded_specs["ace-review"] || Gem::Specification.find_by_name("ace-review")
1725
+ package_root = File.realpath(spec.full_gem_path)
1726
+ unless actual.start_with?("#{package_root}#{File::SEPARATOR}")
1727
+ raise Ace::Bundle::Error, "Prompt source is not owned by the installed ace-review package: #{reference}"
1728
+ end
1729
+
1730
+ @allowed_protocol_files << actual unless @allowed_protocol_files.include?(actual)
1731
+ rescue Gem::LoadError
1732
+ raise Ace::Bundle::Error, "Cannot validate installed ace-review prompt source: #{reference}"
1733
+ end
1734
+
1484
1735
  # Delegate to Atoms::TypoDetector for architectural consistency
1485
1736
  # @deprecated Use Atoms::TypoDetector.typo_distance directly
1486
1737
  def typo_distance(str1, str2)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ace/git"
4
+ require "ace/git/github"
4
5
 
5
6
  module Ace
6
7
  module Bundle
@@ -9,7 +10,7 @@ module Ace
9
10
  #
10
11
  # Responsible for:
11
12
  # - Normalizing PR references (string, array, hash formats)
12
- # - Fetching diffs via Ace::Git::Molecules::PrMetadataFetcher
13
+ # - Fetching diffs via Ace::Git::Github::PrFetcher
13
14
  # - Integrating results into bundle sections
14
15
  # - Error handling and surfacing
15
16
  #
@@ -82,7 +83,7 @@ module Ace
82
83
  # @param pr_ref [String] Single PR reference
83
84
  # @return [Hash, nil] Diff result or nil on skip
84
85
  def fetch_single_diff(bundle, pr_ref)
85
- result = Ace::Git::Molecules::PrMetadataFetcher.fetch_diff(pr_ref, timeout: @timeout)
86
+ result = Ace::Git::Github::PrFetcher.fetch_diff(pr_ref, timeout: @timeout)
86
87
 
87
88
  if result[:success]
88
89
  {
@@ -96,8 +97,8 @@ module Ace
96
97
  {range: pr_range_identifier(pr_ref), output: "Error: #{result[:error]}", success: false, error: result[:error], source: :pr}
97
98
  end
98
99
  rescue Ace::Git::Error => e
99
- # Catches all ace-git errors: GitError, GhNotInstalledError, GhAuthenticationError,
100
- # PrNotFoundError, TimeoutError (all inherit from Ace::Git::Error)
100
+ # Catches all ace-git errors: GitError, ProviderCliMissingError, ProviderAuthenticationError,
101
+ # ProviderObjectNotFoundError, TimeoutError (all inherit from Ace::Git::Error)
101
102
  record_error(bundle, "PR fetch failed for '#{pr_ref}': #{e.message}")
102
103
  {range: pr_range_identifier(pr_ref), output: "Error: #{e.message}", success: false, error: e.message, source: :pr}
103
104
  rescue ArgumentError => e
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Bundle
5
- VERSION = "0.43.8"
5
+ VERSION = "0.44.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-bundle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.43.8
4
+ version: 0.44.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-08-12 00:00:00.000000000 Z
10
+ date: 2026-09-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -29,28 +29,28 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0.17'
32
+ version: '0.18'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '0.17'
39
+ version: '0.18'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: ace-support-core
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '0.31'
46
+ version: '0.32'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '0.31'
53
+ version: '0.32'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: ace-support-fs
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -71,14 +71,28 @@ dependencies:
71
71
  requirements:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: '0.22'
74
+ version: '0.24'
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
- version: '0.22'
81
+ version: '0.24'
82
+ - !ruby/object:Gem::Dependency
83
+ name: ace-git-github
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: 0.1.1
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: 0.1.1
82
96
  - !ruby/object:Gem::Dependency
83
97
  name: ace-support-nav
84
98
  requirement: !ruby/object:Gem::Requirement