woods 2.0.0 → 2.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f9ba72b1db1eb0ba7ac57a190a3ae0cfbee5f86fb35684e4abed69bc93aca64
4
- data.tar.gz: c8f381b43946794d2e7c9d756b352927e4f7ed050c033774273e79803d78e6d0
3
+ metadata.gz: c00b317c5f1df618b940db9d8d2b596c27c1538b4d713d60c9263b1f0ab6acae
4
+ data.tar.gz: b0fceeb5a3220efdd9d3f0f5dd77d9bc12e1d780ddc689dfd2121f163d9ec75d
5
5
  SHA512:
6
- metadata.gz: d1e1c9f53cb2f148c163cc4b2433896c2d69f382df6212fb4f9ad2af806f8500750614a017ac3617e17880e017e066d56c9f5c42d3415f883e33f2d774b2fa70
7
- data.tar.gz: ed939e3dcb486d1fe8c1952ce9141fdeadb0e7c614abf7fafb6420f8782b5147da70aea0632e42e08fae0af6c4991776c34577252d1c69824140d3c723794290
6
+ metadata.gz: 0ec485c382f5a334d3607f2f18dd1489c02a6de17df0ba8922dd1d7271a36c8ffa1a71ec16346fe148460632e26f7ccbfc51c938ad96d1c8d03e68672d590993
7
+ data.tar.gz: 011e559a5cbd6478d579c0fc477c020048352b75872daaab75a52df527dd0b2c0e27645f0627321b03a87c9844aa52b7b5f9fab0e90b7e6ec071a244c8697ef1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.1] - 2026-09-26
11
+
12
+ ### Documentation
13
+
14
+ - Clarify supported Console redaction, SQL projection, adapter timeout and HTTP origin behavior, including maintenance-line differences.
15
+
16
+ ### Fixed
17
+
18
+ - Report invalidly encoded HTTP origin settings with bounded startup diagnostics; preserve existing origin defaults and access checks.
19
+ - Use the same Console configuration error for malformed origins during automatic and manual middleware construction.
20
+ - HTTP startup diagnostics consistently name `WOODS_MCP_HTTP_ALLOWED_ORIGINS` for malformed origins, including under a POSIX (`C`) locale.
21
+
22
+ ### Security
23
+
24
+ - Refuse unsupported PostgreSQL escaped identifiers in raw Console SQL before query execution. Ordinary quoted identifiers, literal contents, and comments retain their existing behavior.
25
+ - Share validated Console query projections between execution and typed EAV redaction context, including whitespace normalization.
26
+ - Refuse SQL relation and CTE column alias lists while output redaction is configured, with an explicit redaction-identity error before execution. Direct unaliased projections remain supported.
27
+ - Harden Console request validation, protected result handling, and HTTP authentication and origin enforcement.
28
+ - Apply typed key/value redaction consistently to case-variant and schema-qualified source table names, retaining all matching model types when source schemas are ambiguous.
29
+ - Isolate cached retrieval contexts between application instances sharing a cache backend.
30
+
10
31
  ## [2.0.0] - 2026-09-23
11
32
 
12
33
  ### Added
data/CONTRIBUTING.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Contributing to Woods
2
2
 
3
3
  <!-- release-state:contributing-intro -->
4
- Woods welcomes bug fixes, extractor coverage, storage and retrieval improvements, MCP compatibility work, documentation, and focused performance changes. This guide covers the shared contribution contract. Coding agents working from a source checkout should also read the repository's [AGENTS.md](https://github.com/lost-in-the/woods/blob/v2.0.0/AGENTS.md).
4
+ Woods welcomes bug fixes, extractor coverage, storage and retrieval improvements, MCP compatibility work, documentation, and focused performance changes. This guide covers the shared contribution contract. Coding agents working from a source checkout should also read the repository's [AGENTS.md](https://github.com/lost-in-the/woods/blob/v2.0.1/AGENTS.md).
5
5
  <!-- release-state:end -->
6
6
 
7
7
  ## Choose the right channel
@@ -46,7 +46,7 @@ Create a branch from current `main`. Keep each pull request to one logical chang
46
46
  | `plugin/skills/` | Distributed Woods skills (setup/upgrade, MCP configuration, investigation, agent enablement, diagnosis) |
47
47
 
48
48
  <!-- release-state:contributing-architecture -->
