site logo

Marico's space

使用 GitHub Copilot 和 Microsoft Agent Framework 在 Python 中构建安全的仓库维护智能体

编程技术 2026-08-11 11:28:46 5

前面折腾过 .NET 版本的仓库维护智能体,能检查代码、提出修复方案、运行测试、报告结果,所有写操作和 shell 命令都需要人工确认。这篇来用 Python 实现同样的功能。

安全策略和实现目标故意保持一致——不想因为 Python 实现起来更方便就悄悄选个更简单的演示。两个版本使用相同的测试用例、相同的指令、在相同的审批节点停下、必须通过相同的测试。唯一不同的是宿主语言。

最终得到的是一个异步 Python 命令行应用,用 GitHub Copilot 的执行框架处理仓库操作,用 Microsoft Agent Framework 处理智能体抽象、流式输出、会话管理和遥测。

💡 代码仓库: Python 和 .NET 两个实现共用同一套测试用例,代码在 github.com/sahansera/safe-repository-maintenance-agent。

智能体负责提议,主机负责决策:和 .NET 版本一样的权限门控循环

要实现什么

智能体接收一个本地仓库路径和一个维护任务。它的系统指令要求它:

  1. 只在指定仓库内操作
  2. 修改任何文件之前先读取 AGENTS.md
  3. 做最小化的连贯修复
  4. 禁止访问网络、安装依赖包、提交代码、推送和创建 Pull Request
  5. 运行针对性的验证
  6. 报告修改的文件、执行的命令和结果

自带的测试用例包含一个小 JavaScript 函数。智能体用 Python 写,但目标仓库不一定是 Python 的:

export function normalizeTitle(value) { return value.toLowerCase().replace(/\s+/g, "-");
}

第一个测试期望普通的标题标准化。第二个测试期望忽略标题周围的空白。在实现加上 trim 之前,第二个测试会失败。

故意选一个语言无关的目标。Python 智能体可以维护 .NET、JavaScript、Go 或者文档仓库。智能体的宿主语言只决定我们如何集成和运行这个框架,不决定它能理解哪些源码文件。

前置条件和包安装

需要 Python 3.11 或更高版本,以及一个有效的 GitHub Copilot 订阅。示例用的是 Python 3.12 作为文档基准,也验证过 Python 3.13。

实际测试环境:

[project]
requires-python = ">=3.11"
dependencies = [ "agent-framework-github-copilot==1.0.1", "github-copilot-sdk==1.0.2",
] [project.optional-dependencies]
dev = ["pytest>=8.4,<9"]

创建隔离环境并安装:

python -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'

完整运行的效果:

当前的 Python SDK 包包含了支持平台的 Copilot 运行时。认证依赖 GitHub Copilot,首次运行可能会要求登录。

Agent Framework 集成是稳定的;但底层的 GitHub Copilot SDK 还不是,编写这两篇文章期间它的预览版不止一次改了接口。建议锁定版本——这是廉价的保险,防止未来的 SDK 升级悄悄改变你的代码行为。

把权限做成小而可测试的策略

智能体可以请求多种能力类型,包括 readwriteshellurlmcp。我们不会一视同仁:

能力类型 默认行为
读取指定仓库内的文件 直接批准
写入文件 询问确认
执行 shell 命令 询问确认
访问 URL 或调用 MCP 拒绝
未知类型 拒绝

策略就是一个普通函数:

from enum import Enum class PolicyDecision(Enum): APPROVE = "approve" PROMPT = "prompt" DENY = "deny" def decide(permission_kind: str) -> PolicyDecision: if permission_kind == "read": return PolicyDecision.APPROVE if permission_kind in {"write", "shell"}: return PolicyDecision.PROMPT return PolicyDecision.DENY

兜底策略拒绝 URL 访问、MCP 调用、新出现的 SDK 权限类型以及任何畸形值。要开启这些能力必须主动修改应用代码。

测试套件把这些契约明确化了:

@pytest.mark.parametrize( ("kind", "expected"), [ ("read", PolicyDecision.APPROVE), ("write", PolicyDecision.PROMPT), ("shell", PolicyDecision.PROMPT), ("url", PolicyDecision.DENY), ("mcp", PolicyDecision.DENY), ("unknown", PolicyDecision.DENY), ],
)
def test_decide_returns_expected_decision(kind, expected): assert decide(kind) is expected

这些测试不需要模型、Copilot 订阅或仓库。它们测试的是应用权限,而不是概率行为。

不需要用 LLM 评估来证明 URL 访问被拒绝了。把确定性的授权策略从智能体中分离出来,像测试其他安全敏感函数一样测试它。

把策略转换成 Copilot 决策

权限处理器接收一个带类型的请求和一个上下文字典。首先打印足够的信息让操作者理解这个动作:

async def handle_permission(request, context): decision = decide(request.kind) print(f"\n[permission: {request.kind}]") print(describe(request)) if decision is PolicyDecision.APPROVE: return PermissionHandler.approve_all(request, context) if decision is PolicyDecision.DENY: return PermissionDecisionReject( feedback="Blocked by the repository agent policy." ) answer = ( await asyncio.to_thread(input, "Approve once? [y/N] ") ).strip().lower() if answer == "y": return PermissionHandler.approve_all(request, context) return PermissionDecisionReject( feedback="The operator denied this action." )

input() 是阻塞调用,所以用 asyncio.to_thread 把它从事件循环中挪出去。这个细节在控制台示例里很容易忽略,但在应用同时输出流式内容或者处理多个会话时会变得重要。

对于写请求,describe 打印 file_namediff。对于 shell 请求,打印 full_command_text。URL 和 MCP 请求在拒绝之前也会显示,留下便于审计的记录。

