Skip to content

refactor(core): shared plugin base layer, hardening, CI matrix - #2

Open
sgarcese wants to merge 18 commits into
CityOfBoston:mainfrom
sgarcese:upstream/core-refactor
Open

sgarcese wants to merge 18 commits into
CityOfBoston:mainfrom
sgarcese:upstream/core-refactor

Conversation

@sgarcese

Copy link
Copy Markdown

First of a four-PR stack contributed back from the sgarcese/OpenContext fork. This one is the foundation; the other three build on it.

What changes

  • Shared plugin base layer in core/: BaseOpenDataPlugin, BasePluginConfig, BaseQueryValidator, ToolHandler, and a shared HTTP_RETRY. CKAN, Socrata, and ArcGIS plugins are migrated onto it, which removes most of the copy-pasted client, config, retry, and tool-dispatch code across the three plugins.
  • Hardening found during the migration:
    • CKAN aggregate_data validates identifiers and metric expressions (and a follow-up restores valid inputs the first pass over-rejected).
    • build_where_clause validates field identifiers.
    • ArcGIS Feature Service URLs are restricted to trusted hosts (trusted_service_hosts), closing an SSRF path where a dataset record could steer requests to arbitrary hosts.
    • Socrata search_datasets enforces its required query argument.
  • Lambda adapter keeps one event loop across warm invocations so plugin httpx clients stay bound to a live loop. This removes the per-invocation plugin shutdown that this repo's Strivacity change added; the loop no longer closes, so the "Event loop is closed" failure it worked around no longer occurs. query_string is still forwarded.
  • Housekeeping: stale duplicate entry points removed, plugin template modernized, config loading deferred, PluginManager tidied, docs updated to describe the base layer and the ArcGIS plugin.
  • CI: Python 3.11/3.12 matrix, Terraform validate job, Dependabot config. The "Sync develop with main" workflow is removed because no develop branch exists here, so it failed on every push.

Verification

  • 466 tests pass on this branch (uv run pytest -q).
  • ruff check core plugins server tests: 412 findings vs 444 on main (no new findings).
  • Conflicts with the Strivacity commit were limited to .gitignore, staging.tfvars, and server/adapters/aws_lambda.py, resolved as described above.

Stack

  1. This PR — core refactor
  2. Opendatasoft plugin
  3. Portal-content guardrails + redirect credential scoping
  4. Catalog metadata enrichment

Each later PR is opened against main but contains this PR's commits until it merges; review the later ones by their last few commits.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TXm8YpvmTqNHgpRuoxtpoX

sgarcese and others added 18 commits September 13, 2026 16:22
server/lambda_handler.py duplicated server/adapters/aws_lambda.py with divergent behavior; it was dead code since the deployed handler is server.adapters.aws_lambda.lambda_handler (wired in terraform/aws/main.tf).

The root local_server.py duplicated scripts/local_server.py, which is the maintained version (better logging, /mcp routing, OPENCONTEXT_CONFIG support).

Docs updated to point at the maintained scripts/local_server.py.

Co-Authored-By: Kimi K2.6 via opencode <noreply@ollama.com>
(cherry picked from commit ac5d8ac)
scripts/local_server.py serves MCP requests on /mcp (not /), so the
FAQ and QUICKSTART curl examples targeting the root path would 404.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 9a311d0)
The warm-start path was as slow as a cold start because lambda_handler
called asyncio.run() per request and _run_with_cleanup's finally block
shut down the plugin manager after every invocation. The next request
then re-initialized the plugin from scratch, including a live HTTP call
to the data portal — defeating Lambda container reuse entirely.

The per-request shutdown was a workaround for "Event loop is closed"
errors with httpx: asyncio.run() creates and closes a fresh loop each
call, so httpx clients created on a previous loop broke on warm starts.

Replace this with a single module-level event loop created lazily via
_get_loop() and reused with loop.run_until_complete(). httpx clients
stay bound to a live loop across invocations, so the plugin manager and
MCP server globals in server/http_handler.py persist as intended. All
existing behavior (OPTIONS handling, base64 bodies, header
lowercasing, error responses) is preserved.

