GitHub Copilot CLI for Bash

GitHub Copilot CLIAcademy lesson 57Cluster 5 · Lesson 6 of 12Intermediate14 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot CLI for BashGitHub Copilot CLI6Intermediate/github-copilot/cli/bash/

Cluster 3 covers Copilot and Bash as a language — quoting rules, arrays, the parameter expansions nobody remembers. This lesson is about something different: what changes when the assistant helping you write a script can also run it.

That difference is larger than it sounds. A wrong Bash suggestion in an editor is text you review. A wrong Bash suggestion in an agentic session is one approval away from executing, and shell scripts are unusually good at doing enormous damage from small typos.

The loop

Writing a script with an agent

The two static checks in the middle are what make this workflow better than writing the script alone. They are objective, they are fast, and they catch precisely the mistakes that generated shell makes most often. An agent that runs them after every edit converges on a correct script much faster than one asked to be careful.

Permissions for script work

Script development wants a narrower policy than it first appears, because “run the script” is exactly the operation you want to remain a decision.

Example policyScript development: let the agent check its own work, keep execution deliberate.
Example tool permission policy
PatternDecisionWhy
shell(bash -n:*)AllowedSyntax checking never executes the script.
shell(shellcheck:*)AllowedStatic analysis, no side effects.
writeAsks firstEvery edit to the script surfaces before it lands.
shell(bash:*)Asks firstActually running it is a separate decision each time.
shell(sudo:*)DeniedA script under development has no business being privileged.

Written for this lesson to illustrate least privilege. Not a GitHub default and not a security guarantee — read every rule before using it.

The distinction between bash -n script.sh and bash script.sh is one flag and the entire risk profile. Unfortunately shell(bash:*) matches both, so the pattern grammar cannot separate them for you — which is an argument for leaving bash on prompt and reading which invocation is being proposed.

The practical project

A health-check script is a good teaching example because the naive version is dangerous in an instructive way: it wants to check disk space, and checking disk space invites cleaning up disk space.

Step 1: state the requirement precisely

Copilot prompt

Write health-check.sh that reports on this host:

  • Root filesystem usage as a percentage
  • Available memory in MB
  • Whether nginx and sshd are active
  • The three largest directories under /var/log

Requirements:

  • Read-only. The script must not delete, move or modify anything.
  • Exit 0 if all checks pass, 1 if any threshold is breached.
  • Disk threshold 90%, memory threshold 200 MB free.
  • POSIX-compatible where practical; if you use a bashism, say why.
  • Run bash -n and shellcheck after writing, and fix what they report.

The last line is the one that changes the outcome most. An agent told to run the checks will iterate against them; an agent not told will hand you a first draft.

“If you use a bashism, say why” is a small technique with a good return — it surfaces a portability decision that would otherwise be silent, and the explanation is usually either convincing or obviously wrong.

Step 2: the checks

Practical example

Validating a generated script

Catch syntax and correctness problems before the script is ever executed.

Status
Example implementation
Not executed here
This code was written against the documentation cited at the end of the lesson but was not run while writing it. Treat the commands as the ones to run, not as output that has been observed.
bash -n health-check.sh          # syntax only, does not execute
shellcheck health-check.sh       # static analysis

bash -n parses without running. It catches unbalanced quotes, an unterminated if, a missing fi — the errors that would otherwise surface halfway through execution, after the script has already done something.

ShellCheck catches the semantic problems: unquoted variables that break on spaces, cd without checking it succeeded, [ $x = y ] where [[ ]] was meant, useless cat. These are exactly the mistakes generated shell makes, because they are the mistakes the training data makes.

Step 3: read it yourself

Static analysis does not catch logic. Read for:

Unanchored paths. Does it assume a working directory? A script that works from its own directory and breaks from / is a common generated pattern.

Threshold comparisons. Is the disk check -gt 90 or -ge 90, and does the value it compares include the % sign? String-versus-integer comparison bugs are extremely common here and they fail silently in the safe direction, which is why nobody notices for months.

Parsing assumptions. Generated scripts love df -h | awk '{print $5}', which breaks when a device name is long enough to wrap onto two lines.

Exit code discipline. Does it actually exit non-zero on failure, or just print a message?

Step 4: run it somewhere harmless

For a read-only health check, running against the real host is fine — it only reads. For anything that writes, build a fixture.

Copilot prompt

Create tests/fixtures/ containing a directory tree that mimics /var/log: several files of different sizes and ages, including one large file and one very old file.

