Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .cargo/audit.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# cargo-audit configuration
# Ignore advisories for transitive dependencies we can't control

[advisories]
ignore = [
# rsa: Marvin timing attack (RUSTSEC-2023-0071)
# Transitive via russh-keys -> ssh-key -> rsa
# Only used for RSA key parsing in SSH; no direct exposure
"RUSTSEC-2023-0071",
]
16 changes: 14 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jobs:
uses: rustsec/audit-check@v2.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
ignore: RUSTSEC-2023-0071

- name: License check (cargo-deny)
uses: EmbarkStudios/cargo-deny-action@v2
Expand All @@ -82,7 +83,7 @@ jobs:
- uses: Swatinem/rust-cache@v2

- name: Run tests
run: cargo test --features http_client
run: cargo test --features http_client,ssh

- name: Run realfs tests
run: cargo test --features realfs -p bashkit --test realfs_tests -p bashkit-cli
Expand All @@ -107,7 +108,7 @@ jobs:
- uses: Swatinem/rust-cache@v2

- name: Build examples
run: cargo build --examples --features "git,http_client"
run: cargo build --examples --features "git,http_client,ssh"

- name: Run examples
run: |
Expand All @@ -122,6 +123,17 @@ jobs:
cargo run --example realfs_readonly --features realfs
cargo run --example realfs_readwrite --features realfs

# SSH integration tests (non-ignored run without network)
- name: Run ssh builtin tests (mock handler)
run: cargo test --features ssh -p bashkit --test ssh_builtin_tests

# Real SSH connection — depends on external service, don't block CI
- name: Run ssh supabase.sh (real connection)
continue-on-error: true
run: |
cargo run --example ssh_supabase --features ssh
cargo test --features ssh -p bashkit --test ssh_supabase_tests -- --ignored

- name: Run realfs bash example
run: |
cargo build -p bashkit-cli --features realfs
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ schemars = "1"
tracing = "0.1"
tower = { version = "0.5", features = ["util"] }

# SSH client (for ssh/scp/sftp builtins)
russh = "0.52"
russh-keys = "0.49"

# Serial test execution
serial_test = "3"

Expand Down
11 changes: 11 additions & 0 deletions crates/bashkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ chrono = { workspace = true }
# HTTP client (for curl/wget) - optional, enabled with http_client feature
reqwest = { workspace = true, optional = true }

# SSH client (for ssh/scp/sftp) - optional, enabled with ssh feature
russh = { workspace = true, optional = true }
russh-keys = { workspace = true, optional = true }

# Fault injection for testing (optional)
fail = { workspace = true, optional = true }

Expand Down Expand Up @@ -86,6 +90,9 @@ logging = ["tracing"]
# Phase 2 will add gix dependency for remote operations
# Usage: cargo build --features git
git = []
# Enable ssh/scp/sftp builtins for remote command execution and file transfer
# Usage: cargo build --features ssh
ssh = ["russh", "russh-keys"]
# Enable ScriptedTool: compose ToolDef+callback pairs into a single Tool
# Usage: cargo build --features scripted_tool
scripted_tool = []
Expand Down Expand Up @@ -125,6 +132,10 @@ required-features = ["http_client"]
name = "git_workflow"
required-features = ["git"]

[[example]]
name = "ssh_supabase"
required-features = ["ssh"]

[[example]]
name = "scripted_tool"
required-features = ["scripted_tool"]
Expand Down
234 changes: 234 additions & 0 deletions crates/bashkit/docs/ssh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
# SSH Support