49
- Read [CLAUDE.md](https://github.com/lost-in-the/woods/blob/v2.0.0/CLAUDE.md) for architecture and implementation gotchas before changing runtime behavior.
49
+ Read [CLAUDE.md](https://github.com/lost-in-the/woods/blob/v2.0.1/CLAUDE.md) for architecture and implementation gotchas before changing runtime behavior.
50
50
  <!-- release-state:end -->
51
51
 
52
52
  ### Agent orientation and static self-map
@@ -460,6 +460,16 @@ fresh tag-push CI run. Updating main's tooling alone never authorizes different
460
460
  candidate bytes. Main's v2 release contract remains unchanged. Do not create or
461
461
  push tags, dispatch, publish, or claim 1.6.3 is available during preparation.
462
462
 
463
+ ### 2.0.1 maintenance CI contract
464
+
465
+ The 2.0.1 candidate uses trusted main's reviewed maintenance profile, not this
466
+ candidate's copy of the release tooling. That profile intentionally omits
467
+ `exact_ci_jobs` and `package_spec`: validation uses the normal v2 required-job
468
+ list and `spec/integration/packaged_gem_spec.rb`, rather than the legacy matrix
469
+ or package spec. The 1.6.4 profile is separate and has no C-locale artifact-reader
470
+ CI lane. Candidate evidence must identify the actual jobs and artifacts tested;
471
+ profile approval and publication remain maintainer steps.
472
+
463
473
  ### Stable branches
464
474
 
465
475
  A stable branch is `N-M-stable`, cut from the release tag. Create one only when a released line needs a patch after a newer major has shipped on `main`; until then, `main` is the development branch. The explicitly approved short-lived `release/1.6.3` security exception above does not establish an `N-M-stable` branch.
@@ -291,6 +291,13 @@ unchanged. This is a reasonable default for hosts that don't bundle `sqlite3`.
291
291
 
292
292
  ## Retrieval cache options
293
293
 
294
+ The 2.0.1 maintenance patch scopes retrieval contexts to one retriever instance.
295
+ Reload retires only that instance's namespace, including results still in flight;
296
+ already-running requests may finish against the previous corpus. Restarting starts
297
+ a fresh context namespace. Retired entries expire under their configured TTL or
298
+ backend eviction; disabling both can retain unused entries indefinitely. Embedding
299
+ caches remain separate and are not cleared by context invalidation.
300
+
294
301
  The optional cache wraps both embedding-provider calls and assembled retrieval
295
302
  contexts. It is disabled by default and is separate from the Index Server's
296
303
  tool-result `_meta` cache hint.
@@ -682,7 +689,7 @@ deployment guide including defense layers.
682
689
  | `console_mcp_enabled` | Boolean | `false` | Master switch. When `false`, stdio exits and the mounted Console middleware passes requests through to Rails. |
683
690
  | `console_mcp_http_enabled` | Boolean | `true` | HTTP transport switch; effective only while the master switch is on. Set `false` for stdio-only use without HTTP token validation or an active HTTP endpoint. Read at request time. |
684
691
  | `console_mcp_token` | String | `ENV['WOODS_CONSOLE_MCP_TOKEN']` or `nil` | Bearer token required on every enabled Console HTTP request. With both Console flags enabled, production boot raises on a missing token; other environments warn and requests fail closed with 401. A configured token shorter than 32 characters raises at boot while HTTP is enabled. Explicit stdio-only configurations skip HTTP token validation. Generate with `SecureRandom.hex(32)`. |
685
- | `console_mcp_allowed_origins` | Array\<String\> | `%w[http://localhost http://127.0.0.1 http://[::1]]` | `OriginGuard` allowlist. Port is stripped before comparison, so `http://localhost` matches any localhost port. Override for tunneled / internal-dashboard access. |
692
+ | `console_mcp_allowed_origins` | Array\<String\> | `%w[http://localhost http://127.0.0.1 http://[::1]]` | `OriginGuard` allowlist shared with SDK dispatch. Portless entries permit same-authority requests; cross-origin ports must be listed explicitly. Default HTTP(S) ports normalize to their omitted form. Invalid entries fail at boot. |
686
693
  | `console_mcp_path` | String | `/mcp/console` | URL path the Rack middleware responds on. |
687
694
  | `console_embedded_read_tools` | Boolean | `false` | Register `console_sql` and `console_query` in supported stdio and Rack modes. |
688
695
  | `console_blocked_tables` | Array\<String\> | `Woods::DEFAULT_CONSOLE_BLOCKED_TABLES` | TableGate denylist (case-insensitive). Bare names match every schema; qualified names (`schema.table`) match exactly. |
@@ -209,7 +209,10 @@ Rails/MCP host. If a browser-based client sends an `Origin` header from a
209
209
  different host, include that exact origin too. This allow-list controls both
210
210
  DNS-rebinding Host checks and browser CORS; keep Rails' own `config.hosts`, TLS,
211
211
  and proxy rules aligned with it. Server-to-server clients normally omit
212
- `Origin`, but their request `Host` must still be allowed.
212
+ `Origin`, but a present request `Host` must still be allowed. Supporting security
213
+ revisions deliberately allow configured non-loopback Hosts through the SDK too;
214
+ 2.0.0 could refuse them at that inner layer despite the Woods allowlist. This
215
+ widening is limited to the configured authorities and retains bearer auth.
213
216
 
214
217
  Do not mount `Woods::Console::RackMiddleware` by itself. The Railtie composes
215
218
  `OriginGuard`, `BearerAuth`, and the Console middleware in the supported order.
@@ -636,6 +639,9 @@ Redaction is defense-in-depth, prefer not storing plaintext secrets in database
636
639
 
637
640
  ### `console_redacted_key_values`
638
641
 
642
+ See [read policy compatibility](#read-policy-compatibility) for SQL column-list
643
+ restrictions, conservative typed masking, exact key spelling and binary columns.
644
+
639
645
  Column-name redaction falls short when credentials are stored in a **key-value (EAV)** table, e.g. a Stripe Connect `authorizations` row of `{key: "stripe_access_token", value: "sk_live_..."}`. The column holding the secret is called `value`, which is generic: adding `value` to `console_redacted_columns` would over-redact every unrelated row in the table.
640
646
 
641
647
  `console_redacted_key_values` takes one or more patterns that describe "when a row has `key_column` set to one of these names, redact its `value_column`":
@@ -700,7 +706,7 @@ these controls, in order:
700
706
 
701
707
  1. `SqlValidator` rejects DML/DDL (`INSERT`/`UPDATE`/`DELETE`/`MERGE`/`DROP`/`TRUNCATE`/`ALTER`/`CREATE`/`REPLACE`), row-lock clauses (`FOR UPDATE`, `FOR SHARE`, `LOCK IN SHARE MODE`), writable CTEs (every `AS (...)` body, not just the first), `UNION`/`INTO`/`COPY`, multi-statement and comment-hidden injections, and most administrative keywords (`DO`, `SET`, `LISTEN`, `NOTIFY`, `CALL`, `LOAD`, `VACUUM`, `PREPARE`, transaction control, `EXPLAIN ANALYZE`) at the string level. Enforces a read-only **function allowlist** (`ALLOWED_FUNCTIONS`), anything not on it is rejected by name, quoted forms (`"pg_terminate_backend"(…)`) included. Only `SELECT`, `WITH…SELECT`, and plain `EXPLAIN` pass.
702
708
  2. `TableGate` refuses any SQL, model, or join that touches a `console_blocked_tables` entry.
703
- 3. `SafeContext` wraps every request in a rolled-back transaction with a short statement timeout. **It does NOT cover async side effects**: ActiveJob `perform_later`, ActionMailer `deliver_later`, direct HTTP egress, `Thread.new`-spawned work, `after_rollback` callbacks, and writes through a different shard all execute as live. Treat the Console MCP as an admin-trust boundary, not a sandbox.
709
+ 3. `SafeContext` wraps every request in a rolled-back transaction with an adapter-dependent statement timeout. **It does NOT cover async side effects**: ActiveJob `perform_later`, ActionMailer `deliver_later`, direct HTTP egress, `Thread.new`-spawned work, `after_rollback` callbacks, and writes through a different shard all execute as live. Treat the Console MCP as an admin-trust boundary, not a sandbox.
704
710
  4. `CredentialScanner` + column/EAV redaction scrub results.
705
711
 
706
712
  Keep the flag off when the host requires a narrower database capability.
@@ -718,7 +724,7 @@ supported transport (stdio, Docker/SSH launcher, and HTTP).
718
724
  | 1 | Blocked tables | `console_blocked_tables` | Tool dispatch, before executor | Reject any tool call that touches a named table (model, table, or sql arg) |
719
725
  | 2 | Credential scanner | `console_disabled_scanner_patterns` (`[:all]` to disable entirely) | After executor, before render | Content-shape redaction of credential-shaped strings anywhere in the response tree |
720
726
  | 3 | Column + EAV redaction | `console_redacted_columns`, `console_redacted_key_values` | After executor, before Layer 2 | Identity-based redaction by column name and by key/value row shape |
721
- | 4 | SqlValidator + SafeContext | built-in | Inside executor | SQL deny-list for `console_sql`; transaction-rollback for every request |
727
+ | 4 | SqlValidator + SafeContext | built-in | Inside executor | SQL validation and function allowlist for `console_sql`; transaction rollback for every request |
722
728
 
723
729
  Layers 0–3 are configured via `Woods.configure`. Layer 4 is always on and has no knobs. Observability hooks, `console.table_gate.rejected` for Layer 1, `console.credential_scan.hits` for Layer 2, emit structured log lines via `Woods::Observability::StructuredLogger` so operators can audit enforcement without scraping MCP wire traffic.
724
730
 
@@ -754,13 +760,20 @@ boundary remain necessary.
754
760
 
755
761
  ### Statement timeout
756
762
 
757
- Each transaction sets a statement timeout before any query runs. The default is **5000ms** (5 seconds). Timeout enforcement is adapter-specific:
763
+ SafeContext attempts a **5000ms** (5-second) timeout. Support depends on the
764
+ adapter and server; an unsupported setting is skipped and logged when a Rails
765
+ logger is available.
758
766
 
759
767
  | Adapter | Mechanism | Scope |
760
768
  |---------|-----------|-------|
761
- | PostgreSQL | `SET statement_timeout = '5000ms'` | All statement types |
762
- | MySQL | `SET max_execution_time = 5000` (session scope; the prior value is restored after the transaction) | SELECT only (MySQL limitation) |
763
- | Other | Best-effort (skipped gracefully) | n/a |
769
+ | PostgreSQL | `SET LOCAL statement_timeout = '5000ms'` | Transaction-local; discarded on rollback |
770
+ | MySQL | `SET max_execution_time = 5000` | SELECT only; previous session value restored in `ensure` |
771
+ | MariaDB | `SET max_statement_time = 5.0` | Seconds; previous session value restored in `ensure` |
772
+ | SQLite / unrecognized family | No supported per-statement timeout | Do not rely on a query time limit |
773
+
774
+ Rollback remains active when a timeout setting is unsupported. Recognition of a
775
+ MySQL-family adapter alone does not establish that its server supports the
776
+ corresponding timeout variable.
764
777
 
765
778
  ### SQL validation (tier 4 `console_sql`)
766
779
 
@@ -771,7 +784,7 @@ Validation runs **once**, inside the executor, with the dialect of the live adap
771
784
 
772
785
  - **Allowed prefixes:** `SELECT`, `WITH...SELECT`, and plain `EXPLAIN`. `EXPLAIN ANALYZE` is rejected, it executes the query rather than just planning it (both the whitespace and `EXPLAIN (ANALYZE, …)` option-list spellings).
773
786
  - **Rejected prefixes:** `INSERT`, `UPDATE`, `DELETE`, `MERGE`, `DROP`, `ALTER`, `TRUNCATE`, `CREATE`, `GRANT`, `REVOKE`
774
- - **Rejected anywhere in query:** `UNION`, `INTO`, `COPY`; row-lock clauses (`FOR UPDATE`, `FOR NO KEY UPDATE`, `FOR SHARE`, `FOR KEY SHARE`, `FOR UPDATE NOWAIT`/`SKIP LOCKED`, MySQL `LOCK IN SHARE MODE`) — these take live row locks even inside the rolled-back transaction. The lock check is adapter-aware: `console_sql` validates with the active adapter's dialect, including MySQL double-quoted strings/backtick identifiers and PostgreSQL quoted identifiers/E-strings. Unknown adapters conservatively scan all supported normalizations. Every view is scanned under both MySQL executable-comment (`/*!...*/`) semantics, so `#` comments and version-guarded comments cannot split a clause apart.
787
+ - **Rejected anywhere in query:** `UNION`, `INTO`, `COPY`; row-lock clauses (`FOR UPDATE`, `FOR NO KEY UPDATE`, `FOR SHARE`, `FOR KEY SHARE`, `FOR UPDATE NOWAIT`/`SKIP LOCKED`, MySQL `LOCK IN SHARE MODE`) — these take live row locks even inside the rolled-back transaction. The lock check is adapter-aware: `console_sql` validates with the active adapter's dialect, including MySQL double-quoted strings/backtick identifiers and PostgreSQL quoted identifiers/E-strings. Direct validator calls without a dialect conservatively scan all supported normalizations; packaged `console_sql` refuses unrecognized adapter families. Every view is scanned under both MySQL executable-comment (`/*!...*/`) semantics, so `#` comments and version-guarded comments cannot split a clause apart.
775
788
  - **Function allowlist (the authoritative function control):** every function-call-shaped identifier must appear in `ALLOWED_FUNCTIONS`, a conservative set of pure read-only functions (aggregates, window functions, string/number/date/JSON readers) kept portable across MySQL, PostgreSQL, and SQLite. Anything else is rejected by name, quoted forms (`"pg_terminate_backend"(…)`) included. This is an allowlist because a denylist cannot enumerate every side-effecting function (`nextval`, `pg_advisory_lock`, `pg_terminate_backend`, …). A legacy `DANGEROUS_FUNCTIONS` denylist (`pg_sleep`, `lo_import`, `lo_export`, `pg_read_file`, `pg_write_file`, `load_file`, `sleep`, `benchmark`) still runs first as belt-and-suspenders.
776
789
  - **Rejected patterns:** multiple statements (semicolons), writable CTEs (every `AS (...)` body is checked, so a writable CTE in any WITH position is refused — `WITH a AS (SELECT 1), b AS (DELETE FROM users RETURNING *) SELECT * FROM b`), a CTE list attached to top-level DML (`WITH a AS (SELECT 1) DELETE FROM users RETURNING *`), comment-hidden injections
777
790
 
@@ -902,3 +915,67 @@ These corrections require `2.0.0.beta4` or a reviewed development revision that
902
915
  contains them. Confirm that a patched release is available before selecting it.
903
916
  On affected versions, disable Console where these policies are required; Index MCP
904
917
  can stay enabled because it reads the published code index separately.
918
+
919
+
920
+ ## Maintenance policy corrections
921
+
922
+ The 2.0.1 maintenance patch applies authentication and origin policy to each
923
+ Console HTTP mount. Keep the normal Railtie setup: these additional checks do not
924
+ make an unreviewed manual mount the recommended installation path. Configure
925
+ allowed origins before boot and restart after changes; malformed entries fail
926
+ once at boot with the offending entry identified.
927
+
928
+ Protected collection values are masked as complete cells. EAV key policy checks
929
+ both stored and cast keys, including in predicates and ordering. Raw SQL refuses
930
+ ambiguous protected source identity, protected whole-row projections, positional
931
+ ordering that could expose protected values, and unsupported result types.
932
+ Explicit unaliased scalar columns and the structured tools are the recovery path.
933
+
934
+ SQL dialect detection follows adapter ancestry before adapter names. PostgreSQL
935
+ subclasses, Mysql2, MariaDB, Trilogy and SQLite use their applicable policies.
936
+ Genuinely unknown families are refused only by raw `console_sql`; this restriction
937
+ does not disable structured Tier-1 tools. Existing tool opt-ins remain unchanged.
938
+
939
+ PostgreSQL Unicode-escaped identifiers are refused by `console_sql` before
940
+ execution. Use ordinary identifiers or standard quoted identifiers instead;
941
+ structured query tools are unaffected by this syntax restriction.
942
+
943
+ ### Read policy compatibility
944
+
945
+ These rules describe the security-patch source; verify the installed revision
946
+ and loaded gem path until its release is published.
947
+
948
+ - **Column alias lists:** when either `console_redacted_columns` or
949
+ `console_redacted_key_values` is nonempty, `console_sql` refuses relation
950
+ and CTE column alias lists before execution, including lists on base tables,
951
+ derived tables, parenthesized `VALUES` sources and table functions. This applies
952
+ even when the selected names are not protected and independently of function
953
+ validation. Ordinary relation
954
+ aliases, CTEs without column lists and allowed scalar functions remain subject
955
+ to the normal SQL policy. Use explicit, unaliased protected columns or a
956
+ structured Console tool.
957
+ - **Conservative typed masking:** EAV type lookup matches the final source-table
958
+ name case-insensitively and includes every matching registered model, even
959
+ across schemas. Any matching type can cause masking. This deliberately may
960
+ mask extra values when table names differ only by case or share that final
961
+ name; qualifying the table does not narrow that type set.
962
+ - **Exact sensitive values:** `sensitive_keys` compares the stored and cast key
963
+ values with exact case after string conversion. Configure their actual raw or
964
+ cast spelling. `CredentialIndex` also matches credential substrings with exact
965
+ case; it does not decode hexadecimal binary output such as PostgreSQL `bytea`.
966
+ Binary cells have no general text-scanning guarantee: non-UTF-8 data can fail
967
+ JSON normalization before scanning. Put binary secret columns in
968
+ `console_redacted_columns` so they are masked before serialization.
969
+ - **Adapter boundary:** raw `console_sql` requires PostgreSQL, MySQL-family
970
+ (including Mysql2, MariaDB and Trilogy), or SQLite classification. Compatible
971
+ subclasses are recognized by ancestry. Unknown families receive a validation
972
+ refusal for raw SQL; structured tools remain available under their normal
973
+ gates. See [statement timeouts](#statement-timeout) for adapter limits.
974
+ - **SQL functions and select entries:** 2.x enforces a read-only function
975
+ allowlist; 1.6.x retains a function denylist. For `console_query`, provide one
976
+ expression per `select` array entry. 2.x refuses comma-combined entries that
977
+ 1.6.4 splits before validation. Execution and typed redaction use the same
978
+ validated projection on all patched lines.
979
+ - **Association counts:** polymorphic `belongs_to` counts remain unsupported
980
+ on this maintenance line and fail closed with a generic execution error.
981
+ The 2.1 functional correction is not included in this backport.
@@ -86,16 +86,68 @@ Clients must send `Authorization: Bearer $WOODS_MCP_HTTP_TOKEN` on every request
86
86
 
87
87
  ### Browser origins (DNS rebinding defense)
88
88
 
89
- A second middleware, `Woods::MCP::OriginGuard`, rejects requests whose `Origin` header is outside an allow-list. Requests without an `Origin` header (curl, MCP stdio clients, server-to-server) pass through, bearer auth still gates them.
89
+ **Unreleased diagnostic correction:** invalid origin encodings also refuse before
90
+ binding HTTP. `woods-mcp-http` exits 2 with one bounded `ConfigurationError`
91
+ message naming the invalid entry; re-enter that origin using an ASCII hostname
92
+ (or its Punycode form). No allowlist keeps the existing defaults. Explicit entries
93
+ are literal origins, not wildcard patterns.
94
+ Retain the actual request Host through a reverse proxy; forwarded headers do not
95
+ replace it. When authentication is configured, requests without Host still pass
96
+ through bearer authentication.
97
+
98
+ In the 2.0.1 maintenance patch, preflight and SDK dispatch share an immutable
99
+ normalized policy. A configured list replaces the default browser origins;
100
+ include loopback explicitly if needed. Cross-origin ports must match an entry,
101
+ while omitted default HTTP(S) ports match their explicit 80/443 forms. A portless
102
+ entry permits same-authority traffic rather than every cross-port browser origin.
103
+ Console defaults remain HTTP loopback; Index HTTP defaults include HTTP and HTTPS
104
+ loopback. When no list is set, these defaults are unchanged. Invalid entries fail
105
+ at boot, naming the offending entry. Restart after changing the configuration.
106
+
107
+ A second middleware, `Woods::MCP::OriginGuard`, rejects requests whose `Origin` header is outside an allow-list. Requests without an `Origin` header still pass through Host validation and configured bearer authentication.
90
108
 
91
109
  | Scenario | `WOODS_MCP_HTTP_ALLOWED_ORIGINS` | Origins accepted |
92
110
  |--------------------|-----------------------------------------|-------------------------------------------------------------------|
93
- | default | unset | `http(s)://localhost`, `127.0.0.1`, `::1` (any port) |
111
+ | default | unset | HTTP(S) loopback, matching request authority; explicit entries for cross-port origins |
94
112
  | explicit list | `https://app.example.com` | exactly `https://app.example.com`, loopback no longer allowed |
95
113
  | multiple origins | `https://a.example,https://b.example` | each listed origin |
96
114
 
97
115
  `OPTIONS` preflights are answered with the matching `Access-Control-Allow-*` headers; successful responses carry `Access-Control-Allow-Origin` and `Vary: Origin`. `Access-Control-Expose-Headers: Mcp-Session-Id` appears only in legacy session mode (`WOODS_MCP_HTTP_STATELESS=0`).
98
116
 
117
+ ### Origin configuration compatibility
118
+
119
+ These details apply to the supporting security-patch revisions described above.
120
+
121
+ The Index HTTP launcher names `WOODS_MCP_HTTP_ALLOWED_ORIGINS` and the offending
122
+ entry when refusing malformed origin configuration, including under a POSIX
123
+ (`C`) locale. It emits one diagnostic before index resolution or HTTP binding.
124
+
125
+ Configured origins are literal values, with lowercasing, one trailing slash
126
+ removed and HTTP(S) default-port normalization. Wildcard-looking hostnames are
127
+ literal hostnames, not patterns. A nonempty explicit list replaces browser-origin
128
+ defaults, including loopback; add the loopback browser origins you need. Console's
129
+ configured defaults are HTTP loopback; the Index defaults include HTTP and HTTPS
130
+ loopback. Loopback **Host** acceptance is separate from browser-origin acceptance.
131
+
132
+ Ruby-configured 2.x origin entries reject surrounding whitespace; 1.6.4 trims it.
133
+ The `woods-mcp-http` comma-separated environment setting trims each entry on both
134
+ lines. Use whitespace-free values for portable configuration. The exact-port and
135
+ same-authority rules above still apply; literal matching does not imply wildcard
136
+ or arbitrary cross-port access.
137
+
138
+ The guards read the actual `Origin` and `Host`; `Forwarded` and
139
+ `X-Forwarded-*` do not replace them. Preserve the public Host through a proxy.
140
+ A genuinely absent Host is accepted by the Host policy, but any supplied Origin
141
+ must still pass its own check. Console requests and token-configured Index
142
+ requests still require bearer authentication before dispatch. The loopback-only
143
+ Index mode without a token retains its documented unauthenticated behavior;
144
+ CORS preflights do not dispatch tools.
145
+
146
+ Malformed Origin/Host values receive constant `403` responses without reflecting
147
+ the values. Invalid Authorization receives `401` when it reaches the auth guard.
148
+ Malformed HTTP framing can instead receive Puma's `400` before Rack runs. Other
149
+ HTTP servers may reject framing at their own boundary.
150
+
99
151
  ### TLS termination
100
152
 
101
153
  The server speaks plain HTTP. Any deployment beyond a single trusted host should front it with a reverse proxy that handles TLS, HTTP/2, and connection limits.
@@ -8,6 +8,35 @@ published 1.6.x security patch as the rollback version.
8
8
  <!-- release-state:upgrade-availability -->
9
9
  <!-- release-state:end -->
10
10
 
11
+ ## 2.0.1 security maintenance update
12
+
13
+ This maintenance line adds Console request/output policy corrections and isolates
14
+ retrieval contexts between retriever instances. It does not add the graph or
15
+ extraction features under development for 2.1. Confirm the installed package
16
+ version and use its matching tag documentation.
17
+
18
+ No index or database schema migration is required. Restart Console/MCP processes
19
+ after upgrading. Explicit malformed HTTP origin entries now fail at boot with the
20
+ offending entry named; fix the entry rather than weakening authentication.
21
+ Automatic and manual Console mounts raise `Woods::ConfigurationError` for these
22
+ settings, including invalidly encoded entries. With
23
+ no allowlist configured, the existing loopback defaults remain unchanged. See
24
+ [HTTP origin matching](MCP_HTTP_TRANSPORT.md#browser-origins-dns-rebinding-defense).
25
+
26
+ Raw `console_sql` now refuses ambiguous protected results and genuinely unknown
27
+ adapter families. PostgreSQL-subclass adapters retain PostgreSQL handling;
28
+ structured Console tools remain available with other adapters. Prefer explicit
29
+ unaliased scalar projections or structured reads when a query is refused.
30
+ [Console setup](CONSOLE_MCP_SETUP.md#maintenance-policy-corrections) describes the
31
+ compatibility boundary. Context caches refill after restart; retired entries
32
+ follow the configured TTL or backend eviction. See
33
+ [retrieval cache options](CONFIGURATION_REFERENCE.md#retrieval-cache-options).
34
+ Rolling back restores the affected behavior.
35
+
36
+ Polymorphic `belongs_to` association counts remain unsupported in 2.0.1 and
37
+ 1.6.4 and return a generic execution error; the 2.1 functional correction is
38
+ not backported.
39
+
11
40
  ## Upgrade outcome
12
41
 
13
42
  After this runbook you will have:
@@ -19,6 +48,37 @@ After this runbook you will have:
19
48
  - an MCP client connected to the v2 packaged tool surface;
20
49
  - a documented way back to v1 if verification fails.
21
50
 
51
+ ### Console read compatibility
52
+
53
+ For supporting security-patch revisions, any nonempty column or EAV redaction
54
+ policy makes `console_sql` refuse relation and CTE column alias lists, including
55
+ lists on base tables, derived tables, parenthesized `VALUES` sources and table
56
+ functions, regardless of the selected names. Use explicit, unaliased protected
57
+ columns or structured tools.
58
+ Typed EAV lookup includes all registered models with a case-insensitive matching
59
+ final table name, including across schemas. It can therefore mask extra values;
60
+ qualification does not narrow this conservative type set. Sensitive key values
61
+ still require their exact stored or cast spelling.
62
+
63
+ The 1.6.x function denylist becomes a read-only function allowlist in 2.x.
64
+ Supply one `console_query` expression per `select` array entry: 2.x refuses
65
+ comma-combined entries that 1.6.4 splits. Raw SQL requires a recognized adapter
66
+ family; structured tools remain available on other adapters. Configure binary
67
+ secret columns explicitly for redaction. See the canonical
68
+ [read policy compatibility](CONSOLE_MCP_SETUP.md#read-policy-compatibility) and
69
+ [statement timeouts](CONSOLE_MCP_SETUP.md#statement-timeout) for limits.
70
+
71
+ Supporting Console HTTP revisions deliberately permit allowlisted non-loopback
72
+ Hosts through the SDK where 2.0.0 could refuse them. Bearer authentication remains
73
+ required. An explicit origin list replaces browser-origin defaults; wildcards
74
+ are not supported. Ruby-configured 2.x origins reject surrounding whitespace
75
+ that 1.6.4 trims; the HTTP executable trims comma-separated environment entries
76
+ on both lines. Review [HTTP origin configuration](MCP_HTTP_TRANSPORT.md#origin-configuration-compatibility).
77
+
78
+ Context-cache namespace rotation retires entries for normal TTL or backend
79
+ eviction; disabling both can retain them indefinitely. See
80
+ [retrieval cache options](CONFIGURATION_REFERENCE.md#retrieval-cache-options).
81
+
22
82
  ## What changes
23
83
 
24
84
  | v2 change | What can break | Required response |
data/exe/woods-mcp-http CHANGED
@@ -46,6 +46,20 @@ require_relative '../lib/woods/embedding/text_preparer'
46
46
  require_relative '../lib/woods/embedding/indexer'
47
47
 
48
48
  begin
49
+ raw_origins = ENV.fetch('WOODS_MCP_HTTP_ALLOWED_ORIGINS', '')
50
+ unless raw_origins.valid_encoding?
51
+ invalid_entry = raw_origins.b.split(',').find do |entry|
52
+ !entry.dup.force_encoding(raw_origins.encoding).valid_encoding?
53
+ end
54
+ label = invalid_entry.to_s.b.byteslice(0, 160).inspect
55
+ raise Woods::ConfigurationError, "Invalid WOODS_MCP_HTTP_ALLOWED_ORIGINS entry #{label}: invalid encoding"
56
+ end
57
+ allowed_origins = raw_origins.split(',').map(&:strip).reject(&:empty?)
58
+ begin
59
+ origin_policy = Woods::MCP::OriginPolicy.new(allowed_origins: allowed_origins)
60
+ rescue ArgumentError => e
61
+ raise Woods::ConfigurationError, "#{e.message} (WOODS_MCP_HTTP_ALLOWED_ORIGINS)"
62
+ end
49
63
  index_dir = Woods::MCP::Bootstrapper.resolve_index_dir(ARGV)
50
64
  retriever, bootstrap_state = Woods::MCP::Bootstrapper.build_retriever(index_dir: index_dir)
51
65
  snapshot_store = Woods::MCP::Bootstrapper.build_snapshot_store(index_dir)
@@ -96,17 +110,10 @@ server = Woods::MCP::Server.build(
96
110
  # hatch is transitional — the spec has removed all three. See
97
111
  # docs/MCP_HTTP_TRANSPORT.md#statelessness.
98
112
  stateless = !%w[0 false no].include?(ENV.fetch('WOODS_MCP_HTTP_STATELESS', '1').strip.downcase)
99
- allowed_origins = ENV.fetch('WOODS_MCP_HTTP_ALLOWED_ORIGINS', '').split(',').map(&:strip).reject(&:empty?)
100
- allowed_hosts = allowed_origins.filter_map do |origin|
101
- URI.parse(origin).host
102
- rescue URI::InvalidURIError
103
- nil
104
- end
105
113
  transport = MCP::Server::Transports::StreamableHTTPTransport.new(
106
114
  server,
107
115
  stateless: stateless,
108
- allowed_origins: allowed_origins,
109
- allowed_hosts: allowed_hosts
116
+ **origin_policy.transport_options
110
117
  )
111
118
  server.transport = transport
112
119
 
@@ -116,7 +123,7 @@ inner = proc do |env|
116
123
  transport.handle_request(Rack::Request.new(env))
117
124
  end
118
125
  app = token ? Woods::MCP::BearerAuth.new(inner, token: token) : inner
119
- app = Woods::MCP::OriginGuard.new(app, allowed_origins: allowed_origins)
126
+ app = Woods::MCP::OriginGuard.new(app, policy: origin_policy)
120
127
 
121
128
  origin_summary = allowed_origins.empty? ? 'loopback' : allowed_origins.join(',')
122
129
  auth_mode = token ? 'bearer' : 'none'
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest'
4
+ require 'securerandom'
4
5
  require_relative 'cache_store'
5
6
  # CachedEmbeddingProvider includes Embedding::Provider::Interface at load
6
7
  # time, so the interface must be defined before this file's class bodies run
@@ -408,6 +409,8 @@ module Woods
408
409
  @retriever = retriever
409
410
  @cache_store = cache_store
410
411
  @context_ttl = context_ttl
412
+ @context_namespace = SecureRandom.hex(16)
413
+ @context_mutex = Mutex.new
411
414
  end
412
415
 
413
416
  # Expose the wrapped stores so the MCP +reload+ tool and
@@ -427,20 +430,17 @@ module Woods
427
430
  @retriever.corpus_status(include_types: include_types) if @retriever.respond_to?(:corpus_status)
428
431
  end
429
432
 
430
- # Invalidate every cached context result. Called from the MCP +reload+
431
- # tool after the retriever's stores have been re-hydrated from a fresh
432
- # embed — otherwise cached results from the old embedding run would
433
- # linger until their TTL expires and contradict the new stores.
433
+ # Retire this retriever's cached contexts after its corpus reloads.
434
434
  #
435
- # Embedding caches (query → vector) are NOT cleared: the query-vector
436
- # mapping is deterministic for a given provider+model and survives any
437
- # index reload. Only context results (query → ranked units) go stale.
435
+ # Each retriever has its own unpredictable namespace, even when several
436
+ # applications share a backend. Rotating it also prevents an in-flight
437
+ # request from repopulating the active cache with a pre-reload result.
438
+ # Old entries expire by their configured TTL; no global backend deletion
439
+ # is needed. Embedding caches are independent and remain available.
438
440
  #
439
441
  # @return [void]
440
442
  def invalidate_context_cache!
441
- @cache_store.clear(namespace: :context)
442
- rescue StandardError => e
443
- warn("[Woods] CachedRetriever context-cache invalidation failed: #{e.message}")
443
+ @context_mutex.synchronize { @context_namespace = SecureRandom.hex(16) }
444
444
  end
445
445
 
446
446
  # Execute the retrieval pipeline with context-level caching.
@@ -492,7 +492,8 @@ module Woods
492
492
  # @param exclude_types [Array<String, Symbol>, nil]
493
493
  # @return [String]
494
494
  def context_key(query, budget, types: nil, exclude_types: nil, packages: nil, source_paths: nil, evidence: 'full') # rubocop:disable Metrics/ParameterLists
495
- parts = [query, budget.to_s, fingerprint(types), fingerprint(exclude_types)]
495
+ namespace = @context_mutex.synchronize { @context_namespace }
496
+ parts = [namespace, query, budget.to_s, fingerprint(types), fingerprint(exclude_types)]
496
497
  parts << 'lexical' if mode == :lexical
497
498
  parts << JSON.generate(evidence: evidence) unless evidence == 'full'
498
499
  if Retrieval::Scope.requested?(packages: packages, source_paths: source_paths)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Console
5
+ # Adapter names differ from the SQL grammar and session settings they use.
6
+ module AdapterFamily
7
+ # Classify a live connection by adapter ancestry, database configuration,
8
+ # then its display name. Unknown families remain nil: SQL callers must
9
+ # refuse rather than silently omit dialect-specific safeguards.
10
+ # @param connection [Object] Active Record connection or compatible adapter
11
+ # @return [Symbol, nil] :postgres, :mysql, :sqlite, or unknown
12
+ def self.for(connection)
13
+ ancestry = connection.class.ancestors.filter_map(&:name)
14
+ return :postgres if ancestry.include?('ActiveRecord::ConnectionAdapters::PostgreSQLAdapter')
15
+ return :mysql if ancestry.any? { |name| name.match?(/::(?:AbstractMysql|Mysql2|Trilogy)Adapter\z/) }
16
+ return :sqlite if ancestry.include?('ActiveRecord::ConnectionAdapters::SQLite3Adapter')
17
+
18
+ from_name(configured_adapter(connection)) || from_name(connection.adapter_name)
19
+ end
20
+
21
+ def self.configured_adapter(connection)
22
+ return unless connection.respond_to?(:pool) && connection.pool.respond_to?(:db_config)
23
+
24
+ connection.pool.db_config.adapter
25
+ end
26
+ private_class_method :configured_adapter
27
+
28
+ def self.from_name(value)
29
+ name = value.to_s.downcase
30
+ return :mysql if name.include?('mysql') || %w[trilogy mariadb].include?(name)
31
+ return :postgres if name.include?('postgre') || %w[postgis cockroachdb redshift].include?(name)
32
+ return :sqlite if name.include?('sqlite')
33
+
34
+ nil
35
+ end
36
+ private_class_method :from_name
37
+ end
38
+ end
39
+ end
@@ -46,7 +46,7 @@ module Woods
46
46
  # index.redact("token: sk_live_actual_secret_value")
47
47
  # # => "token: [REDACTED:credential]"
48
48
  #
49
- class CredentialIndex
49
+ class CredentialIndex # rubocop:disable Metrics/ClassLength
50
50
  # Captured at require time so the mtime-check warning has a stable
51
51
  # reference point even if the clock skews later. Frozen immediately
52
52
  # to prevent accidental mutation.
@@ -187,7 +187,10 @@ module Woods
187
187
  def initialize(secrets:)
188
188
  filtered = Array(secrets).select { |s| s.is_a?(String) && s.length >= MIN_LENGTH }
189
189
  @secrets = filtered.to_set.freeze
190
- @pattern = @secrets.empty? ? nil : Regexp.union(@secrets.to_a)
190
+ # Regexp alternatives match in order: a shorter prefix must not consume
191
+ # only the start of a longer indexed credential and expose its suffix.
192
+ longest_first = @secrets.each_with_index.sort_by { |secret, idx| [-secret.length, idx] }.map(&:first)
193
+ @pattern = @secrets.empty? ? nil : Regexp.union(longest_first)
191
194
  end
192
195
 
193
196
  # @return [Boolean] true when no secrets were collected (missing key,
@@ -212,7 +215,34 @@ module Woods
212
215
  def redact(str)
213
216
  return str if empty? || !str.is_a?(String) || !@pattern.match?(str)
214
217
 
215
- str.gsub(@pattern, REDACTED)
218
+ offset = 0
219
+ parts = []
220
+ redaction_spans(str).each do |start, finish|
221
+ parts << str[offset...start] << REDACTED
222
+ offset = finish
223
+ end
224
+ parts << str[offset..]
225
+ parts.join
226
+ end
227
+
228
+ private
229
+
230
+ # Search from each match's start, rather than its end, so an overlapping
231
+ # credential cannot expose its suffix after an earlier replacement.
232
+ # Union the covered spans before changing the original string.
233
+ def redaction_spans(str)
234
+ spans = []
235
+ offset = 0
236
+ while (match = @pattern.match(str, offset))
237
+ start, finish = match.offset(0)
238
+ if spans.last && start < spans.last[1]
239
+ spans.last[1] = [spans.last[1], finish].max
240
+ else
241
+ spans << [start, finish]
242
+ end
243
+ offset = start + 1
244
+ end
245
+ spans
216
246
  end
217
247
  end
218
248
  end