hexgate 0.2.6__tar.gz → 0.2.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. {hexgate-0.2.6 → hexgate-0.2.7}/PKG-INFO +2 -2
  2. {hexgate-0.2.6 → hexgate-0.2.7}/README.md +1 -1
  3. hexgate-0.2.7/hexgate/adapters/google/mcp.py +95 -0
  4. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/google/runner.py +3 -1
  5. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/google/tools.py +27 -3
  6. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/google/wrapper.py +13 -6
  7. hexgate-0.2.7/hexgate/adapters/langchain/mcp.py +47 -0
  8. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/langchain/tools.py +7 -25
  9. hexgate-0.2.7/hexgate/adapters/openai/mcp.py +97 -0
  10. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/openai/runner.py +23 -4
  11. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/openai/tools.py +28 -3
  12. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/openai/wrapper.py +12 -3
  13. hexgate-0.2.7/hexgate/adapters/pydantic_ai/mcp.py +60 -0
  14. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/pydantic_ai/tools.py +27 -3
  15. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/pydantic_ai/wrapper.py +11 -5
  16. hexgate-0.2.7/hexgate/agents/approvals.py +53 -0
  17. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/google.py +63 -10
  18. hexgate-0.2.7/hexgate/cli/serve.py +692 -0
  19. hexgate-0.2.7/hexgate/mcp/__init__.py +61 -0
  20. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/mcp/proxy.py +162 -52
  21. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate.egg-info/PKG-INFO +2 -2
  22. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate.egg-info/SOURCES.txt +5 -0
  23. {hexgate-0.2.6 → hexgate-0.2.7}/pyproject.toml +1 -1
  24. hexgate-0.2.6/hexgate/cli/serve.py +0 -337
  25. hexgate-0.2.6/hexgate/mcp/__init__.py +0 -47
  26. {hexgate-0.2.6 → hexgate-0.2.7}/LICENSE +0 -0
  27. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/__init__.py +0 -0
  28. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/__init__.py +0 -0
  29. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/google/__init__.py +0 -0
  30. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/langchain/__init__.py +0 -0
  31. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/langchain/agent.py +0 -0
  32. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/langchain/wrapper.py +0 -0
  33. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/openai/__init__.py +0 -0
  34. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/pydantic_ai/__init__.py +0 -0
  35. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/pydantic_ai/agent.py +0 -0
  36. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/__init__.py +0 -0
  37. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/builtin/__init__.py +0 -0
  38. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/builtin/researcher/agent.yaml +0 -0
  39. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/builtin/researcher/policy.yaml +0 -0
  40. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/builtin/researcher/system.md +0 -0
  41. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/factory.py +0 -0
  42. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/loader.py +0 -0
  43. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/models.py +0 -0
  44. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/prompts/agent_system.md +0 -0
  45. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/audit.py +0 -0
  46. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/bootstrap.py +0 -0
  47. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/__init__.py +0 -0
  48. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/_common.py +0 -0
  49. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/chat.py +0 -0
  50. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/policy/__init__.py +0 -0
  51. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/policy/main.py +0 -0
  52. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/__init__.py +0 -0
  53. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/hexgate.py +0 -0
  54. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/langchain.py +0 -0
  55. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/main.py +0 -0
  56. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/manifest.py +0 -0
  57. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/models.py +0 -0
  58. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/openai.py +0 -0
  59. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/pydantic_ai.py +0 -0
  60. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/register.py +0 -0
  61. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/state.py +0 -0
  62. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cloud/__init__.py +0 -0
  63. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cloud/attenuate.py +0 -0
  64. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cloud/biscuit.py +0 -0
  65. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cloud/client.py +0 -0
  66. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/config/__init__.py +0 -0
  67. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/config/env.py +0 -0
  68. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/config/settings.py +0 -0
  69. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/mcp/client.py +0 -0
  70. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/mcp/config.py +0 -0
  71. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/runtime/__init__.py +0 -0
  72. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/runtime/command_policy.py +0 -0
  73. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/runtime/context.py +0 -0
  74. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/runtime/sandbox_runtime.py +0 -0
  75. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/runtime/srt.py +0 -0
  76. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/runtime/workspace.py +0 -0
  77. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/__init__.py +0 -0
  78. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/binding.py +0 -0
  79. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/bundle.py +0 -0
  80. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/constraints.py +0 -0
  81. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/decision.py +0 -0
  82. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/enforcer.py +0 -0
  83. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/errors.py +0 -0
  84. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/file_scope.py +0 -0
  85. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/models.py +0 -0
  86. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/policy.py +0 -0
  87. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/policy_set.py +0 -0
  88. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/rego.py +0 -0
  89. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/rego_wasm.py +0 -0
  90. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/signing.py +0 -0
  91. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/source.py +0 -0
  92. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/wasm_engine.py +0 -0
  93. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/streaming/__init__.py +0 -0
  94. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/streaming/events.py +0 -0
  95. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/streaming/normalize.py +0 -0
  96. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/__init__.py +0 -0
  97. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/bash.py +0 -0
  98. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/decorators.py +0 -0
  99. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/fetch.py +0 -0
  100. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/files/__init__.py +0 -0
  101. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/files/_common.py +0 -0
  102. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/files/edit_file.py +0 -0
  103. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/files/glob.py +0 -0
  104. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/files/grep.py +0 -0
  105. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/files/read_file.py +0 -0
  106. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/files/write_file.py +0 -0
  107. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/refund.py +0 -0
  108. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/websearch.py +0 -0
  109. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tracing/__init__.py +0 -0
  110. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tracing/langfuse.py +0 -0
  111. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/utils/__init__.py +0 -0
  112. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/utils/retry.py +0 -0
  113. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate.egg-info/dependency_links.txt +0 -0
  114. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate.egg-info/entry_points.txt +0 -0
  115. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate.egg-info/requires.txt +0 -0
  116. {hexgate-0.2.6 → hexgate-0.2.7}/hexgate.egg-info/top_level.txt +0 -0
  117. {hexgate-0.2.6 → hexgate-0.2.7}/setup.cfg +0 -0
  118. {hexgate-0.2.6 → hexgate-0.2.7}/tests/test_bootstrap.py +0 -0
  119. {hexgate-0.2.6 → hexgate-0.2.7}/tests/test_demo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexgate
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: Hexgate — authorization infrastructure for AI agents (agent runtime + cloud client).
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.13
@@ -1329,7 +1329,7 @@ FastAPI over SQLite. Run with:
1329
1329
 
