From 7a37342aaeb6f76e33550eeab91e9b2cb0d5c9a2 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:02:46 -0700 Subject: [PATCH 01/13] fix: preserve hosted connection ownership Keep connection settings in application configuration while retaining caller generation options and application defaults. --- python/packages/foundry_hosting/README.md | 5 ++- .../_invocations.py | 2 + .../foundry_hosting/tests/test_invocations.py | 40 ++++++++++++++++--- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index 3a352eba761..b3b014e93e9 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -393,8 +393,9 @@ a **copy** of this turn's caller options without changing the agent's `default_o with string keys. The host rejects reserved platform/session fields, `store`, `extra_body`, and private continuation fields after the hook; callers cannot select another sandbox or enable downstream service continuation through runtime options. It also rejects agent execution controls: `additional_function_arguments`, `function_invocation_kwargs`, -`client_kwargs`, `middleware`, `session`, `tools`, `instructions`, `compaction_strategy`, and `tokenizer`. Trusted tool -arguments belong in developer-configured agent defaults or middleware/factories, not in request options or hook output. +`client_kwargs`, `middleware`, `session`, `tools`, `instructions`, `compaction_strategy`, and `tokenizer`, as well as +connection configuration through `mcp_servers` and `provider`. Trusted tool arguments and connection configuration +belong in developer-configured agent defaults or middleware/factories, not in request options or hook output. A hook may strip denied caller fields before validation; allowed generation and provider-specific options remain available. These restrictions apply to both wire formats and every `unsupported_options` policy. When an agent cannot accept runtime options, `unsupported_options="warn"` (default) logs and ignores diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index 6838e601f5e..9e9702b8828 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -75,7 +75,9 @@ "compaction_strategy", "function_invocation_kwargs", "instructions", + "mcp_servers", "middleware", + "provider", "session", "tokenizer", "tools", diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index 184dc536b14..62d354675b7 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -861,7 +861,9 @@ async def test_reserved_options_are_rejected_before_agent_run(self, options: dic ("compaction_strategy", {}), ("function_invocation_kwargs", {"user_id": "forged"}), ("instructions", "Caller-controlled instructions"), + ("mcp_servers", {"application": {"type": "local", "command": "application-server", "tools": ["*"]}}), ("middleware", []), + ("provider", {"type": "openai", "base_url": "https://example.com/api"}), ("session", {"session_id": "forged"}), ("tokenizer", {}), ("tools", []), @@ -903,8 +905,16 @@ def create_agent() -> _FakeAgent: @pytest.mark.parametrize("stream", [False, True]) @pytest.mark.parametrize("legacy_wire_format", [False, True]) @pytest.mark.parametrize("policy", ["ignore", "warn", "error"]) - async def test_http_route_rejects_tool_context_options_under_every_wire_and_options_policy( - self, stream: bool, legacy_wire_format: bool, policy: Literal["ignore", "warn", "error"] + @pytest.mark.parametrize( + ("field", "value"), + [ + ("additional_function_arguments", {"user_id": "forged", "tenant_id": "forged"}), + ("mcp_servers", {"application": {"type": "local", "command": "application-server", "tools": ["*"]}}), + ("provider", {"type": "openai", "base_url": "https://example.com/api"}), + ], + ) + async def test_http_route_rejects_agent_control_options_under_every_wire_and_options_policy( + self, stream: bool, legacy_wire_format: bool, policy: Literal["ignore", "warn", "error"], field: str, value: Any ) -> None: agent = _make_agent(response_text="ok", stream_texts=["ok"]) provider = _SessionStoreProvider(_mock_session_store()) @@ -930,20 +940,38 @@ def create_host() -> InvocationsHostServer: json={ "message": "hi", "stream": stream, - "options": {"additional_function_arguments": {"user_id": "forged", "tenant_id": "forged"}}, + "options": {field: value}, }, ) assert response.status_code == 400 assert response.headers["content-type"] == "application/json" - assert response.json() == { - "error": "Invocations options cannot set agent-controlled fields: additional_function_arguments." - } + assert response.json() == {"error": f"Invocations options cannot set agent-controlled fields: {field}."} assert agent.default_options == { "additional_function_arguments": {"user_id": "trusted", "tenant_id": "trusted"} } assert agent.calls == [] assert provider.contexts == [] + @pytest.mark.parametrize("stream", [False, True]) + async def test_generation_options_preserve_application_connection_defaults(self, stream: bool) -> None: + agent = _make_agent(response_text="ok", stream_texts=["ok"]) + agent.default_options = { + "mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}, + "provider": {"type": "openai", "base_url": "https://example.com/api"}, + } + server = InvocationsHostServer(agent) + with _request_context(session_id="generation-options"): + response = await server._handle_invoke( # pyright: ignore[reportPrivateUsage] + _make_request({"message": "hi", "stream": stream, "options": {"model": "request-model"}}) + ) + assert await _success_text(response) == "ok" + + assert agent.calls[0]["options"] == {"model": "request-model"} + assert agent.default_options == { + "mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}, + "provider": {"type": "openai", "base_url": "https://example.com/api"}, + } + @pytest.mark.parametrize("result", [None, [], {1: "invalid"}, {"session_id": "forged"}]) async def test_options_hook_must_return_safe_string_keyed_mapping(self, result: Any) -> None: agent = _make_agent(response_text="ok") From bcd4fc2f32c65c3f2e0ab3dd185a6daa13bad91f Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:23:23 -0700 Subject: [PATCH 02/13] fix(copilot)!: guard runtime connections Keep connection ownership in Copilot agents without reserving option names across every Invocations implementation. BREAKING CHANGE: configure connections in default_options or construct Copilot agents with allow_runtime_connection_overrides=True. Refs #9611 --- ...0046-python-copilot-runtime-connections.md | 63 +++++++ python/packages/foundry_hosting/README.md | 17 +- .../_invocations.py | 5 +- .../foundry_hosting/tests/test_invocations.py | 94 ++++++++-- python/packages/github_copilot/AGENTS.md | 7 + python/packages/github_copilot/README.md | 37 +++- .../agent_framework_github_copilot/_agent.py | 43 ++++- .../tests/test_github_copilot_agent.py | 172 +++++++++++++++++- 8 files changed, 408 insertions(+), 30 deletions(-) create mode 100644 docs/decisions/0046-python-copilot-runtime-connections.md diff --git a/docs/decisions/0046-python-copilot-runtime-connections.md b/docs/decisions/0046-python-copilot-runtime-connections.md new file mode 100644 index 00000000000..4c728e22551 --- /dev/null +++ b/docs/decisions/0046-python-copilot-runtime-connections.md @@ -0,0 +1,63 @@ +--- +status: proposed +contact: rogerbarreto +date: 2026-10-22 +deciders: rogerbarreto, eavanvalkenburg, westey-m +--- + +# Let the Copilot agent own runtime connection configuration + +## Context and Problem Statement + +The Invocations host passes application-parsed options to agents. For the Copilot +agent, `provider` and `mcp_servers` are serializable SDK connection configurations, +not Python client objects. Forwarding unfiltered request options can therefore +change the connections selected for a session. + +The same option names can have different meanings for other agent implementations. +An Invocations-wide denylist also rejects connection settings produced by trusted +application parsers and option hooks. The compatibility concern is tracked in +[PR #9611](https://github.com/microsoft/agent-framework/pull/9611). + +## Decision Drivers + +- Keep application-owned connections protected by default. +- Preserve deliberate runtime connection configuration through explicit opt-in. +- Keep ordinary per-run generation options and agent defaults available. +- Apply the policy to direct calls and hosting without provider-specific host coupling. + +## Considered Options + +- Reserve connection names in Invocations. This rejects input early, but changes + every hosted agent's contract and also blocks trusted parser and hook output. +- Disable all runtime options. This is simple, but unnecessarily removes model + and timeout overrides. +- Keep runtime connections enabled by default. This preserves compatibility, but + leaves applications forwarding unfiltered options without default protection. +- Make the Copilot agent reject runtime connections by default with constructor + opt-in. This scopes the policy to the component interpreting the configuration, + but still requires an explicit migration for existing Copilot runtime callers. + +## Decision Outcome + +Choose the agent-owned policy. Both `GitHubCopilotAgent` and +`RawGitHubCopilotAgent` expose `allow_runtime_connection_overrides=False`. +They reject per-run `provider` and `mcp_servers`, including empty or null values, +before SDK startup and again before assembling SDK session configuration. +Application `default_options` remain allowed, and ordinary runtime options retain +their existing behavior. The constructor flag is never read from run options or +forwarded to the SDK. + +Trusted applications may opt in to preserve runtime connection precedence over +defaults. This is an intentional behavioral compatibility change: existing callers +must move connections into defaults or enable the constructor flag. + +Invocations no longer reserves these connection names for every agent. It reports +the existing `AgentInvalidRequestException` as a sanitized HTTP 400 or typed SSE +client error, while retaining generic provider-failure sanitization and legacy wire +behavior. Other agents retain responsibility for their own option semantics. +An updated host alone does not protect older Copilot package versions; release and +deployment must include the updated agent policy. + +The proposed deciders include Python code owners; maintainer and engineering-management +approval is still required. diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index b3b014e93e9..e1d3424b8ee 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -393,11 +393,16 @@ a **copy** of this turn's caller options without changing the agent's `default_o with string keys. The host rejects reserved platform/session fields, `store`, `extra_body`, and private continuation fields after the hook; callers cannot select another sandbox or enable downstream service continuation through runtime options. It also rejects agent execution controls: `additional_function_arguments`, `function_invocation_kwargs`, -`client_kwargs`, `middleware`, `session`, `tools`, `instructions`, `compaction_strategy`, and `tokenizer`, as well as -connection configuration through `mcp_servers` and `provider`. Trusted tool arguments and connection configuration -belong in developer-configured agent defaults or middleware/factories, not in request options or hook output. +`client_kwargs`, `middleware`, `session`, `tools`, `instructions`, `compaction_strategy`, and `tokenizer`. Trusted tool +arguments belong in developer-configured agent defaults or middleware/factories, not in request options or hook output. A hook may strip denied caller fields before validation; allowed generation and provider-specific options remain available. These restrictions apply to both wire formats and every `unsupported_options` policy. +Connection option names such as `provider` and `mcp_servers` are not reserved host-wide. +The hosted agent owns their policy. The updated `GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject those runtime +options by default, while preserving connections in `default_options`. Trusted applications can enable them with the +constructor-only `allow_runtime_connection_overrides=True` flag and filter request input through `prepare_options`. +Update the Copilot package as well as the host to obtain that default-deny policy; older Copilot versions retain their +existing runtime connection behavior. When an agent cannot accept runtime options, `unsupported_options="warn"` (default) logs and ignores them; `"ignore"` silently drops them and `"error"` rejects them. For request-scoped factories, unsupported options discovered after streaming starts are reported as an SSE `error` event with `status: 400`. @@ -405,8 +410,10 @@ discovered after streaming starts are reported as an SSE `error` event with `sta **New default:** non-streaming success is JSON `{"response": "..."}`. Streaming success is real SSE `event: delta` with `{"text": "..."}`, followed by `event: done` with the platform sandbox `session_id`. The `done` event is sent only after the final MAF `ResponseStream` is finalized and the session is persisted. -Client validation errors return JSON HTTP 400 before streaming where possible. Provider errors are logged and -sanitized as JSON HTTP 500 or an SSE `error`; a cross-process ETag conflict is JSON HTTP 409 or an SSE `error` +Client validation errors return JSON HTTP 400 before streaming where possible. An agent's `AgentInvalidRequestException` +is reported as a sanitized JSON HTTP 400 or SSE `error` with `code: "invalid_request"` and `status: 400`. +Provider errors are logged and sanitized as JSON HTTP 500 or an SSE `error`; a cross-process ETag conflict is +JSON HTTP 409 or an SSE `error` with `code: "session_conflict"` and `status: 409`. A stream may emit deltas before an error. The host serializes same-session requests in one process, but a CAS conflict can still follow external tool effects in separate processes; it does not guarantee exactly-once execution. See the diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index 9e9702b8828..2c7e534614b 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -35,6 +35,7 @@ WorkflowExecutor, ) from agent_framework._telemetry import mark_feature_used +from agent_framework.exceptions import AgentInvalidRequestException from anyio import CancelScope from azure.ai.agentserver.core import ( FoundryAgentRequestContext, @@ -75,9 +76,7 @@ "compaction_strategy", "function_invocation_kwargs", "instructions", - "mcp_servers", "middleware", - "provider", "session", "tokenizer", "tools", @@ -253,6 +252,8 @@ def _sse(event: str, data: Mapping[str, Any]) -> str: def _invocation_failure(exc: Exception) -> tuple[str, int, str]: cause: BaseException | None = exc while cause is not None: + if isinstance(cause, AgentInvalidRequestException): + return "The agent rejected the invocation request.", 400, "invalid_request" if isinstance(cause, (FoundryStorageConflictError, FoundryStoragePreconditionError)): return "Another request advanced this agent session; reload before retrying.", 409, "session_conflict" cause = cause.__cause__ diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index 62d354675b7..52f2b001066 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -861,9 +861,7 @@ async def test_reserved_options_are_rejected_before_agent_run(self, options: dic ("compaction_strategy", {}), ("function_invocation_kwargs", {"user_id": "forged"}), ("instructions", "Caller-controlled instructions"), - ("mcp_servers", {"application": {"type": "local", "command": "application-server", "tools": ["*"]}}), ("middleware", []), - ("provider", {"type": "openai", "base_url": "https://example.com/api"}), ("session", {"session_id": "forged"}), ("tokenizer", {}), ("tools", []), @@ -905,16 +903,8 @@ def create_agent() -> _FakeAgent: @pytest.mark.parametrize("stream", [False, True]) @pytest.mark.parametrize("legacy_wire_format", [False, True]) @pytest.mark.parametrize("policy", ["ignore", "warn", "error"]) - @pytest.mark.parametrize( - ("field", "value"), - [ - ("additional_function_arguments", {"user_id": "forged", "tenant_id": "forged"}), - ("mcp_servers", {"application": {"type": "local", "command": "application-server", "tools": ["*"]}}), - ("provider", {"type": "openai", "base_url": "https://example.com/api"}), - ], - ) - async def test_http_route_rejects_agent_control_options_under_every_wire_and_options_policy( - self, stream: bool, legacy_wire_format: bool, policy: Literal["ignore", "warn", "error"], field: str, value: Any + async def test_http_route_rejects_tool_context_options_under_every_wire_and_options_policy( + self, stream: bool, legacy_wire_format: bool, policy: Literal["ignore", "warn", "error"] ) -> None: agent = _make_agent(response_text="ok", stream_texts=["ok"]) provider = _SessionStoreProvider(_mock_session_store()) @@ -940,18 +930,94 @@ def create_host() -> InvocationsHostServer: json={ "message": "hi", "stream": stream, - "options": {field: value}, + "options": {"additional_function_arguments": {"user_id": "forged", "tenant_id": "forged"}}, }, ) assert response.status_code == 400 assert response.headers["content-type"] == "application/json" - assert response.json() == {"error": f"Invocations options cannot set agent-controlled fields: {field}."} + assert response.json() == { + "error": "Invocations options cannot set agent-controlled fields: additional_function_arguments." + } assert agent.default_options == { "additional_function_arguments": {"user_id": "trusted", "tenant_id": "trusted"} } assert agent.calls == [] assert provider.contexts == [] + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("source", ["request", "parser", "hook"]) + @pytest.mark.parametrize( + "options", + [ + {"provider": {"type": "openai", "base_url": "https://example.com/api"}}, + {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, + ], + ) + async def test_connection_options_are_governed_by_the_agent( + self, stream: bool, source: str, options: dict[str, Any] + ) -> None: + agent = _make_agent(response_text="ok", stream_texts=["ok"]) + server = InvocationsHostServer( + agent, + agent_session_store_provider=_SessionStoreProvider(_mock_session_store()), + parse_request=(lambda _request: InvocationRun(messages="hi", options=options, stream=stream)) + if source == "parser" + else None, + prepare_options=(lambda _request, _options: options) if source == "hook" else None, + ) + with _request_context(session_id="connection-options"): + response = await server._handle_invoke( # pyright: ignore[reportPrivateUsage] + _make_request({"message": "hi", "stream": stream, "options": options if source == "request" else {}}) + ) + assert await _success_text(response) == "ok" + + assert agent.calls[0]["options"] == options + + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("agent_type", ["RawGitHubCopilotAgent", "GitHubCopilotAgent"]) + @pytest.mark.parametrize( + "options", + [ + {"provider": {"type": "openai", "base_url": "https://example.com/api"}}, + {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, + ], + ) + async def test_http_route_reports_copilot_connection_policy_rejections_as_client_errors( + self, stream: bool, agent_type: str, options: dict[str, Any] + ) -> None: + copilot_agents = pytest.importorskip("agent_framework_github_copilot") + sdk_client = MagicMock() + sdk_client.start = AsyncMock() + sdk_client.create_session = AsyncMock() + sdk_client.resume_session = AsyncMock() + agent = getattr(copilot_agents, agent_type)(client=sdk_client) + server = InvocationsHostServer(agent, agent_session_store_provider=_SessionStoreProvider(_mock_session_store())) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=server), base_url="http://test") as client: + response = await client.post( + "/invocations", + params={"agent_session_id": f"connection-policy-{uuid.uuid4().hex}"}, + json={"message": "hi", "stream": stream, "options": options}, + ) + + if stream: + assert response.status_code == 200 + assert _parse_sse_events(response.text) == [ + ( + "error", + { + "message": "The agent rejected the invocation request.", + "code": "invalid_request", + "status": 400, + }, + ) + ] + else: + assert response.status_code == 400 + assert response.json() == {"error": "The agent rejected the invocation request."} + sdk_client.start.assert_not_awaited() + sdk_client.create_session.assert_not_awaited() + sdk_client.resume_session.assert_not_awaited() + @pytest.mark.parametrize("stream", [False, True]) async def test_generation_options_preserve_application_connection_defaults(self, stream: bool) -> None: agent = _make_agent(response_text="ok", stream_texts=["ok"]) diff --git a/python/packages/github_copilot/AGENTS.md b/python/packages/github_copilot/AGENTS.md index 8f594e1c73b..953ecc2a0aa 100644 --- a/python/packages/github_copilot/AGENTS.md +++ b/python/packages/github_copilot/AGENTS.md @@ -27,6 +27,13 @@ from agent_framework_github_copilot import GitHubCopilotAgent ## Session Option Defaults +`GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject per-run `provider` and +`mcp_servers` by default. Application defaults remain supported; runtime connection +configuration requires the constructor-only `allow_runtime_connection_overrides=True` +flag. Validate before SDK startup and again when assembling session kwargs so +creation and resumption share the policy. Ordinary runtime options keep their +existing precedence. + `_build_session_kwargs` forwards options to the SDK verbatim except for a few keys that get an explicit default: `on_permission_request` (deny-all) and `enable_file_hooks` (`False`, so a session behaves the same way in every working directory). Callers opt in through diff --git a/python/packages/github_copilot/README.md b/python/packages/github_copilot/README.md index 1dbdbe05a46..b5b40569ce0 100644 --- a/python/packages/github_copilot/README.md +++ b/python/packages/github_copilot/README.md @@ -10,6 +10,42 @@ pip install agent-framework-github-copilot The GitHub Copilot agent enables integration with GitHub Copilot, allowing you to interact with Copilot's agentic capabilities through the Agent Framework. +## Runtime connection configuration + +`GitHubCopilotAgent` and `RawGitHubCopilotAgent` default to +`allow_runtime_connection_overrides=False`. Per-run `provider` and `mcp_servers` +options raise `AgentInvalidRequestException` before the agent starts the SDK client, +creates a session, or resumes one. This includes supplying a connection when no +default is configured, and clearing a configuration with an empty object or `None`. +Ordinary runtime options, such as `model` and `timeout`, remain supported. + +Configure application-owned connections in `default_options`: + +```python +from agent_framework.github import GitHubCopilotAgent, GitHubCopilotOptions + +agent = GitHubCopilotAgent( + default_options=GitHubCopilotOptions( + provider={"type": "openai", "base_url": "https://application.example/v1"}, + ), +) +``` + +**Migration:** existing code passing `provider` or `mcp_servers` through +`run(options=...)` must move those settings to `default_options`, or explicitly +enable trusted runtime connection configuration: + +```python +agent = GitHubCopilotAgent(allow_runtime_connection_overrides=True) +``` + +With opt-in, runtime connection options retain their existing precedence over +defaults. The flag is a constructor argument, not an SDK session option; it cannot +be enabled through the incoming options mapping. Apply the same policy to both +streaming and non-streaming calls. Applications opting in are responsible for +validating or filtering connection settings received over HTTP or from other +untrusted inputs. + ## Tool approval (`approval_mode="always_require"`) The GitHub Copilot SDK owns the tool-calling loop for this provider, so approval for @@ -115,4 +151,3 @@ To make the default visible rather than silent, the agent logs a warning through directory that defines hooks it is not loading. See [`github_copilot_with_file_hooks.py`](../../samples/02-agents/providers/github_copilot/github_copilot_with_file_hooks.py) for a runnable example. - diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 64a9f125e42..c648a705812 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -51,7 +51,7 @@ _get_data_bytes_as_str, # pyright: ignore[reportPrivateUsage] normalize_tools, ) -from agent_framework.exceptions import AgentException, ContentError +from agent_framework.exceptions import AgentException, AgentInvalidRequestException, ContentError from agent_framework.observability import AgentTelemetryLayer from ._feature_usage import FeatureIndex @@ -504,7 +504,8 @@ class GitHubCopilotOptions(TypedDict, total=False): ``reasoning_effort``, ``context_tier``, ``enable_citations``, ``available_tools``, ``memory``, ...) may also be supplied and is forwarded verbatim to the SDK. An unrecognized parameter name surfaces as a ``TypeError`` from the SDK, so typos are - caught rather than silently ignored. + caught rather than silently ignored. Per-run ``provider`` and ``mcp_servers`` require + constructor opt-in through ``allow_runtime_connection_overrides=True``. """ system_message: SystemMessageConfig @@ -661,6 +662,7 @@ def __init__( middleware: Sequence[AgentMiddlewareTypes] | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, default_options: OptionsT | None = None, + allow_runtime_connection_overrides: bool = False, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -681,12 +683,17 @@ def __init__( or tool definition dicts. These are converted to Copilot SDK tools internally. default_options: Default options for the agent. Can include cli_path, model, timeout, log_level, etc. + allow_runtime_connection_overrides: Allow per-run `provider` and `mcp_servers` configuration. + Defaults to False. Configure application-owned connections through `default_options` instead. env_file_path: Optional path to .env file for loading configuration. env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. Raises: ValueError: If required configuration is missing or invalid. """ + if not isinstance(allow_runtime_connection_overrides, bool): + raise TypeError("allow_runtime_connection_overrides must be a bool.") + super().__init__( id=id, name=name, @@ -697,6 +704,7 @@ def __init__( self._client = client self._owns_client = client is None + self._allow_runtime_connection_overrides = allow_runtime_connection_overrides # Parse options opts: dict[str, Any] = dict(default_options) if default_options else {} @@ -894,6 +902,7 @@ def run( When stream=True: A ResponseStream of AgentResponseUpdate items. Raises: + AgentInvalidRequestException: If runtime connection configuration is supplied without constructor opt-in. AgentException: If the request fails. """ if middleware: @@ -966,13 +975,15 @@ async def _run_impl( function_invocation_kwargs: Mapping[str, Any] | None = None, ) -> AgentResponse: """Non-streaming implementation of run.""" + opts: dict[str, Any] = dict(options) if options else {} + self._validate_runtime_connection_options(opts) + if not self._started: await self.start() if not session: session = self.create_session() - opts: dict[str, Any] = dict(options) if options else {} if "on_function_approval" in opts: raise ValueError( "on_function_approval is a security-sensitive option and must be set " @@ -1103,13 +1114,15 @@ async def _stream_updates( Raises: AgentException: If the request fails. """ + opts: dict[str, Any] = dict(options) if options else {} + self._validate_runtime_connection_options(opts) + if not self._started: await self.start() if not session: session = self.create_session() - opts: dict[str, Any] = dict(options) if options else {} if "on_function_approval" in opts: raise ValueError( "on_function_approval is a security-sensitive option and must be set " @@ -1714,6 +1727,17 @@ def _warn_once_about_unloaded_file_hooks(self, session_working_directory: str | working_directory / ".github" / "hooks", ) + def _validate_runtime_connection_options(self, options: Mapping[str, Any]) -> None: + if self._allow_runtime_connection_overrides: + return + reserved = {"mcp_servers", "provider"}.intersection(options) + if reserved: + raise AgentInvalidRequestException( + f"Runtime connection options are disabled: {', '.join(sorted(reserved))}. " + "Configure connections through default_options or set allow_runtime_connection_overrides=True " + "when constructing the agent." + ) + def _build_session_kwargs( self, streaming: bool, @@ -1725,11 +1749,11 @@ def _build_session_kwargs( """Assemble keyword arguments for ``create_session`` / ``resume_session``. Options are layered: the agent's ``default_options`` first, then per-run - ``runtime_options`` which override them. Every key is forwarded verbatim to + ``runtime_options`` which override them. Every accepted key is forwarded verbatim to the Copilot SDK, so any ``create_session`` parameter is supported without a dedicated mapping here (an unknown name surfaces as a ``TypeError`` from the - SDK). A few keys are handled specially because they need a specific default or - transforming: ``on_permission_request`` defaults to denying all requests and is + SDK). Runtime connection options require constructor opt-in. A few keys need + specific defaults or transformations: ``on_permission_request`` defaults to denying all requests and is wrapped so under-specified ``approve-for-session`` decisions are scoped to the request that triggered them, ``enable_file_hooks`` defaults to off, ``tools`` are merged with the agent's tools and converted to SDK tools, and approval callbacks @@ -1748,6 +1772,7 @@ def _build_session_kwargs( The keyword arguments to splat into the SDK session factory. """ opts = runtime_options or {} + self._validate_runtime_connection_options(opts) # Passthrough layer: agent defaults first, per-run options override. kwargs: dict[str, Any] = {**self._default_options, **opts} @@ -1923,6 +1948,7 @@ def __init__( middleware: Sequence[AgentMiddlewareTypes] | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, default_options: OptionsT | None = None, + allow_runtime_connection_overrides: bool = False, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -1943,6 +1969,8 @@ def __init__( These are converted to Copilot SDK tools internally. default_options: Default options for the agent. Can include cli_path, model, timeout, log_level, etc. + allow_runtime_connection_overrides: Allow per-run `provider` and `mcp_servers` configuration. + Defaults to False. Configure application-owned connections through `default_options` instead. env_file_path: Optional path to .env file for loading configuration. env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. """ @@ -1956,6 +1984,7 @@ def __init__( middleware=middleware, tools=tools, default_options=default_options, + allow_runtime_connection_overrides=allow_runtime_connection_overrides, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 175398a3d23..2067f3a9c15 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -32,9 +32,10 @@ MCPStdioTool, Message, ResponseStream, + SessionStore, tool, ) -from agent_framework.exceptions import AgentException +from agent_framework.exceptions import AgentException, AgentInvalidRequestException from copilot.session import PermissionHandler, PermissionInvocation, PreToolUseHookInput from copilot.session_events import ( AssistantUsageData, @@ -1647,6 +1648,174 @@ async def test_disabled_skills_override_on_resumed_session( assert config["disabled_skills"] == ["override-skill"] +class TestGitHubCopilotAgentRuntimeConnections: + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resume", [False, True]) + @pytest.mark.parametrize("configured_connections", [False, True]) + @pytest.mark.parametrize( + "options", + [ + {"provider": {"type": "openai", "base_url": "https://example.com/api"}}, + {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, + ], + ) + async def test_run_rejects_runtime_connections_by_default( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + stream: bool, + resume: bool, + configured_connections: bool, + options: GitHubCopilotOptions, + mock_client: MagicMock, + ) -> None: + agent = agent_type(client=mock_client, default_options=options if configured_connections else None) + session = AgentSession(service_session_id="existing-session" if resume else None) + + with pytest.raises(AgentInvalidRequestException, match="allow_runtime_connection_overrides=True"): + if stream: + async for _ in agent.run("Hello", session=session, options=options, stream=True): + pass + else: + await agent.run("Hello", session=session, options=options) + + mock_client.start.assert_not_awaited() + mock_client.create_session.assert_not_awaited() + mock_client.resume_session.assert_not_awaited() + assert agent.default_options == (options if configured_connections else {}) + + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resume", [False, True]) + @pytest.mark.parametrize("runtime_connections", [False, True]) + async def test_run_preserves_defaults_and_supports_explicit_runtime_opt_in( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + stream: bool, + resume: bool, + runtime_connections: bool, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + defaults: GitHubCopilotOptions = { + "provider": {"type": "azure", "base_url": "https://application.example/api"}, + "mcp_servers": {"application": {"type": "http", "url": "https://application.example/mcp", "tools": ["*"]}}, + } + options: GitHubCopilotOptions = {"model": "runtime-model", "timeout": 3} + if runtime_connections: + options["provider"] = {"type": "openai", "base_url": "https://runtime.example/api"} + options["mcp_servers"] = {"runtime": {"type": "http", "url": "https://runtime.example/mcp", "tools": ["*"]}} + + def mock_on(handler: Any) -> Any: + handler(assistant_delta_event) + handler(session_idle_event) + return lambda: None + + mock_session.on = mock_on + mock_session.send_and_wait.return_value = assistant_message_event + agent = agent_type( + client=mock_client, + default_options=defaults, + allow_runtime_connection_overrides=runtime_connections, + ) + session = AgentSession(service_session_id="existing-session" if resume else None) + if stream: + updates = [update async for update in agent.run("Hello", session=session, options=options, stream=True)] + assert "".join(update.text for update in updates) == "Hello" + else: + response = await agent.run("Hello", session=session, options=options) + assert response.text == "Test response" + + config = (mock_client.resume_session if resume else mock_client.create_session).call_args.kwargs + assert config["provider"] == (options["provider"] if runtime_connections else defaults["provider"]) + assert config["mcp_servers"] == (options["mcp_servers"] if runtime_connections else defaults["mcp_servers"]) + assert config["model"] == "runtime-model" + assert "allow_runtime_connection_overrides" not in config + assert agent.default_options == defaults + + async def test_runtime_options_cannot_enable_connection_overrides(self, mock_client: MagicMock) -> None: + agent: RawGitHubCopilotAgent[GitHubCopilotOptions] = RawGitHubCopilotAgent(client=mock_client) + with pytest.raises(AgentInvalidRequestException, match="provider"): + await agent.run( + "Hello", + options=cast(GitHubCopilotOptions, {"provider": {}, "allow_runtime_connection_overrides": True}), + ) + mock_client.create_session.assert_not_awaited() + + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + @pytest.mark.parametrize("stream", [False, True]) + async def test_invocations_preserves_explicit_runtime_connection_opt_in( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + stream: bool, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + hosting = pytest.importorskip("agent_framework_foundry_hosting") + import httpx + + def mock_on(handler: Any) -> Any: + handler(assistant_delta_event) + handler(session_idle_event) + return lambda: None + + mock_session.on = mock_on + mock_session.send_and_wait.return_value = assistant_message_event + store = MagicMock(spec=SessionStore) + store.get.return_value = None + store_provider = MagicMock(spec=hosting.StoreProvider) + store_provider.get_store.return_value = store + agent = agent_type(client=mock_client, allow_runtime_connection_overrides=True) + server = hosting.InvocationsHostServer(agent, agent_session_store_provider=store_provider) + options: GitHubCopilotOptions = { + "provider": {"type": "openai", "base_url": "https://runtime.example/api"}, + "mcp_servers": {"runtime": {"type": "http", "url": "https://runtime.example/mcp", "tools": ["*"]}}, + } + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=server), base_url="http://test") as client: + response = await client.post( + "/invocations", + params={"agent_session_id": f"trusted-connections-{uuid4().hex}"}, + json={"message": "Hello", "stream": stream, "options": options}, + ) + + assert response.status_code == 200 + if stream: + assert 'event: delta\ndata: {"text": "Hello"}' in response.text + assert "event: done" in response.text + assert "event: error" not in response.text + else: + assert response.json() == {"response": "Test response"} + config = mock_client.create_session.call_args.kwargs + assert config["provider"] == options["provider"] + assert config["mcp_servers"] == options["mcp_servers"] + store.set.assert_awaited_once() + + @pytest.mark.parametrize( + "options", [{"provider": None}, {"provider": {}}, {"mcp_servers": None}, {"mcp_servers": {}}] + ) + async def test_empty_runtime_connection_options_are_rejected( + self, options: dict[str, Any], mock_client: MagicMock + ) -> None: + agent: RawGitHubCopilotAgent[GitHubCopilotOptions] = RawGitHubCopilotAgent(client=mock_client) + with pytest.raises(AgentInvalidRequestException, match="Runtime connection options are disabled"): + await agent.run("Hello", options=cast(GitHubCopilotOptions, options)) + mock_client.start.assert_not_awaited() + + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + @pytest.mark.parametrize("value", [None, 1, "false"]) + def test_connection_override_policy_requires_a_boolean( + self, agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], value: Any + ) -> None: + with pytest.raises(TypeError, match="allow_runtime_connection_overrides must be a bool"): + agent_type(allow_runtime_connection_overrides=value) + + class TestGitHubCopilotAgentMCPServers: """Test cases for MCP server configuration.""" @@ -1849,6 +2018,7 @@ async def test_runtime_provider_takes_precedence( agent = GitHubCopilotAgent( client=mock_client, default_options=copilot_options({"provider": default_provider}), + allow_runtime_connection_overrides=True, ) await agent.start() From 3a65ab60fefec2e84709e953f30b6e1cdf89aa51 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:50:53 -0700 Subject: [PATCH 03/13] test(copilot): isolate hosted SDK telemetry Host construction configures global SDK telemetry and contaminates later Responses log-sanitization tests. Mock SDK tracing for this HTTP test only, matching Foundry's existing isolation fixture. Refs #9611 --- .../github_copilot/tests/test_github_copilot_agent.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 2067f3a9c15..e87db6e0e4d 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -1751,6 +1751,7 @@ async def test_invocations_preserves_explicit_runtime_connection_opt_in( self, agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], stream: bool, + monkeypatch: pytest.MonkeyPatch, mock_client: MagicMock, mock_session: MagicMock, assistant_message_event: SessionEvent, @@ -1760,6 +1761,9 @@ async def test_invocations_preserves_explicit_runtime_connection_opt_in( hosting = pytest.importorskip("agent_framework_foundry_hosting") import httpx + # Host construction configures process-wide SDK telemetry, so isolate it as the Foundry tests do. + monkeypatch.setattr("azure.ai.agentserver.core._tracing._configure_tracing", lambda **kwargs: None) + def mock_on(handler: Any) -> Any: handler(assistant_delta_event) handler(session_idle_event) From 015578d97e702e9d60e5dc90e2b41bf38e082b88 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:01:50 -0700 Subject: [PATCH 04/13] fix(copilot): block nested MCP overrides Runtime custom agents could carry mcp_servers past top-level validation and reach Copilot SDK session configuration. Apply the same explicit opt-in boundary to nested MCP settings while preserving application defaults and custom agents without MCP connections. Refs #9611 --- ...0046-python-copilot-runtime-connections.md | 18 +-- python/packages/foundry_hosting/README.md | 7 +- .../foundry_hosting/tests/test_invocations.py | 9 ++ python/packages/github_copilot/AGENTS.md | 13 +- python/packages/github_copilot/README.md | 28 ++-- .../agent_framework_github_copilot/_agent.py | 26 +++- .../tests/test_github_copilot_agent.py | 121 ++++++++++++++++-- 7 files changed, 172 insertions(+), 50 deletions(-) diff --git a/docs/decisions/0046-python-copilot-runtime-connections.md b/docs/decisions/0046-python-copilot-runtime-connections.md index 4c728e22551..9658d3b5896 100644 --- a/docs/decisions/0046-python-copilot-runtime-connections.md +++ b/docs/decisions/0046-python-copilot-runtime-connections.md @@ -10,9 +10,10 @@ deciders: rogerbarreto, eavanvalkenburg, westey-m ## Context and Problem Statement The Invocations host passes application-parsed options to agents. For the Copilot -agent, `provider` and `mcp_servers` are serializable SDK connection configurations, -not Python client objects. Forwarding unfiltered request options can therefore -change the connections selected for a session. +agent, `provider`, top-level `mcp_servers`, and MCP servers nested in +`custom_agents` are serializable SDK connection configurations, not Python client +objects. Forwarding unfiltered request options can therefore change the +connections selected for a session. The same option names can have different meanings for other agent implementations. An Invocations-wide denylist also rejects connection settings produced by trusted @@ -42,11 +43,12 @@ application parsers and option hooks. The compatibility concern is tracked in Choose the agent-owned policy. Both `GitHubCopilotAgent` and `RawGitHubCopilotAgent` expose `allow_runtime_connection_overrides=False`. -They reject per-run `provider` and `mcp_servers`, including empty or null values, -before SDK startup and again before assembling SDK session configuration. -Application `default_options` remain allowed, and ordinary runtime options retain -their existing behavior. The constructor flag is never read from run options or -forwarded to the SDK. +They reject per-run `provider`, top-level `mcp_servers`, and +`custom_agents[].mcp_servers`, including empty or null values, before SDK startup +and again before assembling SDK session configuration. Application +`default_options` remain allowed, runtime custom agents without MCP configuration +remain supported, and ordinary runtime options retain their existing behavior. The +constructor flag is never read from run options or forwarded to the SDK. Trusted applications may opt in to preserve runtime connection precedence over defaults. This is an intentional behavioral compatibility change: existing callers diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index e1d3424b8ee..c869ac86c4d 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -398,9 +398,10 @@ arguments belong in developer-configured agent defaults or middleware/factories, A hook may strip denied caller fields before validation; allowed generation and provider-specific options remain available. These restrictions apply to both wire formats and every `unsupported_options` policy. Connection option names such as `provider` and `mcp_servers` are not reserved host-wide. -The hosted agent owns their policy. The updated `GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject those runtime -options by default, while preserving connections in `default_options`. Trusted applications can enable them with the -constructor-only `allow_runtime_connection_overrides=True` flag and filter request input through `prepare_options`. +The hosted agent owns their policy. The updated `GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject top-level +runtime connections and MCP servers nested under `custom_agents` by default, while preserving connections in +`default_options`. Trusted applications can enable them with the constructor-only +`allow_runtime_connection_overrides=True` flag and filter request input through `prepare_options`. Update the Copilot package as well as the host to obtain that default-deny policy; older Copilot versions retain their existing runtime connection behavior. When an agent cannot accept runtime options, `unsupported_options="warn"` (default) logs and ignores diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index 52f2b001066..3bf08e8581e 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -951,6 +951,15 @@ def create_host() -> InvocationsHostServer: [ {"provider": {"type": "openai", "base_url": "https://example.com/api"}}, {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, + { + "custom_agents": [ + { + "name": "runtime", + "prompt": "Use the runtime MCP server.", + "mcp_servers": {"runtime": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}, + } + ] + }, ], ) async def test_connection_options_are_governed_by_the_agent( diff --git a/python/packages/github_copilot/AGENTS.md b/python/packages/github_copilot/AGENTS.md index 953ecc2a0aa..4fd8e27563e 100644 --- a/python/packages/github_copilot/AGENTS.md +++ b/python/packages/github_copilot/AGENTS.md @@ -27,12 +27,13 @@ from agent_framework_github_copilot import GitHubCopilotAgent ## Session Option Defaults -`GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject per-run `provider` and -`mcp_servers` by default. Application defaults remain supported; runtime connection -configuration requires the constructor-only `allow_runtime_connection_overrides=True` -flag. Validate before SDK startup and again when assembling session kwargs so -creation and resumption share the policy. Ordinary runtime options keep their -existing precedence. +`GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject per-run `provider`, +top-level `mcp_servers`, and `custom_agents[].mcp_servers` by default. Application +defaults remain supported; runtime connection configuration requires the +constructor-only `allow_runtime_connection_overrides=True` flag. Validate before +SDK startup and again when assembling session kwargs so creation and resumption +share the policy. Runtime custom agents without MCP configuration and ordinary +runtime options keep their existing behavior. `_build_session_kwargs` forwards options to the SDK verbatim except for a few keys that get an explicit default: `on_permission_request` (deny-all) and `enable_file_hooks` (`False`, so diff --git a/python/packages/github_copilot/README.md b/python/packages/github_copilot/README.md index b5b40569ce0..32ed533cb4b 100644 --- a/python/packages/github_copilot/README.md +++ b/python/packages/github_copilot/README.md @@ -13,11 +13,13 @@ The GitHub Copilot agent enables integration with GitHub Copilot, allowing you t ## Runtime connection configuration `GitHubCopilotAgent` and `RawGitHubCopilotAgent` default to -`allow_runtime_connection_overrides=False`. Per-run `provider` and `mcp_servers` -options raise `AgentInvalidRequestException` before the agent starts the SDK client, -creates a session, or resumes one. This includes supplying a connection when no -default is configured, and clearing a configuration with an empty object or `None`. -Ordinary runtime options, such as `model` and `timeout`, remain supported. +`allow_runtime_connection_overrides=False`. Per-run `provider`, top-level +`mcp_servers`, and `custom_agents[].mcp_servers` options raise +`AgentInvalidRequestException` before the agent starts the SDK client, creates a +session, or resumes one. This includes supplying a connection when no default is +configured, and clearing a configuration with an empty object or `None`. Runtime +custom agents without `mcp_servers` remain supported. Ordinary runtime options, +such as `model` and `timeout`, remain supported. Configure application-owned connections in `default_options`: @@ -31,20 +33,20 @@ agent = GitHubCopilotAgent( ) ``` -**Migration:** existing code passing `provider` or `mcp_servers` through -`run(options=...)` must move those settings to `default_options`, or explicitly -enable trusted runtime connection configuration: +**Migration:** existing code passing `provider`, top-level `mcp_servers`, or +custom-agent MCP servers through `run(options=...)` must move those settings to +`default_options`, or explicitly enable trusted runtime connection configuration: ```python agent = GitHubCopilotAgent(allow_runtime_connection_overrides=True) ``` With opt-in, runtime connection options retain their existing precedence over -defaults. The flag is a constructor argument, not an SDK session option; it cannot -be enabled through the incoming options mapping. Apply the same policy to both -streaming and non-streaming calls. Applications opting in are responsible for -validating or filtering connection settings received over HTTP or from other -untrusted inputs. +defaults, including replacement custom-agent configurations. The flag is a +constructor argument, not an SDK session option; it cannot be enabled through the +incoming options mapping. Apply the same policy to both streaming and non-streaming +calls. Applications opting in are responsible for validating or filtering +connection settings received over HTTP or from other untrusted inputs. ## Tool approval (`approval_mode="always_require"`) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index c648a705812..91c5068c1a5 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -504,8 +504,9 @@ class GitHubCopilotOptions(TypedDict, total=False): ``reasoning_effort``, ``context_tier``, ``enable_citations``, ``available_tools``, ``memory``, ...) may also be supplied and is forwarded verbatim to the SDK. An unrecognized parameter name surfaces as a ``TypeError`` from the SDK, so typos are - caught rather than silently ignored. Per-run ``provider`` and ``mcp_servers`` require - constructor opt-in through ``allow_runtime_connection_overrides=True``. + caught rather than silently ignored. Per-run ``provider``, top-level ``mcp_servers``, + and ``custom_agents[].mcp_servers`` require constructor opt-in through + ``allow_runtime_connection_overrides=True``. """ system_message: SystemMessageConfig @@ -683,8 +684,9 @@ def __init__( or tool definition dicts. These are converted to Copilot SDK tools internally. default_options: Default options for the agent. Can include cli_path, model, timeout, log_level, etc. - allow_runtime_connection_overrides: Allow per-run `provider` and `mcp_servers` configuration. - Defaults to False. Configure application-owned connections through `default_options` instead. + allow_runtime_connection_overrides: Allow per-run `provider`, top-level `mcp_servers`, + and `custom_agents[].mcp_servers` configuration. Defaults to False. Configure + application-owned connections through `default_options` instead. env_file_path: Optional path to .env file for loading configuration. env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. @@ -1731,6 +1733,17 @@ def _validate_runtime_connection_options(self, options: Mapping[str, Any]) -> No if self._allow_runtime_connection_overrides: return reserved = {"mcp_servers", "provider"}.intersection(options) + custom_agents = options.get("custom_agents") + if ( + isinstance(custom_agents, Sequence) + and not isinstance(custom_agents, (str, bytes, bytearray)) + and any( + "mcp_servers" in cast(Mapping[object, object], custom_agent) + for custom_agent in cast(Sequence[object], custom_agents) + if isinstance(custom_agent, Mapping) + ) + ): + reserved.add("custom_agents[].mcp_servers") if reserved: raise AgentInvalidRequestException( f"Runtime connection options are disabled: {', '.join(sorted(reserved))}. " @@ -1969,8 +1982,9 @@ def __init__( These are converted to Copilot SDK tools internally. default_options: Default options for the agent. Can include cli_path, model, timeout, log_level, etc. - allow_runtime_connection_overrides: Allow per-run `provider` and `mcp_servers` configuration. - Defaults to False. Configure application-owned connections through `default_options` instead. + allow_runtime_connection_overrides: Allow per-run `provider`, top-level `mcp_servers`, + and `custom_agents[].mcp_servers` configuration. Defaults to False. Configure + application-owned connections through `default_options` instead. env_file_path: Optional path to .env file for loading configuration. env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. """ diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index e87db6e0e4d..ad0908c0814 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -1658,6 +1658,15 @@ class TestGitHubCopilotAgentRuntimeConnections: [ {"provider": {"type": "openai", "base_url": "https://example.com/api"}}, {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, + { + "custom_agents": [ + { + "name": "runtime", + "prompt": "Use the runtime MCP server.", + "mcp_servers": {"runtime": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}, + } + ] + }, ], ) async def test_run_rejects_runtime_connections_by_default( @@ -1687,27 +1696,79 @@ async def test_run_rejects_runtime_connections_by_default( @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) @pytest.mark.parametrize("stream", [False, True]) @pytest.mark.parametrize("resume", [False, True]) - @pytest.mark.parametrize("runtime_connections", [False, True]) + @pytest.mark.parametrize("runtime_mode", ["defaults", "custom_agent", "connections"]) async def test_run_preserves_defaults_and_supports_explicit_runtime_opt_in( self, agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], stream: bool, resume: bool, - runtime_connections: bool, + runtime_mode: str, mock_client: MagicMock, mock_session: MagicMock, assistant_message_event: SessionEvent, assistant_delta_event: SessionEvent, session_idle_event: SessionEvent, ) -> None: - defaults: GitHubCopilotOptions = { - "provider": {"type": "azure", "base_url": "https://application.example/api"}, - "mcp_servers": {"application": {"type": "http", "url": "https://application.example/mcp", "tools": ["*"]}}, - } - options: GitHubCopilotOptions = {"model": "runtime-model", "timeout": 3} - if runtime_connections: - options["provider"] = {"type": "openai", "base_url": "https://runtime.example/api"} - options["mcp_servers"] = {"runtime": {"type": "http", "url": "https://runtime.example/mcp", "tools": ["*"]}} + application_custom_agents: list[dict[str, Any]] = [ + { + "name": "application", + "prompt": "Use the application MCP server.", + "mcp_servers": { + "application": { + "type": "http", + "url": "https://application.example/custom-agent-mcp", + "tools": ["*"], + } + }, + } + ] + defaults = cast( + GitHubCopilotOptions, + { + "provider": {"type": "azure", "base_url": "https://application.example/api"}, + "mcp_servers": { + "application": {"type": "http", "url": "https://application.example/mcp", "tools": ["*"]} + }, + "custom_agents": application_custom_agents, + }, + ) + runtime_custom_agents: list[dict[str, Any]] | None = None + if runtime_mode == "connections": + runtime_custom_agents = [ + { + "name": "runtime", + "prompt": "Use the runtime MCP server.", + "mcp_servers": { + "runtime": { + "type": "http", + "url": "https://runtime.example/custom-agent-mcp", + "tools": ["*"], + } + }, + } + ] + options = cast( + GitHubCopilotOptions, + { + "model": "runtime-model", + "timeout": 3, + "provider": {"type": "openai", "base_url": "https://runtime.example/api"}, + "mcp_servers": {"runtime": {"type": "http", "url": "https://runtime.example/mcp", "tools": ["*"]}}, + "custom_agents": runtime_custom_agents, + }, + ) + elif runtime_mode == "custom_agent": + runtime_custom_agents = [{"name": "runtime", "prompt": "Use only built-in tools."}] + options = cast( + GitHubCopilotOptions, + { + "model": "runtime-model", + "timeout": 3, + "custom_agents": runtime_custom_agents, + }, + ) + else: + options = GitHubCopilotOptions(model="runtime-model", timeout=3) def mock_on(handler: Any) -> Any: handler(assistant_delta_event) @@ -1719,7 +1780,7 @@ def mock_on(handler: Any) -> Any: agent = agent_type( client=mock_client, default_options=defaults, - allow_runtime_connection_overrides=runtime_connections, + allow_runtime_connection_overrides=runtime_mode == "connections", ) session = AgentSession(service_session_id="existing-session" if resume else None) if stream: @@ -1730,8 +1791,11 @@ def mock_on(handler: Any) -> Any: assert response.text == "Test response" config = (mock_client.resume_session if resume else mock_client.create_session).call_args.kwargs - assert config["provider"] == (options["provider"] if runtime_connections else defaults["provider"]) - assert config["mcp_servers"] == (options["mcp_servers"] if runtime_connections else defaults["mcp_servers"]) + assert config["provider"] == (options["provider"] if runtime_mode == "connections" else defaults["provider"]) + assert config["mcp_servers"] == ( + options["mcp_servers"] if runtime_mode == "connections" else defaults["mcp_servers"] + ) + assert config["custom_agents"] == (runtime_custom_agents or application_custom_agents) assert config["model"] == "runtime-model" assert "allow_runtime_connection_overrides" not in config assert agent.default_options == defaults @@ -1777,10 +1841,30 @@ def mock_on(handler: Any) -> Any: store_provider.get_store.return_value = store agent = agent_type(client=mock_client, allow_runtime_connection_overrides=True) server = hosting.InvocationsHostServer(agent, agent_session_store_provider=store_provider) + runtime_custom_agents: list[dict[str, Any]] = [ + { + "name": "runtime", + "prompt": "Use the runtime MCP server.", + "mcp_servers": { + "runtime": { + "type": "http", + "url": "https://runtime.example/custom-agent-mcp", + "tools": ["*"], + } + }, + } + ] options: GitHubCopilotOptions = { "provider": {"type": "openai", "base_url": "https://runtime.example/api"}, "mcp_servers": {"runtime": {"type": "http", "url": "https://runtime.example/mcp", "tools": ["*"]}}, } + options = cast( + GitHubCopilotOptions, + { + **options, + "custom_agents": runtime_custom_agents, + }, + ) async with httpx.AsyncClient(transport=httpx.ASGITransport(app=server), base_url="http://test") as client: response = await client.post( "/invocations", @@ -1798,10 +1882,19 @@ def mock_on(handler: Any) -> Any: config = mock_client.create_session.call_args.kwargs assert config["provider"] == options["provider"] assert config["mcp_servers"] == options["mcp_servers"] + assert config["custom_agents"] == runtime_custom_agents store.set.assert_awaited_once() @pytest.mark.parametrize( - "options", [{"provider": None}, {"provider": {}}, {"mcp_servers": None}, {"mcp_servers": {}}] + "options", + [ + {"provider": None}, + {"provider": {}}, + {"mcp_servers": None}, + {"mcp_servers": {}}, + {"custom_agents": [{"name": "runtime", "prompt": "Empty MCP", "mcp_servers": None}]}, + {"custom_agents": [{"name": "runtime", "prompt": "Empty MCP", "mcp_servers": {}}]}, + ], ) async def test_empty_runtime_connection_options_are_rejected( self, options: dict[str, Any], mock_client: MagicMock From ae88bf50996e5c4cedf7c783d4acf20e7251b8e2 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:50:51 -0700 Subject: [PATCH 05/13] fix(copilot): block runtime providers registry --- docs/decisions/0046-python-copilot-runtime-connections.md | 4 ++-- python/packages/github_copilot/AGENTS.md | 2 +- python/packages/github_copilot/README.md | 4 ++-- .../agent_framework_github_copilot/_agent.py | 2 +- .../github_copilot/tests/test_github_copilot_agent.py | 7 +++++++ 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/decisions/0046-python-copilot-runtime-connections.md b/docs/decisions/0046-python-copilot-runtime-connections.md index 9658d3b5896..9a22ad60fe4 100644 --- a/docs/decisions/0046-python-copilot-runtime-connections.md +++ b/docs/decisions/0046-python-copilot-runtime-connections.md @@ -10,7 +10,7 @@ deciders: rogerbarreto, eavanvalkenburg, westey-m ## Context and Problem Statement The Invocations host passes application-parsed options to agents. For the Copilot -agent, `provider`, top-level `mcp_servers`, and MCP servers nested in +agent, `provider`, the named `providers` registry, top-level `mcp_servers`, and MCP servers nested in `custom_agents` are serializable SDK connection configurations, not Python client objects. Forwarding unfiltered request options can therefore change the connections selected for a session. @@ -43,7 +43,7 @@ application parsers and option hooks. The compatibility concern is tracked in Choose the agent-owned policy. Both `GitHubCopilotAgent` and `RawGitHubCopilotAgent` expose `allow_runtime_connection_overrides=False`. -They reject per-run `provider`, top-level `mcp_servers`, and +They reject per-run `provider`, `providers`, top-level `mcp_servers`, and `custom_agents[].mcp_servers`, including empty or null values, before SDK startup and again before assembling SDK session configuration. Application `default_options` remain allowed, runtime custom agents without MCP configuration diff --git a/python/packages/github_copilot/AGENTS.md b/python/packages/github_copilot/AGENTS.md index 4fd8e27563e..e0a2abf51ef 100644 --- a/python/packages/github_copilot/AGENTS.md +++ b/python/packages/github_copilot/AGENTS.md @@ -27,7 +27,7 @@ from agent_framework_github_copilot import GitHubCopilotAgent ## Session Option Defaults -`GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject per-run `provider`, +`GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject per-run `provider`, `providers`, top-level `mcp_servers`, and `custom_agents[].mcp_servers` by default. Application defaults remain supported; runtime connection configuration requires the constructor-only `allow_runtime_connection_overrides=True` flag. Validate before diff --git a/python/packages/github_copilot/README.md b/python/packages/github_copilot/README.md index 32ed533cb4b..2df0351e251 100644 --- a/python/packages/github_copilot/README.md +++ b/python/packages/github_copilot/README.md @@ -13,7 +13,7 @@ The GitHub Copilot agent enables integration with GitHub Copilot, allowing you t ## Runtime connection configuration `GitHubCopilotAgent` and `RawGitHubCopilotAgent` default to -`allow_runtime_connection_overrides=False`. Per-run `provider`, top-level +`allow_runtime_connection_overrides=False`. Per-run `provider`, `providers`, top-level `mcp_servers`, and `custom_agents[].mcp_servers` options raise `AgentInvalidRequestException` before the agent starts the SDK client, creates a session, or resumes one. This includes supplying a connection when no default is @@ -33,7 +33,7 @@ agent = GitHubCopilotAgent( ) ``` -**Migration:** existing code passing `provider`, top-level `mcp_servers`, or +**Migration:** existing code passing `provider`, `providers`, top-level `mcp_servers`, or custom-agent MCP servers through `run(options=...)` must move those settings to `default_options`, or explicitly enable trusted runtime connection configuration: diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 91c5068c1a5..661f1b7fc4b 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -1732,7 +1732,7 @@ def _warn_once_about_unloaded_file_hooks(self, session_working_directory: str | def _validate_runtime_connection_options(self, options: Mapping[str, Any]) -> None: if self._allow_runtime_connection_overrides: return - reserved = {"mcp_servers", "provider"}.intersection(options) + reserved = {"mcp_servers", "provider", "providers"}.intersection(options) custom_agents = options.get("custom_agents") if ( isinstance(custom_agents, Sequence) diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index ad0908c0814..e74bc4bcaae 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -1890,6 +1890,13 @@ def mock_on(handler: Any) -> Any: [ {"provider": None}, {"provider": {}}, + {"providers": None}, + {"providers": []}, + { + "providers": [{"name": "runtime", "type": "openai", "base_url": "https://runtime.example/api"}], + "models": [{"id": "runtime-model", "provider": "runtime"}], + "model": "runtime-model", + }, {"mcp_servers": None}, {"mcp_servers": {}}, {"custom_agents": [{"name": "runtime", "prompt": "Empty MCP", "mcp_servers": None}]}, From d9e97b601fbe665d35f2978c6fd87ae5c5b12f05 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:12:51 -0700 Subject: [PATCH 06/13] test(copilot): cover named provider policy Exercise default rejection and explicit opt-in across direct and hosted execution so the connection boundary cannot regress silently. --- .../foundry_hosting/tests/test_invocations.py | 5 + .../agent_framework_github_copilot/_agent.py | 16 +-- .../tests/test_github_copilot_agent.py | 132 ++++++++++++++++++ 3 files changed, 145 insertions(+), 8 deletions(-) diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index 3bf08e8581e..3cb384e72d2 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -950,6 +950,11 @@ def create_host() -> InvocationsHostServer: "options", [ {"provider": {"type": "openai", "base_url": "https://example.com/api"}}, + { + "providers": [{"name": "runtime", "type": "openai", "base_url": "https://example.com/api"}], + "models": [{"id": "runtime-model", "provider": "runtime"}], + "model": "runtime/runtime-model", + }, {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, { "custom_agents": [ diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 661f1b7fc4b..2bbdf4cfaf3 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -504,8 +504,8 @@ class GitHubCopilotOptions(TypedDict, total=False): ``reasoning_effort``, ``context_tier``, ``enable_citations``, ``available_tools``, ``memory``, ...) may also be supplied and is forwarded verbatim to the SDK. An unrecognized parameter name surfaces as a ``TypeError`` from the SDK, so typos are - caught rather than silently ignored. Per-run ``provider``, top-level ``mcp_servers``, - and ``custom_agents[].mcp_servers`` require constructor opt-in through + caught rather than silently ignored. Per-run ``provider``, ``providers``, top-level + ``mcp_servers``, and ``custom_agents[].mcp_servers`` require constructor opt-in through ``allow_runtime_connection_overrides=True``. """ @@ -684,9 +684,9 @@ def __init__( or tool definition dicts. These are converted to Copilot SDK tools internally. default_options: Default options for the agent. Can include cli_path, model, timeout, log_level, etc. - allow_runtime_connection_overrides: Allow per-run `provider`, top-level `mcp_servers`, - and `custom_agents[].mcp_servers` configuration. Defaults to False. Configure - application-owned connections through `default_options` instead. + allow_runtime_connection_overrides: Allow per-run `provider`, `providers`, top-level + `mcp_servers`, and `custom_agents[].mcp_servers` configuration. Defaults to False. + Configure application-owned connections through `default_options` instead. env_file_path: Optional path to .env file for loading configuration. env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. @@ -1982,9 +1982,9 @@ def __init__( These are converted to Copilot SDK tools internally. default_options: Default options for the agent. Can include cli_path, model, timeout, log_level, etc. - allow_runtime_connection_overrides: Allow per-run `provider`, top-level `mcp_servers`, - and `custom_agents[].mcp_servers` configuration. Defaults to False. Configure - application-owned connections through `default_options` instead. + allow_runtime_connection_overrides: Allow per-run `provider`, `providers`, top-level + `mcp_servers`, and `custom_agents[].mcp_servers` configuration. Defaults to False. + Configure application-owned connections through `default_options` instead. env_file_path: Optional path to .env file for loading configuration. env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. """ diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index e74bc4bcaae..484738db115 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -1657,6 +1657,11 @@ class TestGitHubCopilotAgentRuntimeConnections: "options", [ {"provider": {"type": "openai", "base_url": "https://example.com/api"}}, + { + "providers": [{"name": "runtime", "type": "openai", "base_url": "https://example.com/api"}], + "models": [{"id": "runtime-model", "provider": "runtime"}], + "model": "runtime/runtime-model", + }, {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, { "custom_agents": [ @@ -1800,6 +1805,72 @@ def mock_on(handler: Any) -> Any: assert "allow_runtime_connection_overrides" not in config assert agent.default_options == defaults + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resume", [False, True]) + @pytest.mark.parametrize("source", ["defaults", "runtime"]) + async def test_named_provider_defaults_and_opt_in_are_forwarded( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + stream: bool, + resume: bool, + source: str, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + providers = [{"name": "runtime", "type": "openai", "base_url": "https://runtime.example/api"}] + models = [{"id": "runtime-model", "provider": "runtime"}] + connections = cast( + GitHubCopilotOptions, + { + "providers": providers, + "models": models, + "model": "runtime/runtime-model", + }, + ) + + def mock_on(handler: Any) -> Any: + handler(assistant_delta_event) + handler(session_idle_event) + return lambda: None + + mock_session.on = mock_on + mock_session.send_and_wait.return_value = assistant_message_event + agent = agent_type( + client=mock_client, + default_options=connections if source == "defaults" else None, + allow_runtime_connection_overrides=source == "runtime", + ) + session = AgentSession(service_session_id="existing-session" if resume else None) + + if stream: + updates = [ + update + async for update in agent.run( + "Hello", + session=session, + options=connections if source == "runtime" else None, + stream=True, + ) + ] + assert "".join(update.text for update in updates) == "Hello" + else: + response = await agent.run( + "Hello", + session=session, + options=connections if source == "runtime" else None, + ) + assert response.text == "Test response" + + config = (mock_client.resume_session if resume else mock_client.create_session).call_args.kwargs + assert config["providers"] == providers + assert config["models"] == models + assert config["model"] == "runtime/runtime-model" + assert agent.default_options == (connections if source == "defaults" else {}) + async def test_runtime_options_cannot_enable_connection_overrides(self, mock_client: MagicMock) -> None: agent: RawGitHubCopilotAgent[GitHubCopilotOptions] = RawGitHubCopilotAgent(client=mock_client) with pytest.raises(AgentInvalidRequestException, match="provider"): @@ -1885,6 +1956,67 @@ def mock_on(handler: Any) -> Any: assert config["custom_agents"] == runtime_custom_agents store.set.assert_awaited_once() + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + @pytest.mark.parametrize("stream", [False, True]) + async def test_invocations_preserves_named_provider_opt_in( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + stream: bool, + monkeypatch: pytest.MonkeyPatch, + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + hosting = pytest.importorskip("agent_framework_foundry_hosting") + import httpx + + monkeypatch.setattr("azure.ai.agentserver.core._tracing._configure_tracing", lambda **kwargs: None) + + def mock_on(handler: Any) -> Any: + handler(assistant_delta_event) + handler(session_idle_event) + return lambda: None + + mock_session.on = mock_on + mock_session.send_and_wait.return_value = assistant_message_event + store = MagicMock(spec=SessionStore) + store.get.return_value = None + store_provider = MagicMock(spec=hosting.StoreProvider) + store_provider.get_store.return_value = store + agent = agent_type(client=mock_client, allow_runtime_connection_overrides=True) + server = hosting.InvocationsHostServer(agent, agent_session_store_provider=store_provider) + providers = [{"name": "runtime", "type": "openai", "base_url": "https://runtime.example/api"}] + models = [{"id": "runtime-model", "provider": "runtime"}] + options = cast( + GitHubCopilotOptions, + { + "providers": providers, + "models": models, + "model": "runtime/runtime-model", + }, + ) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=server), base_url="http://test") as client: + response = await client.post( + "/invocations", + params={"agent_session_id": f"trusted-named-provider-{uuid4().hex}"}, + json={"message": "Hello", "stream": stream, "options": options}, + ) + + assert response.status_code == 200 + if stream: + assert 'event: delta\ndata: {"text": "Hello"}' in response.text + assert "event: done" in response.text + assert "event: error" not in response.text + else: + assert response.json() == {"response": "Test response"} + config = mock_client.create_session.call_args.kwargs + assert config["providers"] == providers + assert config["models"] == models + assert config["model"] == "runtime/runtime-model" + store.set.assert_awaited_once() + @pytest.mark.parametrize( "options", [ From 510cee3c64b48b5a84d56593254a0de3c14d8d1d Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:44:14 -0700 Subject: [PATCH 07/13] test(hosting): cover connection policy matrix Prove Copilot runtime connection rejection for request, parser, and hook options across creation, resumption, streaming, and non-streaming calls. --- .../foundry_hosting/tests/test_invocations.py | 74 ++++++++++--------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index 3cb384e72d2..05eb7c6c8d6 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -67,6 +67,25 @@ pytestmark = pytest.mark.filterwarnings("ignore:.*SessionStore is experimental.*") +_COPILOT_RUNTIME_CONNECTION_OPTIONS: list[dict[str, Any]] = [ + {"provider": {"type": "openai", "base_url": "https://example.com/api"}}, + { + "providers": [{"name": "runtime", "type": "openai", "base_url": "https://example.com/api"}], + "models": [{"id": "runtime-model", "provider": "runtime"}], + "model": "runtime/runtime-model", + }, + {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, + { + "custom_agents": [ + { + "name": "runtime", + "prompt": "Use the runtime MCP server.", + "mcp_servers": {"runtime": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}, + } + ] + }, +] + # region Helpers @@ -946,27 +965,7 @@ def create_host() -> InvocationsHostServer: @pytest.mark.parametrize("stream", [False, True]) @pytest.mark.parametrize("source", ["request", "parser", "hook"]) - @pytest.mark.parametrize( - "options", - [ - {"provider": {"type": "openai", "base_url": "https://example.com/api"}}, - { - "providers": [{"name": "runtime", "type": "openai", "base_url": "https://example.com/api"}], - "models": [{"id": "runtime-model", "provider": "runtime"}], - "model": "runtime/runtime-model", - }, - {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, - { - "custom_agents": [ - { - "name": "runtime", - "prompt": "Use the runtime MCP server.", - "mcp_servers": {"runtime": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}, - } - ] - }, - ], - ) + @pytest.mark.parametrize("options", _COPILOT_RUNTIME_CONNECTION_OPTIONS) async def test_connection_options_are_governed_by_the_agent( self, stream: bool, source: str, options: dict[str, Any] ) -> None: @@ -988,29 +987,38 @@ async def test_connection_options_are_governed_by_the_agent( assert agent.calls[0]["options"] == options @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resume", [False, True]) + @pytest.mark.parametrize("source", ["request", "parser", "hook"]) @pytest.mark.parametrize("agent_type", ["RawGitHubCopilotAgent", "GitHubCopilotAgent"]) - @pytest.mark.parametrize( - "options", - [ - {"provider": {"type": "openai", "base_url": "https://example.com/api"}}, - {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, - ], - ) + @pytest.mark.parametrize("options", _COPILOT_RUNTIME_CONNECTION_OPTIONS) async def test_http_route_reports_copilot_connection_policy_rejections_as_client_errors( - self, stream: bool, agent_type: str, options: dict[str, Any] + self, stream: bool, resume: bool, source: str, agent_type: str, options: dict[str, Any] ) -> None: copilot_agents = pytest.importorskip("agent_framework_github_copilot") sdk_client = MagicMock() sdk_client.start = AsyncMock() - sdk_client.create_session = AsyncMock() - sdk_client.resume_session = AsyncMock() + sdk_client.create_session = AsyncMock(side_effect=AssertionError("create_session must not be called")) + sdk_client.resume_session = AsyncMock(side_effect=AssertionError("resume_session must not be called")) agent = getattr(copilot_agents, agent_type)(client=sdk_client) - server = InvocationsHostServer(agent, agent_session_store_provider=_SessionStoreProvider(_mock_session_store())) + store = _mock_session_store() + if resume: + store.get.return_value = AgentSession( + session_id="connection-policy", + service_session_id="existing-session", + ) + server = InvocationsHostServer( + agent, + agent_session_store_provider=_SessionStoreProvider(store), + parse_request=(lambda _request: InvocationRun(messages="hi", options=options, stream=stream)) + if source == "parser" + else None, + prepare_options=(lambda _request, _options: options) if source == "hook" else None, + ) async with httpx.AsyncClient(transport=httpx.ASGITransport(app=server), base_url="http://test") as client: response = await client.post( "/invocations", params={"agent_session_id": f"connection-policy-{uuid.uuid4().hex}"}, - json={"message": "hi", "stream": stream, "options": options}, + json={"message": "hi", "stream": stream, "options": options if source == "request" else {}}, ) if stream: From 1633e5ec5aa8a1d0bf9065d5297082d6e42de85f Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:58:53 -0700 Subject: [PATCH 08/13] fix(copilot)!: guard runtime discovery roots Keep session discovery and workspace selection application-owned by default. Trusted callers can retain per-run overrides through the existing constructor opt-in. --- ...0046-python-copilot-runtime-connections.md | 20 ++--- .../foundry_hosting/tests/test_invocations.py | 5 ++ python/packages/github_copilot/AGENTS.md | 14 ++-- python/packages/github_copilot/README.md | 11 +-- .../agent_framework_github_copilot/_agent.py | 30 ++++--- .../tests/test_github_copilot_agent.py | 78 +++++++++++++++++++ 6 files changed, 126 insertions(+), 32 deletions(-) diff --git a/docs/decisions/0046-python-copilot-runtime-connections.md b/docs/decisions/0046-python-copilot-runtime-connections.md index 9a22ad60fe4..000b8c64a5e 100644 --- a/docs/decisions/0046-python-copilot-runtime-connections.md +++ b/docs/decisions/0046-python-copilot-runtime-connections.md @@ -10,10 +10,10 @@ deciders: rogerbarreto, eavanvalkenburg, westey-m ## Context and Problem Statement The Invocations host passes application-parsed options to agents. For the Copilot -agent, `provider`, the named `providers` registry, top-level `mcp_servers`, and MCP servers nested in -`custom_agents` are serializable SDK connection configurations, not Python client -objects. Forwarding unfiltered request options can therefore change the -connections selected for a session. +agent, provider settings, MCP settings, custom-agent MCP settings, and discovery +roots are serializable SDK session configuration, not Python client objects. +Forwarding unfiltered request options can therefore change the connections selected +for a session. The same option names can have different meanings for other agent implementations. An Invocations-wide denylist also rejects connection settings produced by trusted @@ -43,12 +43,12 @@ application parsers and option hooks. The compatibility concern is tracked in Choose the agent-owned policy. Both `GitHubCopilotAgent` and `RawGitHubCopilotAgent` expose `allow_runtime_connection_overrides=False`. -They reject per-run `provider`, `providers`, top-level `mcp_servers`, and -`custom_agents[].mcp_servers`, including empty or null values, before SDK startup -and again before assembling SDK session configuration. Application -`default_options` remain allowed, runtime custom agents without MCP configuration -remain supported, and ordinary runtime options retain their existing behavior. The -constructor flag is never read from run options or forwarded to the SDK. +They reject per-run provider configuration, MCP configuration, and discovery roots, +including empty or null values, before SDK startup and again before assembling SDK +session configuration. Application `default_options` remain allowed, runtime custom +agents without MCP configuration remain supported, and ordinary runtime options +retain their existing behavior. The constructor flag is never read from run options +or forwarded to the SDK. Trusted applications may opt in to preserve runtime connection precedence over defaults. This is an intentional behavioral compatibility change: existing callers diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index 05eb7c6c8d6..589ec77970a 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -74,6 +74,11 @@ "models": [{"id": "runtime-model", "provider": "runtime"}], "model": "runtime/runtime-model", }, + {"plugin_directories": ["C:\\caller-plugin"]}, + {"agent_directories": ["C:\\caller-agents"]}, + {"config_directory": "C:\\caller-config"}, + {"enable_config_discovery": True}, + {"working_directory": "C:\\caller-workspace"}, {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, { "custom_agents": [ diff --git a/python/packages/github_copilot/AGENTS.md b/python/packages/github_copilot/AGENTS.md index e0a2abf51ef..f0560660d01 100644 --- a/python/packages/github_copilot/AGENTS.md +++ b/python/packages/github_copilot/AGENTS.md @@ -27,13 +27,13 @@ from agent_framework_github_copilot import GitHubCopilotAgent ## Session Option Defaults -`GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject per-run `provider`, `providers`, -top-level `mcp_servers`, and `custom_agents[].mcp_servers` by default. Application -defaults remain supported; runtime connection configuration requires the -constructor-only `allow_runtime_connection_overrides=True` flag. Validate before -SDK startup and again when assembling session kwargs so creation and resumption -share the policy. Runtime custom agents without MCP configuration and ordinary -runtime options keep their existing behavior. +`GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject per-run provider and MCP +configuration plus discovery roots such as plugin, agent, config, and working +directories by default. Application defaults remain supported; runtime connection +configuration requires the constructor-only `allow_runtime_connection_overrides=True` +flag. Validate before SDK startup and again when assembling session kwargs so creation +and resumption share the policy. Runtime custom agents without MCP configuration and +ordinary runtime options keep their existing behavior. `_build_session_kwargs` forwards options to the SDK verbatim except for a few keys that get an explicit default: `on_permission_request` (deny-all) and `enable_file_hooks` (`False`, so diff --git a/python/packages/github_copilot/README.md b/python/packages/github_copilot/README.md index 2df0351e251..347ec81000c 100644 --- a/python/packages/github_copilot/README.md +++ b/python/packages/github_copilot/README.md @@ -13,8 +13,9 @@ The GitHub Copilot agent enables integration with GitHub Copilot, allowing you t ## Runtime connection configuration `GitHubCopilotAgent` and `RawGitHubCopilotAgent` default to -`allow_runtime_connection_overrides=False`. Per-run `provider`, `providers`, top-level -`mcp_servers`, and `custom_agents[].mcp_servers` options raise +`allow_runtime_connection_overrides=False`. Per-run provider and MCP configuration, +plus discovery roots such as `plugin_directories`, `agent_directories`, +`config_directory`, `enable_config_discovery`, and `working_directory`, raise `AgentInvalidRequestException` before the agent starts the SDK client, creates a session, or resumes one. This includes supplying a connection when no default is configured, and clearing a configuration with an empty object or `None`. Runtime @@ -33,9 +34,9 @@ agent = GitHubCopilotAgent( ) ``` -**Migration:** existing code passing `provider`, `providers`, top-level `mcp_servers`, or -custom-agent MCP servers through `run(options=...)` must move those settings to -`default_options`, or explicitly enable trusted runtime connection configuration: +**Migration:** existing code passing provider configuration, MCP configuration, or +discovery roots through `run(options=...)` must move those settings to `default_options`, +or explicitly enable trusted runtime connection configuration: ```python agent = GitHubCopilotAgent(allow_runtime_connection_overrides=True) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 2bbdf4cfaf3..4c3095b5595 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -504,9 +504,10 @@ class GitHubCopilotOptions(TypedDict, total=False): ``reasoning_effort``, ``context_tier``, ``enable_citations``, ``available_tools``, ``memory``, ...) may also be supplied and is forwarded verbatim to the SDK. An unrecognized parameter name surfaces as a ``TypeError`` from the SDK, so typos are - caught rather than silently ignored. Per-run ``provider``, ``providers``, top-level - ``mcp_servers``, and ``custom_agents[].mcp_servers`` require constructor opt-in through - ``allow_runtime_connection_overrides=True``. + caught rather than silently ignored. Per-run provider configuration, MCP configuration, + and discovery roots such as ``plugin_directories``, ``agent_directories``, + ``config_directory``, ``enable_config_discovery``, and ``working_directory`` require + constructor opt-in through ``allow_runtime_connection_overrides=True``. """ system_message: SystemMessageConfig @@ -684,9 +685,9 @@ def __init__( or tool definition dicts. These are converted to Copilot SDK tools internally. default_options: Default options for the agent. Can include cli_path, model, timeout, log_level, etc. - allow_runtime_connection_overrides: Allow per-run `provider`, `providers`, top-level - `mcp_servers`, and `custom_agents[].mcp_servers` configuration. Defaults to False. - Configure application-owned connections through `default_options` instead. + allow_runtime_connection_overrides: Allow per-run provider configuration, MCP + configuration, and discovery roots. Defaults to False. Configure application-owned + connections and discovery through `default_options` instead. env_file_path: Optional path to .env file for loading configuration. env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. @@ -1732,7 +1733,16 @@ def _warn_once_about_unloaded_file_hooks(self, session_working_directory: str | def _validate_runtime_connection_options(self, options: Mapping[str, Any]) -> None: if self._allow_runtime_connection_overrides: return - reserved = {"mcp_servers", "provider", "providers"}.intersection(options) + reserved = { + "agent_directories", + "config_directory", + "enable_config_discovery", + "mcp_servers", + "plugin_directories", + "provider", + "providers", + "working_directory", + }.intersection(options) custom_agents = options.get("custom_agents") if ( isinstance(custom_agents, Sequence) @@ -1982,9 +1992,9 @@ def __init__( These are converted to Copilot SDK tools internally. default_options: Default options for the agent. Can include cli_path, model, timeout, log_level, etc. - allow_runtime_connection_overrides: Allow per-run `provider`, `providers`, top-level - `mcp_servers`, and `custom_agents[].mcp_servers` configuration. Defaults to False. - Configure application-owned connections through `default_options` instead. + allow_runtime_connection_overrides: Allow per-run provider configuration, MCP + configuration, and discovery roots. Defaults to False. Configure application-owned + connections and discovery through `default_options` instead. env_file_path: Optional path to .env file for loading configuration. env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. """ diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 484738db115..85f7df37758 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -1662,6 +1662,11 @@ class TestGitHubCopilotAgentRuntimeConnections: "models": [{"id": "runtime-model", "provider": "runtime"}], "model": "runtime/runtime-model", }, + {"plugin_directories": ["C:\\caller-plugin"]}, + {"agent_directories": ["C:\\caller-agents"]}, + {"config_directory": "C:\\caller-config"}, + {"enable_config_discovery": True}, + {"working_directory": "C:\\caller-workspace"}, {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, { "custom_agents": [ @@ -1871,6 +1876,79 @@ def mock_on(handler: Any) -> Any: assert config["model"] == "runtime/runtime-model" assert agent.default_options == (connections if source == "defaults" else {}) + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resume", [False, True]) + @pytest.mark.parametrize("source", ["defaults", "runtime"]) + @pytest.mark.parametrize( + "connections", + [ + {"plugin_directories": ["C:\\trusted-plugin"]}, + {"config_directory": "C:\\trusted-config"}, + {"enable_config_discovery": True}, + {"working_directory": "C:\\trusted-workspace"}, + ], + ) + async def test_runtime_connection_defaults_and_opt_in_are_forwarded( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + stream: bool, + resume: bool, + source: str, + connections: dict[str, Any], + mock_client: MagicMock, + mock_session: MagicMock, + assistant_message_event: SessionEvent, + assistant_delta_event: SessionEvent, + session_idle_event: SessionEvent, + ) -> None: + def mock_on(handler: Any) -> Any: + handler(assistant_delta_event) + handler(session_idle_event) + return lambda: None + + mock_session.on = mock_on + mock_session.send_and_wait.return_value = assistant_message_event + agent = agent_type( + client=mock_client, + default_options=cast(GitHubCopilotOptions, connections) if source == "defaults" else None, + allow_runtime_connection_overrides=source == "runtime", + ) + session = AgentSession(service_session_id="existing-session" if resume else None) + options = cast(GitHubCopilotOptions, connections) if source == "runtime" else None + + if stream: + updates = [update async for update in agent.run("Hello", session=session, options=options, stream=True)] + assert "".join(update.text for update in updates) == "Hello" + else: + response = await agent.run("Hello", session=session, options=options) + assert response.text == "Test response" + + config = (mock_client.resume_session if resume else mock_client.create_session).call_args.kwargs + for key, value in connections.items(): + assert config[key] == value + assert agent.default_options == (connections if source == "defaults" else {}) + + @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) + async def test_newer_sdk_runtime_connection_opt_in_is_forwarded( + self, + agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], + mock_session: MagicMock, + assistant_message_event: SessionEvent, + ) -> None: + mock_session.send_and_wait.return_value = assistant_message_event + client = MagicMock() + client.start = AsyncMock() + client.create_session = AsyncMock(return_value=mock_session) + client.resume_session = AsyncMock(return_value=mock_session) + agent = agent_type(client=client, allow_runtime_connection_overrides=True) + options = cast(GitHubCopilotOptions, {"agent_directories": ["C:\\trusted-agents"]}) + + response = await agent.run("Hello", session=AgentSession(), options=options) + + assert response.text == "Test response" + assert client.create_session.call_args.kwargs["agent_directories"] == ["C:\\trusted-agents"] + async def test_runtime_options_cannot_enable_connection_overrides(self, mock_client: MagicMock) -> None: agent: RawGitHubCopilotAgent[GitHubCopilotOptions] = RawGitHubCopilotAgent(client=mock_client) with pytest.raises(AgentInvalidRequestException, match="provider"): From 3601aba47f529807a8b601d86bc5d2df704b5671 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:04:06 -0700 Subject: [PATCH 09/13] fix(copilot)!: guard runtime credentials Keep session authentication and MCP credential controls application-owned by default. Trusted callers can retain per-run overrides through the existing constructor opt-in. --- ...0046-python-copilot-runtime-connections.md | 20 ++++----- .../foundry_hosting/tests/test_invocations.py | 5 +++ python/packages/github_copilot/AGENTS.md | 12 +++--- python/packages/github_copilot/README.md | 10 ++--- .../agent_framework_github_copilot/_agent.py | 42 +++++++++++-------- .../tests/test_github_copilot_agent.py | 14 +++++++ 6 files changed, 65 insertions(+), 38 deletions(-) diff --git a/docs/decisions/0046-python-copilot-runtime-connections.md b/docs/decisions/0046-python-copilot-runtime-connections.md index 000b8c64a5e..18757e364a1 100644 --- a/docs/decisions/0046-python-copilot-runtime-connections.md +++ b/docs/decisions/0046-python-copilot-runtime-connections.md @@ -10,10 +10,10 @@ deciders: rogerbarreto, eavanvalkenburg, westey-m ## Context and Problem Statement The Invocations host passes application-parsed options to agents. For the Copilot -agent, provider settings, MCP settings, custom-agent MCP settings, and discovery -roots are serializable SDK session configuration, not Python client objects. -Forwarding unfiltered request options can therefore change the connections selected -for a session. +agent, provider settings, MCP settings, custom-agent MCP settings, session +credentials, authentication controls, and discovery roots are SDK session +configuration. Forwarding unfiltered request options can therefore change the +connections selected for a session. The same option names can have different meanings for other agent implementations. An Invocations-wide denylist also rejects connection settings produced by trusted @@ -43,12 +43,12 @@ application parsers and option hooks. The compatibility concern is tracked in Choose the agent-owned policy. Both `GitHubCopilotAgent` and `RawGitHubCopilotAgent` expose `allow_runtime_connection_overrides=False`. -They reject per-run provider configuration, MCP configuration, and discovery roots, -including empty or null values, before SDK startup and again before assembling SDK -session configuration. Application `default_options` remain allowed, runtime custom -agents without MCP configuration remain supported, and ordinary runtime options -retain their existing behavior. The constructor flag is never read from run options -or forwarded to the SDK. +They reject per-run provider configuration, MCP configuration, session credentials, +authentication controls, and discovery roots, including empty or null values, before +SDK startup and again before assembling SDK session configuration. Application +`default_options` remain allowed, runtime custom agents without MCP configuration +remain supported, and ordinary runtime options retain their existing behavior. The +constructor flag is never read from run options or forwarded to the SDK. Trusted applications may opt in to preserve runtime connection precedence over defaults. This is an intentional behavioral compatibility change: existing callers diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index 589ec77970a..f474ff2ac7c 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -79,6 +79,11 @@ {"config_directory": "C:\\caller-config"}, {"enable_config_discovery": True}, {"working_directory": "C:\\caller-workspace"}, + {"github_token": "caller-token"}, + {"auth_client_id_metadata_url": "https://caller.example/oauth-client.json"}, + {"mcp_oauth_token_storage": "persistent"}, + {"github_mcp_tool_config": {"enable_all_tools": True}}, + {"disabled_mcp_servers": ["application"]}, {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, { "custom_agents": [ diff --git a/python/packages/github_copilot/AGENTS.md b/python/packages/github_copilot/AGENTS.md index f0560660d01..3a76697db49 100644 --- a/python/packages/github_copilot/AGENTS.md +++ b/python/packages/github_copilot/AGENTS.md @@ -28,12 +28,12 @@ from agent_framework_github_copilot import GitHubCopilotAgent ## Session Option Defaults `GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject per-run provider and MCP -configuration plus discovery roots such as plugin, agent, config, and working -directories by default. Application defaults remain supported; runtime connection -configuration requires the constructor-only `allow_runtime_connection_overrides=True` -flag. Validate before SDK startup and again when assembling session kwargs so creation -and resumption share the policy. Runtime custom agents without MCP configuration and -ordinary runtime options keep their existing behavior. +configuration, session credentials, authentication controls, and discovery roots by +default. Application defaults remain supported; runtime connection configuration +requires the constructor-only `allow_runtime_connection_overrides=True` flag. Validate +before SDK startup and again when assembling session kwargs so creation and resumption +share the policy. Runtime custom agents without MCP configuration and ordinary runtime +options keep their existing behavior. `_build_session_kwargs` forwards options to the SDK verbatim except for a few keys that get an explicit default: `on_permission_request` (deny-all) and `enable_file_hooks` (`False`, so diff --git a/python/packages/github_copilot/README.md b/python/packages/github_copilot/README.md index 347ec81000c..bbacc30b9f4 100644 --- a/python/packages/github_copilot/README.md +++ b/python/packages/github_copilot/README.md @@ -14,8 +14,7 @@ The GitHub Copilot agent enables integration with GitHub Copilot, allowing you t `GitHubCopilotAgent` and `RawGitHubCopilotAgent` default to `allow_runtime_connection_overrides=False`. Per-run provider and MCP configuration, -plus discovery roots such as `plugin_directories`, `agent_directories`, -`config_directory`, `enable_config_discovery`, and `working_directory`, raise +session credentials, authentication controls, and discovery roots raise `AgentInvalidRequestException` before the agent starts the SDK client, creates a session, or resumes one. This includes supplying a connection when no default is configured, and clearing a configuration with an empty object or `None`. Runtime @@ -34,9 +33,10 @@ agent = GitHubCopilotAgent( ) ``` -**Migration:** existing code passing provider configuration, MCP configuration, or -discovery roots through `run(options=...)` must move those settings to `default_options`, -or explicitly enable trusted runtime connection configuration: +**Migration:** existing code passing provider configuration, MCP configuration, +session credentials, authentication controls, or discovery roots through +`run(options=...)` must move those settings to `default_options`, or explicitly enable +trusted runtime connection configuration: ```python agent = GitHubCopilotAgent(allow_runtime_connection_overrides=True) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 4c3095b5595..3a097c8a299 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -207,6 +207,24 @@ def _provider_service_session_state_keys(agent: object, client: object | None) - # Entries disappear once no run holds or awaits the lock. _SESSION_RUN_LOCKS: weakref.WeakValueDictionary[tuple[int, str], asyncio.Lock] = weakref.WeakValueDictionary() +_RUNTIME_CONNECTION_OPTION_NAMES = frozenset({ + "agent_directories", + "auth_client_id_metadata_url", + "config_directory", + "disabled_mcp_servers", + "enable_config_discovery", + "github_mcp_tool_config", + "github_token", + "github_token_provider", + "mcp_oauth_token_storage", + "mcp_servers", + "on_mcp_auth_request", + "plugin_directories", + "provider", + "providers", + "working_directory", +}) + _MCP_TOOL_MESSAGE = ( "MCP server '{name}' cannot be passed to GitHubCopilotAgent as a tool: the Copilot SDK " "connects to MCP servers itself, so a framework-managed MCPTool would keep none of its " @@ -505,9 +523,8 @@ class GitHubCopilotOptions(TypedDict, total=False): ``memory``, ...) may also be supplied and is forwarded verbatim to the SDK. An unrecognized parameter name surfaces as a ``TypeError`` from the SDK, so typos are caught rather than silently ignored. Per-run provider configuration, MCP configuration, - and discovery roots such as ``plugin_directories``, ``agent_directories``, - ``config_directory``, ``enable_config_discovery``, and ``working_directory`` require - constructor opt-in through ``allow_runtime_connection_overrides=True``. + session credentials, authentication controls, and discovery roots require constructor + opt-in through ``allow_runtime_connection_overrides=True``. """ system_message: SystemMessageConfig @@ -686,8 +703,8 @@ def __init__( default_options: Default options for the agent. Can include cli_path, model, timeout, log_level, etc. allow_runtime_connection_overrides: Allow per-run provider configuration, MCP - configuration, and discovery roots. Defaults to False. Configure application-owned - connections and discovery through `default_options` instead. + configuration, session credentials, authentication controls, and discovery roots. + Defaults to False. Configure application-owned connections through `default_options`. env_file_path: Optional path to .env file for loading configuration. env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. @@ -1733,16 +1750,7 @@ def _warn_once_about_unloaded_file_hooks(self, session_working_directory: str | def _validate_runtime_connection_options(self, options: Mapping[str, Any]) -> None: if self._allow_runtime_connection_overrides: return - reserved = { - "agent_directories", - "config_directory", - "enable_config_discovery", - "mcp_servers", - "plugin_directories", - "provider", - "providers", - "working_directory", - }.intersection(options) + reserved = set(_RUNTIME_CONNECTION_OPTION_NAMES.intersection(options)) custom_agents = options.get("custom_agents") if ( isinstance(custom_agents, Sequence) @@ -1993,8 +2001,8 @@ def __init__( default_options: Default options for the agent. Can include cli_path, model, timeout, log_level, etc. allow_runtime_connection_overrides: Allow per-run provider configuration, MCP - configuration, and discovery roots. Defaults to False. Configure application-owned - connections and discovery through `default_options` instead. + configuration, session credentials, authentication controls, and discovery roots. + Defaults to False. Configure application-owned connections through `default_options`. env_file_path: Optional path to .env file for loading configuration. env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. """ diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 85f7df37758..7d5c4623949 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -1667,6 +1667,13 @@ class TestGitHubCopilotAgentRuntimeConnections: {"config_directory": "C:\\caller-config"}, {"enable_config_discovery": True}, {"working_directory": "C:\\caller-workspace"}, + {"github_token": "caller-token"}, + {"github_token_provider": lambda _args: {"kind": "cancelled"}}, + {"auth_client_id_metadata_url": "https://caller.example/oauth-client.json"}, + {"mcp_oauth_token_storage": "persistent"}, + {"on_mcp_auth_request": lambda _request: {"kind": "cancelled"}}, + {"github_mcp_tool_config": {"enable_all_tools": True}}, + {"disabled_mcp_servers": ["application"]}, {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, { "custom_agents": [ @@ -1887,6 +1894,13 @@ def mock_on(handler: Any) -> Any: {"config_directory": "C:\\trusted-config"}, {"enable_config_discovery": True}, {"working_directory": "C:\\trusted-workspace"}, + {"github_token": "trusted-token"}, + {"github_token_provider": lambda _args: {"kind": "cancelled"}}, + {"auth_client_id_metadata_url": "https://trusted.example/oauth-client.json"}, + {"mcp_oauth_token_storage": "persistent"}, + {"on_mcp_auth_request": lambda _request: {"kind": "cancelled"}}, + {"github_mcp_tool_config": {"enable_all_tools": True}}, + {"disabled_mcp_servers": ["application"]}, ], ) async def test_runtime_connection_defaults_and_opt_in_are_forwarded( From 77b087dd468830b44195313c0c28890cc0cb45d9 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:07:12 -0700 Subject: [PATCH 10/13] fix(copilot)!: bound reviewed SDK surface Prevent unreviewed session options from entering the runtime policy through a future SDK release. Widen the bound only after classifying and testing the new surface. --- .../0046-python-copilot-runtime-connections.md | 5 +++++ python/packages/github_copilot/AGENTS.md | 5 +++++ python/packages/github_copilot/README.md | 10 ++++++++-- python/packages/github_copilot/pyproject.toml | 2 +- .../github_copilot/tests/test_github_copilot_agent.py | 10 ++++++++++ python/uv.lock | 2 +- 6 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/decisions/0046-python-copilot-runtime-connections.md b/docs/decisions/0046-python-copilot-runtime-connections.md index 18757e364a1..b25e3dbb45b 100644 --- a/docs/decisions/0046-python-copilot-runtime-connections.md +++ b/docs/decisions/0046-python-copilot-runtime-connections.md @@ -54,6 +54,11 @@ Trusted applications may opt in to preserve runtime connection precedence over defaults. This is an intentional behavioral compatibility change: existing callers must move connections into defaults or enable the constructor flag. +The package bounds `github-copilot-sdk` below 1.0.26 because 1.0.25 is the latest +reviewed session configuration surface. Widening the bound requires classifying new +`create_session` and `resume_session` parameters and adding regression coverage before +publication. + Invocations no longer reserves these connection names for every agent. It reports the existing `AgentInvalidRequestException` as a sanitized HTTP 400 or typed SSE client error, while retaining generic provider-failure sanitization and legacy wire diff --git a/python/packages/github_copilot/AGENTS.md b/python/packages/github_copilot/AGENTS.md index 3a76697db49..af562730378 100644 --- a/python/packages/github_copilot/AGENTS.md +++ b/python/packages/github_copilot/AGENTS.md @@ -35,6 +35,11 @@ before SDK startup and again when assembling session kwargs so creation and resu share the policy. Runtime custom agents without MCP configuration and ordinary runtime options keep their existing behavior. +The `github-copilot-sdk` upper bound reflects the latest reviewed session-option +surface. Before widening it, compare `create_session` and `resume_session` parameters +against `_RUNTIME_CONNECTION_OPTION_NAMES`, add red/green coverage for new connection +paths, and preserve the constructor opt-in. + `_build_session_kwargs` forwards options to the SDK verbatim except for a few keys that get an explicit default: `on_permission_request` (deny-all) and `enable_file_hooks` (`False`, so a session behaves the same way in every working directory). Callers opt in through diff --git a/python/packages/github_copilot/README.md b/python/packages/github_copilot/README.md index bbacc30b9f4..55ab15e9025 100644 --- a/python/packages/github_copilot/README.md +++ b/python/packages/github_copilot/README.md @@ -46,8 +46,14 @@ With opt-in, runtime connection options retain their existing precedence over defaults, including replacement custom-agent configurations. The flag is a constructor argument, not an SDK session option; it cannot be enabled through the incoming options mapping. Apply the same policy to both streaming and non-streaming -calls. Applications opting in are responsible for validating or filtering -connection settings received over HTTP or from other untrusted inputs. +calls. + +Applications opting in are responsible for validating or filtering connection +settings received over HTTP or from other untrusted inputs. + +The package accepts Copilot SDK versions through 1.0.25, the latest session +configuration surface reviewed for this boundary. A later SDK requires an Agent +Framework update that classifies any new session options before widening the bound. ## Tool approval (`approval_mode="always_require"`) diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index caa0cb7706e..e7960e92a9a 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.22.0,<2", - "github-copilot-sdk>=1.0.16,<2.0; python_version >= '3.11'", + "github-copilot-sdk>=1.0.16,<1.0.26; python_version >= '3.11'", ] [tool.uv] diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 7d5c4623949..a14cd0bdce6 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -70,6 +70,16 @@ def pre_tool_use_input(tool_name: str) -> PreToolUseHookInput: } +def test_copilot_sdk_dependency_stops_at_latest_reviewed_version() -> None: + dependency = next( + line.strip() + for line in (Path(__file__).parents[1] / "pyproject.toml").read_text(encoding="utf-8").splitlines() + if line.strip().startswith('"github-copilot-sdk') + ) + + assert "<1.0.26" in dependency + + def shell_request( command_identifiers: Sequence[str], can_offer_session_approval: bool = True ) -> PermissionRequestShell: diff --git a/python/uv.lock b/python/uv.lock index 6571372d261..ecbcd00f143 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -738,7 +738,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.16,<2.0" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.16,<1.0.26" }, ] [[package]] From 9e206b4762006b0254241062b27616559fa93051 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:08:33 -0700 Subject: [PATCH 11/13] test(copilot): cover hosted opt-in matrix Exercise trusted named-provider overrides through request, parser, and hook paths for creation, resumption, streaming, and non-streaming calls. --- .../tests/test_github_copilot_agent.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index a14cd0bdce6..8a629cecbae 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -2060,10 +2060,14 @@ def mock_on(handler: Any) -> Any: @pytest.mark.parametrize("agent_type", [RawGitHubCopilotAgent, GitHubCopilotAgent]) @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resume", [False, True]) + @pytest.mark.parametrize("source", ["request", "parser", "hook"]) async def test_invocations_preserves_named_provider_opt_in( self, agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], stream: bool, + resume: bool, + source: str, monkeypatch: pytest.MonkeyPatch, mock_client: MagicMock, mock_session: MagicMock, @@ -2084,11 +2088,12 @@ def mock_on(handler: Any) -> Any: mock_session.on = mock_on mock_session.send_and_wait.return_value = assistant_message_event store = MagicMock(spec=SessionStore) - store.get.return_value = None + store.get.return_value = ( + AgentSession(session_id="trusted-named-provider", service_session_id="existing-session") if resume else None + ) store_provider = MagicMock(spec=hosting.StoreProvider) store_provider.get_store.return_value = store agent = agent_type(client=mock_client, allow_runtime_connection_overrides=True) - server = hosting.InvocationsHostServer(agent, agent_session_store_provider=store_provider) providers = [{"name": "runtime", "type": "openai", "base_url": "https://runtime.example/api"}] models = [{"id": "runtime-model", "provider": "runtime"}] options = cast( @@ -2099,11 +2104,19 @@ def mock_on(handler: Any) -> Any: "model": "runtime/runtime-model", }, ) + server = hosting.InvocationsHostServer( + agent, + agent_session_store_provider=store_provider, + parse_request=(lambda _request: hosting.InvocationRun(messages="Hello", options=options, stream=stream)) + if source == "parser" + else None, + prepare_options=(lambda _request, _options: options) if source == "hook" else None, + ) async with httpx.AsyncClient(transport=httpx.ASGITransport(app=server), base_url="http://test") as client: response = await client.post( "/invocations", params={"agent_session_id": f"trusted-named-provider-{uuid4().hex}"}, - json={"message": "Hello", "stream": stream, "options": options}, + json={"message": "Hello", "stream": stream, "options": options if source == "request" else {}}, ) assert response.status_code == 200 @@ -2113,7 +2126,7 @@ def mock_on(handler: Any) -> Any: assert "event: error" not in response.text else: assert response.json() == {"response": "Test response"} - config = mock_client.create_session.call_args.kwargs + config = (mock_client.resume_session if resume else mock_client.create_session).call_args.kwargs assert config["providers"] == providers assert config["models"] == models assert config["model"] == "runtime/runtime-model" From 0c8f873db70239c38353ee10f16132511e12fe93 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:11:16 -0700 Subject: [PATCH 12/13] fix(hosting): preflight agent run options Reject invalid per-run options before session storage access. Direct runs and session assembly retain their existing validation as defense in depth. --- ...0046-python-copilot-runtime-connections.md | 4 ++ python/packages/foundry_hosting/README.md | 5 ++- .../_invocations.py | 42 ++++++++++++++----- .../foundry_hosting/tests/test_invocations.py | 2 + python/packages/github_copilot/AGENTS.md | 4 ++ python/packages/github_copilot/README.md | 3 ++ .../agent_framework_github_copilot/_agent.py | 4 ++ 7 files changed, 51 insertions(+), 13 deletions(-) diff --git a/docs/decisions/0046-python-copilot-runtime-connections.md b/docs/decisions/0046-python-copilot-runtime-connections.md index b25e3dbb45b..25cb5437032 100644 --- a/docs/decisions/0046-python-copilot-runtime-connections.md +++ b/docs/decisions/0046-python-copilot-runtime-connections.md @@ -50,6 +50,10 @@ SDK startup and again before assembling SDK session configuration. Application remain supported, and ordinary runtime options retain their existing behavior. The constructor flag is never read from run options or forwarded to the SDK. +The agents expose a side-effect-free `validate_run_options` preflight. Invocations +uses it after resolving the request-scoped agent and before accessing session storage. +Direct runs still validate at entry, and session assembly validates again. + Trusted applications may opt in to preserve runtime connection precedence over defaults. This is an intentional behavioral compatibility change: existing callers must move connections into defaults or enable the constructor flag. diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index c869ac86c4d..b7e67b9f024 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -398,8 +398,9 @@ arguments belong in developer-configured agent defaults or middleware/factories, A hook may strip denied caller fields before validation; allowed generation and provider-specific options remain available. These restrictions apply to both wire formats and every `unsupported_options` policy. Connection option names such as `provider` and `mcp_servers` are not reserved host-wide. -The hosted agent owns their policy. The updated `GitHubCopilotAgent` and `RawGitHubCopilotAgent` reject top-level -runtime connections and MCP servers nested under `custom_agents` by default, while preserving connections in +The hosted agent owns their policy. Agents may expose `validate_run_options(options)` for a side-effect-free +preflight before the host reads or writes session storage. The updated `GitHubCopilotAgent` and +`RawGitHubCopilotAgent` use this preflight to reject runtime connections by default while preserving connections in `default_options`. Trusted applications can enable them with the constructor-only `allow_runtime_connection_overrides=True` flag and filter request input through `prepare_options`. Update the Copilot package as well as the host to obtain that default-deny policy; older Copilot versions retain their diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index 2c7e534614b..96c409c6df1 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -260,6 +260,14 @@ def _invocation_failure(exc: Exception) -> tuple[str, int, str]: return "Agent invocation failed.", 500, "invocation_failed" +def _contains_exception(cause: BaseException | None, exception_type: type[BaseException]) -> bool: + while cause is not None: + if isinstance(cause, exception_type): + return True + cause = cause.__cause__ + return False + + def _workflow_failure(exc: Exception) -> tuple[str, int, str]: if isinstance(exc, _WorkflowOutputError): return str(exc), 500, "invalid_workflow_output" @@ -558,17 +566,18 @@ async def _request_session( yield session finally: failure = sys.exc_info()[1] - try: - await store.set(session_id, session) - except Exception as exc: - logger.exception("Failed to persist invocation session") - if failure is None: - raise - if isinstance(failure, Exception): - raise RuntimeError( - f"Invocation failed: {str(failure) or type(failure).__name__}; " - f"session persistence also failed: {str(exc) or type(exc).__name__}" - ) from exc + if not _contains_exception(failure, AgentInvalidRequestException): + try: + await store.set(session_id, session) + except Exception as exc: + logger.exception("Failed to persist invocation session") + if failure is None: + raise + if isinstance(failure, Exception): + raise RuntimeError( + f"Invocation failed: {str(failure) or type(failure).__name__}; " + f"session persistence also failed: {str(exc) or type(exc).__name__}" + ) from exc async def _parse(self, request: Request) -> InvocationRun | WorkflowTurn[Any]: if self._parse_request is not None: @@ -626,6 +635,15 @@ def _agent_kwargs(self, agent: SupportsAgentRun, options: dict[str, Any]) -> dic return {} return {"options": options} + @staticmethod + async def _validate_agent_options(agent: SupportsAgentRun, options: Mapping[str, Any]) -> None: + validator = getattr(agent, "validate_run_options", None) + if not callable(validator): + return + result = validator(options) + if inspect.isawaitable(result): + await result + @staticmethod async def _close_interrupted_stream(stream: object) -> None: if isinstance(stream, ResponseStream): @@ -674,6 +692,7 @@ async def stream_response() -> AsyncGenerator[str]: lock = self._session_locks.setdefault(partition_key, asyncio.Lock()) async with lock, self._request_agent() as agent: run_kwargs = self._agent_kwargs(agent, options) + await self._validate_agent_options(agent, options) async with self._request_session(partition_key, context, hosted_scope=hosted_scope) as session: stream = agent.run(parsed.messages, session=session, stream=True, **run_kwargs) completed = False @@ -733,6 +752,7 @@ async def stream_response() -> AsyncGenerator[str]: lock = self._session_locks.setdefault(partition_key, asyncio.Lock()) async with lock, self._request_agent() as agent: run_kwargs = self._agent_kwargs(agent, options) + await self._validate_agent_options(agent, options) async with self._request_session(partition_key, context, hosted_scope=hosted_scope) as session: response = await agent.run(parsed.messages, session=session, **run_kwargs) if self._legacy_wire_format: diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index f474ff2ac7c..c9ceb2990fa 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -1049,6 +1049,8 @@ async def test_http_route_reports_copilot_connection_policy_rejections_as_client sdk_client.start.assert_not_awaited() sdk_client.create_session.assert_not_awaited() sdk_client.resume_session.assert_not_awaited() + store.get.assert_not_awaited() + store.set.assert_not_awaited() @pytest.mark.parametrize("stream", [False, True]) async def test_generation_options_preserve_application_connection_defaults(self, stream: bool) -> None: diff --git a/python/packages/github_copilot/AGENTS.md b/python/packages/github_copilot/AGENTS.md index af562730378..589a261c044 100644 --- a/python/packages/github_copilot/AGENTS.md +++ b/python/packages/github_copilot/AGENTS.md @@ -40,6 +40,10 @@ surface. Before widening it, compare `create_session` and `resume_session` param against `_RUNTIME_CONNECTION_OPTION_NAMES`, add red/green coverage for new connection paths, and preserve the constructor opt-in. +`validate_run_options` is the side-effect-free hosting preflight. Keep it aligned +with validation at the start of direct streaming and non-streaming runs and with the +second validation in `_build_session_kwargs`. + `_build_session_kwargs` forwards options to the SDK verbatim except for a few keys that get an explicit default: `on_permission_request` (deny-all) and `enable_file_hooks` (`False`, so a session behaves the same way in every working directory). Callers opt in through diff --git a/python/packages/github_copilot/README.md b/python/packages/github_copilot/README.md index 55ab15e9025..f934480f6a8 100644 --- a/python/packages/github_copilot/README.md +++ b/python/packages/github_copilot/README.md @@ -21,6 +21,9 @@ configured, and clearing a configuration with an empty object or `None`. Runtime custom agents without `mcp_servers` remain supported. Ordinary runtime options, such as `model` and `timeout`, remain supported. +The Invocations host calls `validate_run_options` before loading session storage, so +rejected hosted options do not read or write an agent session. + Configure application-owned connections in `default_options`: ```python diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 3a097c8a299..4bad30881cf 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -1769,6 +1769,10 @@ def _validate_runtime_connection_options(self, options: Mapping[str, Any]) -> No "when constructing the agent." ) + def validate_run_options(self, options: Mapping[str, Any] | None = None) -> None: + """Validate per-run options without starting the SDK client or opening a session.""" + self._validate_runtime_connection_options(options or {}) + def _build_session_kwargs( self, streaming: bool, From 23ea4764d11038dce05f70069bdbe5cac89a5eef Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:16:08 -0700 Subject: [PATCH 13/13] fix(copilot): close runtime policy gaps Preflight request-scoped agents before entering their context, classify CAPI provider controls, and exclude SDK artifacts newer than the exact reviewed release. --- ...0046-python-copilot-runtime-connections.md | 7 ++++--- python/packages/foundry_hosting/README.md | 6 +++--- .../_invocations.py | 9 ++++---- .../foundry_hosting/tests/test_invocations.py | 6 ++++-- .../agent_framework_github_copilot/_agent.py | 1 + python/packages/github_copilot/pyproject.toml | 2 +- .../tests/test_github_copilot_agent.py | 21 +++++++++++++++++-- python/uv.lock | 2 +- 8 files changed, 37 insertions(+), 17 deletions(-) diff --git a/docs/decisions/0046-python-copilot-runtime-connections.md b/docs/decisions/0046-python-copilot-runtime-connections.md index 25cb5437032..72edc2c3287 100644 --- a/docs/decisions/0046-python-copilot-runtime-connections.md +++ b/docs/decisions/0046-python-copilot-runtime-connections.md @@ -51,14 +51,15 @@ remain supported, and ordinary runtime options retain their existing behavior. T constructor flag is never read from run options or forwarded to the SDK. The agents expose a side-effect-free `validate_run_options` preflight. Invocations -uses it after resolving the request-scoped agent and before accessing session storage. -Direct runs still validate at entry, and session assembly validates again. +uses it after resolving the request-scoped agent and before entering its async +context or accessing session storage. Direct runs still validate at entry, and +session assembly validates again. Trusted applications may opt in to preserve runtime connection precedence over defaults. This is an intentional behavioral compatibility change: existing callers must move connections into defaults or enable the constructor flag. -The package bounds `github-copilot-sdk` below 1.0.26 because 1.0.25 is the latest +The package bounds `github-copilot-sdk` at 1.0.25 because it is the latest reviewed session configuration surface. Widening the bound requires classifying new `create_session` and `resume_session` parameters and adding regression coverage before publication. diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index b7e67b9f024..1070f6b0b60 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -399,9 +399,9 @@ A hook may strip denied caller fields before validation; allowed generation and available. These restrictions apply to both wire formats and every `unsupported_options` policy. Connection option names such as `provider` and `mcp_servers` are not reserved host-wide. The hosted agent owns their policy. Agents may expose `validate_run_options(options)` for a side-effect-free -preflight before the host reads or writes session storage. The updated `GitHubCopilotAgent` and -`RawGitHubCopilotAgent` use this preflight to reject runtime connections by default while preserving connections in -`default_options`. Trusted applications can enable them with the constructor-only +preflight before the host enters a request-scoped agent context or reads or writes session storage. The updated +`GitHubCopilotAgent` and `RawGitHubCopilotAgent` use this preflight to reject runtime connections by default while +preserving connections in `default_options`. Trusted applications can enable them with the constructor-only `allow_runtime_connection_overrides=True` flag and filter request input through `prepare_options`. Update the Copilot package as well as the host to obtain that default-deny policy; older Copilot versions retain their existing runtime connection behavior. diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index 96c409c6df1..e82038deae8 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -528,12 +528,13 @@ def _warn_legacy_workflow(self) -> None: logger.warning("DEPRECATION: %s", message) @asynccontextmanager - async def _request_agent(self) -> AsyncGenerator[SupportsAgentRun]: + async def _request_agent(self, options: Mapping[str, Any]) -> AsyncGenerator[SupportsAgentRun]: if self._agent is None: raise RuntimeError("No agent is configured for Invocations.") agent = await resolve_agent(self._agent) if isinstance(agent, WorkflowAgent): self._warn_legacy_workflow() + await self._validate_agent_options(agent, options) async with AsyncExitStack() as resources: if self._owns_request_agent and isinstance(agent, AbstractAsyncContextManager): await resources.enter_async_context(agent) @@ -690,9 +691,8 @@ async def stream_response() -> AsyncGenerator[str]: token: Token[FoundryAgentRequestContext] | None = set_request_context(context) try: lock = self._session_locks.setdefault(partition_key, asyncio.Lock()) - async with lock, self._request_agent() as agent: + async with lock, self._request_agent(options) as agent: run_kwargs = self._agent_kwargs(agent, options) - await self._validate_agent_options(agent, options) async with self._request_session(partition_key, context, hosted_scope=hosted_scope) as session: stream = agent.run(parsed.messages, session=session, stream=True, **run_kwargs) completed = False @@ -750,9 +750,8 @@ async def stream_response() -> AsyncGenerator[str]: try: lock = self._session_locks.setdefault(partition_key, asyncio.Lock()) - async with lock, self._request_agent() as agent: + async with lock, self._request_agent(options) as agent: run_kwargs = self._agent_kwargs(agent, options) - await self._validate_agent_options(agent, options) async with self._request_session(partition_key, context, hosted_scope=hosted_scope) as session: response = await agent.run(parsed.messages, session=session, **run_kwargs) if self._legacy_wire_format: diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index c9ceb2990fa..c86e37c5dce 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -84,6 +84,7 @@ {"mcp_oauth_token_storage": "persistent"}, {"github_mcp_tool_config": {"enable_all_tools": True}}, {"disabled_mcp_servers": ["application"]}, + {"capi": {"enable_web_socket_responses": False}}, {"mcp_servers": {"application": {"type": "http", "url": "https://example.com/mcp", "tools": ["*"]}}}, { "custom_agents": [ @@ -999,10 +1000,11 @@ async def test_connection_options_are_governed_by_the_agent( @pytest.mark.parametrize("stream", [False, True]) @pytest.mark.parametrize("resume", [False, True]) @pytest.mark.parametrize("source", ["request", "parser", "hook"]) + @pytest.mark.parametrize("agent_source", ["instance", "factory"]) @pytest.mark.parametrize("agent_type", ["RawGitHubCopilotAgent", "GitHubCopilotAgent"]) @pytest.mark.parametrize("options", _COPILOT_RUNTIME_CONNECTION_OPTIONS) async def test_http_route_reports_copilot_connection_policy_rejections_as_client_errors( - self, stream: bool, resume: bool, source: str, agent_type: str, options: dict[str, Any] + self, stream: bool, resume: bool, source: str, agent_source: str, agent_type: str, options: dict[str, Any] ) -> None: copilot_agents = pytest.importorskip("agent_framework_github_copilot") sdk_client = MagicMock() @@ -1017,7 +1019,7 @@ async def test_http_route_reports_copilot_connection_policy_rejections_as_client service_session_id="existing-session", ) server = InvocationsHostServer( - agent, + agent if agent_source == "instance" else lambda: agent, agent_session_store_provider=_SessionStoreProvider(store), parse_request=(lambda _request: InvocationRun(messages="hi", options=options, stream=stream)) if source == "parser" diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 4bad30881cf..4c42749a577 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -210,6 +210,7 @@ def _provider_service_session_state_keys(agent: object, client: object | None) - _RUNTIME_CONNECTION_OPTION_NAMES = frozenset({ "agent_directories", "auth_client_id_metadata_url", + "capi", "config_directory", "disabled_mcp_servers", "enable_config_discovery", diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index e7960e92a9a..8cea775272c 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.22.0,<2", - "github-copilot-sdk>=1.0.16,<1.0.26; python_version >= '3.11'", + "github-copilot-sdk>=1.0.16,<=1.0.25; python_version >= '3.11'", ] [tool.uv] diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 8a629cecbae..e35f7803c81 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -77,7 +77,7 @@ def test_copilot_sdk_dependency_stops_at_latest_reviewed_version() -> None: if line.strip().startswith('"github-copilot-sdk') ) - assert "<1.0.26" in dependency + assert "<=1.0.25" in dependency def shell_request( @@ -1694,6 +1694,7 @@ class TestGitHubCopilotAgentRuntimeConnections: } ] }, + {"capi": {"enable_web_socket_responses": False}}, ], ) async def test_run_rejects_runtime_connections_by_default( @@ -1911,6 +1912,7 @@ def mock_on(handler: Any) -> Any: {"on_mcp_auth_request": lambda _request: {"kind": "cancelled"}}, {"github_mcp_tool_config": {"enable_all_tools": True}}, {"disabled_mcp_servers": ["application"]}, + {"capi": {"enable_web_socket_responses": False}}, ], ) async def test_runtime_connection_defaults_and_opt_in_are_forwarded( @@ -2062,12 +2064,14 @@ def mock_on(handler: Any) -> Any: @pytest.mark.parametrize("stream", [False, True]) @pytest.mark.parametrize("resume", [False, True]) @pytest.mark.parametrize("source", ["request", "parser", "hook"]) + @pytest.mark.parametrize("agent_source", ["instance", "factory"]) async def test_invocations_preserves_named_provider_opt_in( self, agent_type: type[RawGitHubCopilotAgent[GitHubCopilotOptions]], stream: bool, resume: bool, source: str, + agent_source: str, monkeypatch: pytest.MonkeyPatch, mock_client: MagicMock, mock_session: MagicMock, @@ -2105,7 +2109,7 @@ def mock_on(handler: Any) -> Any: }, ) server = hosting.InvocationsHostServer( - agent, + agent if agent_source == "instance" else lambda: agent, agent_session_store_provider=store_provider, parse_request=(lambda _request: hosting.InvocationRun(messages="Hello", options=options, stream=stream)) if source == "parser" @@ -2139,6 +2143,19 @@ def mock_on(handler: Any) -> Any: {"provider": {}}, {"providers": None}, {"providers": []}, + {"plugin_directories": []}, + {"agent_directories": []}, + {"config_directory": None}, + {"enable_config_discovery": None}, + {"working_directory": None}, + {"github_token": None}, + {"github_token_provider": None}, + {"auth_client_id_metadata_url": None}, + {"mcp_oauth_token_storage": None}, + {"on_mcp_auth_request": None}, + {"github_mcp_tool_config": {}}, + {"disabled_mcp_servers": []}, + {"capi": {}}, { "providers": [{"name": "runtime", "type": "openai", "base_url": "https://runtime.example/api"}], "models": [{"id": "runtime-model", "provider": "runtime"}], diff --git a/python/uv.lock b/python/uv.lock index ecbcd00f143..f7f7ea4fe04 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -738,7 +738,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.16,<1.0.26" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.16,<=1.0.25" }, ] [[package]]