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 mock tests (no network needed)
- 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
77 changes: 77 additions & 0 deletions crates/bashkit/docs/ssh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# 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:** [`specs/015-ssh-support.md`][spec]

## Quick Start

```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

```bash
# Remote command
ssh host.example.com 'uname -a'

# Heredoc
ssh host.example.com <<'EOF'
psql -c 'SELECT version()'
EOF

# Shell session (TUI services like supabase.sh)
ssh supabase.sh

# SCP
scp local.txt host.example.com:/remote/path.txt
scp host.example.com:/remote/file.txt local.txt

# SFTP (heredoc/pipe mode)
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
```

## Configuration

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

let config = SshConfig::new()
.allow("*.supabase.co") // wildcard subdomain
.allow("bastion.example.com") // exact host
.allow_port(2222) // additional port (default: 22 only)
.default_user("deploy") // when no user@ prefix
.timeout(Duration::from_secs(30)) // connection timeout
.max_response_bytes(10_000_000) // max output size
.max_sessions(5); // concurrent session limit
```

## Authentication

Tried in order: none (public services) → public key (`-i` flag or `default_private_key()`) → password (`default_password()`).

## Security

- Default-deny host allowlist with glob patterns and port restrictions
- Keys read from VFS only, never host `~/.ssh/`
- Remote paths shell-escaped (TM-SSH-008)
- Response size and session count limits

[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(())
}
38 changes: 38 additions & 0 deletions crates/bashkit/src/builtins/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,8 @@ impl Builtin for Gunzip {
http_client: ctx.http_client,
#[cfg(feature = "git")]
git_client: ctx.git_client,
#[cfg(feature = "ssh")]
ssh_client: ctx.ssh_client,
shell: ctx.shell,
};

Expand Down Expand Up @@ -950,6 +952,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand All @@ -970,6 +974,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand Down Expand Up @@ -1005,6 +1011,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand All @@ -1028,6 +1036,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand Down Expand Up @@ -1065,6 +1075,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand All @@ -1090,6 +1102,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand Down Expand Up @@ -1119,6 +1133,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand Down Expand Up @@ -1154,6 +1170,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand All @@ -1173,6 +1191,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand Down Expand Up @@ -1205,6 +1225,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand Down Expand Up @@ -1236,6 +1258,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand All @@ -1254,6 +1278,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand Down Expand Up @@ -1286,6 +1312,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand All @@ -1312,6 +1340,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand Down Expand Up @@ -1341,6 +1371,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand Down Expand Up @@ -1368,6 +1400,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand Down Expand Up @@ -1400,6 +1434,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
Gzip.execute(ctx).await.unwrap();
Expand All @@ -1417,6 +1453,8 @@ mod tests {
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};

Expand Down
Loading
Loading