Skip to content
S
ai

Build a Safe Repository Maintenance Agent with GitHub Copilot and Microsoft Agent Framework in Python

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.

The agent proposes, the host decides: the same permission-gated loop as the .NET version

What we are building

The agent receives a local repository path and a maintenance task. Its system instructions require it to:

  1. Work only inside that repository.
  2. Read its AGENTS.md before changing anything.
  3. Make the smallest coherent repair.
  4. Avoid the network, package installation, commits, pushes, and pull requests.
  5. Run focused validation.
  6. 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:

CapabilityDefault
Read inside the selected repositoryApprove
Write a fileAsk
Run a shell commandAsk
Fetch a URL or call MCPDeny
Anything unknownDeny

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 ✌️

References

Stay in the loop

Practical engineering notes, without the inbox noise.

Notes on distributed systems, resilient software, and engineering in the real world - usually once or twice a month.

Unsubscribe anytime. Prefer a feed? Subscribe via RSS.