1330
1330
  ```bash
1331
1331
  cd platform/api
1332
- uv run uvicorn main:app --reload --port 8000
1332
+ uv run uvicorn hexgate_api.main:app --reload --port 8000
1333
1333
  ```
1334
1334
 
1335
1335
  The default `support-bot` project is seeded on first boot with two agents — `default` (broad access, side-effects gated by `approval_required`) and `read_only` (everything mutating denied).
@@ -1288,7 +1288,7 @@ FastAPI over SQLite. Run with:
1288
1288
 
1289
1289
  ```bash
1290
1290
  cd platform/api
1291
- uv run uvicorn main:app --reload --port 8000
1291
+ uv run uvicorn hexgate_api.main:app --reload --port 8000
1292
1292
  ```
1293
1293
 
1294
1294
  The default `support-bot` project is seeded on first boot with two agents — `default` (broad access, side-effects gated by `approval_required`) and `read_only` (everything mutating denied).
@@ -0,0 +1,95 @@
1
+ """Google ADK adapter for :class:`~hexgate.mcp.MCPToolset`.
2
+
3
+ Every :class:`~hexgate.mcp.MCPToolProxy` produced by the toolset becomes
4
+ a :class:`google.adk.tools.BaseTool` subclass whose ``_get_declaration``
5
+ returns a ``FunctionDeclaration`` carrying the MCP tool's raw
6
+ JSON Schema (via ``parametersJsonSchema``) and whose ``run_async``
7
+ forwards to the proxy's ``call``. Once wrapped, the resulting tools
8
+ are indistinguishable from ADK-native :class:`FunctionTool` instances
9
+ to the rest of the Google ADK path — attach them to an ``Agent``, then
10
+ wrap via :func:`~hexgate.adapters.google.wrap_google_agent` so the
11
+ existing per-tool policy gate covers MCP invocations too.
12
+
13
+ Usage::
14
+
15
+ from google.adk.agents import Agent
16
+ from hexgate.adapters.google import wrap_google_agent
17
+ from hexgate.adapters.google.mcp import wrap_mcp_toolset
18
+ from hexgate.mcp import MCPServerConfig, MCPToolset
19
+
20
+ slack = MCPServerConfig(name="slack", transport="stdio", command="slack-mcp")
21
+ async with MCPToolset(slack) as mcp:
22
+ agent = Agent(
23
+ name="bot",
24
+ tools=[*wrap_mcp_toolset(mcp), *native],
25
+ )
26
+ wrapped, binding = wrap_google_agent(agent, api_key=api_key)
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from typing import Any
32
+
33
+ from google.adk.tools import BaseTool
34
+ from google.adk.tools.tool_context import ToolContext
35
+ from google.genai import types as genai_types
36
+
37
+ from hexgate.mcp.proxy import MCPToolProxy, MCPToolset
38
+
39
+
40
+ class _MCPProxyTool(BaseTool):
41
+ """One :class:`BaseTool` that forwards to an :class:`MCPToolProxy`.
42
+
43
+ ADK's :class:`FunctionTool` derives its schema from a Python
44
+ callable's signature via reflection, which can't express the shapes
45
+ MCP servers advertise (partial ``required``, ``anyOf`` unions,
46
+ nested objects with dynamic keys). Subclassing :class:`BaseTool`
47
+ and returning a hand-built :class:`FunctionDeclaration` from
48
+ ``_get_declaration`` bypasses the reflection path and hands the
49
+ server's raw JSON Schema straight to the model — via ADK's
50
+ ``parametersJsonSchema`` alias which accepts JSON Schema dicts.
51
+ """
52
+
53
+ def __init__(self, proxy: MCPToolProxy) -> None:
54
+ super().__init__(name=proxy.qualified_name, description=proxy.description)
55
+ # Store the schema + call as private attrs — ADK's BaseTool has
56
+ # no field for them, so we ride on the object dict.
57
+ self._input_schema = proxy.input_schema
58
+ self._call = proxy.call
59
+
60
+ def _get_declaration(self) -> genai_types.FunctionDeclaration:
61
+ # Gemini's FunctionDeclaration validator rejects any
62
+ # parametersJsonSchema missing a top-level `type` — literal `{}`,
63
+ # or a partial like `{"properties": {...}}`, or a bare
64
+ # `{"anyOf": [...]}`. All three shapes cause the whole tool
65
+ # list to fail at declaration time, so the agent never gets to
66
+ # run. Fill in `type: "object"` (the only shape Gemini accepts
67
+ # for a function-args container) whenever it's absent, and
68
+ # ensure a `properties` map so the LLM sees an argument surface
69
+ # rather than an opaque object. LangChain and OpenAI Agents
70
+ # both tolerate the missing `type` unchanged.
71
+ schema = self._input_schema if isinstance(self._input_schema, dict) else {}
72
+ if "type" not in schema:
73
+ schema = {"type": "object", "properties": {}, **schema}
74
+ return genai_types.FunctionDeclaration(
75
+ name=self.name,
76
+ description=self.description,
77
+ parametersJsonSchema=schema,
78
+ )
79
+
80
+ async def run_async(
81
+ self, *, args: dict[str, Any], tool_context: ToolContext
82
+ ) -> Any:
83
+ return await self._call(**(args or {}))
84
+
85
+
86
+ def wrap_mcp_toolset(toolset: MCPToolset) -> list[BaseTool]:
87
+ """Wrap every proxy in ``toolset`` as a Google ADK :class:`BaseTool`.
88
+
89
+ The returned tools share the toolset's connection lifecycle — they
90
+ stop working (returning a ``use_after_close`` envelope) once the
91
+ ``async with MCPToolset(...)`` block exits. Combine with
92
+ :func:`~hexgate.adapters.google.wrap_google_agent` to gate every
93
+ invocation through :class:`~hexgate.security.PolicyEnforcer`.
94
+ """
95
+ return [_MCPProxyTool(p) for p in toolset.proxies]
@@ -16,6 +16,7 @@ from langfuse import get_client, propagate_attributes
16
16
  from openinference.instrumentation.google_adk import GoogleADKInstrumentor
17
17
 
18
18
  from hexgate.adapters.google.wrapper import wrap_google_agent
19
+ from hexgate.agents.factory import ApprovalHandler
19
20
  from hexgate.config.env import resolve_api_key
20
21
  from hexgate.runtime import User
21
22
 
@@ -30,6 +31,7 @@ class HexgateRunner:
30
31
  app_name: str,
31
32
  session_service: BaseSessionService,
32
33
  api_key: str | None = None,
34
+ approval_handler: ApprovalHandler | None = None,
33
35
  **runner_kwargs: Any,
34
36
  ):
35
37
  self.api_key = resolve_api_key(api_key)
@@ -41,7 +43,7 @@ class HexgateRunner:
41
43
  # Runner is built once — refresh swaps the enforcer's policy
42
44
  # without touching it.
43
45
  self._wrapped_agent, self._binding = wrap_google_agent(
44
- agent, api_key=self.api_key
46
+ agent, api_key=self.api_key, approval_handler=approval_handler
45
47
  )
46
48
  self._runner = Runner(
47
49
  agent=self._wrapped_agent,
@@ -1,6 +1,11 @@
1
1
  """Google ADK adapter: wrap ``BaseTool`` so ``run_async`` consults a
