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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +400 -1
- data/CONTRIBUTING.md +224 -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 +233 -13
- 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 +158 -2
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +15 -7
- 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 +71 -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/atomic_file.rb +133 -3
- 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 +557 -228
- 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/flow_assembler.rb +87 -8
- data/lib/woods/flow_precomputer.rb +44 -7
- data/lib/woods/gem_mapper.rb +2 -0
- data/lib/woods/git_history.rb +116 -0
- data/lib/woods/graph_analyzer.rb +195 -63
- 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 +29 -15
- 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 +80 -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 +135 -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
data/lib/woods/builder.rb
CHANGED
|
@@ -110,6 +110,11 @@ module Woods
|
|
|
110
110
|
# on disk and passes the populated store here.
|
|
111
111
|
# @return [Retriever, Cache::CachedRetriever] A fully wired retriever
|
|
112
112
|
def build_retriever(vector_store: nil, metadata_store: nil, graph_store: nil)
|
|
113
|
+
if @config.retrieval_mode == :lexical
|
|
114
|
+
return build_lexical_retriever(metadata_store: metadata_store,
|
|
115
|
+
graph_store: graph_store)
|
|
116
|
+
end
|
|
117
|
+
|
|
113
118
|
provider = build_resilient_embedding_provider
|
|
114
119
|
cache = build_cache_store
|
|
115
120
|
|
|
@@ -119,12 +124,21 @@ module Woods
|
|
|
119
124
|
vector_store: vector_store || build_vector_store(dimensions: vector_dimensions(provider)),
|
|
120
125
|
metadata_store: metadata_store || build_metadata_store,
|
|
121
126
|
graph_store: graph_store || build_graph_store,
|
|
122
|
-
embedding_provider: provider
|
|
127
|
+
embedding_provider: provider, default_budget: @config.max_context_tokens
|
|
123
128
|
)
|
|
124
129
|
|
|
125
130
|
cache ? wrap_with_retriever_cache(retriever, cache) : retriever
|
|
126
131
|
end
|
|
127
132
|
|
|
133
|
+
def build_lexical_retriever(metadata_store:, graph_store:)
|
|
134
|
+
retriever = Retriever.new(vector_store: nil, metadata_store: metadata_store || build_metadata_store,
|
|
135
|
+
graph_store: graph_store, embedding_provider: nil, mode: :lexical,
|
|
136
|
+
default_budget: @config.max_context_tokens)
|
|
137
|
+
cache = build_cache_store
|
|
138
|
+
cache ? wrap_with_retriever_cache(retriever, cache) : retriever
|
|
139
|
+
end
|
|
140
|
+
private :build_lexical_retriever
|
|
141
|
+
|
|
128
142
|
# Instantiate the vector store adapter specified by the configuration.
|
|
129
143
|
#
|
|
130
144
|
# The :pgvector branch also ensures the adapter's own schema exists —
|
|
@@ -482,19 +496,21 @@ module Woods
|
|
|
482
496
|
|
|
483
497
|
# Instantiate the metadata store adapter specified by the configuration.
|
|
484
498
|
#
|
|
499
|
+
# @param output_dir [String, Pathname] Default SQLite database directory; an
|
|
500
|
+
# explicit metadata_store_options[:database] still takes precedence.
|
|
485
501
|
# @return [Storage::MetadataStore::Interface] Metadata store adapter instance
|
|
486
502
|
# @raise [ArgumentError] if the configured type is not recognized
|
|
487
|
-
def build_metadata_store
|
|
503
|
+
def build_metadata_store(output_dir: @config.output_dir)
|
|
488
504
|
case @config.metadata_store
|
|
489
505
|
when :in_memory then Storage::MetadataStore::InMemory.new
|
|
490
|
-
when :sqlite then Storage::MetadataStore::SQLite.new(**sqlite_metadata_options)
|
|
506
|
+
when :sqlite then Storage::MetadataStore::SQLite.new(**sqlite_metadata_options(output_dir))
|
|
491
507
|
else raise ArgumentError, "Unknown metadata_store: #{@config.metadata_store}"
|
|
492
508
|
end
|
|
493
509
|
end
|
|
494
510
|
|
|
495
|
-
def sqlite_metadata_options
|
|
511
|
+
def sqlite_metadata_options(output_dir)
|
|
496
512
|
opts = (@config.metadata_store_options || {}).transform_keys(&:to_sym)
|
|
497
|
-
opts[:database] ||= File.join(
|
|
513
|
+
opts[:database] ||= File.join(output_dir.to_s, 'metadata.sqlite3')
|
|
498
514
|
opts
|
|
499
515
|
end
|
|
500
516
|
private :sqlite_metadata_options
|
|
@@ -418,6 +418,8 @@ module Woods
|
|
|
418
418
|
def vector_store = @retriever.vector_store
|
|
419
419
|
def metadata_store = @retriever.metadata_store
|
|
420
420
|
def graph_store = @retriever.graph_store
|
|
421
|
+
def mode = @retriever.respond_to?(:mode) ? @retriever.mode : :semantic
|
|
422
|
+
def default_budget = @retriever.respond_to?(:default_budget) ? @retriever.default_budget : 8000
|
|
421
423
|
|
|
422
424
|
# Invalidate every cached context result. Called from the MCP +reload+
|
|
423
425
|
# tool after the retriever's stores have been re-hydrated from a fresh
|
|
@@ -449,12 +451,17 @@ module Woods
|
|
|
449
451
|
# @param types [Array<String, Symbol>, nil] Include-only filter
|
|
450
452
|
# @param exclude_types [Array<String, Symbol>, nil] Additional exclusions
|
|
451
453
|
# @return [Retriever::RetrievalResult]
|
|
452
|
-
def retrieve(query, budget:
|
|
453
|
-
|
|
454
|
+
def retrieve(query, budget: default_budget, types: nil, exclude_types: nil, packages: nil, source_paths: nil,
|
|
455
|
+
evidence: 'full') # rubocop:disable Metrics/ParameterLists
|
|
456
|
+
Retrieval::SourceEvidence.validate_mode!(evidence)
|
|
457
|
+
scoped = Retrieval::Scope.requested?(packages: packages, source_paths: source_paths)
|
|
458
|
+
scope_options = scoped ? { packages: packages, source_paths: source_paths } : {}
|
|
459
|
+
scope_options[:evidence] = evidence unless evidence == 'full'
|
|
460
|
+
key = context_key(query, budget, types: types, exclude_types: exclude_types, **scope_options)
|
|
454
461
|
cached = @cache_store.read(key)
|
|
455
462
|
return rehydrate_cached(cached, budget) if cached
|
|
456
463
|
|
|
457
|
-
result = @retriever.retrieve(query, budget: budget, types: types, exclude_types: exclude_types)
|
|
464
|
+
result = @retriever.retrieve(query, budget: budget, types: types, exclude_types: exclude_types, **scope_options)
|
|
458
465
|
|
|
459
466
|
begin
|
|
460
467
|
@cache_store.write(key, serialize_result(result), ttl: @context_ttl)
|
|
@@ -478,8 +485,14 @@ module Woods
|
|
|
478
485
|
# @param types [Array<String, Symbol>, nil]
|
|
479
486
|
# @param exclude_types [Array<String, Symbol>, nil]
|
|
480
487
|
# @return [String]
|
|
481
|
-
def context_key(query, budget, types: nil, exclude_types: nil)
|
|
482
|
-
|
|
488
|
+
def context_key(query, budget, types: nil, exclude_types: nil, packages: nil, source_paths: nil, evidence: 'full') # rubocop:disable Metrics/ParameterLists
|
|
489
|
+
parts = [query, budget.to_s, fingerprint(types), fingerprint(exclude_types)]
|
|
490
|
+
parts << 'lexical' if mode == :lexical
|
|
491
|
+
parts << JSON.generate(evidence: evidence) unless evidence == 'full'
|
|
492
|
+
if Retrieval::Scope.requested?(packages: packages, source_paths: source_paths)
|
|
493
|
+
parts << JSON.generate(packages: packages, source_paths: source_paths)
|
|
494
|
+
end
|
|
495
|
+
Cache.cache_key(:context, *parts)
|
|
483
496
|
end
|
|
484
497
|
|
|
485
498
|
def fingerprint(types)
|
|
@@ -504,7 +517,8 @@ module Woods
|
|
|
504
517
|
tokens_used: cached['tokens_used'],
|
|
505
518
|
budget: budget,
|
|
506
519
|
trace: nil,
|
|
507
|
-
type_rank_context: rehydrate_type_rank_context(cached['type_rank_context'])
|
|
520
|
+
type_rank_context: rehydrate_type_rank_context(cached['type_rank_context']),
|
|
521
|
+
applied_scope: rehydrate_scope(cached['applied_scope'])
|
|
508
522
|
)
|
|
509
523
|
end
|
|
510
524
|
|
|
@@ -514,10 +528,17 @@ module Woods
|
|
|
514
528
|
'sources' => result.sources,
|
|
515
529
|
'strategy' => result.strategy&.to_s,
|
|
516
530
|
'tokens_used' => result.tokens_used,
|
|
517
|
-
'type_rank_context' => serialize_type_rank_context(result.type_rank_context)
|
|
531
|
+
'type_rank_context' => serialize_type_rank_context(result.type_rank_context),
|
|
532
|
+
'applied_scope' => result.applied_scope
|
|
518
533
|
}
|
|
519
534
|
end
|
|
520
535
|
|
|
536
|
+
def rehydrate_scope(scope)
|
|
537
|
+
return nil unless scope
|
|
538
|
+
|
|
539
|
+
scope.transform_keys(&:to_sym).tap { |value| value[:outcome] = value[:outcome]&.to_sym }
|
|
540
|
+
end
|
|
541
|
+
|
|
521
542
|
# type_rank_context is a Hash<String => Hash<Symbol, ...>> with
|
|
522
543
|
# :source carrying a Symbol value. JSON-backed caches (Redis,
|
|
523
544
|
# SolidCache) collapse both to strings on the round-trip, so we
|
|
@@ -25,11 +25,10 @@ module Woods
|
|
|
25
25
|
# @param parts [Array<String>] Key components (will be SHA256-hashed if long)
|
|
26
26
|
# @return [String] Namespaced key
|
|
27
27
|
def self.cache_key(domain, *parts)
|
|
28
|
-
raw =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
end
|
|
28
|
+
raw = parts.map do |part|
|
|
29
|
+
value = part.to_s
|
|
30
|
+
"#{value.bytesize}:#{value}"
|
|
31
|
+
end.join
|
|
33
32
|
suffix = raw.length > 64 ? Digest::SHA256.hexdigest(raw) : raw
|
|
34
33
|
"woods:cache:#{domain}:#{suffix}"
|
|
35
34
|
end
|
data/lib/woods/change_set.rb
CHANGED
|
@@ -15,7 +15,8 @@ module Woods
|
|
|
15
15
|
#
|
|
16
16
|
# Paths are held absolute (matching how {DependencyGraph} registers file
|
|
17
17
|
# paths) and exposed relative on demand (matching how {PathDispatcher}
|
|
18
|
-
# rules are written).
|
|
18
|
+
# rules are written). Normalization is lexical, so vanished files remain
|
|
19
|
+
# representable without resolving symlink targets or consulting the filesystem.
|
|
19
20
|
#
|
|
20
21
|
# @example
|
|
21
22
|
# cs = Woods::ChangeSet.new(paths: %w[app/models/user.rb app/models/gone.rb], root: Rails.root)
|
|
@@ -29,7 +30,7 @@ module Woods
|
|
|
29
30
|
# @param paths [Array<String>] changed paths, absolute or root-relative
|
|
30
31
|
# @param root [String, Pathname] application root (usually Rails.root)
|
|
31
32
|
def initialize(paths:, root:)
|
|
32
|
-
@root = Pathname.new(root.to_s)
|
|
33
|
+
@root = Pathname.new(root.to_s).expand_path
|
|
33
34
|
@absolute_paths = Array(paths).filter_map { |p| absolutize(p) }.uniq.freeze
|
|
34
35
|
end
|
|
35
36
|
|
|
@@ -72,7 +73,7 @@ module Woods
|
|
|
72
73
|
# @param path [String] absolute path
|
|
73
74
|
# @return [String] root-relative path, or the input if outside {#root}
|
|
74
75
|
def relativize(path)
|
|
75
|
-
prefix =
|
|
76
|
+
prefix = File.join(@root.to_s, '')
|
|
76
77
|
path.start_with?(prefix) ? path.delete_prefix(prefix) : path
|
|
77
78
|
end
|
|
78
79
|
|
|
@@ -82,7 +83,7 @@ module Woods
|
|
|
82
83
|
str = path.to_s.strip
|
|
83
84
|
return nil if str.empty?
|
|
84
85
|
|
|
85
|
-
|
|
86
|
+
@root.join(str).cleanpath.to_s
|
|
86
87
|
end
|
|
87
88
|
end
|
|
88
89
|
end
|
|
@@ -68,6 +68,7 @@ module Woods
|
|
|
68
68
|
MISSING_KEY_ERRORS = %w[
|
|
69
69
|
ActiveSupport::EncryptedConfiguration::MissingKeyError
|
|
70
70
|
ActiveSupport::EncryptedFile::MissingKeyError
|
|
71
|
+
ActiveSupport::EncryptedFile::MissingContentError
|
|
71
72
|
ActiveSupport::MessageEncryptor::InvalidMessage
|
|
72
73
|
].freeze
|
|
73
74
|
|
|
@@ -90,13 +91,19 @@ module Woods
|
|
|
90
91
|
# @return [CredentialIndex] populated index, or an empty index when
|
|
91
92
|
# the credentials store can't be opened.
|
|
92
93
|
def build(rails_app:)
|
|
93
|
-
|
|
94
|
+
refresh(rails_app: rails_app)
|
|
94
95
|
rescue StandardError => e
|
|
95
96
|
raise unless missing_key_error?(e)
|
|
96
97
|
|
|
97
98
|
new(secrets: [])
|
|
98
99
|
end
|
|
99
100
|
|
|
101
|
+
# Strict refresh: construct the replacement completely before a caller
|
|
102
|
+
# installs it. Unlike boot-time .build, errors must preserve the old index.
|
|
103
|
+
def refresh(rails_app:)
|
|
104
|
+
new(secrets: collect_secrets(rails_app))
|
|
105
|
+
end
|
|
106
|
+
|
|
100
107
|
# Emit a structured log warning when any of the given credentials
|
|
101
108
|
# files has a modification time newer than `process_start`. This
|
|
102
109
|
# catches the common "rotated credentials but forgot to restart"
|
|
@@ -136,12 +143,23 @@ module Woods
|
|
|
136
143
|
private
|
|
137
144
|
|
|
138
145
|
def collect_secrets(rails_app)
|
|
139
|
-
config = rails_app.credentials.config
|
|
146
|
+
config = fresh_credentials(rails_app.credentials).config
|
|
140
147
|
collected = []
|
|
141
148
|
walk(config, collected)
|
|
142
149
|
collected
|
|
143
150
|
end
|
|
144
151
|
|
|
152
|
+
def fresh_credentials(credentials)
|
|
153
|
+
return credentials unless defined?(ActiveSupport::EncryptedConfiguration) &&
|
|
154
|
+
credentials.is_a?(ActiveSupport::EncryptedConfiguration)
|
|
155
|
+
|
|
156
|
+
require_relative 'encrypted_credential_snapshot'
|
|
157
|
+
EncryptedCredentialSnapshot.new(
|
|
158
|
+
config_path: credentials.content_path, key_path: credentials.key_path,
|
|
159
|
+
env_key: credentials.env_key, raise_if_missing_key: true
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
145
163
|
def walk(node, collected)
|
|
146
164
|
case node
|
|
147
165
|
when Hash then node.each_value { |v| walk(v, collected) }
|
|
@@ -155,17 +155,17 @@ module Woods
|
|
|
155
155
|
# for patterns that fired — callers should treat a missing key as zero.
|
|
156
156
|
def scan(value)
|
|
157
157
|
counts = {}
|
|
158
|
-
scanned = walk(value, counts)
|
|
158
|
+
scanned = walk(value, counts, @secret_index)
|
|
159
159
|
[scanned, counts]
|
|
160
160
|
end
|
|
161
161
|
|
|
162
162
|
private
|
|
163
163
|
|
|
164
|
-
def walk(value, counts)
|
|
164
|
+
def walk(value, counts, index)
|
|
165
165
|
case value
|
|
166
|
-
when String then scan_string(value, counts)
|
|
167
|
-
when Hash then walk_hash(value, counts)
|
|
168
|
-
when Array then value.map { |item| walk(item, counts) }
|
|
166
|
+
when String then scan_string(value, counts, index)
|
|
167
|
+
when Hash then walk_hash(value, counts, index)
|
|
168
|
+
when Array then value.map { |item| walk(item, counts, index) }
|
|
169
169
|
else value
|
|
170
170
|
end
|
|
171
171
|
end
|
|
@@ -177,19 +177,19 @@ module Woods
|
|
|
177
177
|
# credential-shaped string is emitted as a Symbol after redaction
|
|
178
178
|
# (e.g. `:"[REDACTED]"`). String keys stay Strings. Non-String,
|
|
179
179
|
# non-Symbol keys (Integer, etc.) pass through unchanged.
|
|
180
|
-
def walk_hash(hash, counts)
|
|
180
|
+
def walk_hash(hash, counts, index)
|
|
181
181
|
hash.each_with_object({}) do |(key, val), out|
|
|
182
182
|
scanned_key = case key
|
|
183
|
-
when String then scan_string(key, counts)
|
|
184
|
-
when Symbol then scan_string(key.to_s, counts).to_sym
|
|
183
|
+
when String then scan_string(key, counts, index)
|
|
184
|
+
when Symbol then scan_string(key.to_s, counts, index).to_sym
|
|
185
185
|
else key
|
|
186
186
|
end
|
|
187
|
-
out[scanned_key] = walk(val, counts)
|
|
187
|
+
out[scanned_key] = walk(val, counts, index)
|
|
188
188
|
end
|
|
189
189
|
end
|
|
190
190
|
|
|
191
|
-
def scan_string(str, counts)
|
|
192
|
-
result = redact_indexed_secrets(str, counts)
|
|
191
|
+
def scan_string(str, counts, index)
|
|
192
|
+
result = redact_indexed_secrets(str, counts, index)
|
|
193
193
|
result = scan_encoded_forms(result, counts)
|
|
194
194
|
@active_patterns.inject(result) do |acc, (name, pattern)|
|
|
195
195
|
acc.gsub(pattern) do
|
|
@@ -290,10 +290,10 @@ module Woods
|
|
|
290
290
|
printable.to_f / str.length >= PRINTABLE_RATIO_THRESHOLD
|
|
291
291
|
end
|
|
292
292
|
|
|
293
|
-
def redact_indexed_secrets(str, counts)
|
|
294
|
-
return str unless
|
|
293
|
+
def redact_indexed_secrets(str, counts, index)
|
|
294
|
+
return str unless index&.match?(str)
|
|
295
295
|
|
|
296
|
-
redacted =
|
|
296
|
+
redacted = index.redact(str)
|
|
297
297
|
counts[INDEX_HIT] = (counts[INDEX_HIT] || 0) + redacted.scan(CredentialIndex::REDACTED).size
|
|
298
298
|
redacted
|
|
299
299
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Console
|
|
5
|
+
# Coordinates scanner construction and rotation without retaining servers
|
|
6
|
+
# after their transports release them. Requests keep their own index snapshot.
|
|
7
|
+
class CredentialScannerRegistry
|
|
8
|
+
def initialize
|
|
9
|
+
@scanners = ObjectSpace::WeakMap.new
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def register
|
|
14
|
+
@mutex.synchronize do
|
|
15
|
+
scanner = yield
|
|
16
|
+
# Older Ruby WeakMap implementations validate the value's liveness
|
|
17
|
+
# when enumerating. Keep the scanner in both weak positions so an
|
|
18
|
+
# immortal sentinel cannot make an abandoned scanner look live.
|
|
19
|
+
@scanners[scanner] = scanner
|
|
20
|
+
scanner
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def rebuild
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
scanners = @scanners.values
|
|
27
|
+
return nil if scanners.empty?
|
|
28
|
+
|
|
29
|
+
index = yield
|
|
30
|
+
scanners.each { |scanner| scanner.replace_index!(index) }
|
|
31
|
+
index
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -1491,7 +1491,7 @@ module Woods
|
|
|
1491
1491
|
# `SqlNoiseStripper` is the same module SqlValidator uses. The
|
|
1492
1492
|
# combined single-pass strip_noise resolves comments and literals
|
|
1493
1493
|
# together so a comment marker inside a literal can't hide a keyword.
|
|
1494
|
-
stripped = SqlNoiseStripper.strip_noise(template)
|
|
1494
|
+
stripped = SqlNoiseStripper.strip_noise(template, dialect: sql_dialect || :postgres, **mysql_quote_modes)
|
|
1495
1495
|
if SCOPE_TEMPLATE_FORBIDDEN.match?(stripped)
|
|
1496
1496
|
raise ValidationError,
|
|
1497
1497
|
'scope template contains forbidden SQL keywords ' \
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/encrypted_configuration'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Console
|
|
7
|
+
# A private, uncached Rails configuration for one credential-index build.
|
|
8
|
+
# Unlike Rails' permissive configuration reader, a missing encrypted file
|
|
9
|
+
# must raise during an explicit refresh rather than erase the last index.
|
|
10
|
+
class EncryptedCredentialSnapshot < ActiveSupport::EncryptedConfiguration
|
|
11
|
+
def read
|
|
12
|
+
ActiveSupport::EncryptedFile.instance_method(:read).bind(self).call
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -81,17 +81,12 @@ module Woods
|
|
|
81
81
|
# for compatibility. Passing it fails closed because console_eval is unavailable.
|
|
82
82
|
# @param unsafe_eval_audit_log_path [String, Pathname, nil] Legacy option retained
|
|
83
83
|
# for compatibility. Passing it fails closed because console_eval is unavailable.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
@unsafe_eval_confirmation = unsafe_eval_confirmation
|
|
91
|
-
@unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
|
|
92
|
-
@stateless = stateless
|
|
93
|
-
@mutex = Mutex.new
|
|
94
|
-
@transport = nil
|
|
84
|
+
# Rails 6.0 forwards middleware options as a positional hash on Ruby 3.
|
|
85
|
+
# Delegate to explicit keywords to preserve required/unknown option checks.
|
|
86
|
+
def initialize(app, options = {}, **keywords)
|
|
87
|
+
raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
initialize_options(app, **options, **keywords)
|
|
95
90
|
end
|
|
96
91
|
|
|
97
92
|
# Rack interface — intercepts requests at the configured path.
|
|
@@ -99,7 +94,8 @@ module Woods
|
|
|
99
94
|
# The enabled flag is read from Woods.configuration on every request,
|
|
100
95
|
# never captured at construction: the railtie inserts this middleware
|
|
101
96
|
# before config/initializers have run (#183). While
|
|
102
|
-
# `console_mcp_enabled` is false (the default),
|
|
97
|
+
# `console_mcp_enabled` is false (the default), or HTTP is explicitly
|
|
98
|
+
# disabled with `console_mcp_http_enabled = false`, requests — even at the
|
|
103
99
|
# mounted path — pass through to the wrapped app unchanged, so a host
|
|
104
100
|
# that never opted in is completely unaffected. Requests at
|
|
105
101
|
# non-matching paths always pass through.
|
|
@@ -118,11 +114,24 @@ module Woods
|
|
|
118
114
|
|
|
119
115
|
private
|
|
120
116
|
|
|
117
|
+
def initialize_options(app, path: '/mcp/console', embedded_read_tools: false, # rubocop:disable Metrics/ParameterLists
|
|
118
|
+
unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil,
|
|
119
|
+
stateless: true)
|
|
120
|
+
@app = app
|
|
121
|
+
@path = path
|
|
122
|
+
@embedded_read_tools = embedded_read_tools
|
|
123
|
+
@unsafe_eval_confirmation = unsafe_eval_confirmation
|
|
124
|
+
@unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
|
|
125
|
+
@stateless = stateless
|
|
126
|
+
@mutex = Mutex.new
|
|
127
|
+
@transport = nil
|
|
128
|
+
end
|
|
129
|
+
|
|
121
130
|
# Whether the console is enabled, read from the live configuration on
|
|
122
131
|
# every request. Nil-safe: the railtie mounts this middleware in every
|
|
123
132
|
# app (#183), including hosts that never called Woods.configure.
|
|
124
133
|
def enabled?
|
|
125
|
-
Woods.configuration&.console_mcp_enabled
|
|
134
|
+
Woods.configuration&.console_mcp_enabled && Woods.configuration.console_mcp_http_enabled
|
|
126
135
|
end
|
|
127
136
|
|
|
128
137
|
# Thread-safe lazy initialization of the MCP server and transport.
|
data/lib/woods/console/server.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative 'audit_logger'
|
|
|
14
14
|
require_relative 'confirmation'
|
|
15
15
|
require_relative 'console_response_renderer'
|
|
16
16
|
require_relative 'credential_scanner'
|
|
17
|
+
require_relative 'credential_scanner_registry'
|
|
17
18
|
require_relative 'eval_guard'
|
|
18
19
|
require_relative 'table_gate'
|
|
19
20
|
require_relative 'redactor'
|
|
@@ -44,10 +45,14 @@ module Woods
|
|
|
44
45
|
# transport.open
|
|
45
46
|
#
|
|
46
47
|
module Server # rubocop:disable Metrics/ModuleLength
|
|
48
|
+
SCANNERS = CredentialScannerRegistry.new
|
|
49
|
+
private_constant :SCANNERS
|
|
50
|
+
|
|
47
51
|
class << self # rubocop:disable Metrics/ClassLength
|
|
48
52
|
# Rebuild the boot-time credential index from fresh Rails credentials
|
|
49
|
-
# and hot-swap it into
|
|
53
|
+
# and hot-swap it into every live scanner without restarting the process.
|
|
50
54
|
#
|
|
55
|
+
# Refresh failures propagate without replacing any live index.
|
|
51
56
|
# Host rotation jobs should call this immediately after `rails credentials:edit`
|
|
52
57
|
# changes are deployed. The swap is atomic on MRI (GVL) — in-flight scans see
|
|
53
58
|
# either the old or the new index, never a partial one.
|
|
@@ -62,14 +67,11 @@ module Woods
|
|
|
62
67
|
# the rebuild was skipped.
|
|
63
68
|
def rebuild_credential_index(rails_app: nil)
|
|
64
69
|
return nil unless credential_defense_enabled?
|
|
65
|
-
return nil unless @active_scanner
|
|
66
70
|
|
|
67
71
|
target_app = rails_app || default_rails_app
|
|
68
72
|
return nil unless target_app
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
@active_scanner.replace_index!(new_index)
|
|
72
|
-
new_index
|
|
74
|
+
SCANNERS.rebuild { CredentialIndex.refresh(rails_app: target_app) }
|
|
73
75
|
end
|
|
74
76
|
|
|
75
77
|
# True when Woods is configured and credential defense is on.
|
|
@@ -288,21 +290,21 @@ module Woods
|
|
|
288
290
|
model_reflections: model_reflections)
|
|
289
291
|
end
|
|
290
292
|
|
|
291
|
-
|
|
292
|
-
disabled_patterns = Array(config&.console_disabled_scanner_patterns)
|
|
293
|
-
scanner = if disabled_patterns.include?(:all)
|
|
294
|
-
nil
|
|
295
|
-
else
|
|
296
|
-
CredentialScanner.new(
|
|
297
|
-
disabled_patterns: disabled_patterns,
|
|
298
|
-
secret_index: secret_index
|
|
299
|
-
)
|
|
300
|
-
end
|
|
301
|
-
@active_scanner = scanner
|
|
293
|
+
scanner = build_scanner(config)
|
|
302
294
|
|
|
303
295
|
ResponseContext.build(safe_ctx: safe_ctx, table_gate: table_gate, credential_scanner: scanner)
|
|
304
296
|
end
|
|
305
297
|
|
|
298
|
+
def build_scanner(config)
|
|
299
|
+
disabled_patterns = Array(config&.console_disabled_scanner_patterns)
|
|
300
|
+
return nil if disabled_patterns.include?(:all)
|
|
301
|
+
|
|
302
|
+
SCANNERS.register do
|
|
303
|
+
CredentialScanner.new(disabled_patterns: disabled_patterns,
|
|
304
|
+
secret_index: build_credential_index(config))
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
306
308
|
# Build the boot-time credential index from Rails.application's encrypted
|
|
307
309
|
# credentials. Returns nil when credential defense is disabled or when no
|
|
308
310
|
# Rails application is reachable (specs, non-Rails hosts) — the scanner
|