Co-Authored-By: GLM 5.2 via opencode <noreply@ollama.com>
(cherry picked from commit 30ce67b)
…ginConfig, BaseQueryValidator)

The CKAN, Socrata, and ArcGIS plugins currently copy-paste ~60% of their
scaffolding: HTTP client creation/close, the tenacity retry policy,
HTTPStatusError -> RuntimeError translation, the tool-dispatch if/elif
chain with per-arg "is required" checks, Record-N record formatting, and
the SQL/SoQL/WHERE security validators.

These new core/ base classes centralize that shared behavior so the three
plugins can be migrated onto them in a later stage:

- core/config_base.py: BasePluginConfig(pydantic.BaseModel) with
  enabled/city_name/timeout fields, extra='forbid', and a reusable
  validate_url classmethod that subclasses attach to their URL fields via
  field_validator('base_url', 'portal_url')(BasePluginConfig.validate_url).

- core/query_validator.py: BaseQueryValidator with the union of the
  existing forbidden keywords, dangerous-pattern regexes, an
  ALLOWED_PREFIXES tuple, a validate_query() entry point, an overridable
  extra_checks() hook for provider-specific rules, and a standalone
  scan_forbidden_keywords() for WHERE-clause-style inputs (ArcGIS).

- core/base_plugin.py: BaseOpenDataPlugin(DataPlugin) that eagerly builds
  plugin_config from config_class, tracks HTTP clients in self._clients
  for a single shutdown() path, exposes a module-level HTTP_RETRY
  decorator, _raise_http_error() that extracts CKAN/Socrata-style error
  messages, a ToolHandler-based execute_tool() dispatcher with required-
  arg validation and str/ToolResult return wrapping, format_records(),
  and build_where_clause(). initialize(), get_tools(), and health_check()
  remain abstract for subclasses.

Tests cover URL validation (good/bad/trailing-slash), extra='forbid',
subclass field_validator reuse; query validator allowed/forbidden/length/
prefix/extra_checks override/scan_forbidden_keywords; base plugin
dispatch, unknown tool, required-arg enforcement, exception wrapping,
str-return wrapping, format_records (empty/header/skip_keys/truncation),
build_where_clause (string escaping/IS NULL/numeric/AND join), and
shutdown closing all clients. All 355 tests pass.

Co-Authored-By: GLM 5.2 via opencode <noreply@ollama.com>
(cherry picked from commit d2dec23)
Migrate the CKAN plugin onto the new shared base classes introduced in
refactor/core-base-classes, removing duplicated boilerplate while keeping
all user-facing text and behavior identical.

- config_schema.py: CKANPluginConfig now subclasses BasePluginConfig, reusing
  the shared enabled/city_name/timeout fields and BasePluginConfig.validate_url
  via field_validator. The duplicated validate_url body is dropped. The CKAN
  timeout default is overridden to 120.0 (base default is 30.0) to preserve
  the historical 120-second behavior; the bound is widened to le=300 so
  existing 120 configs still validate.
- sql_validator.py: SQLValidator now subclasses BaseQueryValidator, reusing
  the shared length/forbidden-keyword/prefix/dangerous-pattern checks.
  ALLOWED_PREFIXES=('SELECT','WITH'). The CKAN-specific checks (sqlparse
  single-statement + statement-type enforcement, and the double-quoted UUID
  resource-id validation) move into extra_checks. MAX_SQL_LENGTH is kept as a
  backwards-compat alias of MAX_QUERY_LENGTH. tests/test_sql_validator.py
  passes unmodified.
- plugin.py: CKANPlugin now subclasses BaseOpenDataPlugin with
  config_class=CKANPluginConfig. The ~160-line execute_tool if/elif ladder is
  replaced by tool_handlers() returning ToolHandler entries (search_datasets,
  get_dataset [dataset_id], query_data [resource_id], get_schema
  [resource_id], execute_sql [sql], aggregate_data [resource_id, metrics]).
  The HTTP client is created via _create_http_client (keeping
  base_url/headers/timeout) so the base class owns shutdown; the plugin's own
  shutdown is removed. _call_ckan_api is decorated with HTTP_RETRY from
  core.base_plugin instead of a local retry() copy. _parse_ckan_error and the
  CKAN-specific 404 resource/dataset parameter hinting are kept in
  _call_ckan_api (provider-specific richness not forced into
  _raise_http_error). _format_query_results/_format_sql_results now delegate
  to self.format_records(...) with their original header text and max_display
  values (5 for query results, 10 for SQL results). aggregate_data uses
  BaseOpenDataPlugin.build_where_clause for its WHERE construction.

  Also fixed while here: aggregate_data's HAVING clause previously hardcoded
  '>' for every condition (f'{expr} > {value}'). having now accepts string
  values that carry their own operator (e.g. {"count(*)": ">= 5"}); numeric
  values keep '>' as the documented default for backward compatibility. The
  aggregate_data tool description documents this.

- tests/test_ckan_plugin.py: updated test_plugin_shutdown_closes_client to
  assert the base class clears the tracked client list (plugin._clients == [])
  instead of the old plugin.client is None, since shutdown is now owned by
  the base. All other behavioral assertions are unchanged; the rest of the
  suite (mocking httpx.AsyncClient, which _create_http_client still calls)
  passes unmodified.