2
2
  :class:`PolicyEnforcer` first. Non-allow outcomes render as markered
3
3
  strings the model sees as tool output.
4
+
5
+ When a caller supplies ``approval_handler``, a ``NEEDS_APPROVAL``
6
+ decision fires the callback and runs the original tool on truthy return;
7
+ falsy return (or a missing handler) keeps today's behavior of surfacing
8
+ the ``[approval_required]`` marker to the model.
4
9
  """
5
10
 
6
11
  from __future__ import annotations
@@ -14,6 +19,9 @@ from google.adk.tools.base_tool import BaseTool
14
19
  from google.adk.tools.function_tool import FunctionTool
15
20
  from google.adk.tools.tool_context import ToolContext
16
21
 
22
+ from hexgate.agents.approvals import resolve_approval_async
23
+ from hexgate.agents.factory import ApprovalHandler
24
+ from hexgate.security.decision import DecisionOutcome
17
25
  from hexgate.security.enforcer import PolicyEnforcer
18
26
 
19
27
 
@@ -32,7 +40,12 @@ def _normalize(tool: ToolEntry) -> BaseTool:
32
40
  )
33
41
 
34
42
 
35
- def wrap_tool(tool: ToolEntry, enforcer: PolicyEnforcer) -> BaseTool:
43
+ def wrap_tool(
44
+ tool: ToolEntry,
45
+ enforcer: PolicyEnforcer,
46
+ *,
47
+ approval_handler: ApprovalHandler | None = None,
48
+ ) -> BaseTool:
36
49
  """Return a copy of ``tool`` with ``run_async`` gated by ``enforcer``."""
37
50
  base = _normalize(tool)
38
51
  name = base.name
@@ -45,6 +58,12 @@ def wrap_tool(tool: ToolEntry, enforcer: PolicyEnforcer) -> BaseTool:
45
58
  decision = enforcer.decide(name, args or {})
46
59
  if decision.allowed:
47
60
  return await original_run_async(args=args, tool_context=tool_context)
61
+ if (
62
+ decision.outcome is DecisionOutcome.NEEDS_APPROVAL
63
+ and approval_handler is not None
64
+ and await resolve_approval_async(approval_handler, decision)
65
+ ):
66
+ return await original_run_async(args=args, tool_context=tool_context)
48
67
  return decision.as_error_message()
49
68
 
50
69
  wrapped = copy.copy(base)
@@ -52,6 +71,11 @@ def wrap_tool(tool: ToolEntry, enforcer: PolicyEnforcer) -> BaseTool:
52
71
  return wrapped
53
72
 
54
73
 
55
- def wrap_tools(tools: list[ToolEntry], enforcer: PolicyEnforcer) -> list[BaseTool]:
74
+ def wrap_tools(
75
+ tools: list[ToolEntry],
76
+ enforcer: PolicyEnforcer,
77
+ *,
78
+ approval_handler: ApprovalHandler | None = None,
79
+ ) -> list[BaseTool]:
56
80
  """Return a fresh list of policy-gated copies."""
57
- return [wrap_tool(t, enforcer) for t in tools]
81
+ return [wrap_tool(t, enforcer, approval_handler=approval_handler) for t in tools]
@@ -13,20 +13,27 @@ from __future__ import annotations
13
13
  from google.adk.agents import BaseAgent
14
14
 
15
15
  from hexgate.adapters.google.tools import wrap_tools
16
+ from hexgate.agents.factory import ApprovalHandler
16
17
  from hexgate.security.binding import PolicyBinding, resolve_policy
17
18
  from hexgate.security.enforcer import build_enforcer
18
19
 
19
20
 
20
21
  def wrap_google_agent(
21
- agent: BaseAgent, *, api_key: str
22
+ agent: BaseAgent,
23
+ *,
24
+ api_key: str,
25
+ approval_handler: ApprovalHandler | None = None,
22
26
  ) -> tuple[BaseAgent, PolicyBinding]:
23
27
  """Return a policy-gated clone of ``agent`` plus its refresh binding.
