woods 2.0.0.beta1 → 2.0.0.beta3

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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +400 -1
  3. data/CONTRIBUTING.md +224 -9
  4. data/README.md +7 -3
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +83 -4
  7. data/docs/AGENT_SETUP.md +82 -1
  8. data/docs/BACKEND_MATRIX.md +20 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +233 -13
  11. data/docs/CONSOLE_MCP_SETUP.md +35 -5
  12. data/docs/DOCKER_SETUP.md +21 -2
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +36 -5
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +158 -2
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +15 -7
  20. data/docs/MCP_SERVERS.md +221 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +33 -18
  22. data/docs/NOTION_INTEGRATION.md +13 -0
  23. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  24. data/docs/PUBLISHED_INDEX.md +71 -0
  25. data/docs/README.md +7 -0
  26. data/docs/RETRIEVAL_GUIDE.md +253 -11
  27. data/docs/RUNTIME_TRACING.md +71 -0
  28. data/docs/SOURCE_FRESHNESS.md +143 -0
  29. data/docs/TROUBLESHOOTING.md +117 -5
  30. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  31. data/docs/UPGRADING_TO_2.md +44 -22
  32. data/docs/WATCH_DAEMON.md +259 -59
  33. data/exe/woods-agent-config +6 -0
  34. data/exe/woods-extract +5 -0
  35. data/exe/woods-hook-context +6 -0
  36. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  37. data/lib/tasks/woods.rake +47 -397
  38. data/lib/woods/agent_configuration/applier.rb +133 -0
  39. data/lib/woods/agent_configuration/cli.rb +101 -0
  40. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  41. data/lib/woods/agent_configuration/document.rb +105 -0
  42. data/lib/woods/agent_configuration/error.rb +7 -0
  43. data/lib/woods/agent_configuration/launcher.rb +75 -0
  44. data/lib/woods/agent_configuration/layout.rb +59 -0
  45. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  46. data/lib/woods/agent_configuration/plan.rb +98 -0
  47. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  48. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  49. data/lib/woods/agent_configuration/planner.rb +63 -0
  50. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  51. data/lib/woods/agent_configuration/preflight.rb +100 -0
  52. data/lib/woods/agent_configuration/recovery.rb +49 -0
  53. data/lib/woods/ast/node.rb +2 -0
  54. data/lib/woods/ast/parser.rb +38 -5
  55. data/lib/woods/atomic_file.rb +133 -3
  56. data/lib/woods/builder.rb +21 -5
  57. data/lib/woods/cache/cache_middleware.rb +28 -7
  58. data/lib/woods/cache/cache_store.rb +4 -5
  59. data/lib/woods/change_set.rb +5 -4
  60. data/lib/woods/console/credential_index.rb +20 -2
  61. data/lib/woods/console/credential_scanner.rb +14 -14
  62. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  63. data/lib/woods/console/embedded_executor.rb +1 -1
  64. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  65. data/lib/woods/console/rack_middleware.rb +22 -13
  66. data/lib/woods/console/server.rb +18 -16
  67. data/lib/woods/dependency_graph.rb +65 -13
  68. data/lib/woods/embedding/corpus.rb +94 -0
  69. data/lib/woods/embedding/indexer.rb +90 -46
  70. data/lib/woods/embedding/openai.rb +17 -6
  71. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  72. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  73. data/lib/woods/export/typed_reader.rb +56 -0
  74. data/lib/woods/extractor.rb +557 -228
  75. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  76. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  77. data/lib/woods/extractors/caching_extractor.rb +3 -1
  78. data/lib/woods/extractors/concern_extractor.rb +64 -6
  79. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  80. data/lib/woods/extractors/controller_extractor.rb +13 -4
  81. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  82. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  83. data/lib/woods/extractors/engine_extractor.rb +3 -1
  84. data/lib/woods/extractors/event_extractor.rb +4 -2
  85. data/lib/woods/extractors/factory_extractor.rb +3 -1
  86. data/lib/woods/extractors/graphql_extractor.rb +8 -2
  87. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  88. data/lib/woods/extractors/job_extractor.rb +6 -19
  89. data/lib/woods/extractors/lib_extractor.rb +3 -1
  90. data/lib/woods/extractors/mailer_extractor.rb +20 -5
  91. data/lib/woods/extractors/manager_extractor.rb +3 -1
  92. data/lib/woods/extractors/method_parameters.rb +53 -0
  93. data/lib/woods/extractors/middleware_argument.rb +65 -0
  94. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  95. data/lib/woods/extractors/migration_extractor.rb +3 -1
  96. data/lib/woods/extractors/model_extractor.rb +39 -33
  97. data/lib/woods/extractors/package_extractor.rb +24 -4
  98. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  99. data/lib/woods/extractors/policy_extractor.rb +3 -1
  100. data/lib/woods/extractors/poro_extractor.rb +3 -1
  101. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  102. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  103. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  104. data/lib/woods/extractors/route_extractor.rb +3 -1
  105. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  106. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  107. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  108. data/lib/woods/extractors/service_extractor.rb +3 -1
  109. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  110. data/lib/woods/extractors/shared_utility_methods.rb +27 -15
  111. data/lib/woods/extractors/source_nesting.rb +1 -1
  112. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  113. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  114. data/lib/woods/extractors/validator_extractor.rb +3 -1
  115. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  116. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  117. data/lib/woods/flow_assembler.rb +87 -8
  118. data/lib/woods/flow_precomputer.rb +44 -7
  119. data/lib/woods/gem_mapper.rb +2 -0
  120. data/lib/woods/git_history.rb +116 -0
  121. data/lib/woods/graph_analyzer.rb +195 -63
  122. data/lib/woods/hooks/context_cli.rb +54 -0
  123. data/lib/woods/hooks/context_event.rb +88 -0
  124. data/lib/woods/hooks/context_hint.rb +73 -0
  125. data/lib/woods/hooks/context_impact.rb +77 -0
  126. data/lib/woods/hooks/context_output.rb +47 -0
  127. data/lib/woods/hooks/context_state.rb +102 -0
  128. data/lib/woods/hooks/refresh.rb +79 -0
  129. data/lib/woods/hooks/rule_projection.rb +78 -0
  130. data/lib/woods/input_rules.rb +19 -0
  131. data/lib/woods/mcp/bearer_auth.rb +20 -12
  132. data/lib/woods/mcp/bootstrapper.rb +62 -0
  133. data/lib/woods/mcp/index_reader.rb +323 -160
  134. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  135. data/lib/woods/mcp/origin_guard.rb +17 -9
  136. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  137. data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
  138. data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
  139. data/lib/woods/mcp/search_results.rb +74 -0
  140. data/lib/woods/mcp/server.rb +158 -37
  141. data/lib/woods/mcp/tool_contract.rb +2 -0
  142. data/lib/woods/mcp/tool_response_renderer.rb +25 -0
  143. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  144. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  145. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  146. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  147. data/lib/woods/notion/exporter.rb +56 -17
  148. data/lib/woods/obsidian/destination_plan.rb +98 -0
  149. data/lib/woods/obsidian/name_mapper.rb +19 -3
  150. data/lib/woods/obsidian/note_builder.rb +19 -10
  151. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  152. data/lib/woods/operator/pipeline_guard.rb +18 -13
  153. data/lib/woods/path_dispatcher.rb +7 -1
  154. data/lib/woods/payload_store.rb +29 -15
  155. data/lib/woods/railtie.rb +3 -3
  156. data/lib/woods/railtie_support.rb +12 -12
  157. data/lib/woods/rake_helpers.rb +392 -0
  158. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  159. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  160. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  161. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  162. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  163. data/lib/woods/resilience/index_validator.rb +112 -23
  164. data/lib/woods/retrieval/context_assembler.rb +50 -15
  165. data/lib/woods/retrieval/lexical_assembler.rb +73 -0
  166. data/lib/woods/retrieval/lexical_index.rb +119 -0
  167. data/lib/woods/retrieval/ranker.rb +4 -2
  168. data/lib/woods/retrieval/scope.rb +108 -0
  169. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  170. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  171. data/lib/woods/retrieval/search_executor.rb +86 -27
  172. data/lib/woods/retrieval/source_evidence.rb +200 -0
  173. data/lib/woods/retriever.rb +98 -22
  174. data/lib/woods/ruby_analyzer/trace_enricher.rb +80 -38
  175. data/lib/woods/session_tracer/middleware.rb +10 -12
  176. data/lib/woods/session_tracer/redis_store.rb +22 -6
  177. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  178. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  179. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  180. data/lib/woods/source_inputs/consumer_errors.rb +27 -0
  181. data/lib/woods/source_inputs/handoff.rb +102 -0
  182. data/lib/woods/source_inputs/launcher.rb +157 -0
  183. data/lib/woods/source_inputs/manifest.rb +124 -0
  184. data/lib/woods/source_inputs/private_key.rb +55 -0
  185. data/lib/woods/source_inputs/scanner.rb +171 -0
  186. data/lib/woods/source_inputs/scopes.rb +71 -0
  187. data/lib/woods/source_inputs/session.rb +214 -0
  188. data/lib/woods/source_inputs/status.rb +84 -0
  189. data/lib/woods/source_inputs/verifier.rb +107 -0
  190. data/lib/woods/storage/metadata_store.rb +25 -25
  191. data/lib/woods/storage/pgvector.rb +29 -8
  192. data/lib/woods/storage/qdrant.rb +17 -7
  193. data/lib/woods/storage/vector_store.rb +18 -6
  194. data/lib/woods/tasks.rb +3 -2
  195. data/lib/woods/temporal/json_snapshot_store.rb +29 -8
  196. data/lib/woods/unblocked/exporter.rb +59 -70
  197. data/lib/woods/version.rb +1 -1
  198. data/lib/woods/watch/boot_snapshot.rb +52 -0
  199. data/lib/woods/watch/daemon.rb +136 -28
  200. data/lib/woods/watch/listen_watcher.rb +4 -0
  201. data/lib/woods/watch/polling_watcher.rb +5 -1
  202. data/lib/woods/watch/status.rb +20 -15
  203. data/lib/woods/watch/tree_scan.rb +21 -13
  204. data/lib/woods/watch/watcher.rb +4 -1
  205. data/lib/woods.rb +135 -11
  206. data/plugin/.claude-plugin/plugin.json +1 -1
  207. data/plugin/hooks/adapters/normalize.jq +15 -0
  208. data/plugin/hooks/adapters/normalize.rb +63 -0
  209. data/plugin/hooks/hooks.json +20 -0
  210. data/plugin/hooks/woods-context.sh +50 -0
  211. data/plugin/hooks/woods-input-rules.sh +159 -0
  212. data/plugin/hooks/woods-opencode.mjs +65 -0
  213. data/plugin/hooks/woods-post-edit.sh +2 -225
  214. data/plugin/hooks/woods-refresh.sh +260 -0
  215. data/plugin/hooks/woods-session-start.sh +47 -55
  216. data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
  217. data/plugin/skills/woods-diagnose/SKILL.md +288 -1
  218. data/plugin/skills/woods-investigate/SKILL.md +106 -0
  219. data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
  220. data/plugin/skills/woods-setup/SKILL.md +107 -6
  221. metadata +84 -5
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module SessionTracer
5
+ # A bare dependency name cannot choose between published extraction types.
6
+ class AmbiguousUnitError < StandardError
7
+ attr_reader :identifier, :types
8
+
9
+ def initialize(identifier, types)
10
+ @identifier = identifier
11
+ @types = types.sort.freeze
12
+ super("Ambiguous session unit #{identifier.inspect}: published types #{@types.join(', ')}. " \
13
+ 'No session context was returned. Use depth: 0 for the timeline, or inspect each type with lookup.')
14
+ end
15
+ end
16
+
17
+ # Per-assembly resolver. The caller pins its complete lifetime to one generation.
18
+ # Summary entries may omit type in legacy indexes; the containing bucket and
19
+ # validated unit body establish the concrete type without reading unrelated bodies.
20
+ class UnitResolver
21
+ def initialize(reader)
22
+ @reader = reader
23
+ @resolved = {}
24
+ end
25
+
26
+ def find(identifier)
27
+ return @resolved[identifier] if @resolved.key?(identifier)
28
+
29
+ units = candidate_types.fetch(identifier, []).filter_map do |type|
30
+ @reader.find_unit(identifier, type: type)
31
+ end
32
+ raise AmbiguousUnitError.new(identifier, units.map { |unit| unit.fetch('type') }) if units.size > 1
33
+
34
+ @resolved[identifier] = units.first
35
+ end
36
+
37
+ private
38
+
39
+ def entries_for(dir)
40
+ path = @reader.payload_dir.join(dir, '_index.json')
41
+ raise IOError, "symlink unit index: #{dir}" if path.symlink?
42
+
43
+ entries = @reader.list_units(type: MCP::IndexReader::DIR_TO_TYPE.fetch(dir))
44
+ expected = @reader.manifest.dig('counts', dir)
45
+ if expected.is_a?(Integer) && expected != entries.size
46
+ raise IOError, "unit count mismatch in #{dir}: expected #{expected}, found #{entries.size}"
47
+ end
48
+
49
+ entries
50
+ end
51
+
52
+ def candidate_types
53
+ @candidate_types ||= MCP::IndexReader::UNIT_TYPES_BY_DIR.each_with_object({}) do |(dir, types), candidates|
54
+ entries_for(dir).each do |entry|
55
+ identifier = entry.fetch('identifier')
56
+ candidates[identifier] ||= []
57
+ candidates[identifier] |= types
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module SourceInputs
5
+ # Error handlers can return the same nil/empty result as a successful
6
+ # negative match. Keep explicit evidence on the consumer instance instead
7
+ # of guessing from results or intercepting the application's logger.
8
+ module ConsumerErrors
9
+ FLAG = :@woods_source_consumer_failed
10
+
11
+ module_function
12
+
13
+ def record(consumer)
14
+ consumer.instance_variable_set(FLAG, true)
15
+ end
16
+
17
+ def failed?(consumer)
18
+ consumer && consumer.instance_variable_get(FLAG) == true
19
+ end
20
+
21
+ def log(consumer, ...)
22
+ record(consumer)
23
+ Rails.logger.error(...)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'woods/source_inputs/manifest'
5
+
6
+ module Woods
7
+ module SourceInputs
8
+ # Private one-process launch handoff. Its environment token names neither an
9
+ # existing index manifest nor a user-supplied proof of runtime freshness.
10
+ module Handoff
11
+ ENV_KEY = 'WOODS_SOURCE_CAPTURE'
12
+
13
+ module_function
14
+
15
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
16
+ # Every independent handoff binding must match before a token is consumed.
17
+ def read(root:, output_dir:, operation:, rules:, key_id:)
18
+ token = ENV.fetch(ENV_KEY, nil)
19
+ return nil if token.nil? || token.empty?
20
+
21
+ descriptor = JSON.parse(token)
22
+ return nil unless descriptor.is_a?(Hash)
23
+
24
+ data = private_data(descriptor.fetch('path'))
25
+ return nil unless data.is_a?(Hash)
26
+
27
+ expected = { 'version' => 1, 'nonce' => descriptor.fetch('nonce'),
28
+ 'root' => File.expand_path(root.to_s), 'output' => File.expand_path(output_dir.to_s),
29
+ 'operation' => operation.to_s, 'rules' => rules, 'launcher_pid' => Process.ppid }
30
+ return nil unless expected.all? { |key, value| data[key] == value }
31
+ return nil unless valid_nonce?(data['nonce'])
32
+
33
+ snapshot = data.fetch('snapshot')
34
+ return nil unless valid_snapshot?(snapshot, expected, key_id)
35
+
36
+ ENV.delete(ENV_KEY)
37
+ snapshot
38
+ rescue JSON::ParserError, KeyError, TypeError, SystemCallError, IOError
39
+ nil
40
+ end
41
+
42
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
43
+
44
+ def private_data(path)
45
+ flags = File::RDONLY | File::NONBLOCK
46
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
47
+ File.open(path, flags) do |file|
48
+ stat = file.stat
49
+ return nil unless stat.file? && stat.uid == Process.uid && stat.mode.nobits?(0o077)
50
+ return nil if stat.size > Manifest::MAX_BYTES
51
+
52
+ JSON.parse(file.read(Manifest::MAX_BYTES + 1))
53
+ end
54
+ end
55
+
56
+ def valid_nonce?(value)
57
+ value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
58
+ end
59
+
60
+ def valid_snapshot?(snapshot, expected, key_id)
61
+ return false unless matching_snapshot?(snapshot, expected, key_id)
62
+
63
+ files = snapshot['files']
64
+ paths = snapshot['scope_paths']
65
+ return false unless valid_tables?(files, paths)
66
+
67
+ expanded = paths.transform_values { |list| list.to_h { |path| [path, files.fetch(path)] } }
68
+ Manifest.build(snapshot: snapshot, scopes: expanded, boot_verified: true, generation: 1)
69
+ true
70
+ rescue Manifest::Invalid, KeyError, TypeError
71
+ false
72
+ end
73
+
74
+ def matching_snapshot?(snapshot, expected, key_id)
75
+ snapshot.is_a?(Hash) && snapshot['root'] == expected['root'] &&
76
+ snapshot['rules'] == expected['rules'] && snapshot['key_id'] == key_id
77
+ end
78
+
79
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
80
+ def valid_tables?(files, paths)
81
+ files.is_a?(Hash) && paths.is_a?(Hash) &&
82
+ files.all? { |path, digest| path.is_a?(String) && valid_nonce?(digest) } &&
83
+ paths.values.all? { |list| list.is_a?(Array) && list.all? { |path| files.key?(path) } }
84
+ end
85
+
86
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
87
+
88
+ def extra_roots
89
+ token = ENV.fetch(ENV_KEY, nil)
90
+ return [] if token.nil? || token.empty?
91
+
92
+ descriptor = JSON.parse(token)
93
+ return [] unless descriptor.is_a?(Hash)
94
+
95
+ roots = descriptor.fetch('extra_roots', [])
96
+ roots.is_a?(Array) ? roots : []
97
+ rescue JSON::ParserError, TypeError
98
+ []
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'tempfile'
5
+ require 'base64'
6
+ require 'securerandom'
7
+ require 'woods/source_inputs/scanner'
8
+ require 'woods/source_inputs/handoff'
9
+ require 'woods/hooks/refresh'
10
+
11
+ module Woods
12
+ module SourceInputs
13
+ # Captures before a fresh child evaluates Bundler, the Rakefile and Rails.
14
+ # The parent waits so all exit paths can remove the private handoff.
15
+ class Launcher # rubocop:disable Metrics/ClassLength -- parser and child lifetime form one CLI contract
16
+ OPERATIONS = %w[full incremental refresh].freeze
17
+
18
+ def self.run(argv, **options)
19
+ new(argv, **options).run
20
+ rescue OptionParser::ParseError, ArgumentError, PrivateKey::Unavailable, SystemCallError => e
21
+ warn "woods-extract: #{e.message}"
22
+ 1
23
+ end
24
+
25
+ def initialize(argv, command: %w[bundle exec rake])
26
+ @arguments = argv.dup
27
+ @command = command
28
+ @original_directory = Dir.pwd
29
+ @root = @original_directory
30
+ @output = ENV.fetch('WOODS_OUTPUT', 'tmp/woods')
31
+ @extra_roots = []
32
+ parse!
33
+ end
34
+
35
+ def run # rubocop:disable Metrics/MethodLength -- handoff lifetime encloses the fresh child
36
+ return 0 if @help
37
+
38
+ operation, task = task_for_arguments
39
+ key = PrivateKey.new(output_dir: @output, create: true)
40
+ rules = Scopes.new(extra_roots: @extra_roots)
41
+ snapshot = Scanner.new(root: @root, output_dir: @output, key: key, scopes: rules).call
42
+ nonce = SecureRandom.hex(32)
43
+ Tempfile.create(['woods-source-capture-', '.json']) do |file|
44
+ file.binmode
45
+ file.chmod(0o600)
46
+ file.write(JSON.generate(version: 1, nonce: nonce, root: @root, output: @output,
47
+ operation: operation, rules: rules.fingerprint,
48
+ launcher_pid: Process.pid, snapshot: snapshot))
49
+ file.flush
50
+ file.fsync
51
+ descriptor = JSON.generate(path: file.path, nonce: nonce, extra_roots: @extra_roots)
52
+ environment = { Handoff::ENV_KEY => descriptor, 'WOODS_OUTPUT' => @output }
53
+ if ENV['BUNDLE_GEMFILE']
54
+ environment['BUNDLE_GEMFILE'] = File.expand_path(ENV.fetch('BUNDLE_GEMFILE'), @original_directory)
55
+ end
56
+ run_child(environment, task)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def parse!
63
+ parser.permute!(@arguments)
64
+ validate_arguments! unless @help
65
+ end
66
+
67
+ def parser
68
+ OptionParser.new do |options|
69
+ options.banner = 'Usage: woods-extract [options] full | incremental PATH... | refresh TYPE...'
70
+ options.on('--root PATH', 'Application root (default: current directory)') { |path| @root = path }
71
+ options.on('--output PATH', 'Index output (default: WOODS_OUTPUT or tmp/woods)') { |path| @output = path }
72
+ options.on('--source-root PATH', 'Additional application-relative runtime source root') do |path|
73
+ @extra_roots << path
74
+ end
75
+ options.on('-h', '--help', 'Show usage') do
76
+ puts options
77
+ @help = true
78
+ end
79
+ end
80
+ end
81
+
82
+ def validate_arguments!
83
+ @root = File.expand_path(@root)
84
+ raise ArgumentError, 'application root does not exist' unless File.directory?(@root)
85
+
86
+ @output = File.expand_path(@output, @root)
87
+ @operation = @arguments.shift
88
+ raise ArgumentError, 'choose full, incremental, or refresh' unless OPERATIONS.include?(@operation)
89
+ if @operation == 'full' && !@arguments.empty?
90
+ raise ArgumentError,
91
+ 'full does not accept paths or extractor names'
92
+ end
93
+ if @operation != 'full' && @arguments.empty?
94
+ raise ArgumentError,
95
+ "#{@operation} requires paths or extractor names"
96
+ end
97
+
98
+ @extra_roots = Scopes.new(extra_roots: @extra_roots).extra_roots
99
+ end
100
+
101
+ def task_for_arguments
102
+ return ['full', 'woods:extract'] if @operation == 'full'
103
+ return refresh_task if @operation == 'refresh'
104
+
105
+ incremental_task
106
+ end
107
+
108
+ def incremental_task
109
+ paths = @arguments.map { |path| relative_path(path) }.uniq
110
+ action = InputRules.new
111
+ operation = paths.any? { |path| action.action(path) == :full } ? 'full' : 'incremental'
112
+ if paths.size > Hooks::Refresh::MAX_EVENTS
113
+ raise ArgumentError, 'too many incremental paths; split the input or run full'
114
+ end
115
+
116
+ batch = Base64.strict_encode64(JSON.generate(version: 1, output: @output,
117
+ events: paths.map { |path| { path: path, operation: 'update' } }))
118
+ raise ArgumentError, 'incremental batch too large; split the input or run full' if batch.bytesize > 120_000
119
+
120
+ [operation, "woods:hook_refresh[#{batch}]"]
121
+ end
122
+
123
+ def refresh_task
124
+ names = @arguments.uniq
125
+ unless names.all? { |name| name.match?(/\A[a-z_]+\z/) && Extractor::EXTRACTORS.key?(name.to_sym) }
126
+ raise ArgumentError, 'refresh requires known extractor names'
127
+ end
128
+
129
+ ['refresh', "woods:refresh[#{names.join(',')}]"]
130
+ end
131
+
132
+ def relative_path(path)
133
+ absolute = File.expand_path(path, @root)
134
+ unless absolute.start_with?("#{@root}/") && !path.include?("\0")
135
+ raise ArgumentError, 'incremental paths must be inside the application root'
136
+ end
137
+
138
+ absolute.delete_prefix("#{@root}/")
139
+ end
140
+
141
+ def run_child(environment, task)
142
+ pid = Process.spawn(environment, *@command, task, chdir: @root, pgroup: true)
143
+ previous = %w[INT TERM].to_h do |signal|
144
+ [signal, Signal.trap(signal) do
145
+ Process.kill(signal, -pid)
146
+ rescue StandardError
147
+ nil
148
+ end]
149
+ end
150
+ _finished, status = Process.wait2(pid)
151
+ status.exitstatus || (128 + status.termsig)
152
+ ensure
153
+ previous&.each { |signal, handler| Signal.trap(signal, handler) }
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Woods
6
+ module SourceInputs
7
+ # Compact wire format: consumers refer to a shared identity table. Internal
8
+ # mutation uses expanded digests so table indices never leak across runs.
9
+ class Manifest
10
+ FILE_NAME = 'source_inputs.json'
11
+ VERSION = 1
12
+ MAX_BYTES = 16 * 1024 * 1024
13
+ class Invalid < StandardError; end
14
+
15
+ attr_reader :data
16
+
17
+ def self.parse(bytes)
18
+ raise Invalid, 'source_manifest_too_large' if bytes.bytesize > MAX_BYTES
19
+
20
+ new(JSON.parse(bytes))
21
+ rescue JSON::ParserError
22
+ raise Invalid, 'invalid_source_manifest'
23
+ end
24
+
25
+ def initialize(data)
26
+ @data = data
27
+ validate!
28
+ end
29
+
30
+ def expanded
31
+ identities = @data.fetch('identities')
32
+ @data.fetch('scopes').transform_values do |paths|
33
+ paths.transform_values { |index| identities.fetch(index) }
34
+ end
35
+ end
36
+
37
+ # Explicit wire fields keep incomplete coverage separate from boot provenance.
38
+ # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
39
+ def self.build(snapshot:, scopes:, boot_verified:, generation:, errors: [], unverified_scopes: [])
40
+ identities = scopes.values.flat_map(&:values).uniq.sort
41
+ indices = identities.each_with_index.to_h
42
+ new('version' => VERSION, 'generation' => generation, 'root' => snapshot.fetch('root'),
43
+ 'key_id' => snapshot.fetch('key_id'), 'rules' => snapshot.fetch('rules'),
44
+ 'extra_roots' => snapshot.fetch('extra_roots'), 'boot_verified' => boot_verified,
45
+ 'complete' => snapshot.fetch('complete'), 'errors' => (snapshot.fetch('errors') + errors).uniq,
46
+ 'unverified_scopes' => unverified_scopes.uniq.sort,
47
+ 'identities' => identities, 'metrics' => snapshot.fetch('metrics'),
48
+ 'scopes' => scopes.sort.to_h.transform_values do |paths|
49
+ paths.sort.to_h.transform_values { |digest| indices.fetch(digest) }
50
+ end)
51
+ end
52
+
53
+ # rubocop:enable Metrics/AbcSize, Metrics/ParameterLists
54
+
55
+ private
56
+
57
+ def validate!
58
+ raise Invalid, 'unsupported_source_manifest' unless supported_version?
59
+ raise Invalid, 'invalid_source_manifest_header' unless valid_header?
60
+
61
+ validate_tables!
62
+ return if @data['extra_roots'].all? { |path| valid_path?(path) }
63
+
64
+ raise Invalid, 'invalid_source_roots'
65
+ end
66
+
67
+ def validate_tables!
68
+ identities = @data['identities']
69
+ scopes = @data['scopes']
70
+ unless identities.is_a?(Array) && identities.all? { |digest| digest?(digest) } && scopes.is_a?(Hash)
71
+ raise Invalid, 'invalid_source_identity_table'
72
+ end
73
+
74
+ scopes.each { |scope, paths| validate_scope!(scope, paths, identities) }
75
+ raise Invalid, 'invalid_source_manifest_coverage' unless valid_coverage?
76
+ end
77
+
78
+ def validate_scope!(scope, paths, identities)
79
+ return if scope.is_a?(String) && paths.is_a?(Hash) &&
80
+ paths.all? { |path, index| valid_entry?(path, index, identities) }
81
+
82
+ raise Invalid, 'invalid_source_scope'
83
+ end
84
+
85
+ def supported_version?
86
+ @data.is_a?(Hash) && @data['version'] == VERSION &&
87
+ @data['generation'].is_a?(Integer) && @data['generation'].positive?
88
+ end
89
+
90
+ def valid_header?
91
+ valid_root? && %w[key_id rules].all? { |key| digest?(@data[key]) } &&
92
+ %w[boot_verified complete].all? { |key| [true, false].include?(@data[key]) }
93
+ end
94
+
95
+ def valid_root?
96
+ root = @data['root']
97
+ root.is_a?(String) && root.start_with?('/') && !root.include?("\0")
98
+ end
99
+
100
+ def valid_coverage?
101
+ @data['errors'].is_a?(Array) && @data['errors'].all? { |error| valid_error?(error) } &&
102
+ %w[extra_roots unverified_scopes].all? { |key| @data[key].is_a?(Array) && @data[key].all?(String) }
103
+ end
104
+
105
+ def valid_error?(error)
106
+ error.is_a?(Hash) && error['reason'].is_a?(String) &&
107
+ (error['path'].nil? || error['path'].is_a?(String))
108
+ end
109
+
110
+ def valid_entry?(path, index, identities)
111
+ valid_path?(path) && index.is_a?(Integer) && index >= 0 && index < identities.size
112
+ end
113
+
114
+ def valid_path?(path)
115
+ path.is_a?(String) && !path.empty? && !path.start_with?('/') && !path.include?("\0") &&
116
+ path.split('/', -1).none? { |part| ['', '.', '..'].include?(part) }
117
+ end
118
+
119
+ def digest?(value)
120
+ value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'digest'
5
+ require 'fileutils'
6
+
7
+ module Woods
8
+ module SourceInputs
9
+ # Never copied into a published generation. Public identities are keyed even
10
+ # for ordinary source, so an unrecognized secret-bearing file stays protected.
11
+ class PrivateKey
12
+ FILE_NAME = '.source-inputs.key'
13
+ class Unavailable < StandardError; end
14
+
15
+ attr_reader :bytes, :identifier
16
+
17
+ def initialize(output_dir:, create: false)
18
+ path = File.join(output_dir.to_s, FILE_NAME)
19
+ create_key(path) if create
20
+ flags = File::RDONLY | File::NONBLOCK
21
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
22
+ File.open(path, flags) do |file|
23
+ stat = file.stat
24
+ raise Unavailable, 'insecure_identity_key' unless private_regular?(stat)
25
+
26
+ @bytes = file.read(33)
27
+ end
28
+ raise Unavailable, 'invalid_identity_key' unless @bytes&.bytesize == 32
29
+
30
+ @identifier = Digest::SHA256.hexdigest(@bytes)
31
+ rescue SystemCallError, IOError
32
+ raise Unavailable, 'identity_key_unavailable'
33
+ end
34
+
35
+ private
36
+
37
+ def private_regular?(stat)
38
+ stat.file? && stat.uid == Process.uid && stat.mode.nobits?(0o077)
39
+ end
40
+
41
+ def create_key(path)
42
+ FileUtils.mkdir_p(File.dirname(path))
43
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
44
+ file.binmode
45
+ file.write(SecureRandom.random_bytes(32))
46
+ file.flush
47
+ file.fsync
48
+ end
49
+ rescue Errno::EEXIST
50
+ # Existing permissions/content are validated, never repaired implicitly.
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ end