feat(harness): add readonly mysql tool
This commit is contained in:
@@ -38,3 +38,4 @@
|
||||
| 2026-06-23 | phase1-infrastructure | 搭建第一阶段基础设施,包括 MySQL、Redis、Milvus、Flyway 和 JPA。 | 基础设施/文档管理 | MySQL, Redis, Milvus, Flyway, JPA, 向量检索, 类别过滤 | - | archived |
|
||||
| 2026-05-29 | chatmodel-abstraction | 抽象 ChatModel 和 EmbeddingModel,支持多模型路由。 | 解耦/多模型路由 | ChatModel, EmbeddingModel, DeepSeek, BGE-M3, SiliconFlow, Spring AI | - | archived |
|
||||
| 2026-07-21 | single-react-rag-log-projections | RAG/log projection adapters through ToolBoundary | Harness/Tool projection | ISS-014, RAG, query_logs, projection, scope, redaction, MOCK, NO_EVIDENCE | openspec/changes/archive/2026-07-21-single-react-rag-log-projections | archived |
|
||||
| 2026-07-21 | single-react-mysql-readonly-tool | Fail-closed read-only MySQL evidence Tool with AST allowlist, JDBC controls and bounded projection | Harness/MySQL security | ISS-014, MySQL, JSqlParser, allowlist, PreparedStatement, timeout, projection | openspec/changes/archive/2026-07-21-single-react-mysql-readonly-tool | archived |
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Acceptance: single-react-mysql-readonly-tool
|
||||
|
||||
## Commit preflight
|
||||
|
||||
- OpenSpec strict validation: passed.
|
||||
- Scope: fail-closed SQL validator, exact allowlist, JDBC read-only executor, bounded MySQL projection, ToolBoundary adapter and query-script safety cleanup.
|
||||
- Non-goals: public Agent/Chat cutover, metadata discovery, Agent persistence database access, dynamic/tenant authorization and live production datasource provisioning.
|
||||
- Security prerequisite: script write branch/default external connection values are explicitly included in this change.
|
||||
|
||||
## Apply acceptance
|
||||
|
||||
- Implemented and verified.
|
||||
- Static, Maven and script evidence is recorded in `evidence.md`.
|
||||
- No browser/manual verification applies; this stage adds no UI or public protocol change.
|
||||
- Residual risk is limited to later live datasource provisioning/driver behavior and stage 4 integration.
|
||||
|
||||
## Archive acceptance
|
||||
|
||||
- All OpenSpec tasks are complete.
|
||||
- `.archive-ready` marker is created after focused verification.
|
||||
- OpenSpec is ready to move to the dated archive directory.
|
||||
- Archive completed at `openspec/changes/archive/2026-07-21-single-react-mysql-readonly-tool`.
|
||||
|
||||
## Remaining work
|
||||
|
||||
- Stage 4 Diagnosis Agent integration and later live datasource/driver E2E remain outside this archive.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Brief: single-react-mysql-readonly-tool
|
||||
|
||||
## Background
|
||||
|
||||
阶段 3A 提供了统一 ToolBoundary 和 canonical invocation store,阶段 3B 提供了 RAG/日志投影。3C 需要独立实现安全敏感的只读 MySQL Tool,避免 Agent 生成的 SQL 直接进入数据库。
|
||||
|
||||
## Goals
|
||||
|
||||
- 使用 JSqlParser 对保守 SELECT 子集进行 fail-closed AST 校验。
|
||||
- 使用逻辑数据源和 schema/table/column 精确 allowlist 授权。
|
||||
- 使用参数绑定、只读 JDBC、超时、取消和结果预算。
|
||||
- 通过阶段 3A boundary 投影为冻结的 `MysqlToolResult`。
|
||||
- 清理查询脚本的写入分支和默认连接风险。
|
||||
|
||||
## Non-goals
|
||||
|
||||
- 不接入公开 Agent/Chat 入口。
|
||||
- 不提供元数据发现、动态授权、租户/行级权限或生产 datasource provisioning。
|
||||
- 不查询 Agent 自身持久化数据库。
|
||||
|
||||
## Classification
|
||||
|
||||
- Scale: complex
|
||||
- Interface impact: L2 internal Harness tool/adapter, plus build dependency and script safety behavior
|
||||
- Issue: ISS-014 stage 3C
|
||||
- Change slug: `single-react-mysql-readonly-tool`
|
||||
@@ -0,0 +1,108 @@
|
||||
# Decisions: single-react-mysql-readonly-tool
|
||||
|
||||
## Discover status
|
||||
|
||||
- Checkpoint: Discover
|
||||
- Capability source: `sm-flow` with local ISS-014, OpenSpec contracts, existing JDBC dependency/configuration and JSqlParser 4.6 already present in the local Maven cache.
|
||||
- Scale: complex, because this stage combines AST policy, authorization, JDBC resource limits, projection, and security cleanup.
|
||||
|
||||
## Evidence-driven findings
|
||||
|
||||
1. `MysqlToolRequest` and `MysqlToolResult` are already frozen under `harness.tool.contract`; no public DTO change is needed.
|
||||
2. The project already has MySQL JDBC/JPA dependencies, but no Agent-facing external read-only executor or SQL policy.
|
||||
3. JSqlParser 4.6 is available in the local Maven cache and exposes `CCJSqlParserUtil`, `Select`, `PlainSelect`, `Table`, `Column`, `Function`, `JdbcParameter` and visitor adapters compatible with Java 17.
|
||||
4. The current application datasource points to the Agent persistence database; the new Tool must use an independently configured logical datasource map and must not reuse that datasource implicitly.
|
||||
5. `scripts/query_mysql.py` currently defaults host/port/user values and contains a non-SELECT commit branch. This violates the ISS-014 security prerequisite and will be changed to read-only, environment-only behavior.
|
||||
|
||||
## Question pool
|
||||
|
||||
| Dimension | Question | Mode | Conclusion | Status |
|
||||
|---|---|---|---|---|
|
||||
| SQL language | Which SQL subset is executable? | evidence-driven | One SELECT, explicit columns, INNER/LEFT JOIN, predicates/group/order, parameter placeholders and allowlisted aggregates. | resolved |
|
||||
| Security | How is authorization decided? | evidence-driven | Independent exact schema/table/column allowlist; parser acceptance alone is insufficient. | resolved |
|
||||
| Data source | Can the Agent pass JDBC coordinates? | evidence-driven | No. Only logical data_source IDs are accepted; connection properties remain configuration/Secret data. | resolved |
|
||||
| Execution | Which JDBC controls are mandatory? | evidence-driven | PreparedStatement, readOnly connection, setMaxRows, query timeout and Run cancellation. | resolved |
|
||||
| Metadata | Can the Tool discover tables/columns? | evidence-driven | No. SHOW/DESCRIBE/information_schema are rejected. | resolved |
|
||||
| Compatibility | Does this cut over public runtime now? | evidence-driven | No. Add internal adapter/executor; Diagnosis Agent integration is stage 4. | resolved |
|
||||
|
||||
## User-confirmed direction
|
||||
|
||||
- Use the frozen `MysqlToolRequest`/`MysqlToolResult` contract.
|
||||
- Reuse the existing ToolBoundary and canonical invocation store.
|
||||
- Keep stage boundaries serial: archive and commit 3C before stage 4.
|
||||
- Do not pause for routine apply/archive/commit confirmation.
|
||||
|
||||
## Pre-apply research
|
||||
|
||||
### Existing implementations and dependencies
|
||||
|
||||
- `src/main/java/com/superbiz/agent/harness/tool/contract/MysqlToolRequest.java`
|
||||
- `src/main/java/com/superbiz/agent/harness/tool/contract/MysqlToolResult.java`
|
||||
- `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundary.java`
|
||||
- `src/main/java/com/superbiz/agent/harness/tool/adapter/QueryLogsToolAdapter.java`
|
||||
- `src/main/resources/application.yml`
|
||||
- `pom.xml` (`mysql-connector-j` already present; add JSqlParser 4.6)
|
||||
- `scripts/query_mysql.py`
|
||||
|
||||
### New classes
|
||||
|
||||
- `MysqlToolLimits`
|
||||
- `MysqlDataSourceDefinition` / allowlist value objects
|
||||
- `MysqlQueryPlan`
|
||||
- `MysqlSqlValidator`
|
||||
- `MysqlReadOnlyExecutor` and JDBC implementation
|
||||
- `MysqlResultProjector`
|
||||
- `MysqlToolAdapter`
|
||||
|
||||
### Risk controls
|
||||
|
||||
- Do not create a generic plugin/DSL layer.
|
||||
- Do not use regex or `startsWith` as SQL authorization.
|
||||
- Fail closed on parser/visitor uncertainty.
|
||||
- Keep raw result canonical-only and expose only bounded projection.
|
||||
|
||||
## Commit checkpoint preparation
|
||||
|
||||
- Proposal scope, design choices, frozen SQL subset, security script cleanup and acceptance scenarios are ready for Commit artifact generation.
|
||||
|
||||
## Commit audit
|
||||
|
||||
- Capability source: `sm-flow` and local OpenSpec CLI.
|
||||
- OpenSpec strict validation: passed for `single-react-mysql-readonly-tool`.
|
||||
- Cross-artifact alignment:
|
||||
- brief goals/non-goals -> proposal scope: aligned.
|
||||
- proposal SQL/security boundaries -> design architecture: aligned.
|
||||
- design validator/executor/projector decisions -> spec requirements: aligned.
|
||||
- spec scenarios -> tasks for dependency, policy, JDBC, projection, adapter and verification: aligned.
|
||||
- Interface impact: L2 internal Harness tool/adapter plus JSqlParser dependency and query-script behavior; no public protocol changes.
|
||||
- Preflight risk accepted: parser ambiguity, datasource isolation, driver cancellation behavior and sensitive result values all fail closed or remain Harness-only.
|
||||
|
||||
## Commit gate
|
||||
|
||||
- [x] proposal, design, specs and tasks exist.
|
||||
- [x] strict OpenSpec validation passes.
|
||||
- [x] all evidence-driven questions are resolved.
|
||||
- [x] no unresolved interface decision remains.
|
||||
- [x] `.committed` marker created for Apply.
|
||||
|
||||
## Apply result
|
||||
|
||||
- Added JSqlParser 4.6 and fail-closed `MysqlSqlValidator`.
|
||||
- Added immutable logical datasource/allowlist/limit/query-plan/raw-result models and independent `MysqlToolProperties` binding.
|
||||
- Added `JdbcMysqlReadOnlyExecutor` with read-only connection, PreparedStatement binding, query timeout, max rows, cell/result limits and Run cancellation callback.
|
||||
- Added `MysqlResultProjector` with sensitive-column redaction, row/cell/total UTF-8 bounds and `NO_EVIDENCE`.
|
||||
- Added `MysqlToolAdapter` through the existing ToolBoundary; invalid SQL is rejected before database execution.
|
||||
- Replaced `scripts/query_mysql.py` with environment-only, read-only transaction behavior and pre-connect write/metadata rejection.
|
||||
|
||||
## Apply conflicts and corrections
|
||||
|
||||
- `COUNT(*)` is represented by JSqlParser as an `AllColumns` parameter in this version; the visitor was corrected to permit only the explicit `COUNT(*)` exception.
|
||||
- JDBC metadata access cannot be used as a checked-exception stream method reference; the implementation uses an explicit column loop.
|
||||
- No OpenSpec/design conflict was found; both corrections were implementation details.
|
||||
|
||||
## Archive result
|
||||
|
||||
- Apply tasks complete and `.archive-ready` created.
|
||||
- OpenSpec archived at `openspec/changes/archive/2026-07-21-single-react-mysql-readonly-tool`.
|
||||
- Main capability specification added at `openspec/specs/mysql-readonly-tool/spec.md`.
|
||||
- Stage 4 may consume the internal adapter only after this stage is committed.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Evidence: single-react-mysql-readonly-tool
|
||||
|
||||
## Static verification
|
||||
|
||||
- `git diff --check`: passed before archive.
|
||||
- Security scan confirms the query helper has no default external host/port/root user and no `commit()` write path.
|
||||
- New MySQL Harness code receives only injected logical DataSources and does not reference `spring.datasource` or the application persistence datasource.
|
||||
- OpenSpec strict validation passed for `single-react-mysql-readonly-tool`.
|
||||
|
||||
## Script/build verification
|
||||
|
||||
- `mvn -q -DskipTests compile`: passed.
|
||||
- Focused suite passed: `MysqlSqlValidatorTest`, `MysqlResultProjectorTest`, `JdbcMysqlReadOnlyExecutorTest`, `MysqlToolAdapterTest`, `MysqlToolContractTest`, `ToolBoundaryTest`, `CanonicalInvocationStoreTest`.
|
||||
- Python syntax compilation passed for `scripts/query_mysql.py`.
|
||||
- Missing connection environment variables exit before connection with code 2.
|
||||
- A write SQL invocation is rejected before connection with code 3.
|
||||
|
||||
## Security coverage
|
||||
|
||||
- Allowed: explicit allowlisted SELECT, parameter placeholders, qualified INNER JOIN and `COUNT(*)`.
|
||||
- Rejected: write, WITH, subquery, UNION, wildcard projection, unknown table/column, ambiguous column, dangerous function, inline literal, CASE, FOR UPDATE, multi-statement and placeholder mismatch.
|
||||
- JDBC controls verified: `setReadOnly(true)`, `PreparedStatement`, `setQueryTimeout`, `setMaxRows`, ordered parameter binding and cancellation-before-execution.
|
||||
- Projection controls verified: max rows, max cell chars, total UTF-8 bytes, sensitive-column redaction, valid bounded JSON and `NO_EVIDENCE`.
|
||||
|
||||
## Not verified in this stage
|
||||
|
||||
- No live production business datasource was provisioned or queried; ISS-014 explicitly assigns live E2E to a later issue/stage.
|
||||
- No public Diagnosis Agent/Chat integration was performed; stage 4 will consume the adapter internally.
|
||||
- JDBC driver timeout/cancel behavior against a real remote MySQL server remains an operational integration risk.
|
||||
@@ -1,6 +1,6 @@
|
||||
# ISS-014 单体 ReAct Agent、Harness 与 ACI 工具瘦身
|
||||
|
||||
**状态**:实施中(阶段 0-3A 已归档,下一阶段 3B)
|
||||
**状态**:实施中(阶段 0-3C 已归档,下一阶段 4)
|
||||
**严重程度**:高
|
||||
**发现时间**:2026-07-20
|
||||
**目标分支**:`refactor/chat-single-react-harness`
|
||||
@@ -1039,11 +1039,11 @@ ISS-014 是总设计 Issue,不创建跨阶段共享的 OpenSpec change。以
|
||||
| 阶段 | OpenSpec change | 状态 |
|
||||
|---|---|---|
|
||||
| 0 | `single-react-design-freeze` | Completed;已归档,Git commit 见阶段历史 |
|
||||
| 1 | `single-react-aci-tool-contracts` | Pending |
|
||||
| 2 | `single-react-harness-run-context` | Pending |
|
||||
| 3A | `single-react-tool-invocation-store` | Pending |
|
||||
| 3B | `single-react-rag-log-projections` | Pending |
|
||||
| 3C | `single-react-mysql-readonly-tool` | Pending |
|
||||
| 1 | `single-react-aci-tool-contracts` | Completed;已归档 |
|
||||
| 2 | `single-react-harness-run-context` | Completed;已归档 |
|
||||
| 3A | `single-react-tool-invocation-store` | Completed;已归档 |
|
||||
| 3B | `single-react-rag-log-projections` | Completed;已归档 |
|
||||
| 3C | `single-react-mysql-readonly-tool` | Completed;已归档 |
|
||||
| 4 | `single-react-diagnosis-agent` | Pending |
|
||||
| 5 | `single-react-evidence-semantic-guards` | Pending |
|
||||
| 6A | `single-react-chat-application-usecase` | Pending |
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Design: single-react-mysql-readonly-tool
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
MysqlToolRequest + ToolCallRequestEnvelope
|
||||
|
|
||||
v
|
||||
MysqlToolAdapter
|
||||
- parse typed request
|
||||
- MysqlSqlValidator -> MysqlQueryPlan
|
||||
- ToolBoundary.execute
|
||||
|
|
||||
+--> MysqlReadOnlyExecutor (JDBC or isolated fake)
|
||||
| - read-only connection
|
||||
| - PreparedStatement params
|
||||
| - timeout/max rows/cancel
|
||||
|
|
||||
+--> MysqlResultProjector
|
||||
- bounded cells/rows/bytes
|
||||
- sensitive column redaction
|
||||
- MysqlToolResult JSON
|
||||
```
|
||||
|
||||
The adapter validates the typed request before entering ToolBoundary. Once validation succeeds, the executor and projector run inside the existing canonical lifecycle. Validation failures have no canonical record because no database call is authorized; execution/projection failures after `PROJECTING` become canonical `ERROR` records.
|
||||
|
||||
## Configuration model
|
||||
|
||||
`MysqlDataSourceDefinition` is an immutable logical definition:
|
||||
|
||||
- logical ID
|
||||
- JDBC URL, username and password supplied by configuration/Secret
|
||||
- default schema
|
||||
- exact schema/table/column allowlist
|
||||
- query timeout seconds, max rows, max cell characters and max result bytes
|
||||
|
||||
The Agent sees only the logical ID. The stage does not create a dynamic datasource registry or reuse the application persistence datasource. A caller supplies a `Map<String, DataSource>` to the JDBC executor, allowing isolated test data sources and later production wiring.
|
||||
|
||||
## SQL validator
|
||||
|
||||
`MysqlSqlValidator` uses `CCJSqlParserUtil.parseStatements` and rejects unless there is exactly one `Select` with a `PlainSelect` body and no CTE/compound body. It rejects `SubSelect`, `SetOperationList`, `ValuesStatement`, `ForUpdate`, metadata statements, wildcard projection except `COUNT(*)`, unsupported joins, unsupported functions, and unknown table/from items.
|
||||
|
||||
The validator builds an alias map for every base table and checks every table/column reference in select items, joins, where, group by, having and order by. Unqualified columns must resolve to exactly one allowlisted table; qualified columns must resolve through a declared alias/table and exact allowlist. It counts `JdbcParameter` nodes and requires an exact match with `params`.
|
||||
|
||||
The function allowlist starts with `COUNT`, `SUM`, `AVG`, `MIN`, and `MAX`. `COUNT(*)` is represented as a function-level exception; a projection `*` or `table.*` is rejected. Any parser exception or policy ambiguity produces a `MysqlSecurityException` before execution.
|
||||
|
||||
## JDBC executor
|
||||
|
||||
`JdbcMysqlReadOnlyExecutor` resolves the validated logical ID to a caller-supplied DataSource, opens a connection, calls `setReadOnly(true)`, prepares the validated SQL, binds parameters in order, applies `setQueryTimeout` and `setMaxRows`, and reads column labels plus structured values. It checks `RunContext` cancellation/deadline while iterating and cancels/closes the statement on abort.
|
||||
|
||||
The executor returns a Harness-only raw record. It never returns a JDBC connection, SQL exception details, credentials or stack traces to the Agent. JDBC errors are mapped by ToolBoundary to a stable safe error.
|
||||
|
||||
## Result projection
|
||||
|
||||
`MysqlResultProjector` parses only the executor raw record and emits `MysqlToolResult`:
|
||||
|
||||
- immutable ordered columns
|
||||
- bounded ordered rows
|
||||
- cell values converted to JSON-safe scalar/text values
|
||||
- redaction for sensitive column names (`password`, `token`, `secret`, `api_key`, etc.)
|
||||
- `returned_count` equal to projected rows
|
||||
- `truncated=true` for row/cell/byte removal
|
||||
- `NO_EVIDENCE` for a successful empty result
|
||||
|
||||
Projection never exposes raw JDBC metadata, connection coordinates, internal error messages or the unbounded raw result.
|
||||
|
||||
## Script safety cleanup
|
||||
|
||||
`scripts/query_mysql.py` will require `SUPERBIZ_MYSQL_HOST`, `SUPERBIZ_MYSQL_PORT`, `SUPERBIZ_MYSQL_USERNAME`, `SUPERBIZ_MYSQL_PASSWORD` and `SUPERBIZ_MYSQL_DATABASE` (with no external defaults), accept only one SELECT statement, and reject all non-SELECT/metadata/write SQL before opening a connection. It will call `rollback`/close defensively and remove the interactive write path.
|
||||
|
||||
## Interface and compatibility impact
|
||||
|
||||
- L2 internal Harness classes plus the JSqlParser build dependency.
|
||||
- No public HTTP/SSE/Chat contract changes.
|
||||
- No changes to legacy tools, recorder, JPA entities, or current application datasource.
|
||||
- The main capability spec will be synchronized after archive.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
- JSqlParser AST API drift: lock version 4.6 and run parser security fixtures.
|
||||
- Alias/column ambiguity: fail closed rather than guessing.
|
||||
- JDBC cancellation is driver-dependent: check Run state before/while iteration and call `Statement.cancel()` on abort.
|
||||
- Sensitive result values: redact by column name before JSON serialization and keep raw only in canonical Harness storage.
|
||||
- A malicious query can still be expensive within SELECT: timeout, max rows, read-only connection and configured byte limits remain mandatory.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Proposal: single-react-mysql-readonly-tool
|
||||
|
||||
## Problem
|
||||
|
||||
阶段 1 已冻结 `query_mysql` 的逻辑请求和有界结果契约,但当前仓库没有一个能把 SQL 安全地转换为可执行查询的实现。直接把 Agent 生成的 SQL 交给 JDBC 会允许写操作、元数据探测、通配符泄漏、allowlist 绕过和无界结果。
|
||||
|
||||
## Proposed change
|
||||
|
||||
- 引入 JSqlParser 4.6,解析单条 SQL 并对保守的 SELECT 子集执行 fail-closed AST 校验。
|
||||
- 增加逻辑数据源定义和静态 `schema -> table -> column` 精确 allowlist;Agent 只能传逻辑数据源 ID。
|
||||
- 增加参数绑定、占位符数量校验、函数 allowlist、显式列校验、JOIN/过滤/分组/排序字段校验和受控 LIMIT。
|
||||
- 增加 JDBC 只读执行器:只使用配置映射的 DataSource,设置 `readOnly`、`PreparedStatement`、`setMaxRows`、查询超时,并在 Run 取消时中止执行。
|
||||
- 增加 MySQL 结果 projector:限制行数、单元格和总 UTF-8 字节,脱敏敏感列,产生 `MysqlToolResult` 和 `NO_EVIDENCE`。
|
||||
- 通过阶段 3A `ToolBoundary` 接入 canonical invocation store、Run budget、生命周期、`evidence_status` 和框架 `tool_call_id`。
|
||||
- 把 `scripts/query_mysql.py` 收敛为仅允许 SELECT/受控 SHOW 的只读查询脚本,移除默认外部连接参数和 commit 分支;凭据与连接信息全部从环境变量读取。
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- SQL AST validator and validated query plan.
|
||||
- Static logical data-source/allowlist model.
|
||||
- JDBC read-only executor abstraction and implementation.
|
||||
- MySQL result projector and ToolBoundary adapter.
|
||||
- Security, truncation, timeout/cancellation, no-evidence and boundary tests.
|
||||
- Query script safety cleanup required by the ISS-014 security prerequisite.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Diagnosis Agent cutover or public Chat/AIOps/SSE changes.
|
||||
- Querying the application's own persistence database through the Agent-facing Tool.
|
||||
- Metadata discovery (`SHOW TABLES`, `SHOW COLUMNS`, `DESCRIBE`, `information_schema`).
|
||||
- Tenant/row-level authorization, dynamic allowlists, arbitrary SQL functions, real production business datasource provisioning.
|
||||
|
||||
## Frozen SQL subset
|
||||
|
||||
- One `SELECT` statement only.
|
||||
- Explicit projection columns; `COUNT(*)` is the only star exception.
|
||||
- `INNER JOIN` and `LEFT JOIN`, normal predicates, `GROUP BY`, `HAVING`, `ORDER BY` and parameter placeholders.
|
||||
- No `WITH`, subquery, `UNION`, window function, `CROSS JOIN`, write statement, metadata query, transaction control, dangerous function, or unknown AST node.
|
||||
|
||||
## Constraints and risks
|
||||
|
||||
- Parser acceptance is not authorization: every table and column used by projection, predicates, joins, grouping and ordering must pass the independent allowlist.
|
||||
- Unknown data source, schema, table, column, function, placeholder mismatch or parser failure fails closed before JDBC execution.
|
||||
- JDBC row/cell/byte limits are enforced in addition to ToolBoundary limits; oversized results are projected with `truncated=true` or become a safe error when no valid bounded result can be produced.
|
||||
- The adapter is not wired into the existing public runtime in this stage; later Diagnosis Agent work will select it through the internal Harness application use case.
|
||||
|
||||
## Acceptance direction
|
||||
|
||||
- Valid explicit-column SELECT and `COUNT(*)` pass AST and allowlist validation.
|
||||
- Write statements, metadata, wildcard projection, nested/compound queries, unknown AST, allowlist bypass and placeholder mismatch are rejected without executor invocation.
|
||||
- JDBC executor uses read-only prepared statements, max rows, timeout and cancellation.
|
||||
- Agent receives only bounded `MysqlToolResult`; raw JDBC rows remain Harness-only canonical data.
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
# mysql-readonly-tool Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Define a fail-closed, parameterized, read-only MySQL evidence Tool that reuses the stage 3A ToolBoundary and exposes only bounded ACI results.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: SQL validation SHALL fail closed on a conservative SELECT subset
|
||||
|
||||
The Tool SHALL parse exactly one SQL statement with JSqlParser and SHALL accept only a single `SELECT` with explicit projection columns, supported predicates/grouping/ordering, `INNER JOIN` or `LEFT JOIN`, parameter placeholders and allowlisted functions. It SHALL reject writes, CTEs, subqueries, set operations, wildcard projections except `COUNT(*)`, metadata discovery, unsupported joins/functions, `FOR UPDATE`, multiple statements and unknown/ambiguous AST structures.
|
||||
|
||||
#### Scenario: Valid explicit-column SELECT
|
||||
|
||||
- WHEN a query selects allowlisted columns from an allowlisted table with matching `?` parameters
|
||||
- THEN validation returns a query plan and the executor may be invoked
|
||||
|
||||
#### Scenario: COUNT star exception
|
||||
|
||||
- WHEN a query uses `SELECT COUNT(*)` against an allowlisted table
|
||||
- THEN validation succeeds without treating the projection as an unrestricted wildcard
|
||||
|
||||
#### Scenario: Security query is rejected
|
||||
|
||||
- WHEN SQL contains INSERT/UPDATE/DELETE, `WITH`, a subquery, `UNION`, `SELECT *`, metadata discovery, `FOR UPDATE`, a dangerous function, multiple statements or an unknown AST node
|
||||
- THEN validation fails before executor invocation
|
||||
|
||||
### Requirement: Data-source and identifier authorization SHALL use exact independent allowlists
|
||||
|
||||
The Tool SHALL accept only a logical `data_source` ID and SHALL authorize every schema, table and column used in projection, join, predicate, grouping and ordering against the configured exact allowlist. It SHALL reject unknown data sources, schemas, tables, columns, aliases and ambiguous unqualified columns. Agent input SHALL NOT provide JDBC coordinates or authorization controls.
|
||||
|
||||
#### Scenario: Allowlisted query
|
||||
|
||||
- WHEN every referenced identifier resolves to one configured schema/table/column
|
||||
- THEN validation succeeds and retains the logical data-source ID only
|
||||
|
||||
#### Scenario: Allowlist bypass
|
||||
|
||||
- WHEN a query references an unconfigured table, column, schema, alias or ambiguous unqualified column
|
||||
- THEN validation fails closed and the executor is not called
|
||||
|
||||
### Requirement: Parameter binding and JDBC execution SHALL be read-only and bounded
|
||||
|
||||
The executor SHALL use a configured logical datasource, a read-only JDBC connection, `PreparedStatement` parameter binding, query timeout, max rows and Run cancellation/deadline checks. Placeholder count SHALL exactly match `params`. The executor SHALL not expose connection details or raw JDBC failures to the Agent.
|
||||
|
||||
#### Scenario: Bound read-only execution
|
||||
|
||||
- WHEN a validated plan has matching parameters and an active Run
|
||||
- THEN the executor binds values in order, sets read-only/timeout/max rows, and returns structured raw rows
|
||||
|
||||
#### Scenario: Timeout or cancellation
|
||||
|
||||
- WHEN the query exceeds its timeout or the Run is cancelled/deadline-expired
|
||||
- THEN the statement is cancelled/closed and ToolBoundary returns a safe error without an Agent result
|
||||
|
||||
### Requirement: MySQL projection SHALL be bounded and evidence-aware
|
||||
|
||||
The projector SHALL expose only ordered columns, bounded JSON-safe rows, returned count and truncation. It SHALL enforce row, cell and total UTF-8 limits, redact sensitive column values, return `NO_EVIDENCE` for a successful empty result, and never expose raw JDBC metadata or credentials.
|
||||
|
||||
#### Scenario: Bounded rows are projected
|
||||
|
||||
- WHEN the executor returns rows within configured limits
|
||||
- THEN the Agent receives `EVIDENCE_FOUND` with ordered columns and structured rows
|
||||
|
||||
#### Scenario: Result exceeds a limit
|
||||
|
||||
- WHEN row count, cell length or total bytes exceed a configured bound
|
||||
- THEN the Agent receives valid bounded JSON with `truncated=true` and no oversized value
|
||||
|
||||
#### Scenario: Empty result
|
||||
|
||||
- WHEN a valid query returns zero rows
|
||||
- THEN the boundary reaches `READY` with `evidence_status=NO_EVIDENCE` and an empty row list
|
||||
|
||||
### Requirement: MySQL Tool SHALL reuse canonical Harness ownership
|
||||
|
||||
The adapter SHALL pass the exact framework `tool_call_id` and RunContext through the existing ToolBoundary and canonical invocation store. It SHALL not create a second ID, use a parallel store, return raw SQL results, or modify legacy audit/public runtime paths.
|
||||
|
||||
#### Scenario: Successful adapter call
|
||||
|
||||
- WHEN a valid request passes validation and execution
|
||||
- THEN the canonical record contains request/raw/bounded result under the exact framework ID and the Agent receives only the bounded result
|
||||
|
||||
#### Scenario: Validation or boundary failure
|
||||
|
||||
- WHEN validation, authorization, duplicate, budget or lifecycle preflight fails
|
||||
- THEN no database call is made and the Agent receives a safe bounded error
|
||||
|
||||
### Requirement: The query helper script SHALL be read-only and secret-free by default
|
||||
|
||||
The repository query helper SHALL require connection values from environment variables, reject non-SELECT and metadata discovery SQL before connection, and SHALL NOT commit writes or expose hardcoded external connection defaults.
|
||||
|
||||
#### Scenario: Unsafe script query
|
||||
|
||||
- WHEN a caller passes a write, multi-statement, metadata or transaction command
|
||||
- THEN the script exits with a safe validation error without opening a connection
|
||||
@@ -0,0 +1,24 @@
|
||||
# Tasks: single-react-mysql-readonly-tool
|
||||
|
||||
## 1. Security prerequisite and dependency
|
||||
|
||||
- [x] 1.1 Add JSqlParser 4.6 and record the locked parser version.
|
||||
- [x] 1.2 Make `scripts/query_mysql.py` environment-only and read-only; remove commit and unsafe interactive paths.
|
||||
|
||||
## 2. SQL policy and allowlist model
|
||||
|
||||
- [x] 2.1 Implement immutable data-source/allowlist/limit models and validated query plan.
|
||||
- [x] 2.2 Implement JSqlParser single-SELECT validator, identifier resolution, function/wildcard/join policy and placeholder count checks.
|
||||
- [x] 2.3 Add parser and allowlist security fixtures for accepted/rejected SQL.
|
||||
|
||||
## 3. JDBC executor and projection
|
||||
|
||||
- [x] 3.1 Implement read-only PreparedStatement executor with timeout, max rows, Run cancellation and safe error mapping.
|
||||
- [x] 3.2 Implement `MysqlResultProjector` with row/cell/byte bounds, redaction, JSON-safe values and `NO_EVIDENCE`.
|
||||
- [x] 3.3 Add isolated executor/projector tests for valid rows, empty rows, truncation, sensitive columns and timeout/cancel behavior.
|
||||
|
||||
## 4. ToolBoundary adapter and verification
|
||||
|
||||
- [x] 4.1 Implement typed MySQL adapter through the existing ToolBoundary and canonical invocation store.
|
||||
- [x] 4.2 Add adapter tests proving exact framework ID, raw isolation, validation-before-execution and safe errors.
|
||||
- [x] 4.3 Run focused compile/tests, validate OpenSpec, update devflow evidence, archive and commit before stage 4.
|
||||
@@ -0,0 +1,31 @@
|
||||
# mysql-readonly-tool Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Define a fail-closed, parameterized, read-only MySQL evidence Tool that reuses ToolBoundary and exposes only bounded ACI results.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: SQL validation SHALL fail closed on a conservative SELECT subset
|
||||
|
||||
The Tool SHALL parse exactly one SQL statement with JSqlParser and SHALL accept only a single `SELECT` with explicit projection columns, supported predicates/grouping/ordering, `INNER JOIN` or `LEFT JOIN`, parameter placeholders and allowlisted functions. It SHALL reject writes, CTEs, subqueries, set operations, wildcard projections except `COUNT(*)`, metadata discovery, unsupported joins/functions, `FOR UPDATE`, multiple statements and unknown/ambiguous AST structures.
|
||||
|
||||
### Requirement: Data-source and identifier authorization SHALL use exact independent allowlists
|
||||
|
||||
The Tool SHALL accept only a logical `data_source` ID and SHALL authorize every schema, table and column used in projection, join, predicate, grouping and ordering against the configured exact allowlist. It SHALL reject unknown data sources, schemas, tables, columns, aliases and ambiguous unqualified columns. Agent input SHALL NOT provide JDBC coordinates or authorization controls.
|
||||
|
||||
### Requirement: Parameter binding and JDBC execution SHALL be read-only and bounded
|
||||
|
||||
The executor SHALL use a configured logical datasource, a read-only JDBC connection, `PreparedStatement` parameter binding, query timeout, max rows and Run cancellation/deadline checks. Placeholder count SHALL exactly match `params`. The executor SHALL not expose connection details or raw JDBC failures to the Agent.
|
||||
|
||||
### Requirement: MySQL projection SHALL be bounded and evidence-aware
|
||||
|
||||
The projector SHALL expose only ordered columns, bounded JSON-safe rows, returned count and truncation. It SHALL enforce row, cell and total UTF-8 limits, redact sensitive column values, return `NO_EVIDENCE` for a successful empty result, and never expose raw JDBC metadata or credentials.
|
||||
|
||||
### Requirement: MySQL Tool SHALL reuse canonical Harness ownership
|
||||
|
||||
The adapter SHALL pass the exact framework `tool_call_id` and RunContext through the existing ToolBoundary and canonical invocation store. It SHALL not create a second ID, use a parallel store, return raw SQL results, or modify legacy audit/public runtime paths.
|
||||
|
||||
### Requirement: The query helper script SHALL be read-only and secret-free by default
|
||||
|
||||
The repository query helper SHALL require connection values from environment variables, reject non-SELECT and metadata discovery SQL before connection, and SHALL NOT commit writes or expose hardcoded external connection defaults.
|
||||
@@ -166,6 +166,11 @@
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.jsqlparser</groupId>
|
||||
<artifactId>jsqlparser</artifactId>
|
||||
<version>4.6</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Flyway 数据库迁移 -->
|
||||
<dependency>
|
||||
|
||||
+75
-47
@@ -1,19 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
通用 MySQL 查询脚本
|
||||
用法:
|
||||
python scripts/query_mysql.py "SELECT * FROM diagnosis_session ORDER BY created_at DESC LIMIT 5"
|
||||
python scripts/query_mysql.py # 交互模式
|
||||
依赖:pip install pymysql
|
||||
"""
|
||||
"""Read-only MySQL query helper used by local verification."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Windows 控制台 UTF-8 输出
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() != 'utf-8':
|
||||
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
@@ -22,6 +14,7 @@ except ImportError:
|
||||
print("缺少依赖,请先执行: pip install pymysql")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def required_env(name: str) -> str:
|
||||
value = os.getenv(name)
|
||||
if value is None or not value.strip():
|
||||
@@ -31,59 +24,94 @@ def required_env(name: str) -> str:
|
||||
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": os.getenv("SUPERBIZ_MYSQL_HOST", "119.29.78.52"),
|
||||
"port": int(os.getenv("SUPERBIZ_MYSQL_PORT", "33306")),
|
||||
"user": os.getenv("SUPERBIZ_MYSQL_USERNAME", "root"),
|
||||
"host": required_env("SUPERBIZ_MYSQL_HOST"),
|
||||
"port": int(required_env("SUPERBIZ_MYSQL_PORT")),
|
||||
"user": required_env("SUPERBIZ_MYSQL_USERNAME"),
|
||||
"password": required_env("SUPERBIZ_MYSQL_PASSWORD"),
|
||||
"database": os.getenv("SUPERBIZ_MYSQL_DATABASE", "superbiz_agent"),
|
||||
"database": required_env("SUPERBIZ_MYSQL_DATABASE"),
|
||||
"charset": "utf8mb4",
|
||||
"cursorclass": pymysql.cursors.DictCursor,
|
||||
"read_timeout": 10,
|
||||
"write_timeout": 10,
|
||||
}
|
||||
|
||||
|
||||
def run_query(sql: str):
|
||||
conn = pymysql.connect(**DB_CONFIG)
|
||||
def validate_read_only_sql(sql: str) -> str:
|
||||
normalized = sql.strip()
|
||||
if not normalized:
|
||||
raise ValueError("SQL 不能为空")
|
||||
statements = [part.strip() for part in normalized.split(";") if part.strip()]
|
||||
if len(statements) != 1:
|
||||
raise ValueError("只允许单条 SELECT")
|
||||
statement = statements[0]
|
||||
upper = statement.upper()
|
||||
if upper != "SELECT" and not upper.startswith("SELECT "):
|
||||
raise ValueError("只允许 SELECT 查询")
|
||||
padded = f" {upper} "
|
||||
forbidden = (
|
||||
" INTO ",
|
||||
" FOR UPDATE",
|
||||
" SHOW ",
|
||||
" DESCRIBE ",
|
||||
" INFORMATION_SCHEMA",
|
||||
" WITH ",
|
||||
" UNION ",
|
||||
" CALL ",
|
||||
)
|
||||
if any(token in padded for token in forbidden):
|
||||
raise ValueError("查询包含禁止的只读或元数据语法")
|
||||
return statement
|
||||
|
||||
|
||||
def run_query(sql: str) -> None:
|
||||
statement = validate_read_only_sql(sql)
|
||||
connection = pymysql.connect(**DB_CONFIG)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
if sql.strip().upper().startswith("SELECT") or sql.strip().upper().startswith("SHOW"):
|
||||
rows = cur.fetchall()
|
||||
if not rows:
|
||||
print("(空结果)")
|
||||
return
|
||||
# 打印列头
|
||||
cols = list(rows[0].keys())
|
||||
col_widths = {c: max(len(c), max(len(str(r[c])) for r in rows)) for c in cols}
|
||||
header = " | ".join(c.ljust(col_widths[c]) for c in cols)
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
for row in rows:
|
||||
print(" | ".join(str(row[c]).ljust(col_widths[c]) for c in cols))
|
||||
print(f"\n({len(rows)} 行)")
|
||||
else:
|
||||
conn.commit()
|
||||
print(f"OK,影响行数: {cur.rowcount}")
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("START TRANSACTION READ ONLY")
|
||||
cursor.execute(statement)
|
||||
rows = cursor.fetchall()
|
||||
if not rows:
|
||||
print("(空结果)")
|
||||
return
|
||||
columns = list(rows[0].keys())
|
||||
widths = {
|
||||
column: max(len(column), max(len(str(row[column])) for row in rows))
|
||||
for column in columns
|
||||
}
|
||||
header = " | ".join(column.ljust(widths[column]) for column in columns)
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
for row in rows:
|
||||
print(" | ".join(str(row[column]).ljust(widths[column]) for column in columns))
|
||||
print(f"\n({len(rows)} 行)")
|
||||
finally:
|
||||
conn.close()
|
||||
connection.rollback()
|
||||
connection.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
sql = " ".join(sys.argv[1:])
|
||||
run_query(sql)
|
||||
try:
|
||||
run_query(" ".join(sys.argv[1:]))
|
||||
except ValueError as exc:
|
||||
print(f"拒绝执行: {exc}")
|
||||
sys.exit(3)
|
||||
else:
|
||||
print("MySQL 交互模式(输入 exit 退出)")
|
||||
print(f"连接:{DB_CONFIG['user']}@{DB_CONFIG['host']}:{DB_CONFIG['port']}/{DB_CONFIG['database']}")
|
||||
print("MySQL 只读交互模式(输入 exit 退出)")
|
||||
print(
|
||||
f"连接: {DB_CONFIG['user']}@{DB_CONFIG['host']}:"
|
||||
f"{DB_CONFIG['port']}/{DB_CONFIG['database']}"
|
||||
)
|
||||
print("-" * 50)
|
||||
while True:
|
||||
try:
|
||||
sql = input("sql> ").strip()
|
||||
if sql.lower() in ("exit", "quit", "q"):
|
||||
break
|
||||
if not sql:
|
||||
continue
|
||||
run_query(sql)
|
||||
if sql:
|
||||
run_query(sql)
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"错误: {e}")
|
||||
except Exception as exc:
|
||||
print(f"错误: {exc}")
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.superbiz.agent.config;
|
||||
|
||||
import com.superbiz.agent.harness.tool.mysql.MysqlDataSourceDefinition;
|
||||
import com.superbiz.agent.harness.tool.mysql.MysqlToolLimits;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Independent logical datasource configuration for the Agent-facing MySQL Tool. */
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "harness.mysql-tools")
|
||||
public class MysqlToolProperties {
|
||||
|
||||
private Map<String, DataSourceProperties> dataSources = new LinkedHashMap<>();
|
||||
|
||||
public Map<String, DataSourceProperties> getDataSources() {
|
||||
return dataSources;
|
||||
}
|
||||
|
||||
public void setDataSources(Map<String, DataSourceProperties> dataSources) {
|
||||
this.dataSources = dataSources == null ? new LinkedHashMap<>() : dataSources;
|
||||
}
|
||||
|
||||
public Map<String, MysqlDataSourceDefinition> definitions() {
|
||||
return dataSources.entrySet().stream().collect(Collectors.toUnmodifiableMap(
|
||||
Map.Entry::getKey,
|
||||
entry -> entry.getValue().toDefinition(entry.getKey())));
|
||||
}
|
||||
|
||||
public static class DataSourceProperties {
|
||||
private String jdbcUrl;
|
||||
private String username;
|
||||
private String password;
|
||||
private String defaultSchema;
|
||||
private int queryTimeoutSeconds = 5;
|
||||
private int maxRows = 100;
|
||||
private int maxCellChars = 2_000;
|
||||
private int maxResultBytes = 64 * 1024;
|
||||
private Map<String, SchemaProperties> allowedSchemas = new LinkedHashMap<>();
|
||||
|
||||
public MysqlDataSourceDefinition toDefinition(String id) {
|
||||
Map<String, Map<String, Set<String>>> schemas = allowedSchemas.entrySet().stream()
|
||||
.collect(Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
entry -> entry.getValue().tables.entrySet().stream()
|
||||
.collect(Collectors.toMap(Map.Entry::getKey,
|
||||
table -> Set.copyOf(table.getValue().columns)))));
|
||||
return new MysqlDataSourceDefinition(id, defaultSchema, schemas,
|
||||
new MysqlToolLimits(maxRows, maxCellChars, maxResultBytes, queryTimeoutSeconds));
|
||||
}
|
||||
|
||||
public String getJdbcUrl() {
|
||||
return jdbcUrl;
|
||||
}
|
||||
|
||||
public void setJdbcUrl(String jdbcUrl) {
|
||||
this.jdbcUrl = jdbcUrl;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getDefaultSchema() {
|
||||
return defaultSchema;
|
||||
}
|
||||
|
||||
public void setDefaultSchema(String defaultSchema) {
|
||||
this.defaultSchema = defaultSchema;
|
||||
}
|
||||
|
||||
public int getQueryTimeoutSeconds() {
|
||||
return queryTimeoutSeconds;
|
||||
}
|
||||
|
||||
public void setQueryTimeoutSeconds(int queryTimeoutSeconds) {
|
||||
this.queryTimeoutSeconds = queryTimeoutSeconds;
|
||||
}
|
||||
|
||||
public int getMaxRows() {
|
||||
return maxRows;
|
||||
}
|
||||
|
||||
public void setMaxRows(int maxRows) {
|
||||
this.maxRows = maxRows;
|
||||
}
|
||||
|
||||
public int getMaxCellChars() {
|
||||
return maxCellChars;
|
||||
}
|
||||
|
||||
public void setMaxCellChars(int maxCellChars) {
|
||||
this.maxCellChars = maxCellChars;
|
||||
}
|
||||
|
||||
public int getMaxResultBytes() {
|
||||
return maxResultBytes;
|
||||
}
|
||||
|
||||
public void setMaxResultBytes(int maxResultBytes) {
|
||||
this.maxResultBytes = maxResultBytes;
|
||||
}
|
||||
|
||||
public Map<String, SchemaProperties> getAllowedSchemas() {
|
||||
return allowedSchemas;
|
||||
}
|
||||
|
||||
public void setAllowedSchemas(Map<String, SchemaProperties> allowedSchemas) {
|
||||
this.allowedSchemas = allowedSchemas == null ? new LinkedHashMap<>() : allowedSchemas;
|
||||
}
|
||||
}
|
||||
|
||||
public static class SchemaProperties {
|
||||
private Map<String, TableProperties> tables = new LinkedHashMap<>();
|
||||
|
||||
public Map<String, TableProperties> getTables() {
|
||||
return tables;
|
||||
}
|
||||
|
||||
public void setTables(Map<String, TableProperties> tables) {
|
||||
this.tables = tables == null ? new LinkedHashMap<>() : tables;
|
||||
}
|
||||
}
|
||||
|
||||
public static class TableProperties {
|
||||
private Set<String> columns = Set.of();
|
||||
|
||||
public Set<String> getColumns() {
|
||||
return columns;
|
||||
}
|
||||
|
||||
public void setColumns(Set<String> columns) {
|
||||
this.columns = columns == null ? Set.of() : columns;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.superbiz.agent.harness.tool.adapter;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.harness.core.RunContext;
|
||||
import com.superbiz.agent.harness.tool.boundary.ToolBoundary;
|
||||
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryErrorCode;
|
||||
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryResult;
|
||||
import com.superbiz.agent.harness.tool.boundary.ToolCallRequestEnvelope;
|
||||
import com.superbiz.agent.harness.tool.contract.MysqlToolRequest;
|
||||
import com.superbiz.agent.harness.tool.mysql.MysqlQueryPlan;
|
||||
import com.superbiz.agent.harness.tool.mysql.MysqlReadOnlyExecutor;
|
||||
import com.superbiz.agent.harness.tool.mysql.MysqlResultProjector;
|
||||
import com.superbiz.agent.harness.tool.mysql.MysqlSecurityException;
|
||||
import com.superbiz.agent.harness.tool.mysql.MysqlSqlValidator;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Validates and runs the logical MySQL Tool through the canonical boundary. */
|
||||
public final class MysqlToolAdapter {
|
||||
|
||||
private final ToolBoundary boundary;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final MysqlSqlValidator validator;
|
||||
private final MysqlReadOnlyExecutor executor;
|
||||
private final MysqlResultProjector projector;
|
||||
|
||||
public MysqlToolAdapter(ToolBoundary boundary, ObjectMapper objectMapper,
|
||||
MysqlSqlValidator validator, MysqlReadOnlyExecutor executor,
|
||||
MysqlResultProjector projector) {
|
||||
this.boundary = Objects.requireNonNull(boundary, "boundary must not be null");
|
||||
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
|
||||
this.validator = Objects.requireNonNull(validator, "validator must not be null");
|
||||
this.executor = Objects.requireNonNull(executor, "executor must not be null");
|
||||
this.projector = Objects.requireNonNull(projector, "projector must not be null");
|
||||
}
|
||||
|
||||
public ToolBoundaryResult execute(RunContext context, ToolCallRequestEnvelope envelope) {
|
||||
try {
|
||||
MysqlToolRequest request = objectMapper.readValue(envelope.requestJson(), MysqlToolRequest.class);
|
||||
MysqlQueryPlan plan = validator.validate(request);
|
||||
return boundary.execute(context, envelope,
|
||||
ignored -> objectMapper.writeValueAsString(executor.execute(plan, context)),
|
||||
raw -> projector.project(request, envelope.toolCallId(), raw, plan.dataSource().limits()));
|
||||
} catch (MysqlSecurityException | IllegalArgumentException e) {
|
||||
return ToolBoundaryResult.error(envelope == null ? null : envelope.toolCallId(),
|
||||
ToolBoundaryErrorCode.INVALID_REQUEST);
|
||||
} catch (Exception e) {
|
||||
return ToolBoundaryResult.error(envelope == null ? null : envelope.toolCallId(),
|
||||
ToolBoundaryErrorCode.INVALID_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
import com.superbiz.agent.harness.core.RunContext;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.time.Clock;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/** JDBC implementation with read-only, timeout, row and cancellation controls. */
|
||||
public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JdbcMysqlReadOnlyExecutor.class);
|
||||
|
||||
private final Map<String, DataSource> dataSources;
|
||||
private final Clock clock;
|
||||
|
||||
public JdbcMysqlReadOnlyExecutor(Map<String, DataSource> dataSources, Clock clock) {
|
||||
this.dataSources = Map.copyOf(dataSources);
|
||||
this.clock = Objects.requireNonNull(clock, "clock must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public MysqlRawResult execute(MysqlQueryPlan plan, RunContext context) throws Exception {
|
||||
DataSource dataSource = dataSources.get(plan.dataSource().id());
|
||||
if (dataSource == null) {
|
||||
throw new MysqlSecurityException("logical data source is not configured");
|
||||
}
|
||||
MysqlToolLimits limits = plan.dataSource().limits();
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
connection.setReadOnly(true);
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
plan.normalizedSql(), ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
|
||||
statement.setQueryTimeout(limits.queryTimeoutSeconds());
|
||||
statement.setMaxRows(limits.maxRows() + 1);
|
||||
bind(statement, plan.params());
|
||||
|
||||
AtomicReference<Statement> statementRef = new AtomicReference<>(statement);
|
||||
context.cancellation().onCancel(ignored -> cancel(statementRef.get()));
|
||||
checkRun(context);
|
||||
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
ResultSetMetaData metadata = resultSet.getMetaData();
|
||||
java.util.ArrayList<String> columns = new java.util.ArrayList<>();
|
||||
for (int i = 1; i <= metadata.getColumnCount(); i++) {
|
||||
columns.add(metadata.getColumnLabel(i));
|
||||
}
|
||||
java.util.ArrayList<Map<String, Object>> rows = new java.util.ArrayList<>();
|
||||
boolean truncated = false;
|
||||
while (resultSet.next()) {
|
||||
checkRun(context);
|
||||
if (rows.size() >= limits.maxRows()) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
for (int i = 1; i <= metadata.getColumnCount(); i++) {
|
||||
String column = metadata.getColumnLabel(i);
|
||||
CellValue cell = jsonSafe(resultSet.getObject(i), limits.maxCellChars());
|
||||
row.put(column, cell.value());
|
||||
truncated |= cell.truncated();
|
||||
}
|
||||
rows.add(row);
|
||||
if (estimatedBytes(rows) > limits.maxResultBytes()) {
|
||||
rows.remove(rows.size() - 1);
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new MysqlRawResult(columns, rows, truncated);
|
||||
} finally {
|
||||
statementRef.set(null);
|
||||
}
|
||||
}
|
||||
} catch (MysqlSecurityException e) {
|
||||
throw e;
|
||||
} catch (SQLException e) {
|
||||
log.debug("MySQL read-only execution failed: sqlState={}", e.getSQLState());
|
||||
throw new SQLException("read-only query failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void bind(PreparedStatement statement, List<Object> params) throws SQLException {
|
||||
for (int i = 0; i < params.size(); i++) {
|
||||
statement.setObject(i + 1, params.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
private void checkRun(RunContext context) throws SQLException {
|
||||
if (context.cancellation().isCancelled() || !clock.instant().isBefore(context.deadline())) {
|
||||
throw new SQLException("run cancelled or deadline exceeded");
|
||||
}
|
||||
}
|
||||
|
||||
private static void cancel(Statement statement) {
|
||||
if (statement == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
statement.cancel();
|
||||
} catch (SQLException e) {
|
||||
log.debug("Unable to cancel MySQL statement", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static CellValue jsonSafe(Object value, int maxCellChars) {
|
||||
if (value == null || value instanceof Number || value instanceof Boolean) {
|
||||
return new CellValue(value, false);
|
||||
}
|
||||
String text;
|
||||
if (value instanceof byte[] bytes) {
|
||||
text = Base64.getEncoder().encodeToString(bytes);
|
||||
} else {
|
||||
text = String.valueOf(value);
|
||||
}
|
||||
return text.length() <= maxCellChars
|
||||
? new CellValue(text, false)
|
||||
: new CellValue(text.substring(0, maxCellChars), true);
|
||||
}
|
||||
|
||||
private static int estimatedBytes(List<Map<String, Object>> rows) {
|
||||
return rows.toString().getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
|
||||
private record CellValue(Object value, boolean truncated) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/** Logical datasource metadata and exact schema/table/column authorization. */
|
||||
public record MysqlDataSourceDefinition(
|
||||
String id,
|
||||
String defaultSchema,
|
||||
Map<String, Map<String, Set<String>>> allowedSchemas,
|
||||
MysqlToolLimits limits) {
|
||||
|
||||
public MysqlDataSourceDefinition {
|
||||
requireText(id, "id");
|
||||
requireText(defaultSchema, "defaultSchema");
|
||||
Objects.requireNonNull(allowedSchemas, "allowedSchemas must not be null");
|
||||
Objects.requireNonNull(limits, "limits must not be null");
|
||||
Map<String, Map<String, Set<String>>> schemas = new LinkedHashMap<>();
|
||||
allowedSchemas.forEach((schema, tables) -> {
|
||||
requireText(schema, "schema");
|
||||
Map<String, Set<String>> copiedTables = new LinkedHashMap<>();
|
||||
tables.forEach((table, columns) -> {
|
||||
requireText(table, "table");
|
||||
copiedTables.put(table, Collections.unmodifiableSet(new TreeSet<>(columns)));
|
||||
});
|
||||
schemas.put(schema, Collections.unmodifiableMap(copiedTables));
|
||||
});
|
||||
allowedSchemas = Collections.unmodifiableMap(schemas);
|
||||
if (!allowedSchemas.containsKey(defaultSchema)) {
|
||||
throw new IllegalArgumentException("defaultSchema must be allowlisted");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean allowsTable(String schema, String table) {
|
||||
return allowedSchemas.containsKey(schema) && allowedSchemas.get(schema).containsKey(table);
|
||||
}
|
||||
|
||||
public boolean allowsColumn(String schema, String table, String column) {
|
||||
return allowsTable(schema, table) && allowedSchemas.get(schema).get(table).contains(column);
|
||||
}
|
||||
|
||||
private static void requireText(String value, String name) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(name + " must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
import com.superbiz.agent.harness.tool.contract.MysqlToolRequest;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record MysqlQueryPlan(
|
||||
MysqlToolRequest request,
|
||||
MysqlDataSourceDefinition dataSource,
|
||||
String normalizedSql,
|
||||
List<Object> params) {
|
||||
|
||||
public MysqlQueryPlan {
|
||||
params = List.copyOf(params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** Harness-only raw query result; never returned directly to an Agent. */
|
||||
public record MysqlRawResult(
|
||||
List<String> columns,
|
||||
List<Map<String, Object>> rows,
|
||||
boolean truncated) {
|
||||
|
||||
public MysqlRawResult {
|
||||
columns = List.copyOf(columns);
|
||||
rows = rows.stream()
|
||||
.map(row -> Collections.unmodifiableMap(new LinkedHashMap<>(row)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
import com.superbiz.agent.harness.core.RunContext;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface MysqlReadOnlyExecutor {
|
||||
MysqlRawResult execute(MysqlQueryPlan plan, RunContext context) throws Exception;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.harness.contract.EvidenceStatus;
|
||||
import com.superbiz.agent.harness.tool.boundary.ProjectedToolResult;
|
||||
import com.superbiz.agent.harness.tool.contract.MysqlToolRequest;
|
||||
import com.superbiz.agent.harness.tool.contract.MysqlToolResult;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/** Projects raw JDBC rows into the bounded Agent-facing MySQL contract. */
|
||||
public final class MysqlResultProjector {
|
||||
|
||||
private static final List<String> SENSITIVE_TOKENS = List.of(
|
||||
"password", "passwd", "token", "secret", "api_key", "apikey", "credential");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final MysqlToolLimits limits;
|
||||
|
||||
public MysqlResultProjector(ObjectMapper objectMapper) {
|
||||
this(objectMapper, MysqlToolLimits.defaults());
|
||||
}
|
||||
|
||||
public MysqlResultProjector(ObjectMapper objectMapper, MysqlToolLimits limits) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.limits = limits;
|
||||
}
|
||||
|
||||
public ProjectedToolResult project(MysqlToolRequest request, String toolCallId,
|
||||
String rawResponse) throws Exception {
|
||||
return project(request, toolCallId, rawResponse, limits);
|
||||
}
|
||||
|
||||
public ProjectedToolResult project(MysqlToolRequest request, String toolCallId,
|
||||
String rawResponse, MysqlToolLimits projectionLimits) throws Exception {
|
||||
if (request == null || toolCallId == null || toolCallId.isBlank()) {
|
||||
throw new IllegalArgumentException("request and tool call ID are required");
|
||||
}
|
||||
JsonNode root = objectMapper.readTree(rawResponse);
|
||||
if (root == null || !root.isObject() || !root.path("columns").isArray()
|
||||
|| !root.path("rows").isArray()) {
|
||||
throw new IllegalArgumentException("MySQL raw result is invalid");
|
||||
}
|
||||
List<String> columns = new ArrayList<>();
|
||||
java.util.LinkedHashSet<String> uniqueColumns = new java.util.LinkedHashSet<>();
|
||||
root.path("columns").forEach(node -> {
|
||||
String column = node.asText();
|
||||
if (column.isBlank() || !uniqueColumns.add(column)) {
|
||||
throw new IllegalArgumentException("MySQL columns must be unique and non-blank");
|
||||
}
|
||||
columns.add(column);
|
||||
});
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
boolean truncated = root.path("truncated").asBoolean(false);
|
||||
for (JsonNode rowNode : root.path("rows")) {
|
||||
if (rows.size() >= projectionLimits.maxRows()) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
for (String column : columns) {
|
||||
JsonNode value = rowNode.get(column);
|
||||
CellProjection cell = projectCell(column, value, projectionLimits.maxCellChars());
|
||||
row.put(column, cell.value());
|
||||
truncated |= cell.truncated();
|
||||
}
|
||||
rows.add(row);
|
||||
if (utf8Bytes(rows.toString()) > projectionLimits.maxResultBytes()) {
|
||||
rows.remove(rows.size() - 1);
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
MysqlToolResult result = new MysqlToolResult(
|
||||
rows.isEmpty() ? EvidenceStatus.NO_EVIDENCE : EvidenceStatus.EVIDENCE_FOUND,
|
||||
toolCallId, columns, rows, rows.size(), truncated);
|
||||
result = fitBudget(result, projectionLimits.maxResultBytes());
|
||||
return new ProjectedToolResult(objectMapper.writeValueAsString(result), result.evidenceStatus());
|
||||
}
|
||||
|
||||
private MysqlToolResult fitBudget(MysqlToolResult result, int maxResultBytes) throws Exception {
|
||||
MysqlToolResult current = result;
|
||||
while (utf8Bytes(objectMapper.writeValueAsString(current)) > maxResultBytes
|
||||
&& !current.rows().isEmpty()) {
|
||||
List<Map<String, Object>> reduced = new ArrayList<>(current.rows());
|
||||
reduced.remove(reduced.size() - 1);
|
||||
current = new MysqlToolResult(
|
||||
reduced.isEmpty() ? EvidenceStatus.NO_EVIDENCE : EvidenceStatus.EVIDENCE_FOUND,
|
||||
current.toolCallId(), current.columns(), reduced, reduced.size(), true);
|
||||
}
|
||||
if (utf8Bytes(objectMapper.writeValueAsString(current)) > maxResultBytes) {
|
||||
throw new IllegalArgumentException("MySQL projection exceeds total budget");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private CellProjection projectCell(String column, JsonNode value, int maxCellChars) {
|
||||
if (value == null || value.isNull()) {
|
||||
return new CellProjection(null, false);
|
||||
}
|
||||
if (isSensitive(column)) {
|
||||
return new CellProjection("[REDACTED]", true);
|
||||
}
|
||||
if (value.isNumber()) {
|
||||
return new CellProjection(value.numberValue(), false);
|
||||
}
|
||||
if (value.isBoolean()) {
|
||||
return new CellProjection(value.booleanValue(), false);
|
||||
}
|
||||
String text = value.isTextual() ? value.textValue() : value.toString();
|
||||
return text.length() <= maxCellChars
|
||||
? new CellProjection(text, false)
|
||||
: new CellProjection(text.substring(0, maxCellChars), true);
|
||||
}
|
||||
|
||||
private static boolean isSensitive(String column) {
|
||||
String normalized = column == null ? "" : column.toLowerCase(Locale.ROOT);
|
||||
return SENSITIVE_TOKENS.stream().anyMatch(normalized::contains);
|
||||
}
|
||||
|
||||
private static int utf8Bytes(String value) {
|
||||
return value.getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
|
||||
private record CellProjection(Object value, boolean truncated) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
public final class MysqlSecurityException extends RuntimeException {
|
||||
|
||||
public MysqlSecurityException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public MysqlSecurityException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
import com.superbiz.agent.harness.tool.contract.MysqlToolRequest;
|
||||
import net.sf.jsqlparser.expression.AnalyticExpression;
|
||||
import net.sf.jsqlparser.expression.CaseExpression;
|
||||
import net.sf.jsqlparser.expression.Function;
|
||||
import net.sf.jsqlparser.expression.DateValue;
|
||||
import net.sf.jsqlparser.expression.DoubleValue;
|
||||
import net.sf.jsqlparser.expression.HexValue;
|
||||
import net.sf.jsqlparser.expression.JdbcParameter;
|
||||
import net.sf.jsqlparser.expression.LongValue;
|
||||
import net.sf.jsqlparser.expression.OracleHierarchicalExpression;
|
||||
import net.sf.jsqlparser.expression.StringValue;
|
||||
import net.sf.jsqlparser.expression.TimeValue;
|
||||
import net.sf.jsqlparser.expression.TimestampValue;
|
||||
import net.sf.jsqlparser.expression.operators.relational.ExistsExpression;
|
||||
import net.sf.jsqlparser.schema.Column;
|
||||
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
|
||||
import net.sf.jsqlparser.schema.Table;
|
||||
import net.sf.jsqlparser.statement.Statement;
|
||||
import net.sf.jsqlparser.statement.Statements;
|
||||
import net.sf.jsqlparser.statement.select.AllColumns;
|
||||
import net.sf.jsqlparser.statement.select.AllTableColumns;
|
||||
import net.sf.jsqlparser.statement.select.FromItem;
|
||||
import net.sf.jsqlparser.statement.select.Join;
|
||||
import net.sf.jsqlparser.statement.select.PlainSelect;
|
||||
import net.sf.jsqlparser.statement.select.Select;
|
||||
import net.sf.jsqlparser.statement.select.SelectBody;
|
||||
import net.sf.jsqlparser.statement.select.SelectExpressionItem;
|
||||
import net.sf.jsqlparser.statement.select.SetOperationList;
|
||||
import net.sf.jsqlparser.statement.select.SubSelect;
|
||||
import net.sf.jsqlparser.statement.values.ValuesStatement;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.expression.ExpressionVisitorAdapter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Fail-closed SQL policy for the Agent-facing MySQL Tool. */
|
||||
public final class MysqlSqlValidator {
|
||||
|
||||
private static final Set<String> ALLOWED_FUNCTIONS = Set.of("COUNT", "SUM", "AVG", "MIN", "MAX");
|
||||
|
||||
private final Map<String, MysqlDataSourceDefinition> dataSources;
|
||||
|
||||
public MysqlSqlValidator(Map<String, MysqlDataSourceDefinition> dataSources) {
|
||||
Objects.requireNonNull(dataSources, "dataSources must not be null");
|
||||
this.dataSources = Map.copyOf(dataSources);
|
||||
}
|
||||
|
||||
public MysqlQueryPlan validate(MysqlToolRequest request) {
|
||||
if (request == null || request.dataSource() == null || request.dataSource().isBlank()
|
||||
|| request.sql() == null || request.sql().isBlank()) {
|
||||
throw new MysqlSecurityException("data_source and sql are required");
|
||||
}
|
||||
MysqlDataSourceDefinition dataSource = dataSources.get(request.dataSource());
|
||||
if (dataSource == null) {
|
||||
throw new MysqlSecurityException("unknown logical data source");
|
||||
}
|
||||
if (request.sql().length() > 16_384) {
|
||||
throw new MysqlSecurityException("SQL exceeds policy length");
|
||||
}
|
||||
try {
|
||||
Statements statements = CCJSqlParserUtil.parseStatements(request.sql());
|
||||
if (statements.getStatements() == null || statements.getStatements().size() != 1) {
|
||||
throw new MysqlSecurityException("exactly one SQL statement is required");
|
||||
}
|
||||
Statement statement = statements.getStatements().get(0);
|
||||
if (!(statement instanceof Select select)) {
|
||||
throw new MysqlSecurityException("only SELECT is allowed");
|
||||
}
|
||||
if (select.getWithItemsList() != null && !select.getWithItemsList().isEmpty()) {
|
||||
throw new MysqlSecurityException("WITH is not allowed");
|
||||
}
|
||||
SelectBody body = select.getSelectBody();
|
||||
if (!(body instanceof PlainSelect plainSelect)
|
||||
|| body instanceof SetOperationList
|
||||
|| body instanceof ValuesStatement) {
|
||||
throw new MysqlSecurityException("only a plain SELECT is allowed");
|
||||
}
|
||||
if (plainSelect.isForUpdate() || plainSelect.isSkipLocked()
|
||||
|| plainSelect.getFromItem() == null) {
|
||||
throw new MysqlSecurityException("locking or missing FROM is not allowed");
|
||||
}
|
||||
if (plainSelect.getIntoTables() != null && !plainSelect.getIntoTables().isEmpty()
|
||||
|| plainSelect.getOffset() != null || plainSelect.getFetch() != null
|
||||
|| plainSelect.getTop() != null || plainSelect.getFirst() != null
|
||||
|| plainSelect.getSkip() != null || plainSelect.getOptimizeFor() != null
|
||||
|| plainSelect.getOracleHierarchical() != null
|
||||
|| plainSelect.getKsqlWindow() != null
|
||||
|| plainSelect.getWindowDefinitions() != null && !plainSelect.getWindowDefinitions().isEmpty()) {
|
||||
throw new MysqlSecurityException("unsupported SELECT clause");
|
||||
}
|
||||
|
||||
Map<String, TableRef> tables = new LinkedHashMap<>();
|
||||
registerTable(plainSelect.getFromItem(), dataSource, tables);
|
||||
List<Join> joins = plainSelect.getJoins() == null ? List.of() : plainSelect.getJoins();
|
||||
for (Join join : joins) {
|
||||
if (join.isCross() || join.isRight() || join.isFull() || join.isOuter()
|
||||
|| (!join.isInner() && !join.isLeft())) {
|
||||
throw new MysqlSecurityException("only INNER/LEFT JOIN is allowed");
|
||||
}
|
||||
registerTable(join.getRightItem(), dataSource, tables);
|
||||
if (join.getOnExpressions() != null) {
|
||||
join.getOnExpressions().forEach(expression ->
|
||||
validateExpression(expression, tables, dataSource));
|
||||
}
|
||||
if (join.getUsingColumns() != null) {
|
||||
for (Column column : join.getUsingColumns()) {
|
||||
validateColumn(column, tables, dataSource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (plainSelect.getSelectItems() == null || plainSelect.getSelectItems().isEmpty()) {
|
||||
throw new MysqlSecurityException("projection must be explicit");
|
||||
}
|
||||
for (var item : plainSelect.getSelectItems()) {
|
||||
if (item instanceof AllColumns || item instanceof AllTableColumns) {
|
||||
throw new MysqlSecurityException("wildcard projection is not allowed");
|
||||
}
|
||||
if (!(item instanceof SelectExpressionItem expressionItem)) {
|
||||
throw new MysqlSecurityException("unsupported select item");
|
||||
}
|
||||
validateExpression(expressionItem.getExpression(), tables, dataSource);
|
||||
}
|
||||
validateExpression(plainSelect.getWhere(), tables, dataSource);
|
||||
validateExpression(plainSelect.getHaving(), tables, dataSource);
|
||||
if (plainSelect.getGroupBy() != null) {
|
||||
for (Expression expression : plainSelect.getGroupBy().getGroupByExpressions()) {
|
||||
validateExpression(expression, tables, dataSource);
|
||||
}
|
||||
}
|
||||
if (plainSelect.getOrderByElements() != null) {
|
||||
plainSelect.getOrderByElements().forEach(order ->
|
||||
validateExpression(order.getExpression(), tables, dataSource));
|
||||
}
|
||||
int placeholders = countPlaceholders(plainSelect);
|
||||
int provided = request.params() == null ? 0 : request.params().size();
|
||||
if (placeholders != provided) {
|
||||
throw new MysqlSecurityException("placeholder count does not match params");
|
||||
}
|
||||
return new MysqlQueryPlan(request, dataSource, statement.toString(),
|
||||
request.params() == null ? List.of() : request.params());
|
||||
} catch (MysqlSecurityException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new MysqlSecurityException("SQL cannot be safely validated", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void registerTable(FromItem item, MysqlDataSourceDefinition dataSource,
|
||||
Map<String, TableRef> tables) {
|
||||
if (!(item instanceof Table table)) {
|
||||
throw new MysqlSecurityException("subqueries and non-table sources are not allowed");
|
||||
}
|
||||
String schema = table.getSchemaName();
|
||||
if (schema == null || schema.isBlank()) {
|
||||
schema = dataSource.defaultSchema();
|
||||
}
|
||||
String name = table.getName();
|
||||
if (!dataSource.allowsTable(schema, name)) {
|
||||
throw new MysqlSecurityException("table is not allowlisted");
|
||||
}
|
||||
String alias = table.getAlias() == null ? name : table.getAlias().getName();
|
||||
String key = alias.toLowerCase(Locale.ROOT);
|
||||
if (tables.putIfAbsent(key, new TableRef(schema, name)) != null) {
|
||||
throw new MysqlSecurityException("duplicate table alias");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateExpression(Expression expression, Map<String, TableRef> tables,
|
||||
MysqlDataSourceDefinition dataSource) {
|
||||
if (expression == null) {
|
||||
return;
|
||||
}
|
||||
expression.accept(new ExpressionVisitorAdapter() {
|
||||
@Override
|
||||
public void visit(Column column) {
|
||||
validateColumn(column, tables, dataSource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(Function function) {
|
||||
String name = function.getName() == null ? "" : function.getName().toUpperCase(Locale.ROOT);
|
||||
if (!ALLOWED_FUNCTIONS.contains(name)) {
|
||||
throw new MysqlSecurityException("function is not allowlisted");
|
||||
}
|
||||
boolean countStar = function.isAllColumns()
|
||||
|| (function.getParameters() != null
|
||||
&& function.getParameters().getExpressions() != null
|
||||
&& function.getParameters().getExpressions().size() == 1
|
||||
&& function.getParameters().getExpressions().get(0) instanceof AllColumns);
|
||||
if (countStar && !"COUNT".equals(name)) {
|
||||
throw new MysqlSecurityException("only COUNT(*) is allowed");
|
||||
}
|
||||
if (countStar) {
|
||||
return;
|
||||
}
|
||||
super.visit(function);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(JdbcParameter parameter) {
|
||||
// Counted separately by the parser walk below.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(StringValue value) {
|
||||
throw new MysqlSecurityException("literal values must use parameters");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(LongValue value) {
|
||||
throw new MysqlSecurityException("literal values must use parameters");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(DoubleValue value) {
|
||||
throw new MysqlSecurityException("literal values must use parameters");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(HexValue value) {
|
||||
throw new MysqlSecurityException("literal values must use parameters");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(DateValue value) {
|
||||
throw new MysqlSecurityException("literal values must use parameters");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(TimeValue value) {
|
||||
throw new MysqlSecurityException("literal values must use parameters");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(TimestampValue value) {
|
||||
throw new MysqlSecurityException("literal values must use parameters");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(SubSelect subSelect) {
|
||||
throw new MysqlSecurityException("subqueries are not allowed");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(AllColumns allColumns) {
|
||||
throw new MysqlSecurityException("wildcard projection is not allowed");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(AllTableColumns allTableColumns) {
|
||||
throw new MysqlSecurityException("wildcard projection is not allowed");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(AnalyticExpression analyticExpression) {
|
||||
throw new MysqlSecurityException("window functions are not allowed");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(CaseExpression caseExpression) {
|
||||
throw new MysqlSecurityException("CASE expressions are not allowed");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(ExistsExpression existsExpression) {
|
||||
throw new MysqlSecurityException("EXISTS is not allowed");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(OracleHierarchicalExpression expression) {
|
||||
throw new MysqlSecurityException("hierarchical expressions are not allowed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void validateColumn(Column column, Map<String, TableRef> tables,
|
||||
MysqlDataSourceDefinition dataSource) {
|
||||
String name = column.getColumnName();
|
||||
if (name == null || name.isBlank() || "*".equals(name)) {
|
||||
throw new MysqlSecurityException("invalid column");
|
||||
}
|
||||
Table table = column.getTable();
|
||||
if (table != null && table.getName() != null && !table.getName().isBlank()) {
|
||||
TableRef ref = tables.get(table.getName().toLowerCase(Locale.ROOT));
|
||||
if (ref == null || !dataSource.allowsColumn(ref.schema(), ref.table(), name)) {
|
||||
throw new MysqlSecurityException("column is not allowlisted");
|
||||
}
|
||||
return;
|
||||
}
|
||||
List<TableRef> matches = tables.values().stream()
|
||||
.filter(ref -> dataSource.allowsColumn(ref.schema(), ref.table(), name))
|
||||
.toList();
|
||||
if (matches.size() != 1) {
|
||||
throw new MysqlSecurityException("unqualified column is ambiguous or not allowlisted");
|
||||
}
|
||||
}
|
||||
|
||||
private static int countPlaceholders(PlainSelect plainSelect) {
|
||||
// Parser assigns JdbcParameter nodes; use the canonical SQL token count only after
|
||||
// the AST has been accepted, so quoted question marks are not counted.
|
||||
PlaceholderCounter counter = new PlaceholderCounter();
|
||||
List<Expression> expressions = new ArrayList<>();
|
||||
for (var item : plainSelect.getSelectItems()) {
|
||||
if (item instanceof SelectExpressionItem expressionItem) {
|
||||
expressions.add(expressionItem.getExpression());
|
||||
}
|
||||
}
|
||||
expressions.add(plainSelect.getWhere());
|
||||
expressions.add(plainSelect.getHaving());
|
||||
if (plainSelect.getGroupBy() != null) {
|
||||
expressions.addAll(plainSelect.getGroupBy().getGroupByExpressions());
|
||||
}
|
||||
if (plainSelect.getOrderByElements() != null) {
|
||||
plainSelect.getOrderByElements().forEach(order -> expressions.add(order.getExpression()));
|
||||
}
|
||||
if (plainSelect.getJoins() != null) {
|
||||
plainSelect.getJoins().forEach(join -> {
|
||||
if (join.getOnExpressions() != null) {
|
||||
expressions.addAll(join.getOnExpressions());
|
||||
}
|
||||
});
|
||||
}
|
||||
for (Expression expression : expressions) {
|
||||
if (expression != null) {
|
||||
expression.accept(counter);
|
||||
}
|
||||
}
|
||||
return counter.count;
|
||||
}
|
||||
|
||||
private static final class PlaceholderCounter extends ExpressionVisitorAdapter {
|
||||
private int count;
|
||||
|
||||
@Override
|
||||
public void visit(JdbcParameter parameter) {
|
||||
count++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(SubSelect subSelect) {
|
||||
throw new MysqlSecurityException("subqueries are not allowed");
|
||||
}
|
||||
}
|
||||
|
||||
private record TableRef(String schema, String table) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
public record MysqlToolLimits(
|
||||
int maxRows,
|
||||
int maxCellChars,
|
||||
int maxResultBytes,
|
||||
int queryTimeoutSeconds) {
|
||||
|
||||
public MysqlToolLimits {
|
||||
if (maxRows <= 0 || maxCellChars <= 0 || maxResultBytes <= 0 || queryTimeoutSeconds <= 0) {
|
||||
throw new IllegalArgumentException("MySQL limits must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public static MysqlToolLimits defaults() {
|
||||
return new MysqlToolLimits(100, 2_000, 64 * 1024, 5);
|
||||
}
|
||||
}
|
||||
@@ -200,3 +200,9 @@ logging:
|
||||
max-file-size: 10MB # 单个日志文件最大 10MB
|
||||
max-history: 30 # 保留 30 天
|
||||
total-size-cap: 1GB # 所有日志文件总大小上限 1GB
|
||||
|
||||
# Agent-facing MySQL Tool uses independent logical datasources only.
|
||||
# Production entries are supplied by a dedicated profile and Secret injection.
|
||||
harness:
|
||||
mysql-tools:
|
||||
data-sources: {}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.superbiz.agent.harness.tool.adapter;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.harness.contract.EvidenceStatus;
|
||||
import com.superbiz.agent.harness.core.HarnessCoreFixtures;
|
||||
import com.superbiz.agent.harness.core.MutableClock;
|
||||
import com.superbiz.agent.harness.core.RunContext;
|
||||
import com.superbiz.agent.harness.tool.boundary.ToolBoundary;
|
||||
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryErrorCode;
|
||||
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryResult;
|
||||
import com.superbiz.agent.harness.tool.boundary.ToolCallRequestEnvelope;
|
||||
import com.superbiz.agent.harness.tool.mysql.MysqlDataSourceDefinition;
|
||||
import com.superbiz.agent.harness.tool.mysql.MysqlRawResult;
|
||||
import com.superbiz.agent.harness.tool.mysql.MysqlResultProjector;
|
||||
import com.superbiz.agent.harness.tool.mysql.MysqlSqlValidator;
|
||||
import com.superbiz.agent.harness.tool.mysql.MysqlToolLimits;
|
||||
import com.superbiz.agent.harness.tool.store.CanonicalInvocationLimits;
|
||||
import com.superbiz.agent.harness.tool.store.CanonicalInvocationStore;
|
||||
import com.superbiz.agent.harness.tool.store.CanonicalToolInvocation;
|
||||
import com.superbiz.agent.harness.tool.store.DuplicateInvocationException;
|
||||
import com.superbiz.agent.harness.tool.store.ToolCallKeyFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MysqlToolAdapterTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void reusesBoundaryAndRejectsInvalidSqlBeforeExecution() {
|
||||
MutableClock clock = new MutableClock(Instant.parse("2026-07-21T10:00:00Z"));
|
||||
FakeStore store = new FakeStore();
|
||||
ToolBoundary boundary = new ToolBoundary(HarnessCoreFixtures.core(clock),
|
||||
new ToolCallKeyFactory("superbiz:harness:tool-call"), store, objectMapper, clock);
|
||||
MysqlDataSourceDefinition definition = new MysqlDataSourceDefinition(
|
||||
"order_readonly", "order_db",
|
||||
Map.of("order_db", Map.of("biz_order", Set.of("order_id", "password"))),
|
||||
MysqlToolLimits.defaults());
|
||||
AtomicInteger executions = new AtomicInteger();
|
||||
MysqlToolAdapter adapter = new MysqlToolAdapter(boundary, objectMapper,
|
||||
new MysqlSqlValidator(Map.of("order_readonly", definition)),
|
||||
(plan, context) -> {
|
||||
executions.incrementAndGet();
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("order_id", "order-1");
|
||||
row.put("password", "raw-secret");
|
||||
return new MysqlRawResult(List.of("order_id", "password"), List.of(row), false);
|
||||
}, new MysqlResultProjector(objectMapper));
|
||||
RunContext context = HarnessCoreFixtures.core(clock).startRun("session", "run-mysql");
|
||||
|
||||
ToolBoundaryResult valid = adapter.execute(context, envelope("run-mysql", "framework-mysql-1",
|
||||
"{\"data_source\":\"order_readonly\",\"sql\":\"SELECT order_id, password FROM order_db.biz_order WHERE order_id = ?\",\"params\":[\"order-1\"]}"));
|
||||
ToolBoundaryResult invalid = adapter.execute(context, envelope("run-mysql", "framework-mysql-2",
|
||||
"{\"data_source\":\"order_readonly\",\"sql\":\"SELECT * FROM order_db.biz_order\",\"params\":[]}"));
|
||||
|
||||
assertEquals(EvidenceStatus.EVIDENCE_FOUND, valid.evidenceStatus());
|
||||
assertEquals("framework-mysql-1", valid.toolCallId());
|
||||
assertFalse(valid.agentResult().contains("raw-secret"));
|
||||
assertTrue(store.records.values().iterator().next().rawResponse().contains("raw-secret"));
|
||||
assertEquals(ToolBoundaryErrorCode.INVALID_REQUEST.name(), invalid.errorCode());
|
||||
assertEquals(1, executions.get());
|
||||
}
|
||||
|
||||
private ToolCallRequestEnvelope envelope(String runId, String callId, String requestJson) {
|
||||
return new ToolCallRequestEnvelope(runId, callId, "query_mysql", requestJson, true, true);
|
||||
}
|
||||
|
||||
private static final class FakeStore implements CanonicalInvocationStore {
|
||||
private final CanonicalInvocationLimits limits =
|
||||
new CanonicalInvocationLimits(Duration.ofHours(1), 100_000, 64_000);
|
||||
private final Map<String, CanonicalToolInvocation> records = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public CanonicalInvocationLimits limits() {
|
||||
return limits;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void begin(String key, CanonicalToolInvocation invocation) {
|
||||
if (records.putIfAbsent(key, invocation) != null) {
|
||||
throw new DuplicateInvocationException();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<CanonicalToolInvocation> find(String key) {
|
||||
return Optional.ofNullable(records.get(key));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CanonicalToolInvocation markReady(String key, String rawResponse, String agentResult,
|
||||
EvidenceStatus evidenceStatus, Instant completedAt) {
|
||||
CanonicalToolInvocation updated = records.get(key)
|
||||
.markReady(rawResponse, agentResult, evidenceStatus, completedAt);
|
||||
records.put(key, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CanonicalToolInvocation markError(String key, String rawResponse,
|
||||
String errorCode, Instant completedAt) {
|
||||
CanonicalToolInvocation updated = records.get(key).markError(rawResponse, errorCode, completedAt);
|
||||
records.put(key, updated);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
import com.superbiz.agent.harness.core.HarnessCoreFixtures;
|
||||
import com.superbiz.agent.harness.core.MutableClock;
|
||||
import com.superbiz.agent.harness.core.RunCancellationReason;
|
||||
import com.superbiz.agent.harness.core.RunContext;
|
||||
import com.superbiz.agent.harness.tool.contract.MysqlToolRequest;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class JdbcMysqlReadOnlyExecutorTest {
|
||||
|
||||
@Test
|
||||
void executesBoundPreparedSelectAndTruncatesRows() throws Exception {
|
||||
JdbcDataSource dataSource = dataSource();
|
||||
MysqlDataSourceDefinition definition = definition(new MysqlToolLimits(1, 20, 1024, 2));
|
||||
MysqlSqlValidator validator = new MysqlSqlValidator(Map.of("order_readonly", definition));
|
||||
MysqlQueryPlan plan = validator.validate(new MysqlToolRequest(
|
||||
"order_readonly", "SELECT order_id, status FROM biz_order WHERE status = ? ORDER BY order_id",
|
||||
List.of("FAILED")));
|
||||
MutableClock clock = new MutableClock(Instant.parse("2026-07-21T10:00:00Z"));
|
||||
RunContext context = HarnessCoreFixtures.core(clock).startRun("session", "run-mysql");
|
||||
|
||||
MysqlRawResult result = new JdbcMysqlReadOnlyExecutor(Map.of("order_readonly", dataSource), clock)
|
||||
.execute(plan, context);
|
||||
|
||||
assertEquals(List.of("ORDER_ID", "STATUS"), result.columns());
|
||||
assertEquals(1, result.rows().size());
|
||||
assertTrue(result.truncated());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesExecutionAfterRunCancellation() throws Exception {
|
||||
JdbcDataSource dataSource = dataSource();
|
||||
MysqlDataSourceDefinition definition = definition(MysqlToolLimits.defaults());
|
||||
MysqlSqlValidator validator = new MysqlSqlValidator(Map.of("order_readonly", definition));
|
||||
MysqlQueryPlan plan = validator.validate(new MysqlToolRequest(
|
||||
"order_readonly", "SELECT order_id FROM biz_order", List.of()));
|
||||
MutableClock clock = new MutableClock(Instant.parse("2026-07-21T10:00:00Z"));
|
||||
RunContext context = HarnessCoreFixtures.core(clock).startRun("session", "run-cancelled");
|
||||
context.cancellation().cancel(RunCancellationReason.USER_REQUESTED);
|
||||
|
||||
assertThrows(SQLException.class,
|
||||
() -> new JdbcMysqlReadOnlyExecutor(Map.of("order_readonly", dataSource), clock)
|
||||
.execute(plan, context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesReadOnlyTimeoutMaxRowsAndParameterBinding() throws Exception {
|
||||
javax.sql.DataSource dataSource = mock(javax.sql.DataSource.class);
|
||||
Connection connection = mock(Connection.class);
|
||||
PreparedStatement statement = mock(PreparedStatement.class);
|
||||
ResultSet resultSet = mock(ResultSet.class);
|
||||
ResultSetMetaData metadata = mock(ResultSetMetaData.class);
|
||||
when(dataSource.getConnection()).thenReturn(connection);
|
||||
when(connection.prepareStatement(anyString(), eq(ResultSet.TYPE_FORWARD_ONLY), eq(ResultSet.CONCUR_READ_ONLY)))
|
||||
.thenReturn(statement);
|
||||
when(statement.executeQuery()).thenReturn(resultSet);
|
||||
when(resultSet.getMetaData()).thenReturn(metadata);
|
||||
when(metadata.getColumnCount()).thenReturn(1);
|
||||
when(metadata.getColumnLabel(1)).thenReturn("order_id");
|
||||
when(resultSet.next()).thenReturn(false);
|
||||
MysqlDataSourceDefinition definition = definition(new MysqlToolLimits(7, 20, 1024, 3));
|
||||
MysqlQueryPlan plan = new MysqlSqlValidator(Map.of("order_readonly", definition))
|
||||
.validate(new MysqlToolRequest("order_readonly",
|
||||
"SELECT order_id FROM biz_order WHERE status = ?", List.of("FAILED")));
|
||||
MutableClock clock = new MutableClock(Instant.parse("2026-07-21T10:00:00Z"));
|
||||
RunContext context = HarnessCoreFixtures.core(clock).startRun("session", "run-controls");
|
||||
|
||||
new JdbcMysqlReadOnlyExecutor(Map.of("order_readonly", dataSource), clock).execute(plan, context);
|
||||
|
||||
verify(connection).setReadOnly(true);
|
||||
verify(statement).setQueryTimeout(3);
|
||||
verify(statement).setMaxRows(8);
|
||||
verify(statement).setObject(1, "FAILED");
|
||||
}
|
||||
|
||||
private JdbcDataSource dataSource() throws Exception {
|
||||
JdbcDataSource dataSource = new JdbcDataSource();
|
||||
dataSource.setURL("jdbc:h2:mem:mysqltool;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var connection = dataSource.getConnection(); var statement = connection.createStatement()) {
|
||||
statement.execute("DROP TABLE IF EXISTS biz_order");
|
||||
statement.execute("CREATE TABLE biz_order(order_id VARCHAR(32), status VARCHAR(20))");
|
||||
statement.execute("INSERT INTO biz_order VALUES ('order-1','FAILED'),('order-2','FAILED'),('order-3','OK')");
|
||||
}
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
private MysqlDataSourceDefinition definition(MysqlToolLimits limits) {
|
||||
return new MysqlDataSourceDefinition("order_readonly", "PUBLIC",
|
||||
Map.of("PUBLIC", Map.of("biz_order", Set.of("order_id", "status"))), limits);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.harness.contract.EvidenceStatus;
|
||||
import com.superbiz.agent.harness.tool.boundary.ProjectedToolResult;
|
||||
import com.superbiz.agent.harness.tool.contract.MysqlToolRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MysqlResultProjectorTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void projectsRowsRedactsSensitiveColumnsAndBoundsValues() throws Exception {
|
||||
MysqlResultProjector projector = new MysqlResultProjector(objectMapper,
|
||||
new MysqlToolLimits(1, 5, 1024, 5));
|
||||
String raw = "{\"columns\":[\"order_id\",\"password\",\"status\"],\"rows\":["
|
||||
+ "{\"order_id\":\"order-123456\",\"password\":\"secret\",\"status\":\"FAILED\"},"
|
||||
+ "{\"order_id\":\"order-2\",\"password\":\"secret2\",\"status\":\"OK\"}],\"truncated\":false}";
|
||||
|
||||
ProjectedToolResult projected = projector.project(
|
||||
new MysqlToolRequest("order_readonly", "SELECT order_id FROM biz_order", List.of()),
|
||||
"framework-mysql-1", raw);
|
||||
JsonNode json = objectMapper.readTree(projected.agentResult());
|
||||
|
||||
assertEquals(EvidenceStatus.EVIDENCE_FOUND, projected.evidenceStatus());
|
||||
assertEquals(1, json.path("returned_count").asInt());
|
||||
assertTrue(json.path("truncated").asBoolean());
|
||||
assertEquals("[REDACTED]", json.path("rows").get(0).path("password").asText());
|
||||
assertEquals("order", json.path("rows").get(0).path("order_id").asText());
|
||||
assertFalse(projected.agentResult().contains("secret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNoEvidenceForEmptyRows() throws Exception {
|
||||
MysqlResultProjector projector = new MysqlResultProjector(objectMapper);
|
||||
ProjectedToolResult projected = projector.project(
|
||||
new MysqlToolRequest("order_readonly", "SELECT order_id FROM biz_order", List.of()),
|
||||
"framework-mysql-2", "{\"columns\":[\"order_id\"],\"rows\":[],\"truncated\":false}");
|
||||
|
||||
assertEquals(EvidenceStatus.NO_EVIDENCE, projected.evidenceStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void enforcesTotalUtf8BudgetWithValidJson() throws Exception {
|
||||
MysqlResultProjector projector = new MysqlResultProjector(objectMapper,
|
||||
new MysqlToolLimits(10, 100, 230, 5));
|
||||
String raw = "{\"columns\":[\"order_id\"],\"rows\":["
|
||||
+ "{\"order_id\":\"" + "a".repeat(80) + "\"},"
|
||||
+ "{\"order_id\":\"" + "b".repeat(80) + "\"}],\"truncated\":false}";
|
||||
|
||||
ProjectedToolResult projected = projector.project(
|
||||
new MysqlToolRequest("order_readonly", "SELECT order_id FROM biz_order", List.of()),
|
||||
"framework-mysql-3", raw);
|
||||
JsonNode json = objectMapper.readTree(projected.agentResult());
|
||||
|
||||
assertTrue(json.path("truncated").asBoolean());
|
||||
assertTrue(projected.agentResult().getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= 230);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
import com.superbiz.agent.harness.tool.contract.MysqlToolRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class MysqlSqlValidatorTest {
|
||||
|
||||
private final MysqlSqlValidator validator = new MysqlSqlValidator(Map.of(
|
||||
"order_readonly", new MysqlDataSourceDefinition(
|
||||
"order_readonly", "order_db",
|
||||
Map.of("order_db", Map.of(
|
||||
"biz_order", Set.of("order_id", "status", "updated_at"),
|
||||
"payment_record", Set.of("order_id", "status"))),
|
||||
MysqlToolLimits.defaults())));
|
||||
|
||||
@Test
|
||||
void acceptsExplicitSelectAndCountStar() {
|
||||
assertDoesNotThrow(() -> validator.validate(new MysqlToolRequest(
|
||||
"order_readonly",
|
||||
"SELECT o.order_id, o.status FROM order_db.biz_order o WHERE o.order_id = ?",
|
||||
List.of("order-1"))));
|
||||
assertDoesNotThrow(() -> validator.validate(new MysqlToolRequest(
|
||||
"order_readonly",
|
||||
"SELECT COUNT(*) AS total FROM order_db.biz_order",
|
||||
List.of())));
|
||||
assertDoesNotThrow(() -> validator.validate(new MysqlToolRequest(
|
||||
"order_readonly",
|
||||
"SELECT o.order_id, p.status FROM order_db.biz_order o INNER JOIN order_db.payment_record p ON o.order_id = p.order_id WHERE o.status = ?",
|
||||
List.of("FAILED"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWritesCompoundQueriesWildcardsAndAllowlistBypass() {
|
||||
List<String> unsafe = List.of(
|
||||
"UPDATE order_db.biz_order SET status = ?",
|
||||
"WITH x AS (SELECT order_id FROM order_db.biz_order) SELECT order_id FROM x",
|
||||
"SELECT * FROM order_db.biz_order",
|
||||
"SELECT order_id FROM order_db.biz_order WHERE order_id IN (SELECT order_id FROM order_db.payment_record)",
|
||||
"SELECT order_id FROM order_db.biz_order UNION SELECT order_id FROM order_db.payment_record",
|
||||
"SELECT order_id FROM order_db.biz_order WHERE secret = ?",
|
||||
"SELECT order_id FROM order_db.unknown_table",
|
||||
"SELECT SLEEP(?) FROM order_db.biz_order",
|
||||
"SELECT order_id FROM order_db.biz_order WHERE status = 'FAILED'",
|
||||
"SELECT CASE WHEN status = ? THEN order_id END FROM order_db.biz_order",
|
||||
"SELECT order_id FROM order_db.biz_order FOR UPDATE",
|
||||
"SELECT order_id FROM order_db.biz_order; SELECT status FROM order_db.biz_order");
|
||||
|
||||
for (String sql : unsafe) {
|
||||
assertThrows(MysqlSecurityException.class,
|
||||
() -> validator.validate(new MysqlToolRequest("order_readonly", sql, List.of("x"))), sql);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPlaceholderMismatchAndAmbiguousColumns() {
|
||||
assertThrows(MysqlSecurityException.class, () -> validator.validate(new MysqlToolRequest(
|
||||
"order_readonly", "SELECT order_id FROM order_db.biz_order WHERE order_id = ?", List.of())));
|
||||
assertThrows(MysqlSecurityException.class, () -> validator.validate(new MysqlToolRequest(
|
||||
"order_readonly", "SELECT order_id FROM order_db.biz_order o INNER JOIN order_db.payment_record p ON o.order_id = p.order_id", List.of())));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user