24
28
 
25
29
  Caller must open a :class:`User` scope around the run.
26
- ``NEEDS_APPROVAL`` outcomes surface as ``[approval_required]``-prefixed
27
- strings in tool results; ``[policy_denied]`` for denials. Refresh the
28
- returned binding at run boundaries (``HexgateRunner`` does). Fail-loud:
29
- an unregistered agent (platform 404) raises — register it first with
30
+ ``NEEDS_APPROVAL`` outcomes fire ``approval_handler`` (async
31
+ ``fn(decision) -> bool`` or ``bool`` shorthand); a truthy return
32
+ runs the tool, falsy or missing handler surfaces the
33
+ ``[approval_required]``-prefixed string as tool result.
34
+ ``[policy_denied]`` marks plain denials. Refresh the returned
35
+ binding at run boundaries (``HexgateRunner`` does). Fail-loud: an
36
+ unregistered agent (platform 404) raises — register it first with
30
37
  ``hexgate register``.
31
38
  """
32
39
  agent_name = getattr(agent, "name", "default")
@@ -34,7 +41,7 @@ def wrap_google_agent(
34
41
 
35
42
  resolved = resolve_policy(agent_name, api_key=api_key)
36
43
  enforcer = build_enforcer(resolved.engine, agent_name=agent_name, api_key=api_key)
37
- guarded_tools = wrap_tools(tools, enforcer)
44
+ guarded_tools = wrap_tools(tools, enforcer, approval_handler=approval_handler)
38
45
  return (
39
46
  agent.model_copy(update={"tools": guarded_tools}),
40
47
  PolicyBinding(enforcer, resolved.source),
@@ -0,0 +1,47 @@
1
+ """LangChain-facing wrapper for :class:`~hexgate.mcp.MCPToolset`.
2
+
3
+ Every :class:`~hexgate.mcp.MCPToolProxy` produced by the toolset becomes
4
+ a :class:`~langchain_core.tools.StructuredTool` that forwards to the
5
+ proxy's ``call``. Once wrapped, the resulting :class:`BaseTool` objects
6
+ are indistinguishable from native ``@agent_tool`` functions to the rest
7
+ of the LangChain path — hand them to :func:`create_agent`, call
8
+ :func:`enforce_policy`, and the existing :class:`GuardedTool` pass
9
+ gates every invocation through :class:`PolicyEnforcer`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from langchain_core.tools import BaseTool, StructuredTool
15
+
16
+ from hexgate.mcp.proxy import MCPToolProxy, MCPToolset
17
+
18
+
19
+ def _wrap_one(proxy: MCPToolProxy) -> BaseTool:
20
+ """Build a single :class:`StructuredTool` around ``proxy.call``.
21
+
22
+ LangChain's ``StructuredTool.from_function(args_schema=<dict>)``
23
+ accepts a raw JSON Schema directly — no Pydantic model generation
24
+ needed. ``proxy.call`` is used verbatim as the coroutine; its
25
+ ``__name__`` is already set to the qualified name by
26
+ :func:`hexgate.mcp.proxy._build_proxy`.
27
+ """
28
+ return StructuredTool.from_function(
29
+ coroutine=proxy.call,
30
+ name=proxy.qualified_name,
31
+ description=proxy.description,
32
+ args_schema=proxy.input_schema,
33
+ )
34
+
35
+
36
+ def wrap_mcp_toolset(toolset: MCPToolset) -> list[BaseTool]:
37
+ """Wrap every proxy in ``toolset`` as a LangChain :class:`BaseTool`.
38
+
39
+ Called implicitly by :meth:`MCPToolset.tools` for back-compat, or
40
+ explicitly by adapter-aware callers that want the same
41
+ ``wrap_mcp_toolset(mcp)`` shape they'd use with the OpenAI /
42
+ Pydantic AI / Google ADK adapters. The returned tools share the
43
+ toolset's connection lifecycle — they stop working (returning a
44
+ ``use_after_close`` envelope) once the ``async with MCPToolset(...)``
45
+ block exits.
46
+ """
47
+ return [_wrap_one(p) for p in toolset.proxies]
@@ -14,15 +14,20 @@ from __future__ import annotations
14
14
 
15
15
  import functools
16
16
  from collections.abc import Awaitable, Callable
17
- from inspect import isawaitable
18
17
  from typing import Any
19
18
 
20
19
  from langchain_core.tools import BaseTool
21
20
  from langchain_core.tools.structured import StructuredTool
22
21
  from pydantic import ConfigDict
23
22
 
23
+ from hexgate.agents.approvals import (
24
+ resolve_approval_async as _resolve_approval_async,
25
+ )
26
+ from hexgate.agents.approvals import (
27
+ resolve_approval_sync as _resolve_approval_sync,
28
+ )
24
29
  from hexgate.agents.factory import ApprovalHandler
25
- from hexgate.security.decision import Decision, DecisionOutcome
30
+ from hexgate.security.decision import DecisionOutcome
26
31
  from hexgate.security.enforcer import PolicyEnforcer
27
32
  from hexgate.tools.decorators import TOOL_METADATA_ATTR
28
33
 
@@ -35,29 +40,6 @@ def _copy_tool_metadata(source: Any, target: Any) -> Any:
35
40
  return target
36
41
 
37
42
 
38
- def _resolve_approval_sync(handler: ApprovalHandler, decision: Decision) -> bool:
39
- """Resolve a NEEDS_APPROVAL decision in a sync caller (rejects coroutines)."""
40
- if isinstance(handler, bool):
41
- return handler
42
- result = handler(decision)
43
- if isawaitable(result):
44
- raise RuntimeError(
45
- "approval_handler returned a coroutine; sync tool invocation cannot "
46
- "await it — use ainvoke/astream/astream_events"
47
- )
48
- return bool(result)
49
-
50
-
51
- async def _resolve_approval_async(handler: ApprovalHandler, decision: Decision) -> bool:
52
- """Resolve a NEEDS_APPROVAL decision in an async caller."""
53
- if isinstance(handler, bool):
54
- return handler
55
- result = handler(decision)
56
- if isawaitable(result):
57
- result = await result
58
- return bool(result)
59
-
60
-
61
43
  class GuardedTool(BaseTool):