Co-Authored-By: GLM 5.2 via opencode <noreply@ollama.com>
(cherry picked from commit 2b166e6)
Port this hardening (adapted from upstream
thealphacubicle/OpenContext security update #37) so aggregate_data
validates all user-supplied SQL fragments before assembling the query,
preventing SQL injection through field names, metric aliases/expressions,
or order_by.

- Add module-level _SAFE_IDENTIFIER = r'^[a-zA-Z_][a-zA-Z0-9_]{0,63}$' and
  _SAFE_METRIC_EXPR (count(*) | sum/avg/min/max/stddev/variance(field),
  case-insensitive) compiled patterns with _validate_identifier(name) and
  _validate_metric_expr(expr) helpers that raise ValueError on mismatch.
- In aggregate_data, validate all group_by fields, metric aliases and
  expressions, filter field names, having expression identifiers, and
  order_by (stripping a leading '-' for descending order) BEFORE building
  SQL. Validation failures return {"error": True, "message": ...} so the
  dict-return contract is preserved.
- Add tests/test_ckan_plugin.py::TestAggregateDataHardening covering
  rejected malicious group_by, metric alias, metric expression, filter
  field, having expression, and order_by, plus a valid-aggregate and a
  leading-dash order_by happy path.

Ported from thealphacubicle/OpenContext (Feature/security update #37).

Co-Authored-By: GLM 5.2 via opencode <noreply@ollama.com>
(cherry picked from commit 6b31bb8)
Migrate Socrata plugin onto BaseOpenDataPlugin / BasePluginConfig / BaseQueryValidator
following the CKAN reference pattern:

- config_schema.py: SocrataPluginConfig now inherits BasePluginConfig; reuse
  BasePluginConfig.validate_url via field_validator; drop duplicated enabled,
  city_name, timeout, and the copy-pasted validate_url.
- soql_validator.py: SoQLValidator now inherits BaseQueryValidator; move the
  extra semicolon multiple-statement check into extra_checks; keep public API
  validate_query(soql) -> (bool, Optional[str]).
- plugin.py: SocrataPlugin now inherits BaseOpenDataPlugin; config_class set;
  replace execute_tool if/elif ladder with tool_handlers(); create both HTTP
  clients via _create_http_client (tracked for base-class shutdown); replace
  copy-pasted @Retry blocks with @HTTP_RETRY; use self._raise_http_error for
  HTTPStatusError translation; replace _format_query_results/_format_sql_results
  bodies with self.format_records(...); use BaseOpenDataPlugin.build_where_clause
  in query_data filter compilation; remove redundant app_token emptiness re-check
  in initialize().

Bug fixes folded in:
- _format_categories: fix duplicated fallback key cat.get("count", cat.get("count",
  "")) to cat.get("count", cat.get("value", "")).
- _list_categories: add max-pages guard (20 pages / 10000 results) to the unbounded
  pagination while-loop, with a warning log when hit.

Tests updated minimally: shutdown assertions now check is_initialized only (base
class manages client lifecycle via _clients list).

Co-Authored-By: Kimi K2.6 via opencode <noreply@ollama.com>
(cherry picked from commit 1efa1fc)
…ith plugin contract

Migrates the ArcGIS plugin onto the shared base classes introduced in
d2dec23, following the patterns established by the CKAN and Socrata
migrations.

Moved to the base classes:
- config_schema.py: ArcGISPluginConfig now subclasses BasePluginConfig.
  Drops the duplicated enabled/city_name/timeout fields and the copy-
  pasted validate_url; reuses the base URL validator via field_validator
  (as CKAN/Socrata do). Preserves the 120s timeout default by overriding
  the timeout field default to 120.0 (base default is 30.0). portal_url
  default and token field retained.
- where_validator.py: WhereValidator now subclasses BaseQueryValidator
  and reuses scan_forbidden_keywords for its forbidden-keyword scan.
  Public API validate(where) -> str is preserved (returns '1=1' for
  empty, raises ValueError on forbidden keywords). As a result, ArcGIS
  gains the GRANT/REVOKE/DECLARE/SET keywords it was previously missing
  versus the other plugins (its old FORBIDDEN_KEYWORDS list omitted
  them). The error message format changes slightly ('Forbidden keyword
  detected in WHERE clause: Forbidden keyword: X').
- plugin.py: ArcGISPlugin now subclasses BaseOpenDataPlugin. Config is
  validated eagerly in __init__ (base behavior) instead of lazily in
  initialize(); get_tools() uses self.plugin_config.city_name directly
  (the 'Unknown' fallback is dropped). Both HTTP clients are created
  via _create_http_client so the base class tracks them for shutdown;
  the plugin's own shutdown() is removed. The execute_tool if/elif
  ladder is replaced with tool_handlers(). health_check now relies on
  raise_for_status() (via _call_hub_api) instead of checking
  status_code == 200, matching the other plugins. _raise_http_error is
  used for HTTP status errors on both API paths. An _call_hub_api
  helper (decorated with @HTTP_RETRY) centralizes hub-client GETs, and
  _call_feature_service (also @HTTP_RETRY) handles feature-service
  GETs. ArcGIS was previously the only plugin with NO retry on its API
  calls; both call paths now retry transient failures (ConnectError,
  ReadTimeout, etc.) up to 3 attempts with exponential backoff, while
  HTTPStatusError and RuntimeError are not retried.

Contract fixes (DataPlugin interface alignment):
a. search_datasets tool input parameter renames from 'q' to 'query'
   to match CKAN/Socrata and the DataPlugin interface; the tool schema
   description is updated accordingly.
b. Adds a get_schema tool + method: fetches the Feature Service layer
   metadata (GET {service_url}/0?f=json after _ensure_layer_url) and
   formats the 'fields' list (name, type, alias) like the other
   plugins' get_schema. Required arg is dataset_id; the service URL is
   resolved via get_dataset (two-hop, like query_data). If the dataset
   has no service URL, a clear ValueError is raised.
c. DataPlugin.query_data(resource_id, filters, limit) previously
   repurposed the filters dict to carry where/out_fields. Restructured:
   an internal async _query_features(dataset_id, where, out_fields,
   limit) carries the tool-path behavior (tool args unchanged: where,
   out_fields, limit), and query_data(resource_id, filters, limit)
   now implements the interface contract — compiling field:value
   filters into a WHERE clause via BaseOpenDataPlugin.build_where_clause
   (validating each field identifier with WhereValidator.scan_forbidden
   _keywords first), defaulting to '1=1' when no filters are supplied.

All other user-facing text (search/dataset/query/aggregation formats)
is kept as-is.

Tests: tests/test_arcgis_plugin.py is rewritten for the new
structure — search_datasets param rename (query vs q), get_schema
tests (fields, layer-index append, no-service-URL error), query_data
contract tests (filters->WHERE compilation, None->IS NULL, forbidden
field rejection, 1=1 default), retry behavior on transient errors
(ConnectError retried, HTTPStatusError not retried), health_check
uses raise_for_status, eager config validation in __init__, two
client creation via _create_http_client, base shutdown closes tracked
clients. WhereValidator tests cover the newly-gained GRANT/REVOKE/
DECLARE/SET keywords. 62 tests (up from 22), all passing.

Co-Authored-By: GLM 5.2 via opencode <noreply@ollama.com>
(cherry picked from commit a8f22c9)
Ported from thealphacubicle/OpenContext (Feature/security update #37).
Prevents a crafted dataset record from steering Feature Service
queries to arbitrary hosts.

Adds a static _validate_feature_url(service_url, portal_url) helper that
parses the resolved Feature Service URL and requires the scheme to be
http/https and the host to either end with '.arcgis.com' or equal the
configured portal host (case-insensitive), raising ValueError
otherwise. The guard is invoked on the resolved service_url in both
Feature-Service query paths (get_schema and _query_features) before
any HTTP request is made.

Unit tests cover allowed hosts (arcgis.com subdomains, exact portal
host match, case-insensitivity) and rejected hosts (arbitrary domains,
localhost, 169.254.169.254 metadata IP, non-http schemes, missing
hostname, lookalike hosts). Integration tests verify the guard fires
in both get_schema and _query_features.

Co-Authored-By: GLM 5.2 via opencode <noreply@ollama.com>
(cherry picked from commit 4b9dddb)
…nManager

1. custom_plugins/template/plugin_template.py
   - Rewrite to inherit from BaseOpenDataPlugin (was raw MCPPlugin)
   - Define config class subclassing BasePluginConfig, set config_class attr
   - initialize() uses _create_http_client for tracked HTTP lifecycle
   - Demonstrate tool_handlers() with required_args via ToolHandler
   - Provide get_tools(), health_check(), and DataPlugin stub methods
   - Keep heavily commented as a teaching template

2. server/http_handler.py
   - Move import-time config/logging load into _configure_logging_from_config()
   - Call it from _initialize_server() before plugin loading
   - Module-level default configure_json_logging(level='INFO') keeps imports side-effect-free
   - Preserve OPENCONTEXT_CONFIG env var behavior

3. scripts/local_server.py
   - Move import-time config load and logging setup into _load_local_config()
   - Run both in init_server() instead of at module import
   - Replace global  with  updated at init time

4. core/plugin_manager.py
   - discover_plugins() now returns (name, path, source_package) tuples
   - _load_plugin_class() receives source_package directly instead of substring-matching paths
   - Store ToolDefinition in self.tools during registration; value becomes (plugin_name, ToolDefinition)
   - get_all_tools() projects directly from self.tools, removing re-call to plugin.get_tools()
   - execute_tool() uses tool_def.name to dispatch, keeping public shapes identical

5. core/validators.py
   - Remove dead defensive check  after validate_plugin_count()
   - Leave multiple-plugins error text untouched

6. docs/CUSTOM_PLUGINS.md
   - Update plugin structure bullet to mention BaseOpenDataPlugin
   - Add tip recommending BaseOpenDataPlugin as the starting point

7. tests/test_plugin_manager.py
   - Update discovery assertion to expect 3-tuple (name, path, source_package)
   - Update tool registration assertions to inspect ToolDefinition.name
   - Update execute_tool manual injection to use ToolDefinition instead of bare string
   - Update _load_plugin_class call sites for new signature

Co-Authored-By: Kimi K2.6 via opencode <noreply@ollama.com>
(cherry picked from commit b59c05c)
…ning

Code review of the hardening commit found four behavior regressions
against main, all fixed here:

- order_by "field DESC" was rejected and "-field" produced invalid SQL
  ("ORDER BY -field"); order_by now accepts "field", "-field", and
  "field ASC|DESC", compiling to proper ORDER BY ... [ASC|DESC].
- count(field) and count(distinct field) were banned while sum(field)
  was allowed; the metric whitelist now permits them.
- HAVING keys naming a declared metric alias errored; aliases are now
  substituted with their expression (PostgreSQL does not allow SELECT
  aliases in HAVING).
- String HAVING values were interpolated verbatim ("HAVING count(*) 5"
  for "5"); values must now be a number with an optional comparison
  operator, and bare numbers default to ">".

Also enforce the search_datasets required 'query' argument at dispatch
(the schema declared it required but nothing checked it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 58f7956)
Code review found the helper trusted callers to validate field names
(only its docstring warned about it), and the three plugins guarded
three different ways — Socrata's query_data not at all. Enforce a safe
identifier pattern in the helper itself so every caller, including
third-party plugins built from the template, is safe by construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 113c15f)
Code review of the migration + SSRF commits found four behavior
regressions against main, all fixed here:

- The SSRF allowlist (*.arcgis.com or portal host only) broke Hub
  datasets whose Feature Services are self-hosted on city domains,
  common in Hub catalogs. New trusted_service_hosts config lists extra
  hosts (exact or subdomain match) without reopening arbitrary-host SSRF.
- WhereValidator scanned quoted string literals, rejecting legitimate
  values like status = 'SET' or call_type = 'Initial Call'. Literals
  are now stripped before the keyword scan; the double-prefixed error
  message is also fixed.
- search_datasets declared 'query' required but never enforced it, so
  old-schema {"q": ...} calls silently returned an unfiltered catalog
  dump as success. The required arg is now enforced at dispatch, and
  get_aggregations' remaining 'q' parameter is renamed to 'query' for
  consistency.
- _format_query_results hand-rolled an uncapped record loop; it now
  uses the shared format_records helper (10-record display cap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 7f177f5)
The tool schema declared 'query' required but dispatch never checked
it, so calls without it silently searched with an empty query. Found
in code review; same gap fixed in the CKAN and ArcGIS plugins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit c7f5b42)
- Matrix the test suite across Python 3.11 and 3.12
- Add Terraform validate job (fmt -check + validate for aws and bootstrap stacks)
- Add Dependabot config for pip, gomod, github-actions, and terraform
- Ignore Claude Code local files (CLAUDE.md, .claude/)
- terraform fmt fixes for staging.tfvars and variables.tf

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 0b5222b)
…c reuse

The field_validator(...)(BasePluginConfig.validate_url) reuse pattern
broke on Python 3.11/3.12 (CI) while passing on 3.14: the bound
classmethod received pydantic's (value, info) pair positionally, so
'cls' swallowed the value and urlparse got a ValidationInfo. A
single-argument staticmethod binds identically on every version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit ad62d8b)
- CUSTOM_PLUGINS.md: new "Decoupled Base Layer" section (BaseOpenDataPlugin,
  ToolHandler dispatch, BasePluginConfig, BaseQueryValidator) with examples
- ARCHITECTURE.md: updated component tree and shared base layer overview
- BUILT_IN_PLUGINS.md: ArcGIS plugin section; CKAN execute_sql and
  aggregate_data (metrics/having/order_by semantics)
- config-example.yaml: document trusted_service_hosts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 14921aa)
The workflow merges main into a `develop` branch on every push to main,
but no `develop` branch exists on this fork or upstream, so the job has
failed on every push since it was added. Development lands on main via
PRs, so the sync has nothing to do; remove it rather than create an
unused branch.

Claude-Session: https://claude.ai/code/session_01TXm8YpvmTqNHgpRuoxtpoX

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit fcd1549)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant