agent-framework-github-copilot 1.0.1__tar.gz → 1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-framework-github-copilot
3
- Version: 1.0.1
3
+ Version: 1.0.2
4
4
  Summary: GitHub Copilot integration for Microsoft Agent Framework.
5
5
  Author-email: Microsoft <af-support@microsoft.com>
6
- Requires-Python: >=3.10
6
+ Requires-Python: >=3.11
7
7
  Description-Content-Type: text/markdown
8
8
  Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Development Status :: 5 - Production/Stable
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.10
13
12
  Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
@@ -75,6 +74,34 @@ agent = GitHubCopilotAgent(
75
74
  > Note: with the default (deny-all) permission handler, an `always_require` tool is denied
76
75
  > unless you wire an approving `on_permission_request`.
77
76
 
77
+ ### Approving for the rest of the session
78
+
79
+ `PermissionDecisionApproveForSession` scopes its approval with either an `approval` (tool
80
+ prompts) or a `domain` (URL prompts). Both are optional, so a bare
81
+ `PermissionDecisionApproveForSession()` carries no scope at all and the Copilot CLI cannot
82
+ interpret it.
83
+
84
+ `GitHubCopilotAgent` therefore scopes such a decision automatically, using the request that
85
+ triggered it — a shell prompt becomes an approval for that prompt's command identifiers, an
86
+ MCP prompt an approval for that server and tool, a URL prompt an approval for that URL's
87
+ domain, and so on:
88
+
89
+ ```python
90
+ from copilot.generated.rpc import PermissionDecisionApproveForSession
91
+
92
+
93
+ def on_permission_request(request, invocation):
94
+ # Scoped to `request` automatically; approves that kind of call for the whole session.
95
+ return PermissionDecisionApproveForSession()
96
+ ```
97
+
98
+ The decision is only ever narrowed, never widened. When the prompt reports that it cannot
99
+ offer session-scoped approval (`can_offer_session_approval=False`), or the request kind has
100
+ no session-scoped approval at all (such as a `hook` prompt), the decision is downgraded to a
101
+ single-use approval and a warning is logged. Pass an explicit `approval=` or `domain=` when
102
+ you want to approve something other than the request being handled — decisions that already
103
+ specify a scope are forwarded unchanged.
104
+
78
105
  ### Deprecated: `on_function_approval`
79
106
 
80
107
  The `on_function_approval` callback is **deprecated**. It still works (and is still enforced
@@ -50,6 +50,34 @@ agent = GitHubCopilotAgent(
50
50
  > Note: with the default (deny-all) permission handler, an `always_require` tool is denied
51
51
  > unless you wire an approving `on_permission_request`.
52
52
 
53
+ ### Approving for the rest of the session
54
+
55
+ `PermissionDecisionApproveForSession` scopes its approval with either an `approval` (tool
56
+ prompts) or a `domain` (URL prompts). Both are optional, so a bare
57
+ `PermissionDecisionApproveForSession()` carries no scope at all and the Copilot CLI cannot
58
+ interpret it.
59
+
60
+ `GitHubCopilotAgent` therefore scopes such a decision automatically, using the request that
61
+ triggered it — a shell prompt becomes an approval for that prompt's command identifiers, an
62
+ MCP prompt an approval for that server and tool, a URL prompt an approval for that URL's
63
+ domain, and so on:
64
+
65
+ ```python
66
+ from copilot.generated.rpc import PermissionDecisionApproveForSession
67
+
68
+
69
+ def on_permission_request(request, invocation):
70
+ # Scoped to `request` automatically; approves that kind of call for the whole session.
71
+ return PermissionDecisionApproveForSession()
72
+ ```
73
+
74
+ The decision is only ever narrowed, never widened. When the prompt reports that it cannot
75
+ offer session-scoped approval (`can_offer_session_approval=False`), or the request kind has
76
+ no session-scoped approval at all (such as a `hook` prompt), the decision is downgraded to a
77
+ single-use approval and a warning is logged. Pass an explicit `approval=` or `domain=` when
78
+ you want to approve something other than the request being handled — decisions that already
79
+ specify a scope are forwarded unchanged.
80
+
53
81
  ### Deprecated: `on_function_approval`
54
82
 
55
83
  The `on_function_approval` callback is **deprecated**. It still works (and is still enforced
@@ -10,6 +10,7 @@ import sys
10
10
  import warnings
11
11
  from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
12
12
  from typing import Any, ClassVar, Generic, Literal, TypedDict, cast, overload
13
+ from urllib.parse import urlparse
13
14
 
14
15
  from agent_framework import (
15
16
  AgentMiddlewareLayer,
@@ -52,7 +53,20 @@ else:
52
53
 
53
54
  try:
54
55
  from copilot import CopilotClient, CopilotSession, RuntimeConnection
55
- from copilot.generated.rpc import PermissionDecisionUserNotAvailable
56
+ from copilot.generated.rpc import (
57
+ PermissionDecisionApproveForSession,
58
+ PermissionDecisionApproveForSessionApproval,
59
+ PermissionDecisionApproveForSessionApprovalCommands,
60
+ PermissionDecisionApproveForSessionApprovalCustomTool,
61
+ PermissionDecisionApproveForSessionApprovalExtensionManagement,
62
+ PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess,
63
+ PermissionDecisionApproveForSessionApprovalMCP,
64
+ PermissionDecisionApproveForSessionApprovalMemory,
65
+ PermissionDecisionApproveForSessionApprovalRead,
66
+ PermissionDecisionApproveForSessionApprovalWrite,
67
+ PermissionDecisionApproveOnce,
68
+ PermissionDecisionUserNotAvailable,
69
+ )
56
70
  from copilot.session import (
57
71
  Attachment,
58
72
  BlobAttachment,
@@ -64,7 +78,21 @@ try:
64
78
  SessionHooks,
65
79
  SystemMessageConfig,
66
80
  )
67
- from copilot.session_events import AssistantUsageData, PermissionRequest, SessionEvent, SessionEventType
81
+ from copilot.session_events import (
82
+ AssistantUsageData,
83
+ PermissionRequest,
84
+ PermissionRequestCustomTool,
85
+ PermissionRequestExtensionManagement,
86
+ PermissionRequestExtensionPermissionAccess,
87
+ PermissionRequestMcp,
88
+ PermissionRequestMemory,
89
+ PermissionRequestRead,
90
+ PermissionRequestShell,
91
+ PermissionRequestUrl,
92
+ PermissionRequestWrite,
93
+ SessionEvent,
94
+ SessionEventType,
95
+ )
68
96
  from copilot.tools import Tool as CopilotTool
69
97
  from copilot.tools import ToolInvocation, ToolResult
70
98
  except ImportError as _copilot_import_error:
@@ -81,6 +109,9 @@ PermissionHandlerType = Callable[
81
109
  ]
82
110
  """Type for permission request handlers. Supports both sync and async callbacks."""
83
111
 
112
+ AsyncPermissionHandlerType = Callable[[PermissionRequest, dict[str, str]], "Awaitable[PermissionRequestResult]"]
113
+ """Type for permission request handlers that are always asynchronous."""
114
+
84
115
 
85
116
  FunctionApprovalCallback = Callable[[Content], "bool | Awaitable[bool]"]
86
117
  """Deprecated approval callback for ``FunctionTool`` instances declared with
@@ -140,6 +171,173 @@ def _deny_all_permissions(
140
171
  return PermissionDecisionUserNotAvailable()
141
172
 
142
173
 
174
+ def _derive_session_approval(request: PermissionRequest) -> PermissionDecisionApproveForSessionApproval | None:
175
+ """Build the session-scoped approval implied by ``request``.
176
+
177
+ ``PermissionDecisionApproveForSession.approval`` describes *what* is being approved for
178
+ the remainder of the session. Its shape is dictated by the prompt that triggered it, so
179
+ it can be reconstructed from the request itself.
180
+
181
+ Args:
182
+ request: The permission request the decision is responding to.
183
+
184
+ Returns:
185
+ The approval covering ``request``, or ``None`` for request kinds that have no
186
+ session-scoped approval representation (such as ``hook`` prompts).
187
+ """
188
+ if isinstance(request, PermissionRequestShell):
189
+ return PermissionDecisionApproveForSessionApprovalCommands(
190
+ command_identifiers=[command.identifier for command in request.commands]
191
+ )
192
+ if isinstance(request, PermissionRequestRead):
193
+ return PermissionDecisionApproveForSessionApprovalRead()
194
+ if isinstance(request, PermissionRequestWrite):
195
+ return PermissionDecisionApproveForSessionApprovalWrite()
196
+ if isinstance(request, PermissionRequestMcp):
197
+ return PermissionDecisionApproveForSessionApprovalMCP(
198
+ server_name=request.server_name, tool_name=request.tool_name
199
+ )
200
+ if isinstance(request, PermissionRequestCustomTool):
201
+ return PermissionDecisionApproveForSessionApprovalCustomTool(tool_name=request.tool_name)
202
+ if isinstance(request, PermissionRequestMemory):
203
+ return PermissionDecisionApproveForSessionApprovalMemory()
204
+ if isinstance(request, PermissionRequestExtensionManagement):
205
+ return PermissionDecisionApproveForSessionApprovalExtensionManagement(operation=request.operation)
206
+ if isinstance(request, PermissionRequestExtensionPermissionAccess):
207
+ return PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess(
208
+ extension_name=request.extension_name
209
+ )
210
+ return None
211
+
212
+
213
+ # Characters the WHATWG URL parser (used by the Copilot CLI) treats specially for
214
+ # special-scheme URLs in ways that can move the authority boundary: backslashes are
215
+ # normalized to forward slashes, and tabs/newlines/carriage returns are stripped before
216
+ # parsing. Python's ``urlparse`` does none of this, so a URL containing any of them may
217
+ # resolve to a different host than the CLI actually contacts.
218
+ _WHATWG_AMBIGUOUS_URL_CHARS = ("\\", "\t", "\n", "\r")
219
+
220
+
221
+ def _derive_url_session_domain(url: str) -> str | None:
222
+ """Return the domain to persist for a URL prompt, or ``None`` when it is unsafe to.
223
+
224
+ The persisted domain must match the host the Copilot CLI actually contacts, but the CLI
225
+ parses URLs with WHATWG semantics while this runs on Python's ``urlparse``. The two
226
+ disagree on crafted authorities -- a backslash before the ``@`` in
227
+ ``https://example.com<backslash>@evil.com`` resolves to ``example.com`` under the CLI
228
+ but ``evil.com`` under ``urlparse`` -- so trusting ``urlparse`` here could persist a
229
+ session-wide approval for an unrelated, attacker-chosen domain.
230
+
231
+ To keep the "narrow, never widen" guarantee, the domain is only returned when the URL
232
+ contains none of the characters the two parsers handle differently; any ambiguity (or a
233
+ URL with no derivable host) yields ``None`` so the caller can approve the single request
234
+ without persisting a domain.
235
+
236
+ Args:
237
+ url: The URL from the permission request.
238
+
239
+ Returns:
240
+ The lower-cased host to approve for the session, or ``None`` when the URL is
241
+ parser-ambiguous or has no host.
242
+ """
243
+ if any(char in url for char in _WHATWG_AMBIGUOUS_URL_CHARS):
244
+ return None
245
+ return urlparse(url).hostname or None
246
+
247
+
248
+ def _normalize_permission_decision(
249
+ decision: PermissionRequestResult,
250
+ request: PermissionRequest,
251
+ ) -> PermissionRequestResult:
252
+ """Fill in the missing scope of an under-specified ``approve-for-session`` decision.
253
+
254
+ ``PermissionDecisionApproveForSession`` carries an optional ``approval`` (tool prompts)
255
+ and an optional ``domain`` (URL prompts), so ``PermissionDecisionApproveForSession()``
256
+ is constructible with neither. That serializes to ``{"kind": "approve-for-session"}``,
257
+ which the Copilot CLI cannot interpret -- it crashes with ``Cannot read properties of
258
+ undefined (reading 'commandIdentifiers')``, taking the whole run down with it. This
259
+ reconstructs the intended scope from ``request``.
260
+
261
+ The decision is only ever narrowed, never widened: when the prompt does not offer
262
+ session-scoped approval, or the request kind has no session approval representation,
263
+ the decision is downgraded to a single-use approval.
264
+
265
+ Args:
266
+ decision: The decision returned by the caller's permission handler.
267
+ request: The permission request the decision is responding to.
268
+
269
+ Returns:
270
+ ``decision`` unchanged unless it is an ``approve-for-session`` decision missing both
271
+ ``approval`` and ``domain``, in which case an equivalent fully-scoped decision (or a
272
+ narrower single-use approval) is returned. The input is never mutated.
273
+ """
274
+ if not isinstance(decision, PermissionDecisionApproveForSession):
275
+ return decision
276
+ if decision.approval is not None or decision.domain is not None:
277
+ return decision
278
+
279
+ try:
280
+ if isinstance(request, PermissionRequestUrl):
281
+ domain = _derive_url_session_domain(request.url)
282
+ if domain:
283
+ return PermissionDecisionApproveForSession(domain=domain)
284
+ logger.warning(
285
+ "Permission handler returned an unscoped 'approve-for-session' decision for a URL prompt, "
286
+ "but no unambiguous domain could be derived from '%s'. Approving this request only. Return "
287
+ "PermissionDecisionApproveForSession(domain=...) to approve a domain for the session.",
288
+ request.url,
289
+ )
290
+ return PermissionDecisionApproveOnce()
291
+
292
+ # Only shell and write prompts advertise this; other kinds always allow session approval.
293
+ if not getattr(request, "can_offer_session_approval", True):
294
+ logger.warning(
295
+ "Permission handler returned an 'approve-for-session' decision for a '%s' prompt that does not "
296
+ "offer session-scoped approval. Approving this request only.",
297
+ request.kind,
298
+ )
299
+ return PermissionDecisionApproveOnce()
300
+
301
+ approval = _derive_session_approval(request)
302
+ except Exception:
303
+ logger.exception(
304
+ "Failed to derive the session approval for a '%s' permission prompt. Approving this request only.",
305
+ getattr(request, "kind", "unknown"),
306
+ )
307
+ return PermissionDecisionApproveOnce()
308
+
309
+ if approval is None:
310
+ logger.warning(
311
+ "Permission handler returned an unscoped 'approve-for-session' decision for a '%s' prompt, which has "
312
+ "no session-scoped approval. Approving this request only.",
313
+ request.kind,
314
+ )
315
+ return PermissionDecisionApproveOnce()
316
+ return PermissionDecisionApproveForSession(approval=approval)
317
+
318
+
319
+ def _with_normalized_permission_decisions(handler: PermissionHandlerType) -> AsyncPermissionHandlerType:
320
+ """Wrap a permission handler so its decisions are normalized before reaching the SDK.
321
+
322
+ Exceptions raised by ``handler`` deliberately propagate: the SDK already catches them
323
+ and denies the request, and preserving that keeps the secure-by-default behavior.
324
+
325
+ Args:
326
+ handler: The caller-supplied permission handler. May be sync or async.
327
+
328
+ Returns:
329
+ An async handler delegating to ``handler`` and normalizing its result.
330
+ """
331
+
332
+ async def normalized_handler(request: PermissionRequest, invocation: dict[str, str]) -> PermissionRequestResult:
333
+ result = handler(request, invocation)
334
+ if inspect.isawaitable(result):
335
+ result = await result
336
+ return _normalize_permission_decision(result, request)
337
+
338
+ return normalized_handler
339
+
340
+
143
341
  class GitHubCopilotSettings(TypedDict, total=False):
144
342
  """GitHub Copilot model settings.
145
343
 
@@ -1201,9 +1399,10 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1201
1399
  the Copilot SDK, so any ``create_session`` parameter is supported without a
1202
1400
  dedicated mapping here (an unknown name surfaces as a ``TypeError`` from the
1203
1401
  SDK). A few keys are handled specially because they need a secure default
1204
- (``on_permission_request`` defaults to denying all requests) or transforming:
1205
- ``tools`` are merged with the agent's tools and converted to SDK tools, and
1206
- approval callbacks are turned into ``hooks``.
1402
+ (``on_permission_request`` defaults to denying all requests, and is wrapped so
1403
+ under-specified ``approve-for-session`` decisions are scoped to the request that
1404
+ triggered them) or transforming: ``tools`` are merged with the agent's tools and
1405
+ converted to SDK tools, and approval callbacks are turned into ``hooks``.
1207
1406
 
1208
1407
  Args:
1209
1408
  streaming: Whether to enable streaming for the session.
@@ -1227,7 +1426,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1227
1426
  # back to the resolved setting (which carries the default_options / env model).
1228
1427
  if not kwargs.get("model"):
1229
1428
  kwargs["model"] = self._settings.get("model") or None
1230
- kwargs["on_permission_request"] = (
1429
+ kwargs["on_permission_request"] = _with_normalized_permission_decisions(
1231
1430
  opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions
1232
1431
  )
1233
1432
  kwargs["hooks"] = self._build_session_hooks(all_tools, kwargs)
@@ -3,8 +3,8 @@ name = "agent-framework-github-copilot"
3
3
  description = "GitHub Copilot integration for Microsoft Agent Framework."
4
4
  authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
5
5
  readme = "README.md"
6
- requires-python = ">=3.10"
7
- version = "1.0.1"
6
+ requires-python = ">=3.11"
7
+ version = "1.0.2"
8
8
  license-files = ["LICENSE"]
9
9
  urls.homepage = "https://aka.ms/agent-framework"
10
10
  urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -15,7 +15,6 @@ classifiers = [
15
15
  "Development Status :: 5 - Production/Stable",
16
16
  "Intended Audience :: Developers",
17
17
  "Programming Language :: Python :: 3",
18
- "Programming Language :: Python :: 3.10",
19
18
  "Programming Language :: Python :: 3.11",
20
19
  "Programming Language :: Python :: 3.12",
21
20
  "Programming Language :: Python :: 3.13",