62
44
  """LangChain tool wrapper that consults a :class:`PolicyEnforcer`.
63
45
 
@@ -0,0 +1,97 @@
1
+ """OpenAI Agents adapter for :class:`~hexgate.mcp.MCPToolset`.
2
+
3
+ Every :class:`~hexgate.mcp.MCPToolProxy` produced by the toolset becomes
4
+ an :class:`agents.FunctionTool` whose ``on_invoke_tool`` forwards to the
5
+ proxy's ``call``. Once wrapped, the resulting :class:`FunctionTool`
6
+ objects are indistinguishable from native ``@function_tool``-decorated
7
+ callables to the rest of the OpenAI Agents path — hand them to an
8
+ ``Agent`` alongside your other tools, then wrap the agent via
9
+ :func:`~hexgate.adapters.openai.wrap_openai_agent` so the existing
10
+ per-tool policy gate covers MCP invocations too.
11
+
12
+ Usage::
13
+
14
+ from agents import Agent
15
+ from hexgate.adapters.openai import wrap_openai_agent
16
+ from hexgate.adapters.openai.mcp import wrap_mcp_toolset
17
+ from hexgate.mcp import MCPServerConfig, MCPToolset
18
+
19
+ slack = MCPServerConfig(name="slack", transport="stdio", command="slack-mcp")
20
+ async with MCPToolset(slack) as mcp:
21
+ agent = Agent(
22
+ name="bot",
23
+ tools=[*wrap_mcp_toolset(mcp), *native_tools],
24
+ )
25
+ wrapped = wrap_openai_agent(agent, enforcer=enforcer)
26
+ await HexgateRunner(api_key).run(wrapped, "…", user=user)
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ from typing import Any
33
+
34
+ from agents import FunctionTool
35
+ from agents.tool import ToolContext
36
+
37
+ from hexgate.mcp.proxy import MCPToolProxy, MCPToolset
38
+
39
+
40
+ def _parse_args(raw: str) -> dict[str, Any]:
41
+ """Best-effort JSON-to-dict parse of a tool-call payload.
42
+
43
+ An empty or unparseable payload becomes ``{}`` — the proxy's
44
+ JSON-Schema validator then decides whether that's acceptable.
45
+ Matches the same tolerance the native OpenAI wrap has.
46
+ """
47
+ if not raw:
48
+ return {}
49
+ try:
50
+ parsed = json.loads(raw)
51
+ except (TypeError, ValueError):
52
+ return {}
53
+ return parsed if isinstance(parsed, dict) else {}
54
+
55
+
56
+ def _wrap_one(proxy: MCPToolProxy) -> FunctionTool:
57
+ """Build a single :class:`FunctionTool` around ``proxy.call``.
58
+
59
+ OpenAI Agents' ``FunctionTool`` accepts a raw JSON Schema in
60
+ ``params_json_schema`` — no dynamic Pydantic model generation
61
+ needed. ``strict_json_schema=False`` is deliberate: MCP servers
62
+ routinely advertise schemas that don't meet OpenAI's strict-mode
63
+ requirements (partial ``required``, optional ``additionalProperties``,
64
+ ``anyOf`` unions), and we'd rather forward the server's spec
65
+ verbatim than reject legitimate tools at wrap time. Our own
66
+ pre-call validator in ``proxy.call`` still catches malformed args
67
+ before the round trip.
68
+
69
+ ``on_invoke_tool`` is the one bit that can't collapse to
70
+ ``proxy.call`` directly — OpenAI hands us the raw JSON string, not
71
+ a parsed dict.
72
+ """
73
+ call = proxy.call
74
+
75
+ async def on_invoke_tool(ctx: ToolContext[Any], raw: str) -> Any:
76
+ return await call(**_parse_args(raw))
77
+
78
+ return FunctionTool(
79
+ name=proxy.qualified_name,
80
+ description=proxy.description,
81
+ params_json_schema=proxy.input_schema,
82
+ on_invoke_tool=on_invoke_tool,
83
+ strict_json_schema=False,
84
+ )
85
+
86
+
87
+ def wrap_mcp_toolset(toolset: MCPToolset) -> list[FunctionTool]:
88
+ """Wrap every proxy in ``toolset`` as an OpenAI Agents
89
+ :class:`FunctionTool`.
90
+
91
+ The returned tools share the toolset's connection lifecycle — they
92
+ stop working (returning a ``use_after_close`` envelope) once the
93
+ ``async with MCPToolset(...)`` block exits. Combine with
94
+ :func:`~hexgate.adapters.openai.wrap_openai_agent` to gate every
95
+ invocation through :class:`~hexgate.security.PolicyEnforcer`.
96
+ """
97
+ return [_wrap_one(p) for p in toolset.proxies]
@@ -25,6 +25,7 @@ from langfuse import get_client, propagate_attributes
25
25
  from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor
26
26
 
27
27
  from hexgate.adapters.openai.wrapper import wrap_openai_agent
28
+ from hexgate.agents.factory import ApprovalHandler
28
29
  from hexgate.config.env import resolve_api_key
29
30
  from hexgate.runtime import User
30
31
  from hexgate.security.binding import PolicyBinding, resolve_policy
@@ -34,7 +35,12 @@ from hexgate.security.enforcer import build_enforcer
34
35
  class HexgateRunner:
35
36
  """Runner for OpenAI agents with Hexgate tool policy and observability."""
36
37
 
37
- def __init__(self, api_key: str | None = None):
38
+ def __init__(
39
+ self,
40
+ api_key: str | None = None,
41
+ *,
42
+ approval_handler: ApprovalHandler | None = None,
43
+ ):
38
44
  self.api_key = resolve_api_key(api_key)
39
45
  if self.api_key is None:
40
46
  raise ValueError(
@@ -42,6 +48,7 @@ class HexgateRunner:
42
48
  )
43
49
  # Cached per agent name — keeps the ETag memory alive across runs.
44
50
  self._bindings: dict[str, PolicyBinding] = {}
51
+ self._approval_handler = approval_handler
45
52
 
46
53
  def _binding_for(self, agent: Agent) -> PolicyBinding:
