Running Scripts: Shebang, Permissions and Execution Policy
Making a script run: the shebang line, execute permission and the PATH in Bash, and .ps1 files, execution policy and script signing in PowerShell.
Two ways to start a script
Every script can be started in two ways: hand the file to its
script host
yourself (bash check-disk.sh,
pwsh -File check-disk.ps1), or run the file as if it
were a program (./check-disk.sh,
./check-disk.ps1) and let the system find the host. The
second way is what makes a script feel like a real command, and it
is where the rules on this page come in. Here is a small disk check,
run as a program, in both shells:
cat > check-disk.sh <<'X'
#!/bin/bash
# Warn when a usage percentage is above the threshold.
usage=$1
threshold=90
if (( usage > threshold )); then
echo "WARNING: disk at ${usage}%"
exit 1
fi
echo "OK: disk at ${usage}%"
X
chmod +x check-disk.sh
./check-disk.sh 72
./check-disk.sh 91
echo "exit status: $?"
OK: disk at 72% WARNING: disk at 91% exit status: 1
@'
param([int]$Usage)
if ($Usage -gt 90) {
"WARNING: disk at $Usage%"
exit 1
}
"OK: disk at $Usage%"
'@ | Set-Content check-disk.ps1
./check-disk.ps1 -Usage 72
./check-disk.ps1 -Usage 91
"LASTEXITCODE: $LASTEXITCODE"
"The calling script carries on."
OK: disk at 72% WARNING: disk at 91% LASTEXITCODE: 1 The calling script carries on.
Both scripts report a full disk with exit code 1. The Bash script
reads its argument as $1; the PowerShell script
declares a named, typed parameter with
param([int]$Usage)
(more on
Functions and Parameters). The examples create their script files with a
here document
or a here-string so that each one runs on its own; in practice you
write them in an editor.
The shebang line
The first line of a Bash script, #!/bin/bash, is the
shebang: when
you run the file as a program, the Linux kernel reads it and starts
that interpreter with the file as its argument. Two forms are
common:
-
#!/bin/bashnames the interpreter's path directly. On Linux servers, that is where Bash lives. -
#!/usr/bin/env bashletsenvsearchPATHforbash. Use it for scripts that must also run on macOS or FreeBSD, where a current Bash is installed somewhere else (see Cross-Platform Scripting).
The shebang decides which shell runs the script, so it must match
the language the script is written in. Starting a Bash script with
sh runs it in a different shell (on Debian and Ubuntu,
dash), and the result can be worse than an error
message:
cat > report.sh <<'X'
#!/bin/bash
if [[ -n $1 ]]; then echo "Report for $1"; fi
X
sh report.sh web01
echo "exit status: $?"
bash report.sh web01
report.sh: 2: [[: not found exit status: 0 Report for web01
dash does not know [[, treats it as a
missing command inside the if condition, skips the
report and still ends with exit status 0. A scheduled job would
report success while doing nothing. Typing sh in front
of a script overrules its shebang; run Bash scripts with
bash or ./.
PowerShell scripts need no shebang on Windows: the
.ps1 extension tells PowerShell what the file is, and
PowerShell runs only .ps1 files as scripts (on Windows,
pwsh -File rejects other extensions). On Linux and
macOS you can add #!/usr/bin/env pwsh as the first line
of a .ps1 file, so that
./check-disk.ps1 also works from a Bash prompt.
Execute permission
On Linux, a file runs as a program only if it has
execute permission: the
x bit in its permissions, which tells the kernel that
this user may start the file as a program (see
Linux permissions). A new file normally does not have it, so the first run of a
fresh script fails with status 126, "found but not executable":
printf '#!/bin/bash\necho "Checking backups"\n' > backup-check.sh
stat -c '%A %n' backup-check.sh
./backup-check.sh
echo "exit status: $?"
bash backup-check.sh
chmod u+x backup-check.sh
stat -c '%A %n' backup-check.sh
./backup-check.sh
-rw-r--r-- backup-check.sh example.sh: line 3: ./backup-check.sh: Permission denied exit status: 126 Checking backups -rwxr--r-- backup-check.sh Checking backups
bash backup-check.sh works without the
x bit, because then Bash is the program being started
and the script is only a file it reads. That is also why execute
permission is a convenience, not a protection: anyone who can read a
script can run it with bash.
chmod u+x gives execute permission to the owner only;
chmod +x gives it to the owner, the group and all other
users. For shared scripts in /usr/local/bin,
755 (owner may write, everyone may read and run) is the
usual mode (see
chmod in practice).
Windows has no execute bit. Whether a .ps1 file may run
is decided by the execution policy, further down this page.
PATH and the ./ prefix
When you type a bare command name, the shell searches only the
folders listed in PATH, and the current folder is
deliberately not one of them (see
How PATH finds a command). That is why you type ./check-disk.sh: the path
tells the shell exactly which file you mean. PowerShell follows the
same rule, on Windows too:
'"Checking backups"' | Set-Content backup-check.ps1
backup-check.ps1
backup-check.ps1: /home/amara/scripts/example.ps1:2
Line |
2 | backup-check.ps1
| ~~~~~~~~~~~~~~~~
| The term 'backup-check.ps1' is not recognized as a name of a cmdlet,
| function, script file, or executable program. Check the spelling of the
| name, or if a path was included, verify that the path is correct and try
| again.
The file is right there, but without ./ (or
.\ on Windows) PowerShell does not look in the current
folder. To run a script from anywhere by name, put it in a folder
that is in PATH. On Linux, ~/.local/bin or
~/bin for yourself and /usr/local/bin for
everyone are the usual places; a script installed as a command
usually drops the .sh
extension:
mkdir -p bin
printf '#!/bin/bash\necho "OK: disk at ${1}%%"\n' > bin/check-disk
chmod +x bin/check-disk
check-disk 72
echo "exit status: $?"
PATH="$PWD/bin:$PATH"
check-disk 72
command -v check-disk | sed "s|$HOME|~|"
example.sh: line 4: check-disk: command not found exit status: 127 OK: disk at 72% ~/bin/check-disk
Line endings
Windows editors end lines with two characters, carriage return and line feed (CRLF); Linux expects only the line feed (LF). A Bash script saved with Windows line endings breaks in two confusing ways:
printf '#!/bin/bash\r\necho "Rotating logs"\r\ncd /tmp\r\n' > rotate.sh
chmod +x rotate.sh
./rotate.sh
echo "exit status: $?"
bash rotate.sh
sed -i 's/\r$//' rotate.sh
./rotate.sh
example.sh: line 3: ./rotate.sh: cannot execute: required file not found exit status: 127 Rotating logs rotate.sh: line 3: cd: $'/tmp\r': No such file or directory Rotating logs
Run as a program, the kernel looks for an interpreter called
/bin/bash followed by an invisible carriage return,
which does not exist. Run with bash, every line ends in
\r, so cd looks for a folder called
/tmp\r. sed -i 's/\r$//' (or the
dos2unix tool) converts the file. To stop it from
happening again, set your editor to LF for .sh files,
and in a Git repository add the line
*.sh text eol=lf to .gitattributes.
PowerShell itself accepts both line endings, but a
#!/usr/bin/env pwsh line on Linux needs LF, just like
Bash.
Exit codes back to the caller
A script ends with an
exit status
that tells whoever started it (you, another script, a scheduler)
whether it worked: 0 for success, anything else for failure. In
Bash, exit 1 sets it and the caller reads it in
$?. In PowerShell, exit 1 ends the script
and the caller finds the number in $LASTEXITCODE, the
same variable that holds the exit code of the last native program:
@'
param([int]$Usage)
if ($Usage -gt 90) { "WARNING: disk at $Usage%"; exit 1 }
"OK: disk at $Usage%"
'@ | Set-Content check-disk.ps1
pwsh -NoProfile -File ./check-disk.ps1 -Usage 95
"Exit code seen by the caller: $LASTEXITCODE"
bash -c 'exit 3'
"bash ended with: $LASTEXITCODE"
WARNING: disk at 95% Exit code seen by the caller: 1 bash ended with: 3
pwsh -File is how a scheduler, a CI job or a Bash
script starts a PowerShell script. Its exit code follows simple
rules: exit N gives N, a script stopped by an unhandled
terminating error gives 1, and a script that reaches its end gives
0, even if some commands in it reported errors along the way. If
failures must be visible to the caller, end with an explicit
exit 1 (see
Error Handling and
Conditions, Tests and Exit Codes).
Sourcing and dot sourcing
Running a script starts it in its own context: whatever variables
and functions it creates are gone when it ends. Sometimes you want
the opposite, for example a shared settings file or a library of
functions that several scripts use.
Dot sourcing runs a script file
inside the current session instead, so its variables and functions
remain available afterward. Bash writes it as
source file or . file, PowerShell as a
dot, a space and the path:
cat > settings.sh <<'X'
log_dir="/var/log/kestrel"
retention_days=14
X
bash settings.sh
echo "After bash: retention_days='$retention_days'"
source settings.sh
echo "After source: retention_days='$retention_days', log_dir='$log_dir'"
After bash: retention_days='' After source: retention_days='14', log_dir='/var/log/kestrel'
@'
$LogDir = '/var/log/kestrel'
function Get-RetentionDays { 14 }
'@ | Set-Content settings.ps1
& ./settings.ps1
"After & : LogDir='$LogDir'"
. ./settings.ps1
"After . : LogDir='$LogDir', retention $(Get-RetentionDays) days"
After & : LogDir='' After . : LogDir='/var/log/kestrel', retention 14 days
&, the call operator, runs the script in a child
scope, just like ./settings.ps1; the space after the
dot in . ./settings.ps1 is what makes it dot sourcing.
For larger libraries, PowerShell has modules (.psm1
files loaded with Import-Module), which control what
they export instead of dumping everything into your session.
Execution policy
The execution policy is a PowerShell setting on Windows that decides which scripts may run: none, only signed ones, or local ones freely and downloaded ones only when signed. The basics, including the advice never to lower it because a web page says so, are on Using the Windows Command Line. The policies:
| Policy | Scripts that run |
|---|---|
Restricted |
None; only interactive commands. Default for Windows
PowerShell 5.1 on Windows client editions (PowerShell 7 on
Windows defaults to RemoteSigned).
|
AllSigned |
Only scripts signed by a trusted publisher, local ones included. |
RemoteSigned |
Local scripts freely; scripts that came from the internet only when signed. Default on Windows Server. |
Unrestricted |
All, with a warning before running a downloaded script. |
Bypass |
All, without warnings or prompts. |
A policy can be set at several scopes, and the first one that is set
wins, in this order: MachinePolicy and
UserPolicy (both from Group Policy),
Process (this session only), CurrentUser,
LocalMachine. If none is set, Windows clients behave as
Restricted (Windows PowerShell 5.1; PowerShell 7 sets
RemoteSigned at LocalMachine) and Windows
Server as RemoteSigned. A blocked script fails before
its first line runs:
PS C:\Scripts> .\check-disk.ps1 -Usage 72
.\check-disk.ps1 : File C:\Scripts\check-disk.ps1 cannot be loaded because running scripts is disabled on this system. For more information, see about_Execution_Policies at https:/go.microsoft.com/fwlink/?LinkID=135170.
For an administrator's own account, the sensible fix is
RemoteSigned for the current user only, which needs no
administrator rights. Check the effective policy per scope first; in
an organization, a Group Policy setting overrides yours anyway:
Get-ExecutionPolicy -List
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Scope ExecutionPolicy
----- ---------------
MachinePolicy Undefined
UserPolicy Undefined
Process Undefined
CurrentUser RemoteSigned
LocalMachine Undefined
Microsoft's own documentation is clear about what this is: the
execution policy is not a security boundary but defense in depth. It
stops a user from running a script by accident, such as one opened
from an email; it does not stop anyone determined, because anyone
can start pwsh -ExecutionPolicy Bypass -File …
or paste the script's contents into a prompt. Real protection comes
from application control (such as App Control for Business or
AppLocker), least privilege and signed code.
On Linux and macOS, PowerShell ignores execution policies
altogether: Get-ExecutionPolicy always returns
Unrestricted (in practice it behaves like
Bypass), and Set-ExecutionPolicy only
reports that the operation is not supported. That is what the
runnable examples on this site see:
Get-ExecutionPolicy
Unrestricted
Bash has no execution policy. The nearest thing on Linux is the
noexec mount option, which some administrators put on
/tmp or home folders: no file there can be run as a
program. And just like the execution policy, it is no hard boundary:
bash script.sh still reads the file.
The Mark of the Web
How does RemoteSigned know a script came from the
internet? Through the
Mark of the Web: a hidden
note that Windows browsers and mail clients attach to files they
download, stored on NTFS in an alternate data stream called
Zone.Identifier. PowerShell, Office and SmartScreen all
read it to treat downloaded files with extra suspicion.
Get-Content .\Get-MeterReport.ps1 -Stream Zone.Identifier
[ZoneTransfer] ZoneId=3 HostUrl=https://downloads.example.com/tools/Get-MeterReport.ps1
ZoneId=3 means the Internet zone. Once you have read
the script and decided to trust it,
Unblock-File removes the mark:
Unblock-File -Path .\Get-MeterReport.ps1
Not every download tool sets the mark: files fetched with
curl.exe, Invoke-WebRequest or
Invoke-RestMethod may arrive without one, and then
RemoteSigned treats them as local. Do not rely on the
mark as your only check; the rule is the same as in Bash: read a
downloaded script before you run it (see
the risk of piping curl into bash).
Script signing
Script signing adds a
digital signature
to a PowerShell script, made with a code-signing certificate, so
that PowerShell can check who published the script and that nobody
has changed it since. AllSigned requires a valid
signature on every script; RemoteSigned requires one on
downloaded scripts. Signing is done on Windows with
Set-AuthenticodeSignature, which appends the signature
to the end of the file as a comment block:
$cert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert
Set-AuthenticodeSignature -FilePath .\check-disk.ps1 -Certificate $cert `
-TimestampServer 'http://timestamp.example.com'
Get-AuthenticodeSignature .\check-disk.ps1 | Select-Object Status, StatusMessage
Status StatusMessage ------ ------------- Valid Signature verified.
Any change to the script after signing, even one space, breaks the
signature (status HashMismatch), so signing belongs at
the end of the release process, not on a file you are still editing.
The time stamp keeps the signature valid after the certificate
expires. In organizations, the code-signing certificate comes from
the internal certificate authority (see
Internal PKI) and its private key is guarded as carefully as an administrator
password: whoever holds it can make any script look trusted.
Bash has no built-in signing. Linux protects scripts by other means:
file permissions (only root can change what is in
/usr/local/bin), signed packages from the package
manager, and checksums or GPG signatures published next to a
download.
Side by side
| Task | Bash | PowerShell |
|---|---|---|
| Name the interpreter |
#!/bin/bash or #!/usr/bin/env bash
|
.ps1 extension (plus
#!/usr/bin/env pwsh on Linux)
|
| Allow it to run | chmod u+x script.sh |
Execution policy (Windows); Unblock-File after
review
|
| Run from the current folder | ./script.sh |
./script.ps1 or .\script.ps1
|
| Run through the host | bash script.sh |
pwsh -NoProfile -File script.ps1 |
| Run in the current session | source script.sh |
. ./script.ps1 |
| Report failure | exit 1, read with $? |
exit 1, read with $LASTEXITCODE
|
| Prove who wrote it | Package signatures, checksums |
Set-AuthenticodeSignature,
AllSigned
|