辅助方法名叫 approve_all 在这个上下文里容易产生误解。它为当前请求构造一个批准响应。应用本身仍然决定哪些请求能到达这行代码。

配置智能体,不依赖环境中的仓库状态

命令在创建智能体之前先解析仓库路径:

repository = args.repository.expanduser().resolve(strict=True) if not repository.is_dir(): raise NotADirectoryError(repository)

Copilot 会话选项包含工作目录和权限回调:

options = GitHubCopilotOptions( working_directory=str(repository), enable_config_discovery=False, on_permission_request=handle_permission,
) agent = GitHubCopilotAgent( instructions=INSTRUCTIONS, default_options=options,
)

仓库指令确实有用,所以关闭配置发现乍一看是反直觉的。写 .NET 版本的时候遇到了原因:测试夹具在开发时放在另一个 Git 仓库里,运行时愉快地往上走到了父仓库的 AGENTS.md,根本没注意到测试夹具自己的文件。嵌套仓库、monorepo、临时 worktree 都容易在不经意间模糊这个边界。

所以应用指令明确告诉智能体去读取自己工作目录内的 AGENTS.md。这样项目指导的来源在工具活动中一目了然,而不是让一个无关的父目录悄悄改变智能体认为的规则。

这不意味着信任仓库指令。仓库可能包含 prompt 注入,就像可能包含恶意构建脚本一样。当指令请求一个被禁止的操作时,宿主策略仍然拥有最终决定权。

仓库指令可以改进补丁,但不能扩大智能体的权限。当项目指导请求一个被禁止的副作用时,主机权限策略说了算。

流式输出维护任务

GitHubCopilotAgent 拥有一个异步客户端,所以自然的 Python 生命周期是异步上下文管理器:

async with agent: async for update in agent.run(args.task, stream=True): print(update.text, end="", flush=True)

上下文管理器在运行抛出异常时也会启动和停止 Copilot 客户端。命令入口点把同步边界保持得很小:

def run() -> None: raise SystemExit(asyncio.run(main()))

这个生命周期在长时间运行的应用中很重要。智能体会话拥有进程、连接、历史记录,有时候还有临时文件。异常不应该让这些资源无限期地挂在 worker 上。

运行同样的修复

用自带的测试夹具启动智能体:

safe-repo-agent ../fixture

它读取源码和测试,然后提出和 .NET 版本一样的一行修改:

- return value.toLowerCase().replace(/\s+/g, "-");
+ return value.trim().toLowerCase().replace(/\s+/g, "-");

写操作要等操作者批准才会执行。后面的 npm test 请求有自己的确认提示,所以批准一个补丁不等于授予持续执行任意命令的权限。

验证运行的结果:

File changed: src/normalize-title.js
Fix: Added .trim() before .toLowerCase().
Validation: Both tests pass (npm test exit 0).

单独运行确定性策略测试:

pytest

所有六个策略用例在没启动 Copilot 的情况下全部通过。

接入 OpenTelemetry

Python 的 GitHubCopilotAgent 内置了 Agent Framework 的遥测层。用于本地探索时,可以在创建智能体之前配置控制台导出器:

from agent_framework.observability import configure_otel_providers configure_otel_providers(enable_console_exporters=True)

在服务中,通过 OTLP 导出到常规的可观测性后端,并把仓库任务标识符附加到外层 trace。有用的信号包括:

  • 端到端运行时间
  • 等待人工审批的时间
  • 按权限类型统计的工具调用
  • 被拒绝的操作
  • 命令执行时间和退出状态
  • 修复尝试和验证失败
  • 清理失败

不要轻易开启 prompt 和 completion 的捕获。仓库路径、源代码、终端输出和环境相关错误可能包含敏感信息。遥测应该解释运行过程,而不是变成智能体能看到的所有机密的又一个副本。

从控制台教程到生产环境 worker

这个控制台应用演示的是控制点,不是一个完整的隔离平台。如果要让它在服务中处理不可信仓库,还需要加:

  • 每个任务一个全新的容器或 microVM
  • 只读基础镜像和可丢弃的可写工作空间
  • asyncio 感知的超时和取消,覆盖 CPU、内存、磁盘和墙上时钟限制,不是简单在配置文件里写个数字
  • 网络默认拒绝
  • 不携带环境中的开发者凭证或云凭证
  • 短期仓库凭证,没有合并权限
  • 持久化的审批记录,而不是终端输入
  • 有限次数的修复循环和 diff 大小限制
  • finally 块中的清理和审计记录,包括被取消的任务

对确定性工具运行的仓库脚本同样适用这些考量。智能体让风险更容易看到,因为它动态选择命令,但仓库及其依赖本来就是不可信的可执行输入。

和 .NET 版本有什么区别?

架构边界没变。两个实现使用相同的执行框架、工作目录、权限表、任务和测试夹具。

Python 版本用 async with 表达生命周期,通过 GitHubCopilotOptions 传递 Copilot 会话设置,把阻塞的操作者输入丢到线程里。.NET 版本用 CopilotClientSessionConfig、带类型的权限请求子类,以及 IAsyncEnumerable 做流式处理。

这些是生态系统的差异,不是不同的安全模型。

同一个智能体写两遍,有一件事变得很清楚:包裹模型的语言几乎无关紧要。重要的是它工具周围的权限边界,以及"它成功了"是否有 diff 和通过的测试支撑,还是只是模型自己说自己成功。

感谢阅读 ✌️

参考资料

  • Build Production-Ready Agents with the GitHub Copilot Harness and Agent Framework
  • GitHub Copilot integration with Microsoft Agent Framework
  • GitHub Copilot agents in Microsoft Agent Framework
  • GitHub Copilot SDK