Then write tests/run-tests.sh which runs health-check.sh against the fixture rather than the real filesystem, and asserts the exit code and the reported values.

Nothing in the tests may touch anything outside tests/fixtures/.

This is the shell equivalent of the discipline from the Python lesson: make the destructive path testable somewhere that does not matter, then read the results.

Why shell is the hardest case for generated code

It is worth being explicit about why shell deserves more caution than the other languages in this cluster, because the reason is structural rather than a matter of model quality.

The syntax is unforgiving and the failures are silent. A missing quote does not raise an error; it changes what the command means. Python raises NameError; Bash quietly substitutes an empty string and carries on.

The blast radius is the machine. A logic bug in a Python function corrupts some data. A logic bug in a shell script can remove a filesystem, because the shell’s job is invoking exactly those tools.

There is no type system and almost no runtime checking. Comparing a string to an integer, passing a filename where a flag was expected, expanding an array in scalar context — none of these are caught before execution.

Portability differences are invisible until they are not. The same script behaves differently under GNU and BSD coreutils, under Bash and ash, on macOS and Linux. Nothing in the script announces which it assumed.

This is why the validation loop matters more here than anywhere else in Cluster 5, and why bash -n and ShellCheck are worth pre-approving so they run constantly. They restore some of the checking that other languages get from their compilers and runtimes.

Where generated Bash goes wrong

Patterns worth checking for specifically, because they recur.

Unquoted expansions. rm $file where $file contains a space becomes two arguments. ShellCheck catches this reliably, which is most of why running it matters.

cd without a guard. cd "$dir"; rm -rf ./* is catastrophic if the cd fails, because ./* then refers to wherever you already were. The fix is cd "$dir" || exit 1.

Parsing ls. Filenames can contain newlines. Generated scripts parse ls output constantly.

Assuming GNU tools. sed -i takes an argument on BSD and macOS and does not on GNU. date -d is GNU-only. A script written on Linux and run on macOS breaks here first.

Silent failure in pipelines. Without pipefail, false | true succeeds.

Word splitting in for. for f in $(ls) breaks on any filename with a space; for f in * does not.

POSIX, Bash, and choosing deliberately

Ask for “a shell script” and you will usually get Bash, often with bashisms the agent did not flag. That is fine when the script runs on your machine and wrong when it runs in an Alpine container where /bin/sh is BusyBox ash.

The failure is unpleasant because it is late and confusing. [[ ]], arrays, local, ${var,,} and process substitution are all Bash features that produce syntax errors — not helpful messages — under a POSIX shell.

State the target explicitly:

Target shell: POSIX sh (BusyBox ash in Alpine).
No bashisms: no [[ ]], no arrays, no local, no process substitution.
The shebang is #!/bin/sh.

ShellCheck enforces this if the shebang is right — it changes its rule set based on the shebang, and flags bashisms under #!/bin/sh. That makes the shebang a functional declaration rather than a formality, and it is worth checking the agent got it right before trusting a clean ShellCheck run.

Exit codes, traps and cleanup

Three areas where generated scripts are consistently weakest, and where the consequences are operational rather than cosmetic.

Exit codes carry meaning. A script consumed by CI or by another script needs to signal failure through its exit status, not only by printing. Generated scripts frequently print an error and then fall off the end, exiting 0. If the script has a contract — 0 for healthy, 1 for threshold breached, 2 for a check that could not run — state it in the prompt, because it will not be inferred.

Traps clean up. A script creating a temporary directory should remove it on exit, including on interruption:

tmp=$(mktemp -d) || exit 1
trap 'rm -rf "$tmp"' EXIT

Agents write the mktemp and often omit the trap. Worth asking for explicitly. Note the quoting on "$tmp" in the trap — an unquoted version reintroduces exactly the empty-variable hazard the trap was meant to tidy up after.

Temporary files should not be predictable. /tmp/myscript.$$ is guessable and is a symlink attack waiting to happen on a shared host. mktemp exists for this. ShellCheck flags some but not all of these cases, so it is worth a direct look.

Debugging an existing script

The reverse direction — an inherited script nobody understands — is where an agent is unusually strong, because reading dense shell is exactly the task it does well and the task humans do slowly.

Copilot prompt

Explain what deploy.sh does, step by step.

Do not run it. Do not modify it.

Identify: anything that deletes or overwrites, anything requiring sudo, anything reaching the network, and any assumption about the environment that is not checked.

“Do not run it” matters more than usual. A deployment script found in a repository may do anything, and the whole reason you are asking is that you do not know what.

That last requested category — unchecked environment assumptions — is where these scripts actually fail. A script assuming AWS_PROFILE is set, or that it is running from the repository root, works for years and then does not.

For debugging a failing script, bash -x traces execution line by line, and feeding that trace back is far more productive than describing the symptom:

Copilot prompt

bash -x produced this trace before the failure:

[paste the trace]

The script should have found three log files and found none. Explain what the trace shows about why, and do not modify the script yet.

Hardening a script you already have

A useful periodic task, and safely read-only:

Copilot prompt

Review backup.sh for correctness and safety.

Do not modify it. For each issue, quote the line, explain the failure it causes, and propose a fix.

Look specifically for: unquoted expansions, unchecked cd, unsafe temp file creation, missing error handling, and anything that would behave destructively if a variable were empty.

That last clause is the highest-value check in shell. rm -rf "$BACKUP_DIR"/* with an unset BACKUP_DIR expands to rm -rf /*. set -u catches the unset case; it does not catch the empty-string case, which is why asking explicitly is worth doing.

Putting the loop in the repository

Everything above is a rule you would otherwise restate every session. Write it once instead, in .github/copilot-instructions.md:

Shell scripts in this repository:

- Target POSIX sh unless a file's shebang says otherwise.
- After any edit to a .sh file, run `bash -n` and `shellcheck` and fix
  what they report before saying the task is done.
- Quote every variable expansion. Guard every `cd` with `|| exit 1`.
- Never propose running a script against real data; use tests/fixtures/.
- Report the actual output of the checks, not a summary of it.

That last line is worth including deliberately. “ShellCheck passes” is a claim; pasted output is evidence, and asking for the output makes the difference visible when they diverge.

The custom instructions lesson covers path-scoped instruction files, which suit this well — rules that apply only to **/*.sh belong in .github/instructions/shell.instructions.md rather than in the repository-wide file, where they would apply to every language you use.

