Module: Woods::Console::Server
- Defined in:
- lib/woods/console/server.rb,
lib/woods/console/tool_specs.rb
Overview
Console MCP Server — queries live Rails application state.
Communicates with a bridge process running inside the Rails environment via JSON-lines over stdio. Exposes 31 tools across 4 tiers (9 read-only / 9 domain-aware / 10 analytics / 3 guarded) through MCP.
Defined Under Namespace
Classes: ToolSpec
Constant Summary collapse
- TIER1_TOOLS =
%w[count sample find pluck aggregate association_count schema recent status].freeze
- TIER2_TOOLS =
%w[diagnose_model data_snapshot validate_record check_setting update_setting check_policy validate_with check_eligibility decorate].freeze
- TIER3_TOOLS =
%w[slow_endpoints error_rates throughput job_queues job_failures job_find job_schedule redis_info cache_stats channel_status].freeze
- TIER4_TOOLS =
%w[eval sql query].freeze
- TOOL_SPECS =
All 31 console tool specifications, grouped by tier:
Tier 1 (read-only, 9 tools) — no guard required, bridge-level table_gate already constrains reach. Tier 2 (domain-aware, 9 tools) — no guard required, validators run inside the app under SafeContext. Tier 3 (analytics, 10 tools) — no guard required, adapters wrap external services (Redis, job queues, cache). Tier 4 (guarded, 3 tools) — `eval`, `sql`, `query`. Guards ARE MANDATORY for these. The handler lambda for each Tier-4 tool captures the relevant validator/guard closure; the Server's {DispatchPipeline} and {EmbeddedExecutor} refuse to execute a Tier-4 tool whose `guard` is missing or nil. Never call `eval`, `sql`, or `query` without wiring EvalGuard / SqlValidator first.Each spec is a ToolSpec; the handler lambda captures any objects that must be built once at spec-definition time (validators, guards).
[ # ── Tier 1: read-only ───────────────────────────────────────────────── ToolSpec.new( name: 'console_count', description: 'Count records matching scope conditions.', properties: { model: { type: 'string', description: 'Model name' }, scope: { type: 'object', description: 'Filter: {status: "paid", total_refund_gt: 0, ' \ 'transaction_id_not_null: true}. ' \ 'Suffixes: _eq _gt _lt _in _null _present. ' \ 'Complex queries: use console_query.' } }, required: ['model'], tier: 1, handler: ->(args) { Tools::Tier1.console_count(model: args[:model], scope: args[:scope]) } ), ToolSpec.new( name: 'console_sample', description: 'Random sample of records.', properties: { model: { type: 'string', description: 'Model name' }, limit: { type: 'integer', description: 'Max records (default 5, max 25)' }, columns: { type: 'array', items: { type: 'string' }, description: 'Columns to include' }, scope: { type: 'object', description: 'Filter: {status: "paid", amount_gt: 100}. ' \ 'Suffixes: _eq _gt _lt _in _null _present. ' \ 'Complex queries: use console_query.' } }, required: ['model'], tier: 1, handler: lambda { |args| Tools::Tier1.console_sample( model: args[:model], scope: args[:scope], limit: args[:limit] || 5, columns: args[:columns] ) } ), ToolSpec.new( name: 'console_find', description: 'Find a single record by primary key or unique column', properties: { model: { type: 'string', description: 'Model name' }, id: { type: 'integer', description: 'Primary key value' }, by: { type: 'object', description: 'Unique column lookup' }, columns: { type: 'array', items: { type: 'string' }, description: 'Columns to include' } }, required: ['model'], tier: 1, handler: lambda { |args| Tools::Tier1.console_find( model: args[:model], id: args[:id], by: args[:by], columns: args[:columns] ) } ), ToolSpec.new( name: 'console_pluck', description: 'Extract column values from records.', properties: { model: { type: 'string', description: 'Model name' }, columns: { type: 'array', items: { type: 'string' }, description: 'Column names to pluck' }, scope: { type: 'object', description: 'Filter: {status_in: ["paid","refunded"], amount_gt: 0}. ' \ 'Suffixes: _eq _gt _lt _in _null _present. ' \ 'Complex queries: use console_query.' }, limit: { type: 'integer', description: 'Max records (default 100, max 1000)' }, distinct: { type: 'boolean', description: 'Return unique values only' } }, required: %w[model columns], tier: 1, handler: lambda { |args| Tools::Tier1.console_pluck( model: args[:model], columns: args[:columns], scope: args[:scope], limit: args[:limit] || 100, distinct: args[:distinct] || false ) } ), ToolSpec.new( name: 'console_aggregate', description: 'Run aggregate function on a column. ' \ 'count omits column to count all rows. ' \ 'Supports scope predicates: {status: "paid", total_gt: 0}. ' \ 'For complex queries use console_query.', properties: { model: { type: 'string', description: 'Model name' }, function: { type: 'string', description: 'Aggregate function: sum, average, minimum, maximum, count' }, column: { type: 'string', description: 'Column to aggregate (optional for count)' }, scope: { type: 'object', description: 'Filter conditions: {col: val} or predicate suffixes ' \ '(_gt, _lt, _in, _null, etc.)' } }, required: %w[model function], tier: 1, handler: lambda { |args| Tools::Tier1.console_aggregate( model: args[:model], function: args[:function], column: args[:column], scope: args[:scope] ) } ), ToolSpec.new( name: 'console_association_count', description: 'Count associated records for a specific record.', properties: { model: { type: 'string', description: 'Model name' }, id: { type: 'integer', description: 'Record primary key' }, association: { type: 'string', description: 'Association name' }, scope: { type: 'object', description: 'Filter on association: {status: "paid", amount_gt: 0}. ' \ 'Suffixes: _eq _gt _lt _in _null _present. ' \ 'Complex queries: use console_query.' } }, required: %w[model id association], tier: 1, handler: lambda { |args| Tools::Tier1.console_association_count( model: args[:model], id: args[:id], association: args[:association], scope: args[:scope] ) } ), ToolSpec.new( name: 'console_schema', description: 'Get database schema for a model', properties: { model: { type: 'string', description: 'Model name' }, include_indexes: { type: 'boolean', description: 'Include index information' } }, required: ['model'], tier: 1, handler: lambda { |args| Tools::Tier1.console_schema(model: args[:model], include_indexes: args[:include_indexes] || false) } ), ToolSpec.new( name: 'console_recent', description: 'Recently created/updated records.', properties: { model: { type: 'string', description: 'Model name' }, order_by: { type: 'string', description: 'Column to sort by (default: created_at)' }, direction: { type: 'string', description: 'Sort direction: asc or desc (default: desc)' }, limit: { type: 'integer', description: 'Max records (default 10, max 50)' }, scope: { type: 'object', description: 'Filter: {status: "paid", total_gt: 0}. ' \ 'Suffixes: _eq _gt _lt _in _null _present. ' \ 'Complex queries: use console_query.' }, columns: { type: 'array', items: { type: 'string' }, description: 'Columns to include' } }, required: ['model'], tier: 1, handler: lambda { |args| Tools::Tier1.console_recent( model: args[:model], order_by: args[:order_by] || 'created_at', direction: args[:direction] || 'desc', limit: args[:limit] || 10, scope: args[:scope], columns: args[:columns] ) } ), ToolSpec.new( name: 'console_status', description: 'System health check - list models and connection status', properties: {}, required: nil, tier: 1, handler: ->(_args) { Tools::Tier1.console_status } ), # ── Tier 2: domain-aware ────────────────────────────────────────────── ToolSpec.new( name: 'console_diagnose_model', description: 'Diagnose a model: count, recent records, aggregates', properties: { model: { type: 'string', description: 'Model name' }, scope: { type: 'object', description: 'Filter conditions' }, sample_size: { type: 'integer', description: 'Sample records (default 5, max 25)' } }, required: ['model'], tier: 2, handler: lambda { |args| Tools::Tier2.console_diagnose_model( model: args[:model], scope: args[:scope], sample_size: args[:sample_size] || 5 ) } ), ToolSpec.new( name: 'console_data_snapshot', description: 'Snapshot a record with associations for debugging', properties: { model: { type: 'string', description: 'Model name' }, id: { type: 'integer', description: 'Record primary key' }, associations: { type: 'array', items: { type: 'string' }, description: 'Association names to include' }, depth: { type: 'integer', description: 'Association depth (default 1, max 3)' } }, required: %w[model id], tier: 2, handler: lambda { |args| Tools::Tier2.console_data_snapshot( model: args[:model], id: args[:id], associations: args[:associations], depth: args[:depth] || 1 ) } ), ToolSpec.new( name: 'console_validate_record', description: 'Run validations on an existing record', properties: { model: { type: 'string', description: 'Model name' }, id: { type: 'integer', description: 'Record primary key' }, attributes: { type: 'object', description: 'Attributes to set before validating' } }, required: %w[model id], tier: 2, handler: lambda { |args| Tools::Tier2.console_validate_record( model: args[:model], id: args[:id], attributes: args[:attributes] ) } ), ToolSpec.new( name: 'console_check_setting', description: 'Check a configuration setting value', properties: { key: { type: 'string', description: 'Setting key' }, namespace: { type: 'string', description: 'Setting namespace' } }, required: ['key'], tier: 2, handler: ->(args) { Tools::Tier2.console_check_setting(key: args[:key], namespace: args[:namespace]) } ), ToolSpec.new( name: 'console_update_setting', description: 'Update a configuration setting (requires confirmation)', properties: { key: { type: 'string', description: 'Setting key' }, value: { type: 'string', description: 'New value' }, namespace: { type: 'string', description: 'Setting namespace' } }, required: %w[key value], tier: 2, handler: lambda { |args| Tools::Tier2.console_update_setting( key: args[:key], value: args[:value], namespace: args[:namespace] ) } ), ToolSpec.new( name: 'console_check_policy', description: 'Check authorization policy for a record and user', properties: { model: { type: 'string', description: 'Model name' }, id: { type: 'integer', description: 'Record primary key' }, user_id: { type: 'integer', description: 'User to check' }, action: { type: 'string', description: 'Policy action' } }, required: %w[model id user_id action], tier: 2, handler: lambda { |args| Tools::Tier2.console_check_policy( model: args[:model], id: args[:id], user_id: args[:user_id], action: args[:action] ) } ), ToolSpec.new( name: 'console_validate_with', description: 'Validate attributes against a model without persisting', properties: { model: { type: 'string', description: 'Model name' }, attributes: { type: 'object', description: 'Attributes to validate' }, context: { type: 'string', description: 'Validation context' } }, required: %w[model attributes], tier: 2, handler: lambda { |args| Tools::Tier2.console_validate_with( model: args[:model], attributes: args[:attributes], context: args[:context] ) } ), ToolSpec.new( name: 'console_check_eligibility', description: 'Check feature eligibility for a record', properties: { model: { type: 'string', description: 'Model name' }, id: { type: 'integer', description: 'Record primary key' }, feature: { type: 'string', description: 'Feature name' } }, required: %w[model id feature], tier: 2, handler: lambda { |args| Tools::Tier2.console_check_eligibility( model: args[:model], id: args[:id], feature: args[:feature] ) } ), ToolSpec.new( name: 'console_decorate', description: 'Invoke a decorator on a record and return computed attributes', properties: { model: { type: 'string', description: 'Model name' }, id: { type: 'integer', description: 'Record primary key' }, methods: { type: 'array', items: { type: 'string' }, description: 'Decorator methods to call' } }, required: %w[model id], tier: 2, handler: lambda { |args| Tools::Tier2.console_decorate(model: args[:model], id: args[:id], methods: args[:methods]) } ), # ── Tier 3: analytics ───────────────────────────────────────────────── ToolSpec.new( name: 'console_slow_endpoints', description: 'List slowest endpoints by response time', properties: { limit: { type: 'integer', description: 'Max endpoints (default 10, max 100)' }, period: { type: 'string', description: 'Time period (default: 1h)' } }, required: nil, tier: 3, handler: lambda { |args| Tools::Tier3.console_slow_endpoints(limit: args[:limit] || 10, period: args[:period] || '1h') } ), ToolSpec.new( name: 'console_error_rates', description: 'Get error rates by controller or overall', properties: { period: { type: 'string', description: 'Time period (default: 1h)' }, controller: { type: 'string', description: 'Filter by controller' } }, required: nil, tier: 3, handler: lambda { |args| Tools::Tier3.console_error_rates(period: args[:period] || '1h', controller: args[:controller]) } ), ToolSpec.new( name: 'console_throughput', description: 'Get request throughput over time', properties: { period: { type: 'string', description: 'Time period (default: 1h)' }, interval: { type: 'string', description: 'Aggregation interval (default: 5m)' } }, required: nil, tier: 3, handler: lambda { |args| Tools::Tier3.console_throughput( period: args[:period] || '1h', interval: args[:interval] || '5m' ) } ), ToolSpec.new( name: 'console_job_queues', description: 'Get job queue statistics', properties: { queue: { type: 'string', description: 'Filter by queue name' } }, required: nil, tier: 3, handler: ->(args) { Tools::Tier3.console_job_queues(queue: args[:queue]) } ), ToolSpec.new( name: 'console_job_failures', description: 'List recent job failures', properties: { limit: { type: 'integer', description: 'Max failures (default 10, max 100)' }, queue: { type: 'string', description: 'Filter by queue name' } }, required: nil, tier: 3, handler: lambda { |args| Tools::Tier3.console_job_failures(limit: args[:limit] || 10, queue: args[:queue]) } ), ToolSpec.new( name: 'console_job_find', description: 'Find a job by ID, optionally retry it (requires confirmation)', properties: { job_id: { type: 'string', description: 'Job identifier' }, retry: { type: 'boolean', description: 'Retry the job (requires confirmation)' } }, required: ['job_id'], tier: 3, handler: ->(args) { Tools::Tier3.console_job_find(job_id: args[:job_id], retry_job: args[:retry]) } ), ToolSpec.new( name: 'console_job_schedule', description: 'List scheduled/upcoming jobs', properties: { limit: { type: 'integer', description: 'Max jobs (default 20, max 100)' } }, required: nil, tier: 3, handler: ->(args) { Tools::Tier3.console_job_schedule(limit: args[:limit] || 20) } ), ToolSpec.new( name: 'console_redis_info', description: 'Get Redis server information', properties: { section: { type: 'string', description: 'INFO section (e.g., memory, stats)' } }, required: nil, tier: 3, handler: ->(args) { Tools::Tier3.console_redis_info(section: args[:section]) } ), ToolSpec.new( name: 'console_cache_stats', description: 'Get cache store statistics', properties: { namespace: { type: 'string', description: 'Cache namespace filter' } }, required: nil, tier: 3, handler: ->(args) { Tools::Tier3.console_cache_stats(namespace: args[:namespace]) } ), ToolSpec.new( name: 'console_channel_status', description: 'Get ActionCable channel status', properties: { channel: { type: 'string', description: 'Filter by channel name' } }, required: nil, tier: 3, handler: ->(args) { Tools::Tier3.console_channel_status(channel: args[:channel]) } ), # ── Tier 4: guarded ─────────────────────────────────────────────────── ToolSpec.new( name: 'console_eval', description: [ 'Propose arbitrary Ruby for execution against the live Rails runtime.', 'CURRENTLY DISABLED in embedded mode — the call will always return an instructional refusal.', 'Before invoking this tool, SHOW the user your proposed Ruby snippet and let them run it ' \ 'manually. Do not retry on failure, and do not hide the snippet behind the tool call.', 'For most cases use console_query (model + select + joins/group_by/having/order) or ' \ 'console_sql instead — both already support aggregates and scoped filters.' ].join(' '), properties: { code: { type: 'string', description: 'Ruby code you propose to run (will be surfaced to the user first)' }, timeout: { type: 'integer', description: 'Timeout in seconds (default 10, max 30)' } }, required: ['code'], tier: 4, handler: begin config = Woods.configuration if Woods.respond_to?(:configuration) guard = EvalGuard.new if config&.console_credential_defense_enabled ->(args) { Tools::Tier4.console_eval(code: args[:code], timeout: args[:timeout] || 10, guard: guard) } end ), ToolSpec.new( name: 'console_sql', description: [ 'Execute read-only SQL against the live database (SELECT/WITH...SELECT only).', 'SqlValidator blocks all DML/DDL. Every query runs inside a rolled-back transaction — no writes persist.', 'Requires embedded_read_tools: true in the rack middleware (see docs/CONSOLE_MCP_SETUP.md).', 'Use console_query instead when you want ActiveRecord query builder rather than raw SQL.' ].join(' '), properties: { sql: { type: 'string', description: 'SQL query (SELECT or WITH...SELECT only)' }, limit: { type: 'integer', description: 'Max rows returned (default unlimited, max 10000)' } }, required: ['sql'], tier: 4, handler: begin validator = SqlValidator.new ->(args) { Tools::Tier4.console_sql(sql: args[:sql], validator: validator, limit: args[:limit]) } end ), ToolSpec.new( name: 'console_query', description: [ 'Build and run a structured ActiveRecord query with optional joins, grouping, and ordering.', 'Example: {model: "Order", select: ["status", "COUNT(*) AS n"], group_by: ["status"]}.', 'Use console_count or console_aggregate for simple aggregates without a custom SELECT.', 'Use console_sql when you need raw SQL that the query builder cannot express.', 'Requires embedded_read_tools: true in the rack middleware (see docs/CONSOLE_MCP_SETUP.md).', 'Max 10,000 rows returned. Returns columns + rows arrays like a SQL result set.' ].join(' '), properties: { model: { type: 'string', description: 'ActiveRecord model name (e.g. "Order")' }, select: { type: 'array', items: { type: 'string' }, description: 'Columns or expressions to select (e.g. ["status", "COUNT(*) AS n"])' }, joins: { type: 'array', items: { type: 'string' }, description: 'Association names to JOIN (e.g. ["line_items", "user"])' }, group_by: { type: 'array', items: { type: 'string' }, description: 'Columns to GROUP BY (e.g. ["status", "user_id"])' }, having: { type: 'string', description: 'HAVING filter applied after GROUP BY (e.g. "COUNT(*) > 5")' }, order: { type: 'object', description: 'Order specification as {column => direction} (e.g. {"created_at" => "desc"})' }, scope: { type: 'object', description: 'WHERE conditions as {column => value} or [sql, bind] array' }, limit: { type: 'integer', description: 'Maximum rows to return (default 10000, hard max 10000)' } }, required: %w[model select], tier: 4, handler: lambda { |args| Tools::Tier4.console_query( model: args[:model], select: args[:select], joins: args[:joins], group_by: args[:group_by], having: args[:having], order: args[:order], scope: args[:scope], limit: args[:limit] ) } ) ].freeze
Class Method Summary collapse
-
.build(config:) ⇒ MCP::Server
Build a configured MCP::Server with console tools using the bridge protocol.
-
.build_embedded(model_validator:, safe_context:, redacted_columns: [], redacted_key_values: [], connection: nil, read_tools_enabled: false, model_tables: {}, model_reflections: {}, unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil) ⇒ MCP::Server
Build a configured MCP::Server using embedded ActiveRecord execution.
-
.build_unsafe_eval_wiring(confirmation:, audit_log_path:) ⇒ Hash
Resolve the three-collaborator eval wiring for the current config.
-
.credential_defense_enabled? ⇒ Boolean
True when Woods is configured and credential defense is on.
-
.default_rails_app ⇒ Object
Resolves ‘Rails.application` when available, else nil.
- .empty_unsafe_eval_wiring ⇒ Object
-
.enforce_unsafe_eval_contract! ⇒ void
Enforce the ‘console_eval` opt-in safety contract at boot.
-
.rebuild_credential_index(rails_app: nil) ⇒ CredentialIndex?
Rebuild the boot-time credential index from fresh Rails credentials and hot-swap it into the active scanner without restarting the process.
-
.register_tier1_tools(server, conn_mgr, ctx = nil, renderer: nil) ⇒ void
Register Tier 1 read-only tools on the server.
-
.register_tier2_tools(server, conn_mgr, ctx = nil, renderer: nil) ⇒ void
Register Tier 2 domain-aware tools on the server.
-
.register_tier3_tools(server, conn_mgr, ctx = nil, renderer: nil) ⇒ void
Register Tier 3 analytics tools on the server.
-
.register_tier4_tools(server, conn_mgr, ctx = nil, renderer: nil) ⇒ void
Register Tier 4 guarded tools on the server.
-
.register_tier_tools(server, conn_mgr, ctx, tier:, renderer: nil) ⇒ void
Register all tool specs for a given tier on the server.
- .require_unsafe_eval_collaborators!(confirmation, audit_log_path) ⇒ Object
-
.unsafe_eval_enabled? ⇒ Boolean
True when the caller has opted into the unsafe ‘console_eval` scaffolding via `WOODS_CONSOLE_UNSAFE_EVAL=true` or an explicit `config.console_unsafe_eval_enabled = true`.
Class Method Details
.build(config:) ⇒ MCP::Server
Build a configured MCP::Server with console tools using the bridge protocol.
⚠ Layer 1 limitation in bridge mode: The server side of the bridge has no access to the remote app’s ‘ActiveRecord::Base.descendants`, so model_tables and model_reflections are empty. `TableGate#check_sql!` still fires against the raw SQL argument of `console_sql`, but `check_model!`, `check_joins!`, and `check_association!` are effectively no-ops for tools that receive a model name rather than SQL (find, sample, count, etc.). A bridge-mode deployment therefore relies on Layer 2 (credential scanning) + Layer 3 (column/EAV redaction) + Layer 4 (SqlValidator + SafeContext rollback) for non-SQL tool calls. If you need full Layer 1 coverage, use `build_embedded` (the stdio entry point `exe/woods-console` does this).
See docs/CONSOLE_MCP_SETUP.md “Bridge vs. embedded defense coverage” for the full matrix.
146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/woods/console/server.rb', line 146 def build(config:) connection_config = config['console'] || config conn_mgr = ConnectionManager.new(config: connection_config) redacted_columns = Array(config['redacted_columns'] || connection_config['redacted_columns']) redacted_key_values = Array( config['redacted_key_values'] || connection_config['redacted_key_values'] ) safe_ctx = build_safe_context(redacted_columns, redacted_key_values) ctx = build_response_context(safe_ctx: safe_ctx, model_tables: {}, model_reflections: {}) build_server(conn_mgr, ctx) end |
.build_embedded(model_validator:, safe_context:, redacted_columns: [], redacted_key_values: [], connection: nil, read_tools_enabled: false, model_tables: {}, model_reflections: {}, unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil) ⇒ MCP::Server
Build a configured MCP::Server using embedded ActiveRecord execution.
No bridge process needed — queries run directly via ActiveRecord. Pass the returned server to StdioTransport or StreamableHTTPTransport.
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 |
# File 'lib/woods/console/server.rb', line 177 def (model_validator:, safe_context:, redacted_columns: [], # rubocop:disable Metrics/ParameterLists redacted_key_values: [], connection: nil, read_tools_enabled: false, model_tables: {}, model_reflections: {}, unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil) require_relative 'embedded_executor' enforce_unsafe_eval_contract! safe_ctx = build_safe_context(redacted_columns, redacted_key_values) ctx = build_response_context(safe_ctx: safe_ctx, model_tables: model_tables, model_reflections: model_reflections) eval_wiring = build_unsafe_eval_wiring( confirmation: unsafe_eval_confirmation, audit_log_path: unsafe_eval_audit_log_path ) # Wire the same TableGate into the executor so sql/query are blocked # PRE-execution against console_blocked_tables (previously TableGate # was only consulted on the render path, leaving the defense inert # for the sql and query tools). table_gate = ctx&.table_gate executor = EmbeddedExecutor.new( model_validator: model_validator, safe_context: safe_context, connection: connection, read_tools_enabled: read_tools_enabled, table_gate: table_gate, eval_guard: eval_wiring[:eval_guard], confirmation: eval_wiring[:confirmation], audit_logger: eval_wiring[:audit_logger], unsafe_eval_enabled: eval_wiring[:unsafe_eval_enabled] ) build_server(executor, ctx) end |
.build_unsafe_eval_wiring(confirmation:, audit_log_path:) ⇒ Hash
Resolve the three-collaborator eval wiring for the current config.
When ‘unsafe_eval_enabled?` is false (default), returns nil for all three collaborators — the executor keeps its hard refusal and EvalGuard is never reached.
When it’s true, BOTH a Confirmation and an audit-log path MUST be provided (via kwargs or config); otherwise we raise so a misconfigured host fails at boot instead of silently running Ruby without approval or audit. See backlog B-053.
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/woods/console/server.rb', line 231 def build_unsafe_eval_wiring(confirmation:, audit_log_path:) return empty_unsafe_eval_wiring unless unsafe_eval_enabled? config = Woods.configuration if Woods.respond_to?(:configuration) confirmation ||= config&.console_unsafe_eval_confirmation audit_log_path ||= config&.console_unsafe_eval_audit_log_path require_unsafe_eval_collaborators!(confirmation, audit_log_path) { eval_guard: EvalGuard.new, confirmation: confirmation, audit_logger: AuditLogger.new(path: audit_log_path.to_s), unsafe_eval_enabled: true } end |
.credential_defense_enabled? ⇒ Boolean
True when Woods is configured and credential defense is on.
71 72 73 74 |
# File 'lib/woods/console/server.rb', line 71 def credential_defense_enabled? config = Woods.configuration if Woods.respond_to?(:configuration) config&.console_credential_defense_enabled ? true : false end |
.default_rails_app ⇒ Object
Resolves ‘Rails.application` when available, else nil.
121 122 123 124 125 |
# File 'lib/woods/console/server.rb', line 121 def default_rails_app return nil unless defined?(Rails) && Rails.respond_to?(:application) Rails.application end |
.empty_unsafe_eval_wiring ⇒ Object
247 248 249 |
# File 'lib/woods/console/server.rb', line 247 def empty_unsafe_eval_wiring { eval_guard: nil, confirmation: nil, audit_logger: nil, unsafe_eval_enabled: false } end |
.enforce_unsafe_eval_contract! ⇒ void
This method returns an undefined value.
Enforce the ‘console_eval` opt-in safety contract at boot.
When ‘WOODS_CONSOLE_UNSAFE_EVAL` is on:
-
refuse outright in ‘Rails.env.production?` (non-negotiable),
-
otherwise emit a LOUD stderr banner so operators know the flag is live even though eval remains unimplemented.
Safe when Rails is not loaded (specs, non-Rails hosts).
106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/woods/console/server.rb', line 106 def enforce_unsafe_eval_contract! return unless unsafe_eval_enabled? if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.respond_to?(:production?) && Rails.env.production? raise Woods::ConfigurationError, 'WOODS_CONSOLE_UNSAFE_EVAL is set but Rails.env.production? is true. ' \ 'console_eval cannot be opted into in production. Unset the flag or ' \ 'restart in a non-production environment.' end warn end |
.rebuild_credential_index(rails_app: nil) ⇒ CredentialIndex?
Rebuild the boot-time credential index from fresh Rails credentials and hot-swap it into the active scanner without restarting the process.
Host rotation jobs should call this immediately after ‘rails credentials:edit` changes are deployed. The swap is atomic on MRI (GVL) — in-flight scans see either the old or the new index, never a partial one.
Returns nil when:
-
‘console_credential_defense_enabled` is false
-
No server has been built yet in this process (‘build` / `build_embedded` have not been called)
Existing callers of ‘build` / `build_embedded` are unaffected — this is an additive class method with no required arguments beyond `rails_app`.
58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/woods/console/server.rb', line 58 def rebuild_credential_index(rails_app: nil) return nil unless credential_defense_enabled? return nil unless @active_scanner target_app = rails_app || default_rails_app return nil unless target_app new_index = CredentialIndex.build(rails_app: target_app) @active_scanner.replace_index!(new_index) new_index end |
.register_tier1_tools(server, conn_mgr, ctx = nil, renderer: nil) ⇒ void
This method returns an undefined value.
Register Tier 1 read-only tools on the server.
288 289 290 |
# File 'lib/woods/console/server.rb', line 288 def register_tier1_tools(server, conn_mgr, ctx = nil, renderer: nil) register_tier_tools(server, conn_mgr, ctx, tier: 1, renderer: renderer) end |
.register_tier2_tools(server, conn_mgr, ctx = nil, renderer: nil) ⇒ void
This method returns an undefined value.
Register Tier 2 domain-aware tools on the server.
298 299 300 |
# File 'lib/woods/console/server.rb', line 298 def register_tier2_tools(server, conn_mgr, ctx = nil, renderer: nil) register_tier_tools(server, conn_mgr, ctx, tier: 2, renderer: renderer) end |
.register_tier3_tools(server, conn_mgr, ctx = nil, renderer: nil) ⇒ void
This method returns an undefined value.
Register Tier 3 analytics tools on the server.
308 309 310 |
# File 'lib/woods/console/server.rb', line 308 def register_tier3_tools(server, conn_mgr, ctx = nil, renderer: nil) register_tier_tools(server, conn_mgr, ctx, tier: 3, renderer: renderer) end |
.register_tier4_tools(server, conn_mgr, ctx = nil, renderer: nil) ⇒ void
This method returns an undefined value.
Register Tier 4 guarded tools on the server.
318 319 320 |
# File 'lib/woods/console/server.rb', line 318 def register_tier4_tools(server, conn_mgr, ctx = nil, renderer: nil) register_tier_tools(server, conn_mgr, ctx, tier: 4, renderer: renderer) end |
.register_tier_tools(server, conn_mgr, ctx, tier:, renderer: nil) ⇒ void
This method returns an undefined value.
Register all tool specs for a given tier on the server.
276 277 278 279 280 |
# File 'lib/woods/console/server.rb', line 276 def register_tier_tools(server, conn_mgr, ctx, tier:, renderer: nil) TOOL_SPECS.select { |spec| spec.tier == tier }.each do |spec| register(spec, server, conn_mgr, ctx, renderer: renderer) end end |
.require_unsafe_eval_collaborators!(confirmation, audit_log_path) ⇒ Object
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 |
# File 'lib/woods/console/server.rb', line 251 def require_unsafe_eval_collaborators!(confirmation, audit_log_path) if confirmation.nil? raise Woods::ConfigurationError, 'WOODS_CONSOLE_UNSAFE_EVAL is set but no Confirmation was provided. ' \ 'Pass `unsafe_eval_confirmation:` to Server.build_embedded / RackMiddleware ' \ 'or set `config.console_unsafe_eval_confirmation`. Fail-closed by design — ' \ 'see backlog B-053 / docs/CONSOLE_MCP_SETUP.md.' end return unless audit_log_path.nil? || audit_log_path.to_s.strip.empty? raise Woods::ConfigurationError, 'WOODS_CONSOLE_UNSAFE_EVAL is set but no audit-log path was provided. ' \ 'Pass `unsafe_eval_audit_log_path:` to Server.build_embedded / RackMiddleware ' \ 'or set `config.console_unsafe_eval_audit_log_path`. Every console_eval run ' \ 'must be audited.' end |
.unsafe_eval_enabled? ⇒ Boolean
True when the caller has opted into the unsafe ‘console_eval` scaffolding via `WOODS_CONSOLE_UNSAFE_EVAL=true` or an explicit `config.console_unsafe_eval_enabled = true`. Explicit config wins over the env var in both directions.
NOTE: returning true here does NOT enable eval execution. The execution path is deliberately unimplemented (backlog unsafe-eval-opt-in). This predicate only governs the boot-time banner and the production-environment refusal below.
87 88 89 90 91 92 93 |
# File 'lib/woods/console/server.rb', line 87 def unsafe_eval_enabled? config = Woods.configuration if Woods.respond_to?(:configuration) explicit = config&.console_unsafe_eval_enabled return explicit if [true, false].include?(explicit) ENV['WOODS_CONSOLE_UNSAFE_EVAL'] == 'true' end |