-
Notifications
You must be signed in to change notification settings - Fork 1
feat(execute_sql): Executor seam — guarded envelope + swappable port (AH-012) #116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
e077f64
5b15f9c
5ce5bbf
fd3163f
3f9dce5
0e55b66
817de66
89f8c55
eb83acc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -871,12 +871,139 @@ def _executor_truncated(stderr: str | None) -> bool: | |
| return False | ||
|
|
||
|
|
||
| # The composition-root executor (AH-012). ``None`` (the default) means "fork the execute_sql | ||
| # subprocess" — the byte-identical local/single-user path. A consumer injects a ``ports.Executor`` | ||
| # via ``create_app(adapters=…)`` to run execution IN-PROCESS behind the same guard (no fork, native | ||
| # rows). Process-global on purpose: the executor is a composition-root singleton, not per-request. | ||
| _INJECTED_EXECUTOR: Any | None = None | ||
|
|
||
|
|
||
| def set_injected_executor(executor: Any | None) -> None: | ||
| """Register (or clear) the composition-root executor. Called once by ``mcp_http.create_app`` from | ||
| ``adapters.executor``; ``None`` keeps the default subprocess path. Validates the shape at | ||
| registration so a malformed adapter fails fast at app construction, not as an ``AttributeError`` | ||
| at query time.""" | ||
| global _INJECTED_EXECUTOR | ||
| if executor is not None: | ||
| import ports | ||
|
|
||
| if not isinstance(executor, ports.Executor): # runtime_checkable: has execute(...) | ||
| raise TypeError( | ||
| "injected executor must satisfy ports.Executor " | ||
| "(an execute(vetted_sql, creds, *, profile) method)" | ||
| ) | ||
| _INJECTED_EXECUTOR = executor | ||
|
|
||
|
|
||
| def _finalize_execution( | ||
| columns: list, data_rows: list, truncated: bool, *, profile: str, sql: str, | ||
| execution_ms: int, args: dict[str, Any], | ||
| ) -> str: | ||
| """Shape a successful result (units + exact-render markdown + trust receipt), log the execution | ||
| through the single sink, and return the tool JSON. Shared by both execution paths — the subprocess | ||
| fork and the in-process executor — so a query returns the identical envelope whichever ran it.""" | ||
| # Deterministic, exact rendering — so the numbers a user verifies don't depend on | ||
| # how the host LLM chooses to format them. `markdown` is the table to display | ||
| # verbatim; `rows` stays raw (exact CSV values) for charting / programmatic use. | ||
| unit_map = _resolve_units(profile, sql) | ||
| try: | ||
| from semantic_model import units # stdlib-only; safe even without model deps | ||
|
|
||
| markdown = units.format_table(columns, data_rows, unit_map) | ||
| except Exception: | ||
| markdown = None | ||
|
|
||
| result = { | ||
| "columns": columns, | ||
| "rows": data_rows, | ||
| "row_count": len(data_rows), | ||
| "truncated": truncated, | ||
| "units": unit_map, | ||
| "markdown": markdown, # exact, full numbers (currency symbol + grouping) — render as-is | ||
| "sql": sql, | ||
| "execution_ms": execution_ms, | ||
| # Trust receipt — provenance + anything unapproved this answer used. Same assembler | ||
| # the agami-query skill renders, so Desktop gets the same trust panel. Clients should | ||
| # surface receipt.warnings and any receipt.metrics whose review_state != "approved" | ||
| # (offer to approve/correct via the save_correction tool). | ||
| "receipt": _resolve_receipt(profile, sql), | ||
| } | ||
|
|
||
| # Log the execution through the single chokepoint: the DB sink when AGAMI_DB_URL is set (one | ||
| # query_executions row), else the local jsonl the skills use. Best-effort either way. | ||
| _record_query( | ||
| { | ||
| "ts": _now_iso(), | ||
| "profile": profile, | ||
| "question": args.get("raw_query"), | ||
| "sql": sql, | ||
| "row_count": len(data_rows), | ||
| "source": "mcp_server", | ||
| } | ||
| ) | ||
| return json.dumps(result, indent=2, default=str) | ||
|
|
||
|
|
||
| def _run_in_process( | ||
| sql: str, profile: str, area: str | None, max_rows: int | None, executor: Any | ||
| ) -> tuple[list, list, bool] | dict: | ||
| """Run through the in-process executor behind the shared guarded envelope (no subprocess, no CSV | ||
| round-trip). Returns ``(columns, data_rows, truncated)`` on success, or an error dict on a guard | ||
| refusal / execution failure — the same error shape the subprocess branch produces. | ||
|
|
||
| Rows are textualized to match the subprocess CSV wire (``None`` → ``""``, else ``str``) so the | ||
| two paths return observably identical JSON. Native-typed rows are a deliberately deferred decision | ||
| (see the AH-012 spec); flipping this one coercion is the follow-up once that's settled.""" | ||
| import execute_sql | ||
|
|
||
| # The per-call cap rides execute_sql's module global (ACE-044). ACE-028, which makes in-process | ||
| # the real serving path, must thread the cap per-call instead — this global is not safe under | ||
| # concurrent in-process queries with different caps. Save/restore keeps it inert by default. | ||
| prev_cap = execute_sql._max_rows_override | ||
| execute_sql._max_rows_override = max_rows | ||
| try: | ||
| result = execute_sql.execute_guarded(sql, profile, area, executor=executor) | ||
| except execute_sql.GuardRefused as refusal: | ||
| # A read-only refusal (envelope present) is already caught by tool_execute_sql's upstream | ||
| # check_read_only fast-fail, so in practice only the model-safety branch (envelope None) is | ||
| # reached here; both are handled for defence-in-depth. | ||
| if refusal.envelope is not None: | ||
| return {"error": refusal.envelope["error"]} | ||
| # A model-safety refusal wrote its detail to the server log (stderr); surface a clean refusal. | ||
| return {"error": {"kind": "permission", | ||
| "remediation": "Query refused by the semantic-model safety pass."}} | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct — and I've fixed the overclaim in Successful results are identical across paths (the valuable guarantee). The divergence is only for guard refusals, and it's structural: True structured-refusal parity requires
That's a change to the security guard's error-reporting contract, separable from the executor seam. Folding it into this PR would expand a security-sensitive, byte-identical-by-design change into the guard internals. So I've corrected the docstrings to scope the "identical envelope" claim to successful results, clarified the in-process remediation points to the server log, and I'm tracking structured-refusal parity as a follow-up (natural fit with the |
||
| except execute_sql.ExecutorError as exc: | ||
| return {"error": {"kind": _classify_exit(exc.code), "remediation": exc.msg}} | ||
|
Comment on lines
+969
to
+984
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 817de66 with the fail-closed net you suggested: The deeper refactor (convert those helpers' |
||
| except SystemExit as exc: | ||
| # Defence-in-depth. The known credential/DSN failures now raise ExecutorError (handled above, | ||
| # carrying their detailed message), so this net catches only a residual/future sys.exit deep | ||
| # in a driver — ensuring an in-process query can never take down the host; it becomes a | ||
| # fail-closed tool error instead. | ||
| code = exc.code if isinstance(exc.code, int) else 2 | ||
| return {"error": {"kind": _classify_exit(code), | ||
| "remediation": "Datasource configuration error."}} | ||
| finally: | ||
| execute_sql._max_rows_override = prev_cap | ||
|
|
||
|
Comment on lines
+969
to
+995
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — fixed properly in 89f8c55, and I went a different way than stderr-capture on purpose. Capturing stderr around
Ripple handled: |
||
| columns = list(result.columns) | ||
| data_rows = [["" if v is None else str(v) for v in row] for row in result.rows] | ||
| truncated = result.truncated | ||
| if max_rows is not None and len(data_rows) > max_rows: # backstop, matches the subprocess branch | ||
| data_rows = data_rows[:max_rows] | ||
| truncated = True | ||
| return columns, data_rows, truncated | ||
|
|
||
|
|
||
| def tool_execute_sql(args: dict[str, Any]) -> str: | ||
| """Local analog of Ask Agami `execute_sql`: run a read-only SELECT locally. | ||
|
|
||
| Routes through the sibling execute_sql.py (Tier-3 Python executor) so all | ||
| DB types are handled identically and nothing but the rows leaves the | ||
| process. Enforces the same read-only guarantee as the hosted connector. | ||
|
|
||
| Two execution paths behind the same guard: the default forks the execute_sql subprocess | ||
| (isolation, byte-identical local/single-user); an injected executor (AH-012) runs in-process with | ||
| native rows. Both funnel through `_finalize_execution` so the returned envelope is identical. | ||
| """ | ||
| sql = args.get("sql") | ||
| if not isinstance(sql, str) or not sql.strip(): | ||
|
|
@@ -903,6 +1030,23 @@ def tool_execute_sql(args: dict[str, Any]) -> str: | |
| if max_rows is not None: | ||
| max_rows = max(1, min(max_rows, 10_000)) | ||
|
|
||
| area = str(args["area"]) if args.get("area") else None | ||
|
|
||
| # In-process path (AH-012): a consumer injected an executor, so run behind the shared guarded | ||
| # envelope with no subprocess and no CSV round-trip. Falls through to the subprocess fork below | ||
| # when no executor is injected (the default) — that path stays byte-identical. | ||
| if _INJECTED_EXECUTOR is not None: | ||
| started = time.monotonic() | ||
| outcome = _run_in_process(sql, profile, area, max_rows, _INJECTED_EXECUTOR) | ||
| execution_ms = int((time.monotonic() - started) * 1000) | ||
| if isinstance(outcome, dict): # guard refusal / execution error | ||
| return json.dumps({**outcome, "sql": sql, "execution_ms": execution_ms}, indent=2) | ||
| columns, data_rows, truncated = outcome | ||
| return _finalize_execution( | ||
| columns, data_rows, truncated, | ||
| profile=profile, sql=sql, execution_ms=execution_ms, args=args, | ||
| ) | ||
|
|
||
| # The model safety pass (fan/chasm pre-flight + default_filters) runs inside | ||
| # execute_sql.py; pass the subject area so default_filters scope correctly. | ||
| # Route through the unified executor as a module (the package is installed alongside | ||
|
|
@@ -954,46 +1098,10 @@ def tool_execute_sql(args: dict[str, Any]) -> str: | |
| data_rows = data_rows[:max_rows] | ||
| truncated = True | ||
|
|
||
| # Deterministic, exact rendering — so the numbers a user verifies don't depend on | ||
| # how the host LLM chooses to format them. `markdown` is the table to display | ||
| # verbatim; `rows` stays raw (exact CSV values) for charting / programmatic use. | ||
| unit_map = _resolve_units(profile, sql) | ||
| try: | ||
| from semantic_model import units # stdlib-only; safe even without model deps | ||
|
|
||
| markdown = units.format_table(columns, data_rows, unit_map) | ||
| except Exception: | ||
| markdown = None | ||
|
|
||
| result = { | ||
| "columns": columns, | ||
| "rows": data_rows, | ||
| "row_count": len(data_rows), | ||
| "truncated": truncated, | ||
| "units": unit_map, | ||
| "markdown": markdown, # exact, full numbers (currency symbol + grouping) — render as-is | ||
| "sql": sql, | ||
| "execution_ms": execution_ms, | ||
| # Trust receipt — provenance + anything unapproved this answer used. Same assembler | ||
| # the agami-query skill renders, so Desktop gets the same trust panel. Clients should | ||
| # surface receipt.warnings and any receipt.metrics whose review_state != "approved" | ||
| # (offer to approve/correct via the save_correction tool). | ||
| "receipt": _resolve_receipt(profile, sql), | ||
| } | ||
|
|
||
| # Log the execution through the single chokepoint: the DB sink when AGAMI_DB_URL is set (one | ||
| # query_executions row), else the local jsonl the skills use. Best-effort either way. | ||
| _record_query( | ||
| { | ||
| "ts": _now_iso(), | ||
| "profile": profile, | ||
| "question": args.get("raw_query"), | ||
| "sql": sql, | ||
| "row_count": len(data_rows), | ||
| "source": "mcp_server", | ||
| } | ||
| return _finalize_execution( | ||
| columns, data_rows, truncated, | ||
| profile=profile, sql=sql, execution_ms=execution_ms, args=args, | ||
| ) | ||
| return json.dumps(result, indent=2, default=str) | ||
|
|
||
|
|
||
| def _now_iso() -> str: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in 817de66 —
set_injected_executornowisinstance-checks againstports.Executor(runtime_checkable) at registration, so a malformed adapter fails fast atcreate_appconstruction instead of anAttributeErrorat query time.