Reading a script the agent wrote

A generated script arrives all at once, which is different from one you wrote line by line and understand as a consequence of having written it. Reading it properly has a shape worth learning.

Read the top first. The shebang decides which shell, which decides which syntax is legal and what ShellCheck enforces. set options come next, and they tell you what the script assumes about failure.

Find the destructive operations before anything else. Search for rm, mv, >, dd, mkfs, chmod, chown, kill. If there are none, the risk profile collapses and you can read the rest for correctness rather than for safety.

Trace the variables that appear in those operations. Where does each come from? An argument, an environment variable, command substitution? What happens if it is empty? This is the single highest-value question in shell review, and it is the one static tools answer least well.

Read the error paths. The happy path in generated shell is usually fine. What happens when curl fails, when the directory does not exist, when the disk is full — those are where the assumptions live.

Check what it does not do. No cleanup on failure, no lock file, no check that it is already running. Absences are invisible in a diff and are frequently the actual bug.

What to carry forward

Make the agent run bash -n and ShellCheck after every edit. State it in the prompt or put it in your instructions file so it happens every time.

Keep bash script.sh on prompt. Writing and checking are safe; running is a decision.

Test destructive logic against fixtures, never against the real target.

Read for the empty-variable case. It is the shell failure mode with the worst consequences and the least visibility.

Ask for explanations of dense lines. Checkable, fast, and it teaches you something.

Shell is also the place where the habit of reading before approving pays for itself most directly, because the gap between a correct command and a destructive one is frequently a single character in a variable you cannot see the value of.

Where the agent changes the economics

Shell scripting has always had a particular friction: the syntax is unmemorable enough that writing a fifty-line script means looking things up, and looking things up is slow enough that people reach for a longer, worse solution in a language they know better.

An agent removes that friction, which is genuinely valuable and creates a second problem. Shell becomes easy to produce and remains hard to review, so the volume of shell in a codebase can grow faster than anyone’s ability to check it.

The habit that keeps this healthy is treating generated shell as more suspicious than generated Python, not less — precisely because it was cheaper to produce. bash -n and ShellCheck on every edit are what make that practical rather than merely virtuous, and they are the reason this lesson keeps returning to them.

Next

Python applies the same validation discipline to a language with a real test runner and a type checker, which makes the loop tighter. DevOps is where these scripts meet infrastructure tooling, and Linux covers operating the systems the scripts run on.

For Bash as a language rather than as an agent workflow, Cluster 3’s Copilot for Bash is the companion piece.

Sources

Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.

Primary sources