Class: Woods::Console::SafeContext
- Inherits:
-
Object
- Object
- Woods::Console::SafeContext
- 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.
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
-
#redacted_columns ⇒ Array<String>
readonly
Column names whose values are replaced with “[REDACTED]”.
-
#redacted_key_values ⇒ Array<Hash>
readonly
Normalized EAV redaction patterns.
Instance Method Summary collapse
-
#execute {|connection| ... } ⇒ Object
Execute a block within a rolled-back transaction with statement timeout.
-
#initialize(connection: nil, pool: nil, timeout_ms: 5000, redacted_columns: [], redacted_key_values: []) ⇒ SafeContext
constructor
A new instance of SafeContext.
-
#redact(hash, _model_name = nil) ⇒ Hash
Replace values of redacted columns with “[REDACTED]”.
Constructor Details
#initialize(connection: nil, pool: nil, timeout_ms: 5000, redacted_columns: [], redacted_key_values: []) ⇒ SafeContext
Returns a new instance of SafeContext.
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_columns ⇒ Array<String> (readonly)
Returns 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_values ⇒ Array<Hash> (readonly)
Returns 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.
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.
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 |