Copilot CLI: An Agent in the Terminal, Plan Mode, Sandboxing, Automation with `copilot -p` and ACP
copilot -p can exit with 0 while the answer is wrong: in Bookstore both runs with a local model returned 0, one printing a useless JSON fragment, the other bullets that are not in the commit. Installation, plan mode, --allow-tool and --deny-tool rules, sandboxing and --cloud, GitHub Actions with GITHUB_TOKEN and copilot-requests, the permission actionlint does not know, BYOK and an ACP client.
Version note: The commands in this post were run with GitHub Copilot CLI 1.0.112 and the code with .NET 10; the workflow file was validated with actionlint 1.7.12. The CLI ran without a GitHub account, with COPILOT_OFFLINE=true and a local Ollama model (qwen3:4b); the agent output shown comes from this small model, not from Copilot's own models. Plan mode, sandboxing, --cloud, /delegate and authentication in GitHub Actions are taken from docs.github.com and the CLI's own copilot help topics as of 15 November 2026.
Guess First
For Bookstore, the online bookstore used as the shared example across this site's series, you want a CI step that drafts release notes on every commit to main: copilot -p "summarize the last commit in three bullet points". The step runs, the exit code is 0, the output file is created.
What is your guess? Does exit code 0 show that Copilot did the job you asked for? Which commands can the step run without approval? How much permission does the workflow need? We ran copilot -p twice with a local model for this post; the answer is in the first run.
Installation and the First Session
Copilot CLI runs on Linux, macOS and Windows (PowerShell and WSL). On Windows it needs PowerShell 6 or later; you install it with WinGet, npm (Node.js 22 or later), or on macOS and Linux with Homebrew or the install script:
PS> winget install GitHub.Copilot
PS> npm install -g @github/copilot@1.0.112
PS> copilot --version
GitHub Copilot CLI 1.0.112.
Run 'copilot update' to check for updates.
The CLI updates itself by default. In CI, and wherever you want a fixed version, use COPILOT_AUTO_UPDATE=false or --no-auto-update; when the CLI sees environment variables such as CI, it already turns automatic updates off.
Typing copilot opens an interactive session. On first start the CLI asks whether you trust the folder you started it in and everything below it; as we saw in the sixth post, .mcp.json, skill and hook files in an untrusted folder are not loaded. The documentation says plainly that this scoping is heuristic and does not guarantee that every file outside the trusted folder is protected. In the session, Shift+Tab cycles through the modes: the default mode, plan mode and autopilot. In plan mode Copilot first asks questions and builds an implementation plan without writing code; it is the terminal counterpart of the plan-first, code-later flow from the third post. You can choose the same modes at start-up with --mode plan or --plan.
Tool Approvals and Permission Rules
The CLI asks before it runs a shell command, edits a file, goes outside the allowed paths and URLs, or calls an MCP tool; commands it recognizes as read-only run without asking. The "don't ask again in this repository" option in the approval prompt saves the decision to ~/.copilot/permissions-config.json. On the command line, rules apply to that run:
--allow-tool='shell(git:*)'runs every git command without asking,--allow-tool='shell(git show:*)'onlygit show. Tools such asdotnet,npmandghare matched together with their subcommand.--deny-tool='shell(git push)'and--deny-tool='write(.env)'beat every allow; even--allow-alland--yolocannot override them.--available-toolsand--excluded-toolsare not permissions; they decide which tools the model can see.
One warning in the help text matters: because shell(gh pr) only knows the first subcommand, it also leaves gh pr merge and gh pr close without approval; shell rules cannot allow only gh pr create. A limit like that needs the preToolUse hook from the sixth post.
Sandboxing and Cloud Sessions
Approval rules answer "what can run"; the sandbox answers "what can a running command reach". According to copilot help sandbox, command sandboxing is off by default: while it is off, shell commands run with all the permissions of your user account. You turn it on in a session with /sandbox enable, or for one session with --sandbox, and it uses the operating system's own isolation through Microsoft Execution Containers (MXC); a container mechanism on Windows, bubblewrap on Linux. A command that goes outside the allowed paths fails instead of affecting the system. One limit is spelled out: a command inside the sandbox inherits the shell environment apart from a fixed blocklist, so another credential in the environment (such as a cloud provider key) stays visible unless you configure masking. An organization's managed policy can require the sandbox.
There are two ways to take the work off the machine entirely. copilot --cloud starts the session in a cloud sandbox hosted by GitHub; you can follow and steer it from another Copilot client. /delegate (or a prompt prefixed with &) sends the task to the cloud agent from the seventh post: it offers to commit uncommitted changes as a checkpoint on a new branch, and the agent opens a draft pull request. Note: the copilot --help output of 1.0.112 lists neither --cloud nor --sandbox; both appear in the documentation and in the help sandbox topic. Check the documentation before assuming your version has them.
Scripts and CI with copilot -p
-p runs the prompt and exits; -s prints only the agent's answer. Because non-interactive mode cannot ask for approval, every tool call that was not allowed beforehand is denied. In Bookstore we ran this command with the local model:
$ export COPILOT_OFFLINE=true COPILOT_PROVIDER_BASE_URL=http://127.0.0.1:11434/v1 COPILOT_MODEL=qwen3:4b
$ copilot -p "Use git show --stat HEAD to summarize the last commit in at most three bullet points." \
-s --no-ask-user --available-tools bash \
--allow-tool='shell(git show:*)' --deny-tool='shell(git push)' --usage-output-file usage.json
{
"command": "dotnet run eng/check-instructions.cs"
}
$ echo $?
0
$ copilot -p "Run the shell command git show --stat HEAD, then summarize that commit in at most three bullet points." \
-s --no-ask-user --available-tools bash \
--allow-tool='shell(git show:*)' --deny-tool='shell(git push)' --usage-output-file usage.json
The commit successfully aligns with the project's rules:
- All changes to code-review instructions (`code-review.instructions.md`, `SKILL.md`, `main-review.json`, and `.github/skills` artifacts) avoid secrets/connection strings
- No module-level database interaction violations (rules applied to infrastructure)
- New code reviews follow `TypedResults`/`ProblemDetails` error handling pattern
- 136 lines of instructions maintain UTC time alignment and `decimal` currency rules
- Checks are now consistent via `eng/check-instructions.cs` (exit code 0 confirms validation)
No additional changes required to `AGENTS.md` since the instructions file reconciliation is already resolved. ✅
$ jq -c '.modelMetrics' usage.json
{"qwen3:4b":{"requests":{"count":2,"cost":0},"usage":{"inputTokens":4100,"outputTokens":3146,"cacheReadTokens":8,"cacheWriteTokens":0},"totalNanoAiu":0,"aiCreditsStatus":"unavailable"}}
In both runs the model first ran the allowed git show --stat HEAD command; the session log shows the command and its output. In the first run, instead of three bullet points, it printed a JSON fragment that looks lifted from the instruction files, and the CLI exited with 0. For the second run we changed the prompt slightly: this time a bulleted answer came back, but with five bullets instead of three, and some of them said things that are not in the commit. The 136 lines the commit adds are real; "136 lines of instructions maintaining UTC and decimal rules" is not, and the bullet about TypedResults has nothing to do with the commit. That is the answer to Guess First: exit code 0 came back both times.
The exit code only says that the session ended without an error, not that the answer is right. The JSON written by --usage-output-file supports this: this run has two model requests and token counts, but no signal about what was done. With your own model provider, aiCreditsStatus in the file is unavailable; GitHub does not charge for these models and shows no quota.
The workflow that does the same job in GitHub Actions. For organization repositories the documentation recommends GITHUB_TOKEN instead of a personal token; for that, the workflow needs the copilot-requests: write permission and the organization needs the "Allow use of Copilot CLI billed to the organization" policy turned on:
name: Copilot commit summary
on:
push:
branches: [main]
# GITHUB_TOKEN may only read the code and make Copilot requests; it cannot push or comment.
permissions:
contents: read
copilot-requests: write
jobs:
summary:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 2
- uses: actions/setup-node@v7
with:
node-version: 24
- name: Install Copilot CLI
run: npm install -g @github/copilot@1.0.112
- name: Summarize the commit
env:
GITHUB_TOKEN: ${{ github.token }}
COPILOT_AUTO_UPDATE: "false"
run: |
copilot -p "Run git show --stat HEAD and summarize this commit for the release notes in at most five bullet points." \
-s --no-ask-user \
--available-tools bash view \
--allow-tool='shell(git show:*)' --deny-tool='shell(git push)' \
--max-ai-credits 30 \
--usage-output-file usage.json > summary.md
# copilot -p can exit with 0 even when the answer is useless; check the output before using it.
if ! grep -q '^[-*] ' summary.md; then
echo "::error::The summary has no bullet points"
exit 1
fi
cat summary.md >> "$GITHUB_STEP_SUMMARY"
jq '{requests: .modelMetrics | map_values(.requests.count)}' usage.json
The workflow's layers of defense: the token can only read and make Copilot requests, the model only sees bash and view, the only command that runs without approval is git show, the session can spend at most 30 AI Credits (the smallest limit the CLI accepts), and the output's format is checked before it is used. That check would stop the first answer above but not the second; a format check is a rough filter, not content validation. Running it through actionlint gave an interesting result:
$ actionlint .github/workflows/copilot-commit-summary.yml
.github/workflows/copilot-commit-summary.yml:10:3: unknown permission scope "copilot-requests". all available permission scopes are "actions", "artifact-metadata", "attestations", "checks", "contents", "deployments", "discussions", "id-token", "issues", "models", "packages", "pages", "pull-requests", "repository-projects", "security-events", "statuses" [permissions]
|
10 | copilot-requests: write
| ^~~~~~~~~~~~~~~~~
actionlint 1.7.12 (the latest release on 15 November 2026) does not know the copilot-requests permission yet. Instead of deleting the permission, we added an actionlint setting that ignores this one warning for this file only:
# actionlint 1.7.12 does not know the copilot-requests permission yet (docs.github.com documents it).
paths:
.github/workflows/copilot-commit-summary.yml:
ignore:
- 'unknown permission scope "copilot-requests"'
For this scenario GitHub recommends GitHub Agentic Workflows over calling copilot directly; they use GITHUB_TOKEN by default and add guardrails for automation. If you do call it directly, take the documentation's warning seriously: the CLI runs with broad access to the workflow environment, and workflows triggered by pull requests from forks are especially at risk.
The Repository's Extension Files in the CLI
All the files we wrote in the fourth and sixth posts also work in the CLI and can be checked with commands that need no login: instructions with copilot instruction list, MCP servers with copilot mcp list and copilot mcp get, skills with copilot skill. --agent migration-reviewer selects the custom agent; hooks load from .github/hooks/*.json and ~/.copilot/hooks. Copilot Memory has a copilot memories command; in prompt mode memory is off by default and --enable-memory turns it on.
You can also connect your own model provider. COPILOT_PROVIDER_BASE_URL points to an OpenAI-compatible endpoint (Ollama, vLLM, Foundry Local), and according to the CLI's help text no GitHub sign-in is needed in that case; the CLI also finds Ollama on the machine at 127.0.0.1:11434 by itself. COPILOT_OFFLINE=true turns off all network access: GitHub sign-in, web tools, the GitHub MCP server, delegation to the cloud agent, telemetry and automatic updates. Every run in this post was done that way. One limit: the cloud agent, code review, remote sessions and cloud sandboxes run on GitHub's servers, so they use GitHub's models, not yours.
Copilot in Other Tools with ACP
The Agent Client Protocol (ACP) makes Copilot CLI the agent of another client. In editors that support ACP, such as Zed or a JetBrains IDE, you register copilot --acp as the command; the editor starts the CLI and talks to it over stdio with newline-delimited JSON-RPC messages. As of 15 November 2026 the feature is in public preview. To see what the protocol looks like, we wrote a small C# client:
// Excerpt; Request() writes one JSON line, then reads lines until the matching response arrives.
ProcessStartInfo start = new(agent, "--acp --stdio --available-tools=view,glob")
{
RedirectStandardInput = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
using Process copilot = Process.Start(start)!;
int nextId = 1;
JsonNode initialize = await Request("initialize", new JsonObject
{
["protocolVersion"] = 1,
["clientCapabilities"] = new JsonObject { ["fs"] = new JsonObject { ["readTextFile"] = false, ["writeTextFile"] = false } },
});
Console.WriteLine($"agent: {initialize["agentInfo"]?["name"]} {initialize["agentInfo"]?["version"]}, protocol {initialize["protocolVersion"]}");
Console.WriteLine($"auth methods: {string.Join(", ", initialize["authMethods"]?.AsArray().Select(m => m?["id"]?.ToString()) ?? [])}");
JsonNode session = await Request("session/new", new JsonObject { ["cwd"] = Environment.CurrentDirectory, ["mcpServers"] = new JsonArray() });
string sessionId = session["sessionId"]!.ToString();
Console.WriteLine($"session: {sessionId[..8]}...");
JsonNode result = await Request("session/prompt", new JsonObject
{
["sessionId"] = sessionId,
["prompt"] = new JsonArray(new JsonObject { ["type"] = "text", ["text"] = prompt }),
});
Console.WriteLine($"\nstop reason: {result["stopReason"]}");
copilot.StandardInput.Close();
await copilot.WaitForExitAsync();
$ export COPILOT_OFFLINE=true COPILOT_PROVIDER_BASE_URL=http://127.0.0.1:11434/v1 COPILOT_MODEL=qwen3:4b
$ dotnet run eng/acp-client.cs "Use the glob tool to list the .cs files in the eng folder, then answer with their names only."
agent: Copilot 1.0.112, protocol 1
auth methods: copilot-login
session: 75630f81...
Info: Disabled tools: bash, bookstore-get_book, bookstore-list_institutions, bookstore-search_books, create, edit, grep, playwright-browser_click, ..., sql, task, write_agent
[tool] Finding files matching eng/**/*.cs (read)
acp-client.cs
review-context.cs
context-size.cs
check-instructions.cs
mcp-smoke.cs
ai-credits.cs
stop reason: end_turn
In order: initialize returns the agent's name, version and authentication method (copilot-login); session/new opens a session; while session/prompt runs, the agent sends tool calls and pieces of the answer as session/update notifications. According to the help text, the CLI refuses to open an ACP session with neither a sign-in nor a model provider of your own; with the local model we could open one. The --available-tools=view,glob we passed when starting the server applies to every session; session/new cannot change it, and the "Disabled tools" line lists everything else. In that line Playwright shows only the five tools we enabled in .mcp.json in the sixth post, so the tools list really is applied in the CLI.
A short mapping table; the details are in the Claude Code and OpenAI Codex series:
| Task | Copilot CLI | Claude Code | Codex CLI |
|---|---|---|---|
| Project instructions | AGENTS.md, .github/copilot-instructions.md | CLAUDE.md | AGENTS.md |
| Running from a script | copilot -p | claude -p | codex exec |
| Project MCP settings | .mcp.json | .mcp.json | config.toml |
| Skill folder | .github/skills, .claude/skills, .agents/skills | .claude/skills | .agents/skills |
| Command isolation | /sandbox, --cloud | Sandbox and permission modes | Sandbox modes and approval policy |
In Production
Do not use automatic approval in CI. --yolo or COPILOT_ALLOW_ALL=true approves every tool, and written exactly as "true" it also trusts the working folder, which means the hooks and MCP servers in that folder are loaded. Code from a pull request could run its own hook with your CI token.
Hide secrets. --secret-env-vars removes the given environment variables from the shell and MCP server environment; according to the help text, a value that reaches a tool result some other way still goes to the model and is saved in the session history.
Set a session limit. --max-ai-credits is a soft limit: usage is only known once a response returns, so one response can go past the limit, and the next model call is then blocked.
Pin the version. In the workflow we used @github/copilot@1.0.112 and COPILOT_AUTO_UPDATE=false; otherwise the same workflow can run with a different CLI two days apart.
Common Pitfalls
Running in CI in automatic approval mode with a broad token. With contents: write and --yolo together, even a commit message with a prompt injection could write to the repository.
Treating exit code 0 as success. Add a step that checks the format and content of the output.
Broad rules such as shell(gh pr). Because they match on the first subcommand, they leave commands you did not expect without approval.
Mistaking the sandbox for secret protection. The sandbox inherits the environment; credentials that are not masked stay visible.
Assuming an option from the documentation exists in every version. The CLI ships often; copilot --help and copilot help <topic> tell you what the installed version really has.
A Small Experiment
★☆☆ Read copilot help permissions in your own repository and write an --allow-tool and --deny-tool list for your daily work. Which rule blocks dotnet ef database update?
★★☆ Add a check to Bookstore's copilot-commit-summary.yml that fails the step if the summary names a file the commit did not change (compare with git show --name-only HEAD).
★★★ Change eng/acp-client.cs so that it asks about the agent's session/request_permission requests on the console; add bash to --available-tools, ask for a build command and record which options (optionId, kind) arrive.
So far we have used Copilot from a single developer's point of view. In the last post of the series we look at the team and the organization: policies, content exclusion, model access, usage metrics, the AI Credits budget, and choosing between Copilot, Claude Code and Codex.