Bashkit provides `ssh`, `scp`, and `sftp` builtins for remote command execution
and file transfer over SSH. The default transport uses [russh](https://crates.io/crates/russh).

**See also:**
- [Threat Model](./threat-model.md) - Security considerations (TM-SSH-*)
- [Custom Builtins](./custom_builtins.md) - Writing your own builtins
- [`specs/015-ssh-support.md`][spec] - Full specification

## Quick Start

Enable the `ssh` feature and configure allowed hosts:

```rust,no_run
use bashkit::{Bash, SshConfig};

# #[tokio::main]
# async fn main() -> bashkit::Result<()> {
let mut bash = Bash::builder()
.ssh(SshConfig::new().allow("supabase.sh"))
.build();

let result = bash.exec("ssh supabase.sh").await?;
# Ok(())
# }
```

## Usage Patterns

### Remote Command Execution

```bash
ssh host.example.com 'uname -a'
ssh deploy@host.example.com 'systemctl status nginx'
```

### Heredoc (Multi-Command)

```bash
ssh db.example.com <<'EOF'
psql -c 'SELECT version()'
psql -c '\dt'
EOF
```

### Shell Session (No Command)

For SSH services that present a TUI or greeting on connect:

```bash
ssh supabase.sh
```

### Pipe Through Local Builtins

```bash
ssh host.example.com 'cat /var/log/app.log' | grep ERROR | wc -l
```

### Capture Into Variables

```bash
VERSION=$(ssh host.example.com 'cat /etc/os-release' | grep VERSION_ID | cut -d= -f2)
echo "Remote version: $VERSION"
```

### SCP: File Transfer

```bash
# Upload
scp /tmp/config.yaml host.example.com:/etc/app/config.yaml

# Download
scp host.example.com:/var/backups/dump.sql /tmp/dump.sql
```

### SFTP: Batch Operations

SFTP works in non-interactive mode via heredoc or pipe:

```bash
sftp host.example.com <<'EOF'
put /tmp/data.csv /var/import/data.csv
get /var/export/report.csv /tmp/report.csv
ls /var/import
EOF
```

Supported SFTP commands: `put`, `get`, `ls`.

## Configuration

### Host Allowlist

SSH uses a default-deny host allowlist. Only explicitly allowed hosts can be
connected to.

```rust,no_run
use bashkit::SshConfig;

let config = SshConfig::new()
.allow("db.example.com") // exact host
.allow("*.supabase.co") // wildcard subdomain
.allow("192.168.1.100"); // IP address
```

### Port Control

By default only port 22 is allowed. Add more with `allow_port()`:

```rust,no_run
use bashkit::SshConfig;

let config = SshConfig::new()
.allow("bastion.example.com")
.allow_port(22)
.allow_port(2222);
```

### Authentication

The default russh transport tries authentication methods in this order:

1. **None** - for public SSH services (e.g. `ssh supabase.sh`)
2. **Public key** - from `-i` flag or `default_private_key()`
3. **Password** - from `default_password()`

```rust,no_run
use bashkit::SshConfig;

// No auth needed (public services)
let config = SshConfig::new().allow("supabase.sh");

// Private key from VFS (-i flag in ssh command)
// ssh -i /keys/id_ed25519 host.example.com 'ls'

// Private key from config (no -i needed)
let config = SshConfig::new()
.allow("host.example.com")
.default_private_key(std::fs::read_to_string("~/.ssh/id_ed25519").unwrap());

// Password auth
let config = SshConfig::new()
.allow("host.example.com")
.default_password("secret");
```

### Resource Limits

```rust,no_run
use bashkit::SshConfig;
use std::time::Duration;

let config = SshConfig::new()
.allow("host.example.com")
.timeout(Duration::from_secs(30)) // connection timeout (default: 30s)
.max_response_bytes(10_000_000) // max output size (default: 10MB)
.max_sessions(5); // concurrent sessions (default: 5)
```

## Custom SSH Handler

Implement `SshHandler` to intercept, mock, proxy, or log SSH operations:

```rust,no_run
use bashkit::{Bash, SshConfig, SshHandler, SshOutput, SshTarget};
use async_trait::async_trait;

struct LoggingHandler;

#[async_trait]
impl SshHandler for LoggingHandler {
async fn exec(&self, target: &SshTarget, command: &str) -> Result<SshOutput, String> {
eprintln!("[ssh] {}@{}:{} -> {}", target.user, target.host, target.port, command);
// Delegate to real transport or return mock output
Ok(SshOutput {
stdout: format!("executed: {command}\n"),
stderr: String::new(),
exit_code: 0,
})
}

async fn shell(&self, target: &SshTarget) -> Result<SshOutput, String> {
Ok(SshOutput {
stdout: format!("Welcome to {}\n", target.host),
stderr: String::new(),
exit_code: 0,
})
}

async fn upload(&self, _: &SshTarget, path: &str, content: &[u8], _: u32) -> Result<(), String> {
eprintln!("[scp] upload {} bytes to {}", content.len(), path);
Ok(())
}

async fn download(&self, _: &SshTarget, path: &str) -> Result<Vec<u8>, String> {
eprintln!("[scp] download {}", path);
Ok(Vec::new())
}
}

# #[tokio::main]
# async fn main() -> bashkit::Result<()> {
let mut bash = Bash::builder()
.ssh(SshConfig::new().allow("host.example.com"))
.ssh_handler(Box::new(LoggingHandler))
.build();
# Ok(())
# }
```

## SSH Flags

| Flag | Builtin | Description |
|------|---------|-------------|
| `-p port` | ssh | Remote port |
| `-i keyfile` | ssh, scp, sftp | Identity file (from VFS) |
| `-o option` | ssh | Ignored (compatibility) |
| `-q` | ssh | Quiet mode |
| `-P port` | scp | Remote port |
| `-r` | scp | Recursive (accepted, no-op) |

## Security

- **Default-deny**: empty allowlist blocks all connections
- **Host allowlist**: glob patterns with port restrictions
- **No host filesystem access**: keys read from VFS only
- **Shell escaping**: remote paths are escaped to prevent injection (TM-SSH-008)
- **Response limits**: max output size prevents memory exhaustion
- **Session limits**: max concurrent connections prevents resource exhaustion
- **Timeouts**: connection and inactivity timeouts prevent hangs

[spec]: https://github.com/everruns/bashkit/blob/main/specs/015-ssh-support.md
29 changes: 29 additions & 0 deletions crates/bashkit/examples/ssh_supabase.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//! SSH Supabase example — `ssh supabase.sh`
//!
//! Connects to Supabase's public SSH service, exactly like running
//! `ssh supabase.sh` in a terminal. No credentials needed.
//!
//! Run with: cargo run --example ssh_supabase --features ssh

use bashkit::{Bash, SshConfig};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
println!("=== Bashkit: ssh supabase.sh ===\n");

let mut bash = Bash::builder()
.ssh(SshConfig::new().allow("supabase.sh"))
.build();

println!("$ ssh supabase.sh\n");
let result = bash.exec("ssh supabase.sh").await?;

print!("{}", result.stdout);
if !result.stderr.is_empty() {
eprint!("{}", result.stderr);
}

println!("\nexit code: {}", result.exit_code);
println!("\n=== Done ===");
Ok(())
}
Loading
Loading