The coding part is mostly solved. An agent can branch, patch, write the tests, open the PR. Then the change has to land on an actual server — a service restarted, a migration run, an nginx config edited — and the autonomy stops cold. Deploying needs SSH, SSH needs credentials, and nobody in their right mind wants to paste a private key into an agent's environment.
That hesitation is correct, and it's worth being precise about why. An SSH private key is a bearer credential: whoever holds the bytes is you. Sharing it with an agent is not like sharing it with a colleague, because you can't take it back. Once a key has passed through an agent's context — an env var, a mounted file, a config blob — you can no longer prove it didn't end up in a log, a transcript, or a tool call you never read. The only honest answer to "did the model see my key?" is to rotate the key, on every host that trusts it. That's the irreversibility problem, and it's why "just give the agent a key" feels wrong even to people who can't articulate the threat model.
Three ways to do it badly
Paste the raw key. The direct route: drop id_ed25519 into the agent's config or environment and let it run ssh itself. Beyond the irreversibility above, an SSH key has no scope and no expiry — the key that deploys to staging can usually also read the production database credentials two hops away. And revocation means rotation, which means touching authorized_keys on every box, which means you'll put it off.
Mint a long-lived token. A deploy token, a PAT, an "automation" credential that expires never. It feels more hygienic than a key, but it's the same bearer problem with extra steps. The rule of thumb: if revoking a credential requires remembering it exists, it will outlive the experiment it was created for.
Give the agent its own root account. This one at least shows the right instinct — a separate identity you can revoke separately. But done the usual way (drop a public key into authorized_keys for a deploy-bot user, walk away), it ships with no audit trail. Nothing distinguishes the agent's commands from anyone else's, nothing records what it actually did, and when something breaks at 2 a.m. you're reconstructing an agent's session from bash history and vibes.
The common thread: in all three, the agent holds a standing credential, and observability is an afterthought.
What good looks like: a custodian, not a copy
Flip the model. The agent should never hold the credential at all.
- The agent asks; a custodian signs. A broker process holds the keys — or mints short-lived certificates — and performs the authentication on the agent's behalf. The agent's environment contains nothing worth stealing: compromise the context and you get the ability to request actions through the broker, not the ability to impersonate you from anywhere on the internet.
- Scope is explicit. The custodian only reaches the hosts you've listed. A new host is a new decision, not a default.
- A human can watch. Live, while it happens — not just in the post-mortem. And the record must distinguish "the agent did this" from "I did this".
- Revocation is a toggle, not a rotation. Because nothing was ever shared, turning access off costs nothing.
None of this is exotic — it's roughly how certificate-based SSH already works at companies with real infrastructure teams. The catch has always been that wiring it up yourself is a project, so most people skip straight to one of the bad options above.
How Termalin implements it
Termalin is an SSH client with a built-in MCP server. Your agent — Claude, or anything that speaks the Model Context Protocol — gets tools like hosts_list, ssh_exec, SFTP reads and writes, and persistent sessions. Termalin does the authenticating.
On your machine. Register the bundled local server with your agent:
claude mcp add termalin -- <path>/termalin-mcp
The agent reaches only the hosts you've enabled in Settings → MCP — agent access is off by default, and the host inventory is written with agent-only auth, so no passwords land on disk. Authentication goes through Termalin's key custodian: you unlock your keys once, and Termalin signs on the agent's behalf. No ssh-add, no key file for the agent to read, no key touching disk at all.
And you can actually watch. Agent sessions run as live terminal tabs, the watch grid mirrors every open session side by side, and the tiles an agent is driving glow. Letting the agent type into a session you already have open is a separate consent toggle, not a default. Every agent command is marked in the session recording — which captures output only, never your keystrokes — and written to the audit log with the device and IP it came from.
No app running. If your agent lives where your desktop isn't — CI, a cloud sandbox — create an API key in the web cabinet and point it at the hosted MCP endpoint:
{
"mcpServers": {
"termalin": {
"url": "https://termal.in/api/v1/mcp",
"headers": { "Authorization": "Bearer tk_live_…" }
}
}
}
The hosted endpoint reaches only servers enrolled with the tunnel agent, and it authenticates each run with a short-lived certificate — again, no standing key is ever handed out. The API keys themselves are scoped to specific servers, carry an expiry (30, 90 or 365 days), and can be revoked any time; hosted runs are rate-limited and time-boxed per key.
Either way, the property you wanted holds: the agent can deploy, and it has never seen a key.
Start with one boring host
Don't begin with production. Enroll one low-stakes box — the staging server, a toy VPS. Give the agent a real chore: deploy the branch, tail the log until the error shows up, fix the config, restart the service. Keep the watch grid open while it works. What you learn in the first hour — how it behaves, where it hesitates, what it does with ambiguity — tells you whether the second host gets enrolled.
That's the quiet payoff of the custodian model: you get to expand one host at a time, because no step you take is one you can't take back.
Termalin's Free tier is the full desktop app, and every new account starts with a 14-day Pro trial — download it, or start with the MCP docs.
Top comments (13)
For the prior-art question, the cleanest pattern I have seen is CI workload identity rather than something agent-specific: an OIDC identity for one run is exchanged at a broker (for example Vault's SSH secrets engine) for an OpenSSH certificate with a minutes-long TTL. Claims such as repository, environment, run ID, and actor map to allowed principals and hosts; certificate critical options can further constrain source address or force a command.
The extra agent-specific edge is plan drift. I would bind the capability to an immutable plan hash plus the artifact digest and command class, then re-evaluate each tool call instead of authorizing the whole conversation. If the model changes the plan, it needs a new capability. Single-use nonces and replay checks matter too. That makes human approval mean “approve this exact deployment plan,” not the much broader “allow SSH.”
That's the reference I was reaching for, thanks - the OIDC->broker->short-TTL cert flow (Vault's SSH secrets engine being the clean example) is exactly the human-CI prior art, and mapping claims to principals is what makes it scope cleanly. The gap I keep hitting: a CI job has a fixed plan (the pipeline), so "one identity per run" maps neatly. An agent's "run" is open-ended - which is exactly your plan-drift point.
Binding to an immutable plan hash + artifact digest + command class, re-evaluated per tool call, is the sharpest framing I've seen. The part I'd stress-test is what counts as "the plan" when the agent legitimately needs to adapt (the log shows something unexpected, so the fix changes): too tight and you re-prompt the human on every deviation; too loose and the hash stops meaning anything. Feels like the plan wants to be a bounded envelope (these hosts, these command classes, this artifact) with re-approval only on envelope escape, not a literal command list.
Single-use nonces + replay-after-revocation testing - 100%, and it's the check everyone skips. One thing I'm still chewing on: do you bind at the certificate layer (critical options / force-command) or above it in the broker's policy? Curious where you'd draw that line.
I'd split the responsibility. Put the independently enforceable baseline at the certificate layer: principal, host/user scope, short expiry, and source constraints. Keep the adaptive envelope — artifact digest, command classes, approval state, and nonce consumption — in the broker policy.
force-commandis useful when there is one stable entry point, but it becomes too blunt for an agent that legitimately adapts.Then treat an agent run as a chain of small authorizations: validate live preconditions, issue a fresh short-TTL cert for the next bounded step, and require re-approval only when the step leaves the envelope. That preserves a fail-closed SSH boundary without forcing every valid adaptation into certificate semantics.
The edge I'd test hardest is TOCTOU: a cert can be issued for state A and used after the target changes. A server-side shim may still need to consume the nonce and re-check the digest immediately before exec. So my line would be: coarse, independently verifiable constraints in the cert; stateful and adaptive constraints in the broker, with a final check at execution.
This is the cleanest articulation of that split I've seen, and it's where we landed too: the cert carries only what's independently verifiable without external state (principal, host, short expiry, source), and everything stateful (digest, command class, approval, nonce) lives in the broker. Push adaptive state into the cert and you're re-minting on every micro-decision, which defeats the bounded-step idea.
TOCTOU is the right thing to hammer on, and "a final check at execution" is exactly where it gets uncomfortable. If a server-side shim has to re-check the digest and consume the nonce immediately before exec, the target has to reach the broker (or carry a broker-signed, exec-time token) in the hot path - which puts the broker on the critical path for every command and hands you a new availability/latency dependency right where you least want one.
The two ways I keep circling: (a) a ForceCommand/PAM shim that calls the broker synchronously and fails closed, vs (b) a short-enough cert TTL that the window is "small enough" plus an idempotent/append-only target so a stale re-exec is harmless - trading a hard guarantee for a probabilistic one. Feels like it comes down to whether the protected action is idempotent: if it is, squeeze the window; if it isn't, you're stuck paying for the synchronous check. Which way would you lean for the common deploy case?
For the common deploy case I'd take a third path: keep the broker off the hot path, but don't rely on TTL alone. Have it mint a single-use, signed exec token bound to the host, principal, artifact digest, operation, expected current release, nonce, and a very short expiry. The target shim verifies that locally, records the nonce durably, stages the content-addressed artifact, then performs an atomic compare-and-swap of the release pointer.
That makes the usual deploy idempotent and turns stale state into a failed precondition rather than a probabilistic window. The broker is needed to authorize the step, not to execute it. For irreversible operations that can't be reduced to an atomic/idempotent primitive — schema changes are the obvious one — I'd pay for the synchronous fail-closed broker check, and probably explicit approval.
So my split would be: local signed token + replay protection + CAS for normal deploys; synchronous broker for the small non-idempotent tail.
For the common deploy case, I'd use both, but not symmetrically. Make staging content-addressed and idempotent: upload to an immutable release path, verify the digest there, then prepare everything short of activation. Retries in that phase are harmless.
Keep the synchronous, fail-closed broker check only at the non-idempotent commit point: switching the current symlink, changing traffic, restarting against the new artifact, or running a migration. That keeps the broker out of every shell command while preserving a hard guarantee where stale authorization actually matters. The cert TTL then limits ambient access; it is not carrying the correctness argument by itself.
If the broker is unavailable, staging can continue but activation waits. I would treat schema migrations as a separate capability with an explicit precondition (expected schema version) and a one-time token, rather than bundling them into the general deploy envelope.
One gap in the broker model as usually implemented: it secures the deploy step but assumes the artifact being deployed is already trustworthy. In practice the agent wrote that artifact too, so the credential-custody problem and the 'did the agent's own code introduce a vuln' problem are separate and both need solving — a perfectly scoped short-TTL cert still happily deploys a broken auth check the same agent just wrote five minutes earlier. Feels like the broker belongs right after a gate that actually reads the diff, not just signs it.
Completely fair, and it's a distinction we're careful not to blur: custody secures the channel (can this agent reach prod, as whom, for how long) - it says nothing about whether the payload is correct. Two orthogonal trust problems, "can it act" vs "is what it's shipping any good," and solving the first doesn't touch the second.
Where they connect is the artifact digest. Bind the capability to a specific digest and the broker can enforce "only deploy the exact artifact that cleared the gate" - but it cannot BE the gate. The gate is the boring stuff that has to hold regardless of who wrote the code: tests, a human or a second model reading the diff, SAST, a canary that watches error rates before full rollout. The agent authoring the artifact just means you cannot skip any of it on the theory that "a person wrote this, so it is probably fine."
So I would put it exactly where you did: broker right after a gate that reads the diff, cert bound to the digest that gate approved. Custody stops the agent deploying to the wrong place or as the wrong identity; the review gate stops it deploying wrong code. You need both - conflating them is how you end up with a beautifully scoped cert shipping a broken auth check.
Binding the cert to the digest that cleared the gate is the right enforcement point, and it also means the gate doesn't need to be a full CI run to be useful. For agent-authored diffs specifically, the highest-signal checks are narrow: did this diff touch an authorization path, does a new query take a caller-supplied ID without a corresponding session check, did a service-role-equivalent credential move into a client-reachable file. Those are checkable in seconds and catch the exact failure mode the broker structurally can't see. A slower general SAST pass can run after, but the fast narrow gate is what keeps the broker from becoming a fig leaf.
That framing - the gate as a delta check on the trust boundary, not a correctness proof - is what makes it cheap enough to sit in the hot path. You're diffing the authz surface (did a caller-supplied ID reach a query without a session check, did a service-role cred cross into client-reachable code), not re-verifying the app. Semgrep-rule territory: seconds, per-diff, and it catches exactly the class the broker is blind to.
The pairing I like: the gate emits a verdict keyed to the artifact digest, and the broker only mints a cert for a digest that passed - so "reviewed" and "deployed" become the same object, not two hopeful steps.
For agent-authored diffs there's a nastier variant worth the narrow gate: make the agent declare which trust-boundary files it touched, and fail closed if the declared set doesn't match the actual diff. Catches the agent quietly editing an auth check it never mentioned.
The one place it stays a fig leaf: if the ruleset lags your own authz conventions - custom middleware, a new framework's session model - it green-lights the exact diff it should catch. So the fast gate has to encode your boundary patterns, not generic ones. Which loops back to your point: narrow and specific beats broad and slow, as long as "narrow" tracks the real boundary.
Yes - this is the synthesis I was fishing for, and "the broker authorizes the step, not executes it" is the line that makes it click. A single-use signed token carrying the expected current release turns the TOCTOU window into a failed precondition instead of a race, and CAS on the release pointer is what makes the "harmless retry" property real rather than aspirational. Reserving the synchronous fail-closed broker call for the small non-idempotent tail (activation, migrations) is exactly the right place to spend the availability cost.
The part I'd still poke at is "records the nonce durably" on the target. Per-target, crash-safe nonce storage is a small consistency problem in its own right - it has to survive the shim dying between "record nonce" and the CAS, or you either replay or wedge. Feels solvable by making the nonce burn and the swap commit-or-fail together: idempotency key = (nonce, digest) written in the same atomic step as the pointer CAS, or lean on the pointer store's own CAS to double as the replay guard.
Treating schema migrations as a separate capability with an expected-schema-version precondition + a one-time token is the cleanest framing I've seen for the irreversible tail - it stops the general deploy envelope from silently growing teeth. Genuinely good thread; you've basically speced the hard 20%.
The custodian model is the right direction. One boundary worth making explicit: in hosted mode the agent still holds a bearer API key, and 30–365 days is a long-lived capability even if it cannot exfiltrate SSH keys. A compromised context can still issue authorized actions through the broker. I’d treat that key as bootstrap only: exchange it for a per-run, minutes-lived capability bound to host(s), allowed tool or command class, deployment artifact digest/change ticket, max duration, and requester identity. Reauthorize at execution time, log denials, and test replay after revocation. That preserves key custody while also containing delegated authority—the remaining thing an attacker actually wants.
Sharp cut — thanks. You're right that the API key is the one standing bearer left in the hosted path. The SSH auth underneath is already a short-lived cert minted per run, but the key that authorizes the minting isn't, and "revocable within 30–365 days" is doing a lot of work in that sentence.
What's shipped today is partial containment, not the thing you're describing: keys are scoped to specific enrolled hosts, carry a per-key command policy, and hosted runs are rate-limited and time-boxed — so a leaked key can't reach an unlisted host or run outside its allowed command class. What it can still do is replay authorized actions inside that envelope until someone revokes it. Exactly your point.
The bootstrap→per-run exchange is where I want this to go: treat the API key as enrollment only, have it mint a minutes-lived capability bound to host + command class + a change/artifact reference + requester identity, reauthorized at execution time with denials logged. Custody stays the same; the delegated-authority blast radius drops to a single run. And +1 on testing replay after revocation — easy to skip, embarrassing to skip.
Have you seen this done cleanly for agents specifically? Most of the prior art I know is human-facing CI, and the agent case has a couple of extra sharp edges.