-
Notifications
You must be signed in to change notification settings - Fork 0
USAGE
⚠️ Auto-generated from the repository — do not edit here. Source: https://github.com/luisgf/ssh-broker/tree/main/docs
This document covers practical usage of the five MCP tools exposed by
cmd/mcp-broker (stdio) and cmd/mcp-broker-http (HTTP+OAuth2/OIDC).
Keep this file up to date whenever a tool is added, removed, renamed, or its parameters or behaviour change.
- Before you start
- ssh_execute — one-shot command
- ssh_session_open / exec / close — persistent session
- Common patterns
- Error handling
- Quick reference
- Reviewing audit logs
- Session recording
Always call ssh_list_servers first. It returns the hosts accessible to
you, together with the capabilities that determine which parameters are valid
for each host. In the HTTP+OAuth frontend the list is already filtered by your
RBAC groups — a host you cannot sign for is not listed.
tool: ssh_list_servers
params: (none)
Example response:
web01 (sudo=true pty=true)
db01 (sudo=false pty=false)
bastion (sudo=false pty=false)
app02 (sudo=true pty=true via=bastion)
What the fields mean:
| Field | Value | What it implies |
|---|---|---|
allow_sudo |
true |
You may use sudo=true in execute/session calls. |
allow_sudo |
false |
Do not pass sudo=true. The signer will reject it. Do not retry. |
allow_pty |
true |
You may use pty=true (execute) or mode=pty (session). |
allow_pty |
false |
Do not request a PTY. Do not retry. |
jump |
"bastion" |
The host is reached via a ProxyJump hop through bastion. Transparent — no extra action needed. |
Operator note — host groups, CA keys and broker-ctl (v1.11.1): each host in signer.json belongs to one or more groups. Those groups determine both RBAC visibility (which broker CN can reach the host) and, if the operator has configured ca_keys, which CA key signs its certificates. From the model's perspective this is transparent — the tools and their parameters are unchanged. Operators manage hosts, CA keys, callers, and command policy via broker-ctl; see OPERATIONS.md §4. Per-host command policy (allowlist, denylist, require-approval) is configured with broker-ctl host add --policy-mode flags and takes effect after the next broker-ctl reload.
Use ssh_execute when you need to run one command (or several independent
commands) on a host. Each call issues a fresh ephemeral certificate scoped to
exactly that command (force-command in the cert). The connection is opened,
the command runs, and the connection is discarded.
tool: ssh_execute
params:
server: "web01"
command: "uptime"
Response:
12:03:47 up 42 days, 3:21, 1 user, load average: 0.12, 0.08, 0.05
[exit=0 serial=1042]
exit=0 means success. serial is only useful for audit log correlation — the
model should not reason over it.
tool: ssh_execute
params:
server: "web01"
command: "ls /nonexistent"
Response:
[stderr]
ls: cannot access '/nonexistent': No such file or directory
[exit=2 serial=1043]
exit_code != 0 is a remote command failure, not a tool error. Handle it
the same way you would a process that exits with a non-zero status.
Requires allow_sudo=true on the host (check ssh_list_servers first).
tool: ssh_execute
params:
server: "web01"
command: "systemctl restart nginx"
sudo: true
The signer bakes sudo -n -- /bin/sh -c 'systemctl restart nginx' into the
cert's force-command. sshd enforces it; the broker cannot modify it.
tool: ssh_execute
params:
server: "web01"
command: "id"
sudo: true
sudo_user: "deploy"
sudo_user must be in the host's allowed_sudo_users list. Empty sudo_user
defaults to root.
Use pty=true only when the command explicitly requires a terminal (checks
isatty(), uses terminal control sequences, etc.). Requires allow_pty=true.
tool: ssh_execute
params:
server: "web01"
command: "top -b -n 1"
pty: true
Important: with pty=true, stdout and stderr are merged in the PTY stream.
The stderr field in the response will be empty.
By default the broker requests the maximum TTL allowed by the host policy. Pass
ttl_seconds to request a shorter window.
tool: ssh_execute
params:
server: "web01"
command: "date"
ttl_seconds: 30
Pass dry_run=true to check whether a command would be allowed by the host's
command policy (and whether it would require human approval) without connecting
or running anything. Nothing executes; no stdout is produced.
tool: ssh_execute
params:
server: "web01"
command: "systemctl restart nginx"
dry_run: true
Response (allowed, but approval-gated):
[dry-run] ALLOWED (requires human approval before executing)
rule: require_approval:^systemctl restart
force-command: systemctl restart nginx
ttl: 120s
Response (denied by command policy):
[dry-run] DENIED: command not allowed on "web01" by command_policy (allowlist:no-match)
Use dry-run to decide whether to proceed before committing an action. A host may
restrict commands via an allowlist or denylist (see the
AI-action firewall in ARCHITECTURE.md).
Hosts with a command policy do not allow sessions — use ssh_execute on them.
Some commands are configured to require human approval before they run
(require_approval in the host policy). When you call ssh_execute for such a
command, the tool blocks while a human is asked to approve out-of-band:
tool: ssh_execute
params:
server: "web01"
command: "systemctl restart nginx"
- If approved, the call returns normally (stdout / exit code) once the human approves — this may take seconds to minutes.
- If denied or it times out, the call returns an error explaining the command was not approved. Do not retry automatically; surface the outcome to the user.
You can check ahead of time whether a command needs approval with
dry_run: true (the response says "requires human approval"). This lets you
warn the user that the action will pause for approval before you run it.
Approve-and-learn. When a human approves, they may also learn the decision
(broker-ctl approval allow <id> --learn --ttl 2h). After that, the same command
on that host runs without prompting again until the waiver expires — so a follow-up
ssh_execute (or a dry_run) of that exact command returns immediately instead of
blocking. This is decided entirely by the human/operator; from your side nothing
changes except that a previously-gated command may stop pausing for a while. When the
waiver expires the approval gate returns automatically.
Note: approval can also be triggered dynamically by behavioral guardrails — e.g. the first time you touch a new host, or run an unusual command, the deployment may require a human to approve it even if the command itself isn't on the approval list. Additionally, a burst of commands may be rate-limited (the call returns a "rate limit" error); if that happens, slow down and inform the user rather than retrying in a tight loop.
The operator's notification channel (how the approval request reaches the human)
is configured server-side — options include a process log, a generic webhook, or
a Microsoft Teams card (notifier: "teams" in control-plane.json). This does
not affect how the tool behaves from the model's perspective.
Use sessions when you need:
-
Stateful execution —
cdto a directory and run subsequent commands there, or set environment variables that persist across calls. - Reduced latency — the SSH connection is established once and reused.
-
Interactive programs — editors, pagers, or programs that require a real
TTY (
mode=pty).
Always close a session when done with
ssh_session_close. Open sessions hold an SSH connection until they are closed or reaped. The reaper closes a session aftersession_idle_secondsof inactivity orsession_max_secondsof total lifetime — not when the certificate TTL elapses: OpenSSH validates the certificate only at authentication, so an already-established connection survives certificate expiry. Setsession_max_secondsto the maximum exposure window you are willing to accept.
Each ssh_session_exec call runs in a separate SSH channel. State (working
directory, variables) does not persist between calls. Use this when you want
connection reuse without state leakage.
tool: ssh_session_open
params:
server: "web01"
mode: "exec"
Response: session_id=abc123 serial=1050
tool: ssh_session_exec
params:
session_id: "abc123"
command: "hostname"
Response: web01\n[exit=0 serial=1051]
tool: ssh_session_close
params:
session_id: "abc123"
Response: closed
A single /bin/sh process is started. cd, variable assignments, and
environment changes persist across ssh_session_exec calls.
tool: ssh_session_open
params:
server: "web01"
mode: "shell"
Response: session_id=def456 serial=1060
tool: ssh_session_exec
params:
session_id: "def456"
command: "cd /var/log && pwd"
Response: /var/log\n[exit=0 serial=1061]
tool: ssh_session_exec
params:
session_id: "def456"
command: "ls -lh nginx/"
Response (still in /var/log):
total 48M
-rw-r--r-- 1 root root 12M Jun 5 11:00 access.log
-rw-r--r-- 1 root root 256K Jun 5 11:00 error.log
[exit=0 serial=1062]
tool: ssh_session_close
params:
session_id: "def456"
Opens a shell with a pseudo-terminal. Use for programs that call isatty(),
check $TERM, or produce terminal-formatted output. Requires allow_pty=true.
stdout and stderr are merged in the PTY stream.
tool: ssh_session_open
params:
server: "web01"
mode: "pty"
Response: session_id=ghi789 serial=1070
tool: ssh_session_exec
params:
session_id: "ghi789"
command: "journalctl -u nginx --no-pager -n 20"
tool: ssh_session_close
params:
session_id: "ghi789"
Requires allow_sudo=true (check ssh_list_servers).
mode=shell or mode=pty with sudo: the entire shell process is launched as
sudo -n -- /bin/sh. Every command in the session runs elevated.
tool: ssh_session_open
params:
server: "web01"
mode: "shell"
sudo: true
tool: ssh_session_exec
params:
session_id: "<id>"
command: "cat /etc/shadow | head -3"
mode=exec with sudo: each command is individually prefixed with the sudo
prefix returned by the signer (elevation_prefix).
tool: ssh_session_open
params:
server: "web01"
mode: "exec"
sudo: true
tool: ssh_session_open
params:
server: "web01"
mode: "shell"
sudo: true
sudo_user: "appuser"
# For each host in the list:
tool: ssh_execute
params:
server: "web01"
command: "systemctl is-active nginx"
tool: ssh_execute
params:
server: "app02"
command: "systemctl is-active myapp"
tool: ssh_session_open
params:
server: "web01"
mode: "shell"
sudo: true
tool: ssh_session_exec
params:
session_id: "<id>"
command: "cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak"
tool: ssh_session_exec
params:
session_id: "<id>"
command: "printf 'server { listen 80; ... }' > /etc/nginx/conf.d/app.conf"
tool: ssh_session_exec
params:
session_id: "<id>"
command: "nginx -t && systemctl reload nginx"
tool: ssh_session_close
params:
session_id: "<id>"
tool: ssh_session_open
params:
server: "web01"
mode: "shell"
sudo: true
tool: ssh_session_exec
params:
session_id: "<id>"
command: "systemctl status myapp --no-pager"
tool: ssh_session_exec
params:
session_id: "<id>"
command: "journalctl -u myapp --no-pager -n 50"
tool: ssh_session_exec
params:
session_id: "<id>"
command: "ss -tlnp | grep :8080"
tool: ssh_session_close
params:
session_id: "<id>"
tool: ssh_execute
params:
server: "web01"
command: "df -h && du -sh /var/log/* 2>/dev/null | sort -rh | head -10"
No special parameters needed. The broker resolves the hop chain automatically.
ssh_list_servers will show jump=bastion on the host entry.
tool: ssh_execute
params:
server: "app02"
command: "uptime"
The broker signs two certificates (one for the bastion, one for app02) and
tunnels through automatically.
exit_code != 0 means the remote command failed. It is not a tool error.
Treat it as you would any process that exits non-zero.
# exit_code=1 from grep when no match found — not an error:
tool: ssh_execute
params:
server: "web01"
command: "grep 'pattern' /var/log/app.log"
# Response: [exit=1 serial=...] → no match, not a failure worth alerting on
A tool error (IsError: true in the MCP response) means the broker itself
could not complete the request — e.g., the host is unknown, the signer rejected
the intent, or the SSH connection failed. These are actual errors that need
attention.
If allow_sudo=false on a host and you pass sudo=true, the signer returns 403
and the tool returns an error. Do not retry with sudo. Inform the user that
the host does not permit privilege escalation.
# ssh_list_servers shows: db01 (sudo=false pty=false)
# Attempting sudo on db01 → tool error:
# host "db01" does not allow elevation (allow_sudo=false)
# Correct response: inform the user, do not retry
Same rule: if allow_pty=false, do not pass pty=true or mode=pty. Do not
retry.
If ssh_session_exec returns an error for a session ID that was previously
valid, the session was most likely closed by the reaper — after
session_idle_seconds of inactivity or session_max_seconds of total lifetime.
Open a new session.
Commands must not contain \n or \r:
-
ssh_execute(one-shot): the signer rejects them — a newline would smuggle extra command lines past the host's command policy. Compose with;or&&instead. -
ssh_session_execinshell/ptysessions: the broker rejects them, since a newline would inject additional commands into the persistent shell. (exec-mode sessions run each command in an isolated channel and have no such restriction.) Use multiplessh_session_execcalls instead.
# Wrong — will be rejected:
command: "echo foo\necho bar"
# Correct:
tool: ssh_session_exec command: "echo foo"
tool: ssh_session_exec command: "echo bar"
| Scenario | Tool |
|---|---|
| Single independent command | ssh_execute |
| Multiple commands, no shared state needed |
ssh_execute (repeated) |
Multiple commands with cd or env state |
ssh_session_open (mode=shell) + ssh_session_exec
|
| Program that requires a TTY |
ssh_execute (pty=true) or ssh_session_open (mode=pty) |
| Lowest latency for many commands |
ssh_session_open (mode=exec) + ssh_session_exec
|
| Parameter | Constraint |
|---|---|
sudo=true |
Only when allow_sudo=true (from ssh_list_servers). Never retry if false. |
sudo_user |
Must be in allowed_sudo_users for the host. Empty = root. |
pty=true / mode=pty
|
Only when allow_pty=true. Never retry if false. |
command |
Must not contain \n or \r (one-shot ssh_execute and shell/pty session exec; use ; or &&). On hosts with a command policy, must satisfy the allowlist/denylist. |
ttl_seconds |
Optional. Omit to use the host policy maximum. |
dry_run=true |
ssh_execute only. Simulates policy (allow/deny + approval) without executing. Nothing runs. |
1. ssh_list_servers → discover hosts and their capabilities
2. ssh_execute → for simple, independent commands
— or —
ssh_session_open → for stateful / multi-step work
ssh_session_exec (×N)
ssh_session_close → always close when done
Both the broker and the signer write append-only, Ed25519-signed, SHA-256-chained
audit logs. Every entry carries a serial (issued by the signer) and a seq
(monotonic per log), allowing precise correlation across the three audit sources
(signer, broker, sshd).
Default log paths (from the active config):
| Service | Log file | Signing seed |
|---|---|---|
| Broker | audit.log |
pki/audit.seed |
| Signer | signer_audit.log |
pki/signer_audit.seed |
Follow the broker log as commands execute:
broker-ctl audit tail --log audit.log
broker-ctl audit tail --log audit.log -n 50 # show last 50 lines before followingFollow the signer log (certificate issuances):
broker-ctl audit tail --log signer_audit.logOutput columns: TIME · SEQ · CALLER · HOST · OUTCOME · SERIAL · DETAIL
Filter by host (substring match):
broker-ctl audit show --log audit.log --host web01Filter by outcome:
# Broker outcomes: executed, denied, error, session_open, session_exec,
# session_close, dry_run_allowed, dry_run_denied
broker-ctl audit show --log audit.log --outcome denied
# Signer outcomes: issued, denied, approval-required, dry_run_allowed,
# dry_run_denied, reloaded, reload-denied, reload-failed
broker-ctl audit show --log signer_audit.log --outcome issuedFilter by caller:
broker-ctl audit show --log audit.log --caller mcp-stdioFilter by time window:
broker-ctl audit show --log audit.log --since 2026-06-05
broker-ctl audit show --log audit.log --since 2026-06-05T12:00:00ZCombine filters and limit output:
broker-ctl audit show --log audit.log --host db01 --outcome denied --limit 20Raw JSON output (--json) pipes directly into jq:
# All denied entries as pretty JSON:
broker-ctl audit show --log audit.log --outcome denied --json | jq .
# Extract serial numbers from denied signer entries:
broker-ctl audit show --log signer_audit.log --outcome denied --json \
| jq -r .serial
# Correlate broker and signer by serial — find the cert that matches execution 1042:
broker-ctl audit show --log audit.log --json | jq 'select(.serial==1042)'
broker-ctl audit show --log signer_audit.log --json | jq 'select(.serial==1042)'
# Count executions per host in the last hour:
broker-ctl audit show --log audit.log \
--since "$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)" \
--outcome executed --json \
| jq -r .host | sort | uniq -c | sort -rn
# Show all sudo (elevated) entries:
broker-ctl audit show --log audit.log --json \
| jq 'select(.elevation != null and .elevation != "")'
# Show all PTY sessions:
broker-ctl audit show --log audit.log --json | jq 'select(.pty == true)'Verify hash chain only (no key needed):
broker-ctl audit verify --log audit.log
# OK: 1234 entries, chain intact (pass --key to also verify signatures)
broker-ctl audit verify --log signer_audit.logVerify hash chain and Ed25519 signatures:
broker-ctl audit verify --log audit.log --key pki/audit.seed
broker-ctl audit verify --log signer_audit.log --key pki/signer_audit.seed
# OK: 1234 entries, chain intact, all signatures validIf tampering is detected, verify exits with code 1 and prints the affected
sequence number(s):
ERROR: seq 42 — prev_hash mismatch
expected: 3a7f...
got: 000...
FAIL: 1234 entries checked, 1 error(s) found
| Field | Type | Description |
|---|---|---|
time |
RFC3339 | Timestamp (UTC) |
seq |
uint64 | Monotonic counter within this log file |
caller |
string | Broker CN (mTLS) or OIDC sub (HTTP) |
host |
string | Logical host name (broker) or FQDN/addr (signer) |
user |
string | Remote SSH account |
principal |
string | SSH certificate principal |
command |
string | Command executed (one-shot) or session mode |
ttl |
string | Certificate TTL granted |
serial |
uint64 | Certificate serial — correlates broker ↔ signer ↔ sshd |
session_id |
string | Session UUID (session events only) |
outcome |
string | See table in §7.2 |
exit_code |
int | Remote exit code (execution events) |
err |
string | Error detail (on failure) |
elevation |
string |
sudo:root or sudo:<user> if escalated |
pty |
bool |
true if PTY was requested |
prev_hash |
string | SHA-256 hex of the previous raw JSON line |
sig |
string | Base64 Ed25519 signature over the entry (with sig="") |
When session_recording_dir is set in config.json, the broker records
shell and pty sessions to ASCIIcast v2 files (.cast). Each file
captures stdin (what the agent typed), stdout, and stderr with millisecond
timestamps.
{
"session_recording_dir": "/var/log/ssh-broker/recordings"
}The directory must exist and be writable by the broker process. File permissions
are set to 0600 (owner-read only).
One file per session: <session_id>.cast
/var/log/ssh-broker/recordings/
a3f1b2c4d5e6789a.cast
b7c8d9e0f1a2b3c4.cast
The session_id matches the session_id field in the broker's audit log.
Use the audit log as an index to find recordings by agent, host, or time:
# Find all sessions opened by alice on web01 today
broker-ctl audit show --log audit.log --outcome session_open \
--host web01 --caller alice --since 2026-06-09 --json \
| jq -r '.session_id'
# List the corresponding recording files
broker-ctl audit show --log audit.log --outcome session_open --json \
| jq -r '.session_id' \
| xargs -I{} ls /var/log/ssh-broker/recordings/{}.cast 2>/dev/nullThe file is ASCIIcast v2 JSONL — one JSON line per event:
{"version":2,"width":220,"height":40,"timestamp":1749470400,
"title":"session a3f1b2c4 — alice@web01",
"env":{"TERM":"xterm-256color"},
"ssh_broker":{"session_id":"a3f1b2c4","caller":"alice","host":"web01",
"serial":1042,"started_at":"2026-06-09T14:00:01Z"}}
[0.000, "i", "df -h /\n"]
[0.012, "o", "Filesystem Size Used Avail Use% Mounted on\n"]
[0.013, "o", "/dev/sda1 50G 18G 30G 38% /\n"]
[1.244, "i", "uptime\n"]
[1.251, "o", " 14:00:02 up 3 days, 2:00\n"]
| Field | Description |
|---|---|
| Header line | Session metadata: session_id, caller, host, serial, timestamps |
[delta, "i", data] |
Input (command typed by the agent) |
[delta, "o", data] |
Output (stdout, or merged PTY output) |
[delta, "e", data] |
Stderr (non-PTY sessions only) |
delta |
Seconds since session start (float, millisecond precision) |
The ssh_broker extension field in the header is a private extension within the
ASCIIcast v2 spec (extra fields are allowed). Standard tools ignore it.
Any tool that supports ASCIIcast v2 can replay the recording:
# Install asciinema if not present
pip install asciinema # or: brew install asciinema
# Play a session recording
asciinema play /var/log/ssh-broker/recordings/a3f1b2c4d5e6789a.cast
# Print the recording as plain text (no timing)
asciinema cat /var/log/ssh-broker/recordings/a3f1b2c4d5e6789a.cast
# Stream new events as they are written (live tail)
tail -f /var/log/ssh-broker/recordings/a3f1b2c4d5e6789a.cast \
| asciinema cat /dev/stdinOnly shell and pty sessions are recorded. One-shot ssh_execute commands
and exec-mode sessions are not recorded (those are fully captured in the audit
log including stdout/stderr content via ssh_execute responses).
Recording files are not rotated automatically. Use standard tools to manage retention:
# Delete recordings older than 90 days
find /var/log/ssh-broker/recordings -name "*.cast" -mtime +90 -delete