47
54
  """Get-or-resolve the cached policy binding for ``agent``'s name.
@@ -98,7 +105,11 @@ class HexgateRunner:
98
105
  self._setup_observability()
99
106
  binding = self._binding_for(agent)
100
107
  await binding.refresh_async() # per-run policy pull; 304 when unchanged
101
- wrapped_agent = wrap_openai_agent(agent, enforcer=binding.enforcer)
108
+ wrapped_agent = wrap_openai_agent(
109
+ agent,
110
+ enforcer=binding.enforcer,
111
+ approval_handler=self._approval_handler,
112
+ )
102
113
  async with user:
103
114
  with self._propagate(user, agent.name):
104
115
  return await Runner.run(
@@ -117,7 +128,11 @@ class HexgateRunner:
117
128
  self._setup_observability()
118
129
  binding = self._binding_for(agent)
119
130
  binding.refresh() # per-run policy pull; 304 when unchanged
120
- wrapped_agent = wrap_openai_agent(agent, enforcer=binding.enforcer)
131
+ wrapped_agent = wrap_openai_agent(
132
+ agent,
133
+ enforcer=binding.enforcer,
134
+ approval_handler=self._approval_handler,
135
+ )
121
136
  with user.sync_scope():
122
137
  with self._propagate(user, agent.name):
123
138
  return Runner.run_sync(
@@ -143,7 +158,11 @@ class HexgateRunner:
143
158
  self._setup_observability()
144
159
  binding = self._binding_for(agent)
145
160
  binding.refresh() # must precede the wrap + setup
146
- wrapped_agent = wrap_openai_agent(agent, enforcer=binding.enforcer)
161
+ wrapped_agent = wrap_openai_agent(
162
+ agent,
163
+ enforcer=binding.enforcer,
164
+ approval_handler=self._approval_handler,
165
+ )
147
166
 
148
167
  with user.sync_scope():
149
168
  with self._propagate(user, agent.name):
@@ -1,6 +1,11 @@
1
1
  """OpenAI Agents adapter: wrap ``FunctionTool`` so ``on_invoke_tool``
2
2
  consults a :class:`PolicyEnforcer` first. Non-allow outcomes render as
3
3
  markered strings the model sees as tool output.
4
+
5
+ When a caller supplies ``approval_handler``, a ``NEEDS_APPROVAL``
6
+ decision fires the callback and runs the original tool on truthy return;
7
+ falsy return (or a missing handler) keeps today's behavior of surfacing
8
+ the ``[approval_required]`` marker to the model.
4
9
  """
5
10
 
6
11
  from __future__ import annotations
@@ -13,6 +18,9 @@ from typing import Any
13
18
  from agents import FunctionTool
14
19
  from agents.tool import ToolContext
15
20
 
21
+ from hexgate.agents.approvals import resolve_approval_async
22
+ from hexgate.agents.factory import ApprovalHandler
23
+ from hexgate.security.decision import DecisionOutcome
16
24
  from hexgate.security.enforcer import PolicyEnforcer
17
25
 
18
26
 
@@ -27,7 +35,12 @@ def _parse_args(raw: str) -> dict[str, Any] | None:
27
35
  return parsed if isinstance(parsed, dict) else None
28
36
 
29
37
 
30
- def wrap_tool(tool: FunctionTool, enforcer: PolicyEnforcer) -> FunctionTool:
38
+ def wrap_tool(
39
+ tool: FunctionTool,
40
+ enforcer: PolicyEnforcer,
41
+ *,
42
+ approval_handler: ApprovalHandler | None = None,
43
+ ) -> FunctionTool:
31
44
  """Return a copy of ``tool`` with ``on_invoke_tool`` gated by ``enforcer``."""
32
45
  if not isinstance(tool, FunctionTool):
33
46
  raise TypeError(
@@ -43,6 +56,12 @@ def wrap_tool(tool: FunctionTool, enforcer: PolicyEnforcer) -> FunctionTool:
43
56
  decision = enforcer.decide(name, _parse_args(input) or {})
44
57
  if decision.allowed:
45
58
  return await original_invoke(ctx, input)
59
+ if (
60
+ decision.outcome is DecisionOutcome.NEEDS_APPROVAL
61
+ and approval_handler is not None
62
+ and await resolve_approval_async(approval_handler, decision)
63
+ ):
64
+ return await original_invoke(ctx, input)
46
65
  return decision.as_error_message()
47
66
 
48
67
  wrapped = copy.copy(tool)
@@ -51,7 +70,13 @@ def wrap_tool(tool: FunctionTool, enforcer: PolicyEnforcer) -> FunctionTool:
51
70
 
52
71
 
53
72
  def wrap_tools(
54
- tools: list[FunctionTool], enforcer: PolicyEnforcer
73
+ tools: list[FunctionTool],
74
+ enforcer: PolicyEnforcer,
75
+ *,
76
+ approval_handler: ApprovalHandler | None = None,
55
77
  ) -> list[FunctionTool]:
56
78
  """Return a fresh list of policy-gated copies."""
57
- return [wrap_tool(t, enforcer) for t in tools]
79
+ return [wrap_tool(t, enforcer, approval_handler=approval_handler) for t in tools]
80
+
81
+
82
+ __all__ = ["_parse_args", "wrap_tool", "wrap_tools"]
@@ -15,14 +15,23 @@ import dataclasses
15
15
  from agents import Agent
16
16
 
17
17
  from hexgate.adapters.openai.tools import wrap_tools
18
+ from hexgate.agents.factory import ApprovalHandler
18
19
  from hexgate.security.enforcer import PolicyEnforcer
19
20
 
20
21
 
21
- def wrap_openai_agent(agent: Agent, *, enforcer: PolicyEnforcer) -> Agent:
22
+ def wrap_openai_agent(
23
+ agent: Agent,
24
+ *,
25
+ enforcer: PolicyEnforcer,
26
+ approval_handler: ApprovalHandler | None = None,
27
+ ) -> Agent:
22
28
  """Return a clone of ``agent`` whose tools are gated by ``enforcer``.
