Class: Woods::Console::SafeContext

Inherits:
Object
  • Object
show all
Defined in:
lib/woods/console/safe_context.rb

Overview

Wraps tool execution in a rolled-back transaction with statement timeout.

Safety layers:

  • Every query runs inside a transaction that is always rolled back

  • Statement timeout uses ‘SET LOCAL` so it cannot leak to the next pool consumer

  • Column redaction replaces sensitive values with “[REDACTED]”

What SafeContext does NOT cover

The rolled-back transaction is a strong guardrail but not absolute. Known escape paths — callers of #execute should assume anything below is effectively live:

  • ‘ActiveJob` / `ActionMailer` async deliveries. Earlier versions tried to swap the global queue_adapter/delivery_method to `:test` for the block’s duration, but those settings are process-wide class state: in a Puma worker serving both the host app and the Console MCP, a concurrent host request would briefly see the test adapter and silently drop real jobs / mail. We now leave them alone — treat callback-triggered enqueues / deliveries as live.

  • ‘after_rollback` callbacks (fire on rollback, can still enqueue jobs or call external services).

  • ‘Thread.new` / `Fiber.new` inside the block — they lease a fresh connection outside the transaction.

  • Direct HTTP egress (Net::HTTP, Faraday, HTTP gem, …).

  • File I/O / shell-outs initiated from within AR callbacks.

  • Writes through a different pool or shard than the one this SafeContext was built with.

  • ‘raw_connection.execute` on some adapters when the adapter’s transaction bookkeeping is out-of-band.

Treat SafeContext as “rolls back the database”, not “prevents every side effect” — operators must still apply the upstream defenses (TableGate, SqlValidator, EvalGuard, BearerAuth).

Two construction modes are supported:

  • ‘connection:` — wraps the supplied connection in a single-use pool adapter so the execution path is identical to the `pool:` form. Useful in tests and for callers that already manage their own connection lifecycle (e.g. bridge mode, `exe/woods-console`).

  • ‘pool:` — each call to #execute leases a fresh connection via `pool.with_connection { |c| … }`, so the connection is returned to the pool immediately after the block. The leased connection is also exposed via `Thread.current` so dispatch handlers (e.g. EmbeddedExecutor#active_connection) can reuse the same connection without re-leasing.

In both forms the connection is resolved *per #execute call* —SafeContext never holds a connection ivar. This is the key invariant for multi-DB / sharded hosts: if you supply a shard pool (or shard connection), the rolled-back transaction is opened on that shard’s connection, not on the default pool.

Examples:

connection: form

ctx = SafeContext.new(connection: conn, timeout_ms: 5000, redacted_columns: %w[ssn])
ctx.execute { |c| c.execute("SELECT count(*) FROM users") }

pool: form (per-request lease)

ctx = SafeContext.new(pool: ActiveRecord::Base.connection_pool)
ctx.execute { |c| c.select_all("SELECT count(*) FROM users") }

Shard pool — rollback covers the shard

shard_pool = ShardedModel.connection_pool
ctx = SafeContext.new(pool: shard_pool)
ctx.execute { |c| c.select_all("SELECT * FROM shard_table") }

Key-value (EAV) redaction

ctx = SafeContext.new(
  connection: conn,
  redacted_key_values: [
    { key_column: 'key', value_column: 'value',
      sensitive_keys: %w[stripe_access_token oauth_token] }
  ]
)

Defined Under Namespace

Classes: SingleConnectionPool

Constant Summary collapse

LEASED_CONNECTION_KEY =

Thread-local key that exposes the connection currently leased for the in-flight #execute block. Handlers should prefer this over acquiring their own connection so every request stays on a single leased connection inside the rolled-back transaction.

:woods_console_leased_connection

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(connection: nil, pool: nil, timeout_ms: 5000, redacted_columns: [], redacted_key_values: []) ⇒ SafeContext

Returns a new instance of SafeContext.

Parameters:

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

    Database connection (or mock). Mutually exclusive with ‘pool:` — pass one or the other (or neither, if this SafeContext is only being used for #redact). The connection is wrapped in SingleConnectionPool so execution always flows through `pool.with_connection`.

  • pool (#with_connection, nil) (defaults to: nil)

    Connection pool to lease from per request. Each #execute call wraps ‘pool.with_connection`.

  • timeout_ms (Integer) (defaults to: 5000)

    Statement timeout in milliseconds

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

    Column names whose values should be redacted

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

    EAV-style redaction patterns. Each pattern: ‘key’, value_column: ‘value’, sensitive_keys: %w[stripe_access_token …]. When a row’s ‘key_column` cell matches one of `sensitive_keys`, the same row’s ‘value_column` cell is replaced with “[REDACTED]”.



130
131
132
133
134
135
136
# File 'lib/woods/console/safe_context.rb', line 130

def initialize(connection: nil, pool: nil, timeout_ms: 5000,
               redacted_columns: [], redacted_key_values: [])
  @pool = pool || (connection && SingleConnectionPool.new(connection))
  @timeout_ms = timeout_ms
  @redacted_columns = redacted_columns.map(&:to_s)
  @redacted_key_values = normalize_key_value_patterns(redacted_key_values)
end

Instance Attribute Details

#redacted_columnsArray<String> (readonly)

Returns Column names whose values are replaced with “[REDACTED]”.

Returns:

  • (Array<String>)

    Column names whose values are replaced with “[REDACTED]”



110
111
112
# File 'lib/woods/console/safe_context.rb', line 110

def redacted_columns
  @redacted_columns
end

#redacted_key_valuesArray<Hash> (readonly)

Returns Normalized EAV redaction patterns. Each entry has string keys: ‘key_column’, ‘value_column’, ‘sensitive_keys’.

Returns:

  • (Array<Hash>)

    Normalized EAV redaction patterns. Each entry has string keys: ‘key_column’, ‘value_column’, ‘sensitive_keys’.



114
115
116
# File 'lib/woods/console/safe_context.rb', line 114

def redacted_key_values
  @redacted_key_values
end

Instance Method Details

#execute {|connection| ... } ⇒ Object

Execute a block within a rolled-back transaction with statement timeout.

The transaction is always rolled back to ensure read-only behavior. A fresh connection is leased from the pool on every call via ‘pool.with_connection`. The leased connection is published as `Thread.current` for the duration of the block and cleared in `ensure` (even on exceptions) so dispatch handlers can pick it up without re-leasing.

Yields:

  • (connection)

    The database connection

Returns:

  • (Object)

    The block’s return value

Raises:

  • (ArgumentError)

    when neither ‘connection:` nor `pool:` was supplied at construction time. Deferred to #execute so callers that only use #redact can construct with neither.



152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/woods/console/safe_context.rb', line 152

def execute(&block)
  raise ArgumentError, 'SafeContext#execute requires connection: or pool: at construction time' unless @pool

  # NOTE: on async side effects: earlier iterations of SafeContext
  # tried to swap `ActiveJob::Base.queue_adapter` / `ActionMailer::Base
  # .delivery_method` to `:test` for the duration of this block.
  # That's unsafe — those settings are process-wide class state, so
  # any concurrent request served by the SAME Puma worker (the host
  # app running alongside the Console MCP) would race and briefly
  # see the test adapter, silently dropping real jobs and mail.
  # The gap is documented in the class docstring instead; operators
  # must treat callback-triggered enqueues / deliveries as live.
  @pool.with_connection { |conn| run_with_timeout(conn, &block) }
end

#redact(hash, _model_name = nil) ⇒ Hash

Replace values of redacted columns with “[REDACTED]”.

Runs column-name redaction first, then EAV key-value redaction — a row like “stripe_access_token”, value: “sk_live_…” has its ‘value` column replaced when “stripe_access_token” is in the sensitive_keys list, regardless of whether `value` itself is in redacted_columns.

Parameters:

  • hash (Hash)

    Record attributes

  • _model_name (String) (defaults to: nil)

    Model name (reserved for per-model redaction rules)

Returns:

  • (Hash)

    Redacted copy of the hash



177
178
179
180
181
182
183
184
# File 'lib/woods/console/safe_context.rb', line 177

def redact(hash, _model_name = nil)
  return hash if @redacted_columns.empty? && @redacted_key_values.empty?

  redacted = hash.transform_keys(&:to_s).each_with_object({}) do |(key, value), out|
    out[key] = @redacted_columns.include?(key) ? '[REDACTED]' : value
  end
  apply_key_value_redaction(redacted)
end