A compiled Go binary that lets AI agents run read-only diagnostic commands on remote hosts via SSH β safely. Commands are validated against a strict allowlist before execution. Destructive operations (rm, sudo, bash, etc.) are hardcoded-blocked and cannot be overridden.
Works as both an MCP server (for AI agents) and a CLI tool (for humans).
# macOS (Apple Silicon)
curl -sL https://github.com/aspectrr/lily/releases/latest/download/lily_darwin_arm64.tar.gz | tar xz
sudo mv lily /usr/local/bin/
# macOS (Intel)
curl -sL https://github.com/aspectrr/lily/releases/latest/download/lily_darwin_amd64.tar.gz | tar xz
sudo mv lily /usr/local/bin/
# Linux (x86_64)
curl -sL https://github.com/aspectrr/lily/releases/latest/download/lily_linux_amd64.tar.gz | tar xz
sudo mv lily /usr/local/bin/brew install aspectrr/lily/lilygo install github.com/aspectrr/lily/cmd/lily@latest
git clone https://github.com/aspectrr/lily.git cd lily make build sudo make install # copies bin/lily to /usr/local/bin
## Quick Start
```bash
# List hosts from your SSH config
lily hosts
# Check connectivity
lily check web1
# Run a diagnostic command
lily run web1 "systemctl status nginx"
# Install into your AI agent
lily install-skill claude-code
lily [flags]
lily <command> [arguments]
| Command | Description |
|---|---|
serve |
Start MCP server on stdio (default if no command given) |
hosts |
List hosts from ~/.ssh/config |
run <host> <command> |
Execute a validated read-only command on a host |
validate <command> |
Check if a command is allowed without executing |
check <host> |
Test SSH connectivity to a host |
list-commands |
Show all allowed commands and subcommand restrictions |
config-path |
Print the config file path |
validate-config |
Validate the lily.yaml config file |
install-skill <agent|all> |
Install lily into an agent's MCP config |
uninstall-skill <agent|all> |
Remove lily from an agent's MCP config |
list-agents |
Show detected agents that support MCP |
version |
Print version |
| Flag | Default | Description |
|---|---|---|
-config <path> |
~/.ssh/config |
Path to SSH config |
-config-file <path> |
~/.config/lily/lily.yaml |
Path to lily config |
-timeout <duration> |
30s |
SSH command timeout |
When you run lily serve (or just lily), it starts an MCP server on stdio that exposes 5 tools to the connected AI agent:
Discover hosts from SSH config.
β list_hosts()
β Found 2 host(s) in SSH config:
web1 192.168.1.10 (user: deploy)
db1 db.example.com (user: postgres)
Test SSH connectivity before running commands.
β check_host(host="web1")
β Host "web1" is reachable.
Check whether a command would be allowed without executing it.
β validate_command(command="rm -rf /")
β BLOCKED: command "rm" is not allowed in read-only mode
Execute a validated read-only command on a remote host. The command is checked against the full allowlist before execution. If it fails validation, the command is never sent to the host.
β run_command(host="web1", command="journalctl -u nginx --no-pager -n 50")
β Apr 30 00:12:03 web1 systemd[1]: Started nginx.service...
Show all currently allowed commands including user-configured additions.
Add to your agent's MCP configuration:
{
"mcpServers": {
"lily": {
"command": "lily",
"args": ["serve"]
}
}
}With custom flags:
{
"mcpServers": {
"lily": {
"command": "/usr/local/bin/lily",
"args": [
"serve",
"-timeout",
"60s",
"-config-file",
"/etc/lily/lily.yaml"
]
}
}
}lily list-agents # See which agents are detected
lily install-skill all # Install to all detected agents
lily install-skill claude-code # Specific agent
lily uninstall-skill cursor # Remove from an agentSupported agents: Claude Code, Claude Desktop, Cursor, Windsurf, Cline, Pi, Goose
install-skill writes the MCP config entry and deploys a default lily.yaml if one doesn't already exist.
Lily enforces configurable limits to prevent agents from abusing remote hosts:
| Setting | Default | Config Key | Description |
|---|---|---|---|
| Rate limit | 1 command/sec | rate_limit |
Minimum interval between commands |
| Max output | 1 MB | max_output_bytes |
Output cap per command (stdout + stderr) |
| SSH timeout | 30s | -timeout flag |
Maximum execution time per command |
Rate limiting applies to run_command and check_host only. Read-only tools like list_hosts and validate_command are not rate-limited.
lily reads ~/.ssh/config to find available hosts. Only hosts explicitly defined in SSH config can be accessed β the agent cannot connect to arbitrary hosts.
Host web1
HostName 192.168.1.10
User deploy
Port 2222
IdentityFile ~/.ssh/web1_key
Host db1
HostName db.example.com
User postgres
lily uses the user's existing SSH credentials:
- SSH agent (
SSH_AUTH_SOCK) β loaded keys are tried first - IdentityFile β if specified in SSH config for the host
- Default keys β
~/.ssh/id_ed25519,~/.ssh/id_ecdsa,~/.ssh/id_rsa
No special setup or server-side installation is needed. If you can ssh web1 from your terminal, lily can too.
Lily uses Trust On First Use (TOFU) for SSH host key verification:
- First connection to a host β the host key is automatically recorded in
~/.ssh/known_hosts - Subsequent connections β the key is verified against the recorded value
- Key mismatch β the connection is rejected with a clear MITM warning
If ~/.ssh/known_hosts doesn't exist, Lily creates it automatically. No manual setup required.
Lily supports SSH ProxyJump for reaching hosts behind a bastion or jump server. This is the standard scenario where your local machine connects to a jump host, and from there tunnels to internal hosts that aren't directly reachable.
Configure it in ~/.ssh/config:
Host bastion
HostName 203.0.113.1
User admin
Host web1
HostName 10.0.0.5
User deploy
ProxyJump bastion
Host db1
HostName 10.0.0.6
ProxyJump bastion
Now lily run web1 "systemctl status nginx" automatically tunnels through bastion. The AI agent doesn't need to know about the proxy β it just targets web1 as usual.
- Lily reads
ProxyJumpfrom your SSH config - Dials the jump host directly
- Opens an SSH tunnel through the jump host to the target
- Runs the validated command on the target
- All intermediate connections are kept alive and cleaned up together
ProxyJump supports chaining through multiple hosts:
Host gateway
HostName 203.0.113.1
Host bastion
HostName 10.0.0.1
ProxyJump gateway
Host web1
HostName 192.168.1.5
ProxyJump bastion
This creates a chain: local β gateway β bastion β web1. Lily resolves the chain recursively and detects loops.
You can also use comma-separated proxies (no recursive resolution):
Host web1
HostName 10.0.0.5
ProxyJump jump1,jump2
Each hop in the chain uses its own SSH config (user, identity file, port). The SSH agent and default keys are tried for every hop, so if your agent has keys for both the bastion and the target, everything works automatically.
Lily only supports ProxyJump (SSH-native tunneling). ProxyCommand is not supported because it executes arbitrary local commands, which is a security risk. If your SSH config uses ProxyCommand, convert it to ProxyJump:
- ProxyCommand ssh -W %h:%p bastion
+ ProxyJump bastionIf a host has ProxyCommand but no ProxyJump, Lily prints a warning and attempts a direct connection (which will likely fail if the host truly requires a proxy).
The list_hosts tool and lily hosts command show which hosts use a proxy:
β list_hosts()
β Found 3 host(s) in SSH config:
bastion 203.0.113.1 (user: admin)
web1 10.0.0.5 (user: deploy) [via bastion]
db1 10.0.0.6 [via bastion]
check_host also indicates the proxy:
β check_host(host="web1")
β Host "web1" is reachable (via bastion).
The validated command is sent over SSH and executed by the remote host's default shell. No agent, daemon, or restricted shell is installed on the remote β all safety enforcement happens client-side in the compiled binary.
Location: ~/.config/lily/lily.yaml (run lily config-path to find it)
The config file can only add to the base allowlist. The hardcoded blocklist (rm, sudo, bash, etc.) cannot be overridden β even if the YAML is edited to include them, they are silently ignored.
# ββ Execution Limits ββββββββββββββββββββββββββββββββββββββββββββββ
# Minimum interval between command executions.
# Prevents agents from flooding hosts with rapid-fire commands.
# Default: "1s"
rate_limit: "1s"
# Maximum output (stdout + stderr) captured per command, in bytes.
# Output beyond this limit is silently truncated.
# Default: 1048576 (1 MB). Minimum: 1024 (1 KB).
max_output_bytes: 1048576
# ββ Command Allowlist ββββββββββββββββββββββββββββββββββββββββββββ
# Add commands beyond the built-in allowlist.
# Still subject to metacharacter checks and subcommand restrictions.
extra_commands:
- docker
- kubectl
# Restrict which subcommands are allowed for extra commands.
extra_subcommand_restrictions:
docker:
- ps
- logs
- inspect
- stats
- top
- events
- images
- network ls
- volume ls
kubectl:
- get
- describe
- logs
- top
- explain
- api-resources
- api-versions
# Block specific flags for any command (built-in or extra).
extra_blocked_flags:
docker:
- exec
- run
- rm
- rmi
- build
- push
- pull
kubectl:
- delete
- apply
- create
- edit
- patch
- replacelily validate-config
# Config valid.
# Extra commands: 2
# Extra restrictions: 2
# Extra blocked flags: 2
# Rate limit: 1s
# Max output: 1048576 bytesThe config file was renamed from allowlist.yaml to lily.yaml in v0.2.0. Lily automatically falls back to ~/.config/lily/allowlist.yaml if lily.yaml doesn't exist, so existing setups continue to work without changes.
Run lily list-commands for the full list.
cat ls find head tail stat file wc du tree strings md5sum sha256sum readlink realpath basename dirname base64
ps top pgrep systemctl (read-only) journalctl dmesg
ss netstat ip ifconfig dig nslookup ping curl (GET only) openssl (read-only)
df lsblk blkid
dpkg (list/status) rpm (query) apt (list/show) pip (list/show)
uname hostname uptime free lscpu lsmod lspci lsusb arch nproc
whoami id groups who w last
date which type echo test
grep awk sed (no -i) sort uniq cut tr xargs
| Command | Allowed subcommands |
|---|---|
systemctl |
status, show, list-units, is-active, is-enabled |
dpkg |
-l, --list, -s, --status |
rpm |
-qa, -q |
apt |
list, show |
pip |
list, show |
openssl |
x509, verify, s_client, crl, version, ciphers, req (display only) |
curl |
GET requests only |
| Command | Blocked flags |
|---|---|
sed |
-i, --in-place |
curl |
-X, -d, --data, --data-raw, --data-binary, --data-urlencode, -F, --form, -T, --upload-file, -o, --output, -O, --remote-name, -K, --config, -x, --proxy |
These commands are hardcoded in the compiled binary and can never be allowed, even via user config:
| Category | Commands |
|---|---|
| Destructive | rm rmdir mv cp dd chmod chown chgrp chattr kill killall pkill shutdown reboot halt poweroff mkfs mount umount fdisk parted |
| Privilege escalation | sudo su pkexec |
| User management | useradd userdel usermod groupadd groupdel groupmod passwd chpasswd |
| Shells / interpreters | bash sh zsh dash csh tcsh fish python python3 python2 perl ruby node php lua |
| Editors | vi vim nano emacs pico |
| File transfer | scp rsync sftp ftp wget |
| Build tools | make gcc g++ cc |
| Firewalls | iptables ip6tables nft |
| Cron | crontab at batch |
- Command substitution:
$(...)and backticks - Variable expansion:
$var,${var} - Output redirection:
>and>> - Input redirection:
< - Process substitution:
<(...)and>(...) - Subshells:
(...)and(...) - Newlines and carriage returns
- Environment variable assignments:
FOO=bar cmd
Lily uses a 7-layer defense-in-depth pipeline:
- Metacharacter scanner β blocks
$(), backticks,${},$var,>,>>,<,<<,(), newlines - Redirection scanner β blocks unquoted
> - Environment scanner β blocks
VAR=valueassignments (preventsLD_PRELOADinjection) - Pipeline splitter β validates each segment independently
- Per-segment validation β allowlist, blocklist, subcommand restrictions, blocked flags, argument validators
- Command sanitizer β reconstructs the entire command with safe single-quoting
- SSH transport β output caps, timeouts, Trust On First Use (TOFU) host key verification
For the full security model, threat analysis, and bypass vectors, see SECURITY.md.
Lily is designed to run inside a sandboxed environment where the AI agent has no filesystem write access and no network access except via the Lily binary. Without sandboxing, an agent could bypass Lily and SSH directly, modify ~/.ssh/config, or edit lily.yaml.
Shuru provides lightweight Linux microVMs using Apple Virtualization.framework (macOS) and KVM (Linux ARM64). Sandboxes are offline by default.
# Install
brew tap superhq-ai/tap && brew install shuru # macOS
# or: curl -fsSL https://shuru.run/install.sh | sh
# Create project config
cat > shuru.json << 'EOF'
{
"cpus": 1,
"memory": 512,
"mounts": [
"~/.ssh:/home/agent/.ssh:ro",
"~/.config/lily:/home/agent/.config/lily:ro"
]
}
EOF
# Install Lily binary into sandbox
shuru run --mount ./bin/lily:/usr/local/bin/lily:ro -- lily hosts
# Run your agent inside the sandbox
shuru run --mount ./bin/lily:/usr/local/bin/lily:ro -- your-agent-command| Resource | Setting | Why |
|---|---|---|
| Network | Offline (default) | Agent can't SSH/curl directly |
~/.ssh/ |
Read-only mount | Agent can't modify SSH config |
lily.yaml |
Read-only mount | Agent can't edit allowlist |
/usr/local/bin/lily |
Read-only mount | Agent can't replace the binary |
| Memory/CPU | Minimal (512 MB, 1 CPU) | Contain resource usage |
vmsan provides Firecracker microVMs with per-VM network namespaces, seccomp-bpf filters, and cgroup resource limits.
# Install
curl -fsSL https://vmsan.dev/install | bash
vmsan doctor # verify KVM, disk space, binaries
# Create isolated VM with no network access
vmsan create \
--vcpus 1 \
--memory 256 \
--disk 5gb \
--network-policy deny-all \
--timeout 2h
# Get the VM ID
VM_ID=$(vmsan ls --json | jq -r '.[0].id')
# Upload Lily binary and config
vmsan upload "$VM_ID" ./bin/lily
vmsan exec "$VM_ID" -- mkdir -p /root/.config/lily
vmsan upload "$VM_ID" ./lily.yaml
vmsan exec "$VM_ID" -- mv /root/lily.yaml /root/.config/lily/lily.yaml
# Run the agent
vmsan exec "$VM_ID" -- your-agent-commandTo allow SSH to internal hosts while blocking everything else:
vmsan create \
--vcpus 1 \
--memory 256 \
--network-policy custom \
--allowed-cidr "10.0.0.0/8" \
--allowed-cidr "192.168.0.0/16" \
--denied-cidr "169.254.0.0/16" \
--denied-cidr "100.100.0.0/16" \
--timeout 2h| Resource | Flag | Effect |
|---|---|---|
| Network | --network-policy deny-all |
No outbound from VM |
| seccomp-bpf | Enabled by default | Restricts syscalls |
| PID namespace | Enabled by default | Process isolation |
| cgroups | Enabled by default | Memory/CPU limits |
| Timeout | --timeout 2h |
Auto-shutdown after idle period |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Sandbox (shuru / vmsan) β
β β
β βββββββββββββββ ββββββββββββ ββββββββββββββββββββ β
β β AI Agent βββββΆβ Lily CLI βββββΆβ SSH to Remote β β
β β β β β β Hosts β β
β β No network β β Validatesβ β β β
β β No SSH dir β β Sanitizesβ β Read-only cmds β β
β β No curl β β Rate lim β β only β β
β βββββββββββββββ ββββββββββββ ββββββββββββββββββββ β
β β β β
β read-only read-only β
β lily.yaml ~/.ssh/ β
β β
β β No raw SSH, curl, wget, nc β
β β No write access to ~/.ssh/ or lily.yaml β
β β No outbound network (except via Lily SSH) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
lily/
βββ cmd/lily/main.go # CLI entry point + all subcommands
βββ internal/
β βββ allowlist/ # YAML config parsing + execution limits
β β βββ allowlist.go # Config struct, loading, defaults
β β βββ allowlist_test.go
β βββ install/ # Agent config install/uninstall
β β βββ install.go # MCP config writing, config deployment
β β βββ install_test.go
β βββ mcp/ # MCP server + 5 tools + rate limiter
β β βββ server.go # Tool handlers, rate limiting
β β βββ server_test.go
β βββ readonly/ # Command validation engine
β β βββ validate.go # 7-layer validation pipeline
β β βββ validate_test.go # 75+ attack vector regression tests
β βββ sshconfig/ # SSH config parser
β β βββ sshconfig.go # Host, ProxyJump, ProxyCommand parsing
β β βββ sshconfig_test.go
β βββ sshexec/ # SSH execution (direct + ProxyJump)
β βββ sshexec.go # Executor with output caps, proxy chains
β βββ sshexec_test.go
βββ lily.yaml # Example config
βββ SECURITY.md # Full security model & sandboxing guide
βββ SKILL.md # Agent skill reference
βββ AGENTS.md # Agent integration guide
βββ Makefile
βββ README.md
The compiled binary prevents the AI agent from inspecting or modifying the validation logic at runtime. A Python-based solution could be read by the agent, who could then edit the source to bypass restrictions. Go produces a single static binary with no runtime dependencies.
make build # Build to bin/lily
make test # Run all tests (75 tests across 7 packages)
make all # Test + build
make install # Build + copy to /usr/local/bin
make install-go # Install via go install
make fmt # Format code
make vet # Run go vet
make clean # Remove bin/