woods 2.0.0.beta2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +262 -1
- data/CONTRIBUTING.md +173 -9
- data/README.md +7 -3
- data/SECURITY.md +9 -6
- data/docs/AGENT_GUIDE.md +83 -4
- data/docs/AGENT_SETUP.md +82 -1
- data/docs/BACKEND_MATRIX.md +20 -0
- data/docs/CLIENT_HOOKS.md +111 -0
- data/docs/CONFIGURATION_REFERENCE.md +199 -14
- data/docs/CONSOLE_MCP_SETUP.md +35 -5
- data/docs/DOCKER_SETUP.md +21 -2
- data/docs/EVALUATION.md +464 -1
- data/docs/EXTRACTOR_REFERENCE.md +36 -5
- data/docs/FAQ.md +11 -12
- data/docs/GETTING_STARTED.md +17 -5
- data/docs/INCREMENTAL_EXTRACTION.md +117 -1
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +7 -2
- data/docs/MCP_SERVERS.md +221 -5
- data/docs/MCP_TOOL_COOKBOOK.md +33 -18
- data/docs/NOTION_INTEGRATION.md +13 -0
- data/docs/OBSIDIAN_INTEGRATION.md +57 -9
- data/docs/PUBLISHED_INDEX.md +55 -0
- data/docs/README.md +7 -0
- data/docs/RETRIEVAL_GUIDE.md +253 -11
- data/docs/RUNTIME_TRACING.md +71 -0
- data/docs/SOURCE_FRESHNESS.md +143 -0
- data/docs/TROUBLESHOOTING.md +117 -5
- data/docs/UNBLOCKED_INTEGRATION.md +25 -0
- data/docs/UPGRADING_TO_2.md +44 -22
- data/docs/WATCH_DAEMON.md +259 -59
- data/exe/woods-agent-config +6 -0
- data/exe/woods-extract +5 -0
- data/exe/woods-hook-context +6 -0
- data/lib/generators/woods/templates/woods.rb.tt +1 -3
- data/lib/tasks/woods.rake +47 -397
- data/lib/woods/agent_configuration/applier.rb +133 -0
- data/lib/woods/agent_configuration/cli.rb +101 -0
- data/lib/woods/agent_configuration/cli_options.rb +29 -0
- data/lib/woods/agent_configuration/document.rb +105 -0
- data/lib/woods/agent_configuration/error.rb +7 -0
- data/lib/woods/agent_configuration/launcher.rb +75 -0
- data/lib/woods/agent_configuration/layout.rb +59 -0
- data/lib/woods/agent_configuration/managed_section.rb +62 -0
- data/lib/woods/agent_configuration/plan.rb +98 -0
- data/lib/woods/agent_configuration/plan_diff.rb +38 -0
- data/lib/woods/agent_configuration/planned_files.rb +61 -0
- data/lib/woods/agent_configuration/planner.rb +63 -0
- data/lib/woods/agent_configuration/planner_validation.rb +77 -0
- data/lib/woods/agent_configuration/preflight.rb +100 -0
- data/lib/woods/agent_configuration/recovery.rb +49 -0
- data/lib/woods/ast/node.rb +2 -0
- data/lib/woods/ast/parser.rb +38 -5
- data/lib/woods/builder.rb +21 -5
- data/lib/woods/cache/cache_middleware.rb +28 -7
- data/lib/woods/cache/cache_store.rb +4 -5
- data/lib/woods/change_set.rb +5 -4
- data/lib/woods/console/credential_index.rb +20 -2
- data/lib/woods/console/credential_scanner.rb +14 -14
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/embedded_executor.rb +1 -1
- data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
- data/lib/woods/console/rack_middleware.rb +22 -13
- data/lib/woods/console/server.rb +18 -16
- data/lib/woods/dependency_graph.rb +65 -13
- data/lib/woods/embedding/corpus.rb +94 -0
- data/lib/woods/embedding/indexer.rb +90 -46
- data/lib/woods/embedding/openai.rb +17 -6
- data/lib/woods/evaluation/ablation_executor.rb +6 -1
- data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
- data/lib/woods/export/typed_reader.rb +56 -0
- data/lib/woods/extractor.rb +232 -137
- data/lib/woods/extractors/action_cable_extractor.rb +3 -1
- data/lib/woods/extractors/behavioral_profile.rb +9 -7
- data/lib/woods/extractors/caching_extractor.rb +3 -1
- data/lib/woods/extractors/concern_extractor.rb +64 -6
- data/lib/woods/extractors/configuration_extractor.rb +7 -3
- data/lib/woods/extractors/controller_extractor.rb +13 -4
- data/lib/woods/extractors/database_view_extractor.rb +3 -1
- data/lib/woods/extractors/decorator_extractor.rb +3 -1
- data/lib/woods/extractors/engine_extractor.rb +3 -1
- data/lib/woods/extractors/event_extractor.rb +4 -2
- data/lib/woods/extractors/factory_extractor.rb +3 -1
- data/lib/woods/extractors/graphql_extractor.rb +8 -2
- data/lib/woods/extractors/i18n_extractor.rb +3 -1
- data/lib/woods/extractors/job_extractor.rb +6 -19
- data/lib/woods/extractors/lib_extractor.rb +3 -1
- data/lib/woods/extractors/mailer_extractor.rb +20 -5
- data/lib/woods/extractors/manager_extractor.rb +3 -1
- data/lib/woods/extractors/method_parameters.rb +53 -0
- data/lib/woods/extractors/middleware_argument.rb +65 -0
- data/lib/woods/extractors/middleware_extractor.rb +9 -3
- data/lib/woods/extractors/migration_extractor.rb +3 -1
- data/lib/woods/extractors/model_extractor.rb +39 -33
- data/lib/woods/extractors/package_extractor.rb +24 -4
- data/lib/woods/extractors/phlex_extractor.rb +3 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -1
- data/lib/woods/extractors/poro_extractor.rb +3 -1
- data/lib/woods/extractors/pundit_extractor.rb +3 -1
- data/lib/woods/extractors/rails_source_extractor.rb +4 -2
- data/lib/woods/extractors/rake_task_extractor.rb +4 -2
- data/lib/woods/extractors/route_extractor.rb +3 -1
- data/lib/woods/extractors/route_helper_resolver.rb +10 -33
- data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
- data/lib/woods/extractors/serializer_extractor.rb +4 -2
- data/lib/woods/extractors/service_extractor.rb +3 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
- data/lib/woods/extractors/shared_utility_methods.rb +27 -15
- data/lib/woods/extractors/source_nesting.rb +1 -1
- data/lib/woods/extractors/state_machine_extractor.rb +3 -1
- data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
- data/lib/woods/extractors/validator_extractor.rb +3 -1
- data/lib/woods/extractors/view_component_extractor.rb +3 -1
- data/lib/woods/extractors/view_template_extractor.rb +3 -1
- data/lib/woods/gem_mapper.rb +2 -0
- data/lib/woods/git_history.rb +116 -0
- data/lib/woods/graph_analyzer.rb +35 -6
- data/lib/woods/hooks/context_cli.rb +54 -0
- data/lib/woods/hooks/context_event.rb +88 -0
- data/lib/woods/hooks/context_hint.rb +73 -0
- data/lib/woods/hooks/context_impact.rb +77 -0
- data/lib/woods/hooks/context_output.rb +47 -0
- data/lib/woods/hooks/context_state.rb +102 -0
- data/lib/woods/hooks/refresh.rb +79 -0
- data/lib/woods/hooks/rule_projection.rb +78 -0
- data/lib/woods/input_rules.rb +19 -0
- data/lib/woods/mcp/bearer_auth.rb +20 -12
- data/lib/woods/mcp/bootstrapper.rb +62 -0
- data/lib/woods/mcp/index_reader.rb +323 -160
- data/lib/woods/mcp/initialization_guidance.rb +27 -0
- data/lib/woods/mcp/origin_guard.rb +17 -9
- data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
- data/lib/woods/mcp/search_results.rb +74 -0
- data/lib/woods/mcp/server.rb +158 -37
- data/lib/woods/mcp/tool_contract.rb +2 -0
- data/lib/woods/mcp/tool_response_renderer.rb +25 -0
- data/lib/woods/mcp/traversal_evidence.rb +113 -0
- data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
- data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
- data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
- data/lib/woods/notion/exporter.rb +56 -17
- data/lib/woods/obsidian/destination_plan.rb +98 -0
- data/lib/woods/obsidian/name_mapper.rb +19 -3
- data/lib/woods/obsidian/note_builder.rb +19 -10
- data/lib/woods/obsidian/vault_exporter.rb +88 -32
- data/lib/woods/operator/pipeline_guard.rb +18 -13
- data/lib/woods/path_dispatcher.rb +7 -1
- data/lib/woods/payload_store.rb +27 -26
- data/lib/woods/railtie.rb +3 -3
- data/lib/woods/railtie_support.rb +12 -12
- data/lib/woods/rake_helpers.rb +392 -0
- data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
- data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
- data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
- data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
- data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
- data/lib/woods/resilience/index_validator.rb +112 -23
- data/lib/woods/retrieval/context_assembler.rb +50 -15
- data/lib/woods/retrieval/lexical_assembler.rb +73 -0
- data/lib/woods/retrieval/lexical_index.rb +119 -0
- data/lib/woods/retrieval/ranker.rb +4 -2
- data/lib/woods/retrieval/scope.rb +108 -0
- data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
- data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
- data/lib/woods/retrieval/search_executor.rb +86 -27
- data/lib/woods/retrieval/source_evidence.rb +200 -0
- data/lib/woods/retriever.rb +98 -22
- data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
- data/lib/woods/session_tracer/middleware.rb +10 -12
- data/lib/woods/session_tracer/redis_store.rb +22 -6
- data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
- data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
- data/lib/woods/session_tracer/unit_resolver.rb +63 -0
- data/lib/woods/source_inputs/consumer_errors.rb +27 -0
- data/lib/woods/source_inputs/handoff.rb +102 -0
- data/lib/woods/source_inputs/launcher.rb +157 -0
- data/lib/woods/source_inputs/manifest.rb +124 -0
- data/lib/woods/source_inputs/private_key.rb +55 -0
- data/lib/woods/source_inputs/scanner.rb +171 -0
- data/lib/woods/source_inputs/scopes.rb +71 -0
- data/lib/woods/source_inputs/session.rb +214 -0
- data/lib/woods/source_inputs/status.rb +84 -0
- data/lib/woods/source_inputs/verifier.rb +107 -0
- data/lib/woods/storage/metadata_store.rb +25 -25
- data/lib/woods/storage/pgvector.rb +29 -8
- data/lib/woods/storage/qdrant.rb +17 -7
- data/lib/woods/storage/vector_store.rb +18 -6
- data/lib/woods/tasks.rb +3 -2
- data/lib/woods/temporal/json_snapshot_store.rb +29 -8
- data/lib/woods/unblocked/exporter.rb +59 -70
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/boot_snapshot.rb +52 -0
- data/lib/woods/watch/daemon.rb +136 -28
- data/lib/woods/watch/listen_watcher.rb +4 -0
- data/lib/woods/watch/polling_watcher.rb +5 -1
- data/lib/woods/watch/status.rb +20 -15
- data/lib/woods/watch/tree_scan.rb +21 -13
- data/lib/woods/watch/watcher.rb +4 -1
- data/lib/woods.rb +50 -11
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.jq +15 -0
- data/plugin/hooks/adapters/normalize.rb +63 -0
- data/plugin/hooks/hooks.json +20 -0
- data/plugin/hooks/woods-context.sh +50 -0
- data/plugin/hooks/woods-input-rules.sh +159 -0
- data/plugin/hooks/woods-opencode.mjs +65 -0
- data/plugin/hooks/woods-post-edit.sh +2 -225
- data/plugin/hooks/woods-refresh.sh +260 -0
- data/plugin/hooks/woods-session-start.sh +47 -55
- data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
- data/plugin/skills/woods-diagnose/SKILL.md +288 -1
- data/plugin/skills/woods-investigate/SKILL.md +106 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
- data/plugin/skills/woods-setup/SKILL.md +107 -6
- 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
|