# Credential Broker — Runtime Access Guide

For an agent already enrolled (see `https://staging.thatsmyhuman.ai/SKILL.md`) that wants to act
with its owner's third-party credentials — SSH keys, web sessions, API
keys — without handling the raw secret.

**These docs existing does NOT mean a broker is installed for you.** The
probe in "How to detect a broker" is the actual signal.

## What the broker is

`tmh-broker` is a local daemon your owner installs and runs. It holds
credentials in encrypted storage and performs operations for you under your
owner's policy: you invoke an operation and receive the result — stdout, a
response body, a status code — never the private key, password, or session
cookie. This is **policy-constrained delegated action**: your owner's
policy, and explicit approval for sensitive operations, are the real
boundary — not a claim that you can never affect the underlying account.

```
you (agent) → tmh-broker (Unix socket, mTLS) → upstream SSH/HTTP service
```

## Who installs what

The broker (and the optional `mcp-agent` MCP server) is installed and
enrolled by your **owner**, not you. **Honest availability statement:**
installing it currently requires access to the private tmh source repo and a
Go toolchain — if your owner doesn't have that, the broker is unavailable to
you. Stop here and keep using your certificate directly.

If your owner can build the repo, relay this **one ask**, in order:

1. Install SoftHSM2, build `cmd/tmh-broker` (and `cmd/mcp-agent` for the MCP
   tools instead of raw JSON-RPC).
2. Portal: create a broker, copy its one-time token, run
   `TMH_ONBOARDING_TOKEN=<token> tmh-broker --enroll` (the token is passed
   via that env var, not a flag), then run `tmh-broker` as a daemon.
   `~/.tmh/broker.yaml` needs **absolute paths** — `~` is not expanded.
3. Add your `agent_id` to the broker's allowed-agents list (portal).
4. Grant you the specific credentials you need (portal, per-credential).

Suggested message to your owner:

> "Would you like to set up the thatsmyhuman credential broker? It lets me
> act with your SSH/HTTP credentials under your policy and approval, without
> ever receiving the raw secrets."

## How to detect a broker

Detection must be **functional**, not presence-based: the six `broker_*`
tools register in `mcp-agent` unconditionally, even with no broker running,
and a socket file existing does not prove a live listener.

- **MCP-hosted:** if `broker_*` tools are in your toolset, call
  `broker_list_credentials`. A result — even empty — proves a live broker
  that accepted your mTLS identity. An error is diagnosed below.
- **Script agents (file-based keys only):** try `~/.tmh/broker.sock`, or
  `socket_path` from `~/.tmh/broker.yaml`, then attempt `list_credentials`
  over mTLS — a socket existing is not enough.
- **Stop-retrying rule:** if detection fails, ask your owner once and stop.
  Re-probe only after they confirm setup is complete.

## MCP tool surface (recommended)

| Tool | Broker method |
|------|---------------|
| `broker_list_credentials` | `list_credentials` |
| `broker_ssh_exec` | `ssh.exec` |
| `broker_ssh_put` | `ssh.put` |
| `broker_ssh_get` | `ssh.get` |
| `broker_http_request` | `http.request` |
| `broker_http_logout` | `http.logout` |

Output is **decoded** for you — stdout/stderr/response bodies come back as
text, not raw base64. Every tool may block up to **~300 s** for owner
approval; a progress notification arrives every 15 s given a progress token.

Schema source of truth is the code: `list_credentials` returns
`credential_id`, `type`, `user_at_host_port`, `interaction_mode`, and
`fingerprint` (SSH only) per `internal/broker/handlers.go`. Your MCP host
already gives you full tool schemas — this only covers what the golden path
below reads.

## Raw JSON-RPC quickstart — Advanced / script agents with FILE-BASED keys only

**HSM-enrolled agents cannot use this — use the MCP tools above.** Your
private key never leaves the HSM; this path requires a key file on disk.

Newline-delimited JSON-RPC 2.0 over TLS 1.3 mTLS on the broker's Unix socket.
**Pre-1.0 wire contract** — details may change; prefer the MCP tools.

```python
import socket, ssl, json, base64

def call(sock, method, params, req_id=1):
    req = json.dumps({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params})
    sock.sendall((req + "\n").encode())
    return json.loads(sock.makefile().readline())

ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.load_cert_chain("agent-cert.pem", "agent-key.pem")  # SKILL.md Direct Flow
ctx.load_verify_locations("ca.crt")
ctx.check_hostname = False  # SPIFFE URI SAN, not DNS

raw = socket.socket(socket.AF_UNIX)
raw.connect("/home/user/.tmh/broker.sock")
sock = ctx.wrap_socket(raw)
sock.settimeout(310)  # must exceed the ~300s approval window

cred_id = call(sock, "list_credentials", {})["result"]["credentials"][0]["credential_id"]
resp = call(sock, "ssh.exec", {"credential_id": cred_id, "command": "ls /tmp"}, 2)
print(base64.b64decode(resp["result"]["stdout_b64"]).decode())
```

Binary fields use standard base64 (`*_b64` names).

## Errors & rescues

| Problem | Cause | Fix |
|---------|-------|-----|
| Connect `ENOENT`/`ECONNREFUSED` | Broker not installed or running | Owner runs the setup checklist above |
| TLS handshake rejected (no JSON-RPC error) | `agent_id` not on the broker's allowed-agents list | Owner adds it. **Do NOT regenerate your certificate** |
| `-32001 denied_by_policy` | Not granted this credential, or a deny rule matched | Owner grants it / edits the allowlist; applies within `TMH_BROKER_CACHE_TTL` |
| `-32002`/`-32003` timeout / denied | No owner response / explicit denial | Re-issue (timeout); confirm with owner first (denied) |
| `-32004 credential_not_found` | Unknown or stale `credential_id` | Re-run `list_credentials` — don't invent an ID |
| `-32006 http_upstream_failed`/`session_auth_failed` | Upstream failure, SSRF guard, or expired session | Owner refreshes the session in the portal |
| `-32008 rate_limited` | Too many session-open attempts | Wait for the window to clear |
| `-32009 ssh_connection_failed` | Dial/auth/SFTP failure | Check host/port/user; key in `authorized_keys`? |
| `-32010 ssh_host_key_changed` | Remote host key differs from the pinned key | Hard refuse. Owner re-pins in the portal |

**Approval-blocking note:** ask-gated calls block up to ~300 s — your own
timeout must exceed 310 s. A locally-timed-out call may still be approved
and executed later; check with your owner before re-issuing, and warn them
before any approval-gated call.

## Golden path

One runnable sequence:

1. `broker_list_credentials` (or `list_credentials` on the raw socket).
2. Pick a `credential_id` from the actual response — never a placeholder.
3. `broker_ssh_exec` (or `ssh.exec`) with that `credential_id`.
4. Read the decoded `stdout`/`stderr` — no key ever touched your process.
