
Build a Safe Repository Maintenance Agent with GitHub Copilot and Microsoft Agent Framework in Python
#ai#python#github#copilot#agentic-coding9 min read
In the .NET tutorial, we built a repository agent that could inspect a checkout, propose a one-line repair, run its tests, and report the result. Every write and shell command required explicit approval.
Now we will build the same agent in Python.
The outcome and safety policy stay identical on purpose - I didn’t want to quietly pick an easier demo just because Python made one more convenient. Both implementations work against the same failing fixture, get the same instructions, stop at the same approval boundaries, and have to pass the same tests. Only the host-language implementation changes.
The result is an async Python command-line application using the GitHub Copilot harness for repository work and Microsoft Agent Framework for the agent abstraction, streaming, sessions, and telemetry.
💡 Code repository: The complete Python and .NET implementations use the same fixture and are available at github.com/sahansera/safe-repository-maintenance-agent.

What we are building
The agent receives a local repository path and a maintenance task. Its system instructions require it to:
- Work only inside that repository.
- Read its
AGENTS.mdbefore changing anything. - Make the smallest coherent repair.
- Avoid the network, package installation, commits, pushes, and pull requests.
- Run focused validation.
- Report the changed files, commands, and result.
The included fixture contains a small JavaScript function. The agent is written in Python, but the target repository does not have to be:
export function normalizeTitle(value) {
return value.toLowerCase().replace(/\s+/g, "-");
}
One test expects ordinary title normalization. A second expects whitespace around the title to be ignored. The second test fails until the implementation trims the input.
Using a language-neutral target is deliberate. A Python agent can maintain a .NET, JavaScript, Go, or documentation repository. The agent host language determines how we integrate and operate the harness, not which source files the harness can understand.
Prerequisites and package setup
You need Python 3.11 or later and an active GitHub Copilot subscription. The example uses Python 3.12 as its documented baseline and was also verified with Python 3.13.
Here’s the environment I actually tested this against:
[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"]
Create an isolated environment and install the project:
python -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
Here’s what it looks like when run end to end:

The current Python SDK package includes the Copilot runtime for supported platforms. Authentication still depends on GitHub Copilot and may ask you to sign in when you run the agent for the first time.
The Agent Framework integration is stable; the GitHub Copilot SDK underneath it isn’t, and its preview label didn’t stop it from changing shape more than once while I was writing this pair of posts. Pin these versions - it’s cheap insurance against a future SDK bump silently rewriting what you’re reading.
Make permissions a small, testable policy
The agent can request several capability types, including read, write, shell, url, and mcp.
We will not treat them equally:
| Capability | Default |
|---|---|
| Read inside the selected repository | Approve |
| Write a file | Ask |
| Run a shell command | Ask |
| Fetch a URL or call MCP | Deny |
| Anything unknown | Deny |
The policy is an ordinary function:
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
The fallback denies URL access, MCP calls, new SDK permission kinds, and any malformed value. The application has to be changed deliberately before one of those capabilities becomes available.
The test suite makes that contract visible:
@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
These tests do not need a model, a Copilot subscription, or a repository. They test application authority rather than probabilistic behavior.
Convert policy into Copilot decisions
The permission handler receives a typed request and a context dictionary. It first prints enough detail for the operator to understand the action:
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() is blocking, so asyncio.to_thread keeps it away from the event loop. That detail is easy to
miss in a console sample and becomes more important when the application also streams output or
handles more than one session.
For a write request, describe prints file_name and diff. For a shell request, it prints
full_command_text. URL and MCP requests are displayed before being denied, leaving an audit-friendly
record of what the agent attempted.
The helper name approve_all can be misleading in this context. It constructs an approval response
for the current request. Our application still decides which requests reach that line.
Configure the agent without ambient repository state
The command resolves the repository path before creating the agent:
repository = args.repository.expanduser().resolve(strict=True)
if not repository.is_dir():
raise NotADirectoryError(repository)
The Copilot session options include that working directory and our permission callback:
options = GitHubCopilotOptions(
working_directory=str(repository),
enable_config_discovery=False,
on_permission_request=handle_permission,
)
agent = GitHubCopilotAgent(
instructions=INSTRUCTIONS,
default_options=options,
)
Repository instructions are genuinely useful, so turning configuration discovery off seems backwards
at first. I ran into the reason for it while building the .NET version of this project: the fixture
sat inside another Git checkout during development, and the runtime happily walked up to that parent
repo’s AGENTS.md before it ever noticed the fixture’s own. Nested repositories, monorepos, and
temporary worktrees all make that boundary easy to blur without meaning to.
So the application instructions explicitly tell the agent to read AGENTS.md inside its working
directory itself. That keeps the source of project guidance visible in the tool activity, instead of
letting an unrelated parent directory quietly change what the agent thinks the rules are.
That does not make repository instructions trusted. A repository can contain prompt injection just as it can contain a malicious build script. The host policy remains authoritative when instructions ask for a forbidden action.
Stream the maintenance run
GitHubCopilotAgent owns an async client, so the natural Python lifecycle is an async context manager:
async with agent:
async for update in agent.run(args.task, stream=True):
print(update.text, end="", flush=True)
The context manager starts and stops the Copilot client even if the run raises an exception. The command entry point keeps the synchronous boundary small:
def run() -> None:
raise SystemExit(asyncio.run(main()))
This lifecycle matters in longer-running applications. Agent sessions own processes, connections, history, and sometimes temporary files. An exception should not leave those resources attached to a worker indefinitely.
Run the same repair
Start the agent against the included fixture:
safe-repo-agent ../fixture
It reads the source and tests, then proposes the same one-line change as the .NET version:
- return value.toLowerCase().replace(/\s+/g, "-");
+ return value.trim().toLowerCase().replace(/\s+/g, "-");
The write does not happen until the operator approves it. The later npm test request has its own
prompt, so approving a patch does not grant standing permission to execute arbitrary commands.
The verified run ended with:
File changed: src/normalize-title.js
Fix: Added .trim() before .toLowerCase().
Validation: Both tests pass (npm test exit 0).
Run the deterministic policy tests separately:
pytest
All six policy cases pass without starting Copilot.
Adding OpenTelemetry
The Python GitHubCopilotAgent includes Agent Framework’s telemetry layer. For local exploration, the
framework can configure console exporters before the agent is created:
from agent_framework.observability import configure_otel_providers
configure_otel_providers(enable_console_exporters=True)
In a service, export through OTLP to your normal observability backend and attach the repository job identifier to the surrounding trace. Useful signals include:
- End-to-end run duration
- Time waiting for human approval
- Tool calls by permission kind
- Denied actions
- Command duration and exit status
- Repair attempts and validation failures
- Cleanup failures
Do not enable prompt and completion capture casually. Repository paths, source code, terminal output, and environment-related errors can contain sensitive information. Telemetry should explain the run without becoming another copy of every secret the agent could see.
From console tutorial to production worker
The console application demonstrates the control points, not a complete isolation platform. Before I would let it process untrusted repositories in a service, I would add:
- A fresh container or microVM for every job
- A read-only base image and disposable writable workspace
asyncio-aware timeouts and cancellation for CPU, memory, disk, and wall-clock limits, not just a number typed into a config file- Network deny-by-default
- No ambient developer or cloud credentials
- Short-lived repository credentials without merge permission
- Durable approval records instead of terminal input
- A bounded repair loop and diff-size limit
- Cleanup and audit recording in
finally, including cancelled tasks
The same concerns apply to a deterministic tool that runs repository scripts. The agent makes the risk easier to see because it chooses commands dynamically, but the repository and its dependencies were already untrusted executable input.
What differs from .NET?
The architectural boundaries did not change. Both implementations use the same harness, working directory, permission table, task, and fixture.
The Python version expresses the lifecycle with async with, passes Copilot session settings through
GitHubCopilotOptions, and moves blocking operator input to a thread. The .NET version uses
CopilotClient, SessionConfig, typed permission request subclasses, and IAsyncEnumerable for
streaming.
Those are ecosystem differences, not different safety models.
Building the same agent twice made one thing obvious: the language wrapping the model barely mattered. What mattered was the authority boundary around its tools, and whether “it worked” was backed by a diff and a passing test, or just the model’s own word for it.
Thanks for reading ✌️