Skip to content

Commit 403d10b

Browse files
committed
feat(ssh): add ssh/scp/sftp builtins with russh transport
- SSH support as opt-in feature (ssh), following git/http_client pattern - SshHandler trait for pluggable transport, default RusshHandler (russh 0.52) - SshAllowlist with glob patterns and port restrictions (default-deny) - SshConfig builder: timeouts, response limits, session limits, auth - ssh builtin: remote exec, heredoc, shell sessions (ssh supabase.sh) - scp builtin: upload/download between VFS and remote hosts - sftp builtin: non-interactive put/get/ls via heredoc/pipe - Auth: none (public services), pubkey, or password - Shell injection fix (TM-SSH-008): remote paths are shell-escaped - Example: ssh supabase.sh — no credentials needed - 24 mock integration tests + 2 real connection tests - Spec 015, SSH rustdoc guide, cargo-vet exemptions, cargo-audit ignore
1 parent 5f72ac0 commit 403d10b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

72 files changed

+3636
-27
lines changed

.cargo/audit.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# cargo-audit configuration
2+
# Ignore advisories for transitive dependencies we can't control
3+
4+
[advisories]
5+
ignore = [
6+
# rsa: Marvin timing attack (RUSTSEC-2023-0071)
7+
# Transitive via russh-keys -> ssh-key -> rsa
8+
# Only used for RSA key parsing in SSH; no direct exposure
9+
"RUSTSEC-2023-0071",
10+
]

.github/workflows/ci.yml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ jobs:
5656
uses: rustsec/audit-check@v2.0.0
5757
with:
5858
token: ${{ secrets.GITHUB_TOKEN }}
59+
ignore: RUSTSEC-2023-0071
5960

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

8485
- name: Run tests
85-
run: cargo test --features http_client
86+
run: cargo test --features http_client,ssh
8687

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

109110
- name: Build examples
110-
run: cargo build --examples --features "git,http_client"
111+
run: cargo build --examples --features "git,http_client,ssh"
111112

112113
- name: Run examples
113114
run: |
@@ -122,6 +123,15 @@ jobs:
122123
cargo run --example realfs_readonly --features realfs
123124
cargo run --example realfs_readwrite --features realfs
124125
126+
# SSH tests
127+
- name: Run ssh builtin tests (mock handler)
128+
run: cargo test --features ssh -p bashkit --test ssh_builtin_tests
129+
130+
- name: Run ssh supabase.sh example and tests
131+
run: |
132+
cargo run --example ssh_supabase --features ssh
133+
cargo test --features ssh -p bashkit --test ssh_supabase_tests
134+
125135
- name: Run realfs bash example
126136
run: |
127137
cargo build -p bashkit-cli --features realfs

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ schemars = "1"
8787
tracing = "0.1"
8888
tower = { version = "0.5", features = ["util"] }
8989

90+
# SSH client (for ssh/scp/sftp builtins)
91+
russh = "0.52"
92+
russh-keys = "0.49"
93+
9094
# Serial test execution
9195
serial_test = "3"
9296

crates/bashkit/Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ chrono = { workspace = true }
4141
# HTTP client (for curl/wget) - optional, enabled with http_client feature
4242
reqwest = { workspace = true, optional = true }
4343

44+
# SSH client (for ssh/scp/sftp) - optional, enabled with ssh feature
45+
russh = { workspace = true, optional = true }
46+
russh-keys = { workspace = true, optional = true }
47+
4448
# Fault injection for testing (optional)
4549
fail = { workspace = true, optional = true }
4650

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

135+
[[example]]
136+
name = "ssh_supabase"
137+
required-features = ["ssh"]
138+
128139
[[example]]
129140
name = "scripted_tool"
130141
required-features = ["scripted_tool"]

