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.

Examples:

server = Woods::Console::Server.build(config: config)
transport = MCP::Server::Transports::StdioTransport.new(server)
transport.open

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

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.

Parameters:

  • config (Hash)

    Configuration hash (from YAML or env)

Returns:

  • (MCP::Server)

    Configured server ready for transport



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.

Parameters:

  • model_validator (ModelValidator)

    Validates model/column names

  • safe_context (SafeContext)

    Wraps queries in rolled-back transactions

  • redacted_columns (Array<String>) (defaults to: [])

    Column names to redact from output

  • redacted_key_values (Array<Hash>) (defaults to: [])

    EAV redaction patterns. Each pattern: value_column:, sensitive_keys: []. See SafeContext for semantics.

  • connection (Object, nil) (defaults to: nil)

    Database connection for adapter detection

  • read_tools_enabled (Boolean) (defaults to: false)

    Enable sql/query tools in embedded mode (default: false)

  • unsafe_eval_confirmation (Confirmation, nil) (defaults to: nil)

    Approval callback for ‘console_eval`. Required when the opt-in is on; the server refuses to boot without it. Ignored when the opt-in is off.

  • unsafe_eval_audit_log_path (String, Pathname, nil) (defaults to: nil)

    JSONL audit log path for ‘console_eval`. Required when the opt-in is on.

Returns:

  • (MCP::Server)

    Configured server ready for transport



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 build_embedded(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.

Parameters:

  • confirmation (Confirmation, nil)

    Explicit kwarg wins over ‘config.console_unsafe_eval_confirmation`.

  • audit_log_path (String, Pathname, nil)

    Explicit kwarg wins over ‘config.console_unsafe_eval_audit_log_path`.

Returns:

  • (Hash)

    { eval_guard:, confirmation:, audit_logger:, unsafe_eval_enabled: }

Raises:



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.

Returns:

  • (Boolean)


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_appObject

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_wiringObject



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).

Raises:



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 unsafe_eval_banner
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`.

Parameters:

  • rails_app (#credentials) (defaults to: nil)

    The Rails application to re-read. Defaults to ‘Rails.application` when `Rails` is defined, otherwise the caller must supply it explicitly.

Returns:

  • (CredentialIndex, nil)

    The newly built index, or nil when the rebuild was skipped.



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.

Parameters:



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.

Parameters:



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.

Parameters:



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.

Parameters:



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.

Parameters:



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.

Returns:

  • (Boolean)


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