23
29
 
24
30
  Mechanics only — resolution/refresh live with the caller. Caller
25
- must open a :class:`User` scope around the run.
31
+ must open a :class:`User` scope around the run. ``approval_handler``
32
+ (async ``fn(decision) -> bool`` or ``bool`` shorthand) fires when a
33
+ tool call carries a ``NEEDS_APPROVAL`` outcome; a truthy return runs
34
+ the tool, falsy surfaces the ``[approval_required]`` marker.
26
35
  """
27
- guarded_tools = wrap_tools(agent.tools, enforcer)
36
+ guarded_tools = wrap_tools(agent.tools, enforcer, approval_handler=approval_handler)
28
37
  return dataclasses.replace(agent, tools=guarded_tools)
@@ -0,0 +1,60 @@
1
+ """Pydantic AI adapter for :class:`~hexgate.mcp.MCPToolset`.
2
+
3
+ Every :class:`~hexgate.mcp.MCPToolProxy` produced by the toolset becomes
4
+ a :class:`pydantic_ai.tools.Tool` via ``Tool.from_schema`` — Pydantic
5
+ AI's raw-JSON-Schema entry point. Once wrapped, the resulting Tools
6
+ are indistinguishable from ``@agent.tool``-decorated callables to the
7
+ rest of the Pydantic AI path — attach them to an ``Agent`` and wrap
8
+ via :func:`~hexgate.adapters.pydantic_ai.wrap_pydantic_agent` so the
9
+ existing per-tool policy gate covers MCP invocations too.
10
+
11
+ Usage::
12
+
13
+ from pydantic_ai import Agent
14
+ from hexgate.adapters.pydantic_ai import wrap_pydantic_agent
15
+ from hexgate.adapters.pydantic_ai.mcp import wrap_mcp_toolset
16
+ from hexgate.mcp import MCPServerConfig, MCPToolset
17
+
18
+ slack = MCPServerConfig(name="slack", transport="stdio", command="slack-mcp")
19
+ async with MCPToolset(slack) as mcp:
20
+ agent = Agent("openai:gpt-5.4", tools=[*wrap_mcp_toolset(mcp), *native])
21
+ proxy = wrap_pydantic_agent(agent=agent)
22
+ await proxy.run("…", user=user)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from pydantic_ai.tools import Tool
28
+
29
+ from hexgate.mcp.proxy import MCPToolProxy, MCPToolset
30
+
31
+
32
+ def _wrap_one(proxy: MCPToolProxy) -> Tool:
33
+ """Build a single :class:`Tool` around ``proxy.call``.
34
+
35
+ Pydantic AI's ``Tool.from_schema`` accepts a raw JSON Schema — no
36
+ dynamic Pydantic model generation is needed. ``proxy.call`` is
37
+ passed verbatim as the function; its ``__name__`` is already the
38
+ qualified name (set by :func:`hexgate.mcp.proxy._build_proxy`).
39
+ Pydantic AI's own validator (built from the same schema) is the
40
+ outer gate; our ``proxy.call`` runs its own JSON-Schema check as a
41
+ defence-in-depth layer before the server round-trip.
42
+ """
43
+ return Tool.from_schema(
44
+ function=proxy.call,
45
+ name=proxy.qualified_name,
46
+ description=proxy.description,
47
+ json_schema=proxy.input_schema,
48
+ )
49
+
50
+
51
+ def wrap_mcp_toolset(toolset: MCPToolset) -> list[Tool]:
52
+ """Wrap every proxy in ``toolset`` as a Pydantic AI :class:`Tool`.
53
+
54
+ The returned tools share the toolset's connection lifecycle — they
55
+ stop working (returning a ``use_after_close`` envelope) once the
56
+ ``async with MCPToolset(...)`` block exits. Combine with
57
+ :func:`~hexgate.adapters.pydantic_ai.wrap_pydantic_agent` to gate
58
+ every invocation through :class:`~hexgate.security.PolicyEnforcer`.
59
+ """
60
+ return [_wrap_one(p) for p in toolset.proxies]