crates/bashkit/docs/ssh.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# SSH Support
2+
3+
Bashkit provides `ssh`, `scp`, and `sftp` builtins for remote command execution
4+
and file transfer over SSH. The default transport uses [russh](https://crates.io/crates/russh).
5+
6+
**See also:** [`specs/015-ssh-support.md`][spec]
7+
8+
## Quick Start
9+
10+
```rust,no_run
11+
use bashkit::{Bash, SshConfig};
12+
13+
# #[tokio::main]
14+
# async fn main() -> bashkit::Result<()> {
15+
let mut bash = Bash::builder()
16+
.ssh(SshConfig::new().allow("supabase.sh"))
17+
.build();
18+
19+
let result = bash.exec("ssh supabase.sh").await?;
20+
# Ok(())
21+
# }
22+
```
23+
24+
## Usage
25+
26+
```bash
27+
# Remote command
28+
ssh host.example.com 'uname -a'
29+
30+
# Heredoc
31+
ssh host.example.com <<'EOF'
32+
psql -c 'SELECT version()'
33+
EOF
34+
35+
# Shell session (TUI services like supabase.sh)
36+
ssh supabase.sh
37+
38+
# SCP
39+
scp local.txt host.example.com:/remote/path.txt
40+
scp host.example.com:/remote/file.txt local.txt
41+
42+
# SFTP (heredoc/pipe mode)
43+
sftp host.example.com <<'EOF'
44+
put /tmp/data.csv /var/import/data.csv
45+
get /var/export/report.csv /tmp/report.csv
46+
ls /var/import
47+
EOF
48+
```
49+
50+
## Configuration
51+
52+
```rust,no_run
53+
use bashkit::SshConfig;
54+
use std::time::Duration;
55+
56+
let config = SshConfig::new()
57+
.allow("*.supabase.co") // wildcard subdomain
58+
.allow("bastion.example.com") // exact host
59+
.allow_port(2222) // additional port (default: 22 only)
60+
.default_user("deploy") // when no user@ prefix
61+
.timeout(Duration::from_secs(30)) // connection timeout
62+
.max_response_bytes(10_000_000) // max output size
63+
.max_sessions(5); // concurrent session limit
64+
```
65+
66+
## Authentication
67+
68+
Tried in order: none (public services) → public key (`-i` flag or `default_private_key()`) → password (`default_password()`).
69+
70+
## Security
71+
72+
- Default-deny host allowlist with glob patterns and port restrictions
73+
- Keys read from VFS only, never host `~/.ssh/`
74+
- Remote paths shell-escaped (TM-SSH-008)
75+
- Response size and session count limits
76+
77+
[spec]: https://github.com/everruns/bashkit/blob/main/specs/015-ssh-support.md
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//! SSH Supabase example — `ssh supabase.sh`
2+
//!
3+
//! Connects to Supabase's public SSH service, exactly like running
4+
//! `ssh supabase.sh` in a terminal. No credentials needed.
5+
//!
6+
//! Run with: cargo run --example ssh_supabase --features ssh
7+
8+
use bashkit::{Bash, SshConfig};
9+
10+
#[tokio::main]
11+
async fn main() -> anyhow::Result<()> {
12+
println!("=== Bashkit: ssh supabase.sh ===\n");
13+
14+
let mut bash = Bash::builder()
15+
.ssh(SshConfig::new().allow("supabase.sh"))
16+
.build();
17+
18+
println!("$ ssh supabase.sh\n");
19+
let result = bash.exec("ssh supabase.sh").await?;
20+
21+
print!("{}", result.stdout);
22+
if !result.stderr.is_empty() {
23+
eprint!("{}", result.stderr);
24+
}
25+
26+
println!("\nexit code: {}", result.exit_code);
27+
println!("\n=== Done ===");
28+
Ok(())
29+
}

crates/bashkit/src/builtins/archive.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,8 @@ impl Builtin for Gunzip {
895895
http_client: ctx.http_client,
896896
#[cfg(feature = "git")]
897897
git_client: ctx.git_client,
898+
#[cfg(feature = "ssh")]
899+
ssh_client: ctx.ssh_client,
898900
shell: ctx.shell,
899901
};
900902

@@ -950,6 +952,8 @@ mod tests {
950952
http_client: None,
951953
#[cfg(feature = "git")]
952954
git_client: None,
955+
#[cfg(feature = "ssh")]
956+
ssh_client: None,
953957
shell: None,
954958
};
955959

@@ -970,6 +974,8 @@ mod tests {
970974
http_client: None,
971975
#[cfg(feature = "git")]
972976
git_client: None,
977+
#[cfg(feature = "ssh")]
978+
ssh_client: None,
973979
shell: None,
974980
};
975981

@@ -1005,6 +1011,8 @@ mod tests {
10051011
http_client: None,
10061012
#[cfg(feature = "git")]
10071013
git_client: None,
1014+
#[cfg(feature = "ssh")]
1015+
ssh_client: None,
10081016
shell: None,
10091017
};
10101018

@@ -1028,6 +1036,8 @@ mod tests {
10281036
http_client: None,
10291037
#[cfg(feature = "git")]
10301038
git_client: None,
1039+
#[cfg(feature = "ssh")]
1040+
ssh_client: None,
10311041
shell: None,
10321042
};
10331043

@@ -1065,6 +1075,8 @@ mod tests {
10651075
http_client: None,
10661076
#[cfg(feature = "git")]
10671077
git_client: None,
1078+
#[cfg(feature = "ssh")]
1079+
ssh_client: None,
10681080
shell: None,
10691081
};
10701082

@@ -1090,6 +1102,8 @@ mod tests {
10901102
http_client: None,
10911103
#[cfg(feature = "git")]
10921104
git_client: None,
1105+
#[cfg(feature = "ssh")]
1106+
ssh_client: None,
10931107
shell: None,
10941108
};
10951109

@@ -1119,6 +1133,8 @@ mod tests {
11191133
http_client: None,
11201134
#[cfg(feature = "git")]
11211135
git_client: None,
1136+
#[cfg(feature = "ssh")]
1137+
ssh_client: None,
11221138
shell: None,
11231139
};
11241140

@@ -1154,6 +1170,8 @@ mod tests {
11541170
http_client: None,
11551171
#[cfg(feature = "git")]
11561172
git_client: None,
1173+
#[cfg(feature = "ssh")]
1174+
ssh_client: None,
11571175
shell: None,
11581176
};
11591177

@@ -1173,6 +1191,8 @@ mod tests {
11731191
http_client: None,
11741192
#[cfg(feature = "git")]
11751193
git_client: None,
1194+
#[cfg(feature = "ssh")]
1195+
ssh_client: None,
11761196
shell: None,
11771197
};
11781198

@@ -1205,6 +1225,8 @@ mod tests {
12051225
http_client: None,
12061226
#[cfg(feature = "git")]
12071227
git_client: None,
1228+
#[cfg(feature = "ssh")]
1229+
ssh_client: None,
12081230
shell: None,
12091231
};
12101232

@@ -1236,6 +1258,8 @@ mod tests {
12361258
http_client: None,
12371259
#[cfg(feature = "git")]
12381260
git_client: None,
1261+
#[cfg(feature = "ssh")]
1262+
ssh_client: None,
12391263
shell: None,
12401264
};
12411265

@@ -1254,6 +1278,8 @@ mod tests {
12541278
http_client: None,
12551279
#[cfg(feature = "git")]
12561280
git_client: None,
1281+
#[cfg(feature = "ssh")]
1282+
ssh_client: None,
12571283
shell: None,
12581284
};
12591285

@@ -1286,6 +1312,8 @@ mod tests {
12861312
http_client: None,
12871313
#[cfg(feature = "git")]
12881314
git_client: None,
1315+
#[cfg(feature = "ssh")]
1316+
ssh_client: None,
12891317
shell: None,
12901318
};
12911319

@@ -1312,6 +1340,8 @@ mod tests {
13121340
http_client: None,
13131341
#[cfg(feature = "git")]
13141342
git_client: None,
1343+
#[cfg(feature = "ssh")]
1344+
ssh_client: None,
13151345
shell: None,
13161346
};
13171347

@@ -1341,6 +1371,8 @@ mod tests {
13411371
http_client: None,
13421372
#[cfg(feature = "git")]
13431373
git_client: None,
1374+
#[cfg(feature = "ssh")]
1375+
ssh_client: None,
13441376
shell: None,
13451377
};
13461378

@@ -1368,6 +1400,8 @@ mod tests {
13681400
http_client: None,
13691401
#[cfg(feature = "git")]
13701402
git_client: None,
1403+
#[cfg(feature = "ssh")]
1404+
ssh_client: None,
13711405
shell: None,
13721406
};
13731407

@@ -1400,6 +1434,8 @@ mod tests {
14001434
http_client: None,
14011435
#[cfg(feature = "git")]
14021436
git_client: None,
1437+
#[cfg(feature = "ssh")]
1438+
ssh_client: None,
14031439
shell: None,
14041440
};
14051441
Gzip.execute(ctx).await.unwrap();
@@ -1417,6 +1453,8 @@ mod tests {
14171453
http_client: None,
14181454
#[cfg(feature = "git")]
14191455
git_client: None,
1456+
#[cfg(feature = "ssh")]
1457+
ssh_client: None,
14201458
shell: None,
14211459
};
14221460

0 commit comments

Comments
 (0)