GitHub Copilot CLI for 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.
Key takeaways
- The validation loop is the value:
bash -nfor syntax, ShellCheck for correctness, then a controlled run. Pre-approve the first two; think about the third. bash -ncatches syntax errors without executing. It is free, fast, and the agent should run it after every edit.- ShellCheck catches the class of bug generated shell is most prone to —
unquoted expansions, unchecked
cd, misused[— and it is a static check, so it costs nothing to run. set -euo pipefailis a good default and is not a safety net. It does not catch a command that succeeds while doing the wrong thing.- Test destructive scripts against a fixture directory, never against the data they are ultimately meant to operate on.
The loop
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.
| Pattern | Decision | Why |
|---|---|---|
shell(bash -n:*) | Allowed | Syntax checking never executes the script. |
shell(shellcheck:*) | Allowed | Static analysis, no side effects. |
write | Asks first | Every edit to the script surfaces before it lands. |
shell(bash:*) | Asks first | Actually running it is a separate decision each time. |
shell(sudo:*) | Denied | A 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
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 analysisbash -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.
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"' EXITAgents 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.
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:
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:
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
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.Sync across devices if you want it everywhere.
Saved in this browser and synced to your account, so it follows you between devices. Manage or delete it.
Was this lesson helpful?
We record which lesson you rated and whether it helped. Nothing identifies you — no account, no cookie, no session.