Skip to content

Commit 5463fdc

Browse files
committed
feat(ssh): native SSH support via an allowlisted ssh_exec tool
Lets the agent run commands on remote machines without giving up the security boundary. The agent targets hosts by NAME (e.g. \"staging\"), never by free-form user@host strings — even with prompt injection, the model can only pick from the user's enrolled allowlist. Components: - src/ssh/config.ts — reads + validates ~/.codebase/ssh.json (user-wide) and <cwd>/.codebase/ssh.json (project, project wins on name conflict). Rejects host fields that smell like smuggled user@host:port syntax; enforces name pattern, port range, identityFile shape. - src/ssh/store.ts — atomic add/list/rm of user-wide hosts; writes the file at mode 0600. - src/ssh/cli.ts — `codebase ssh add | list | rm | test | keygen`. keygen defaults to Ed25519 (faster, smaller, modern); --rsa generates RSA-4096 for compliance / legacy infra. Files chmod 0600 on the private key, 0644 on the public key. Passphrase-less by design (the agent must run non-interactively), documented in the keygen output with the `ssh-keygen -p` recipe if the user wants to add one later. - src/tools/ssh-exec.ts — wraps `ssh` with safe defaults: BatchMode=yes (no password prompts ever), ConnectTimeout=10s, StrictHostKeyChecking=accept-new (TOFU on first connection, strict after), ServerAliveInterval=30s, IdentitiesOnly=yes when --key is set. Applies the local shell-validator to the remote command too — rm -rf / over SSH blocks the same way. Tests: 20 new (config + store + tool integration). 774 total passing. Docs: SSH section in CLAUDE.md covering enrollment, security model, config schema.
1 parent 6c2c52c commit 5463fdc

12 files changed

Lines changed: 1219 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,88 @@ default — only an actual exit-2 blocks.
271271
}
272272
```
273273

274+
## SSH (remote machine access)
275+
276+
The agent can run shell commands on remote machines via the `ssh_exec`
277+
tool. The host list is an allowlist managed by the user — the agent
278+
picks a name from the enrolled set, not arbitrary `user@host` strings.
279+
280+
### Enrollment
281+
282+
```sh
283+
# Generate a key (default Ed25519, --rsa for RSA-4096 if your
284+
# compliance / legacy infra requires it). Passphrase-less by default
285+
# because the agent runs non-interactively.
286+
codebase ssh keygen staging
287+
288+
# Print the pubkey + a one-liner to install it on the remote.
289+
290+
# Register the host:
291+
codebase ssh add staging staging.example.com --user deploy --key ~/.codebase/ssh/staging
292+
293+
# Verify connectivity:
294+
codebase ssh test staging
295+
296+
# Inspect / remove:
297+
codebase ssh list
298+
codebase ssh rm staging
299+
```
300+
301+
### How the agent uses it
302+
303+
When asked to "deploy the build to staging", the model issues:
304+
305+
```ts
306+
ssh_exec({ host: "staging", command: "cd /app && systemctl restart codebase" })
307+
```
308+
309+
The host argument is the registered NAME, not a hostname. The tool
310+
resolves it against `~/.codebase/ssh.json` and (optionally) project
311+
overrides at `<cwd>/.codebase/ssh.json`. Project entries override
312+
user entries with the same name.
313+
314+
### Security model
315+
316+
- **Allowlist by name, not free-form.** The agent can target `staging`
317+
only if the user enrolled `staging`. Even with prompt injection,
318+
the model can't pick a destination the user didn't pre-approve.
319+
- **Same shell-validator as the local `shell` tool.** `rm -rf /`,
320+
fork bombs, raw writes to `/dev/sda` etc. are blocked before the
321+
ssh spawn, regardless of which host they target.
322+
- **BatchMode=yes.** Never prompts for a password. If the key isn't
323+
accepted, the call fails fast instead of stalling.
324+
- **StrictHostKeyChecking=accept-new.** First connection pins the
325+
host key (TOFU); a later host-key mismatch refuses to connect.
326+
- **ConnectTimeout=10s + ServerAliveInterval=30s.** Unreachable
327+
hosts fail in seconds, dead network paths are detected during
328+
long-running commands.
329+
- **IdentitiesOnly=yes when --key given.** Predictable auth path —
330+
ssh doesn't fall back to other keys in the agent.
331+
332+
The validator is advisory, not a security boundary. Real isolation
333+
for hostile workloads still belongs in container / sandbox
334+
boundaries on the remote.
335+
336+
### Config schema (`~/.codebase/ssh.json` and `<cwd>/.codebase/ssh.json`)
337+
338+
```json
339+
{
340+
"hosts": [
341+
{
342+
"name": "staging",
343+
"host": "staging.example.com",
344+
"user": "deploy",
345+
"port": 22,
346+
"identityFile": "~/.codebase/ssh/staging",
347+
"description": "staging app server (us-east-1)"
348+
}
349+
]
350+
}
351+
```
352+
353+
`name` must match `[a-z0-9][a-z0-9_-]*`. `host` rejects anything
354+
that looks like `user@host:port` syntax — use separate fields.
355+
274356
## In-flight features
275357

276358
- **MCP**: real MCP client support hasn't shipped (the `/mcp`

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebase-cli",
3-
"version": "2.0.0-pre.59",
3+
"version": "2.0.0-pre.60",
44
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
55
"keywords": [
66
"ai",

src/cli.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { runAuthSubcommand } from "./auth/cli.js";
66
import { loadDotEnv } from "./dotenv/loader.js";
77
import { type HeadlessOutputFormat, runHeadless } from "./headless/run.js";
88
import { runProjectSubcommand } from "./projects/cli.js";
9+
import { runSshSubcommand } from "./ssh/cli.js";
910
import { App } from "./ui/App.js";
1011
import { installTerminalRestoreHandlers } from "./ui/terminal-restore.js";
1112
import { setTerminalTitle } from "./ui/terminal-title.js";
@@ -56,6 +57,8 @@ if (argv[0] === "--version" || argv[0] === "-v") {
5657
process.exit(0);
5758
} else if (argv[0] === "auth") {
5859
runAuthSubcommand(argv).then((code) => process.exit(code));
60+
} else if (argv[0] === "ssh") {
61+
runSshSubcommand(argv).then((code) => process.exit(code));
5962
} else if (argv[0] === "project" || argv[0] === "projects") {
6063
runProjectSubcommand(argv).then((code) => process.exit(code));
6164
} else if (argv[0] === "app-server") {
@@ -157,6 +160,9 @@ function printHelp(): void {
157160
" codebase auth status show current sign-in",
158161
" codebase auth refresh force-refresh the access token",
159162
" codebase auth <cbk_xxx> save a manual API key (for SSH / headless)",
163+
" codebase ssh add <name> <host> enroll a remote machine the agent can target",
164+
" codebase ssh list / rm / test manage enrolled SSH hosts",
165+
" codebase ssh keygen <name> generate an Ed25519 (or --rsa) keypair",
160166
" codebase project list list your projects on codebase.design",
161167
" codebase project pull <id> download a project as a ZIP",
162168
" codebase app-server JSON-RPC server on stdio (for IDE extensions)",

0 commit comments

Comments
 (0)