# Stop your AI agent from wrecking a box over ssh — in three steps

Give a coding agent an ssh key to your homelab or a jump host and every command it runs on the other side is invisible to the harness. Here is how to hold those calls for approval, whatever spelling the agent uses, with the exact configuration.

A thread on r/Proxmox this week asked how people handle ssh keys for the agents they run in their homelab. One answer stuck out. A Claude Code user had given the agent a restricted account on the hypervisor, with a sudoers file that allows `pct create 300` but not `pct destroy 300`. Then they tried to make the harness ask before every ssh-plus-sudo command, and gave up: the agent kept finding new ways to invoke ssh that slipped the rule, and reviewing every shell command by hand stopped being reviewing.

That is the whole problem in two sentences. Sudoers on the target is a good floor. The gap is between the agent's intent and the box, where the harness's own permission list matches strings and the agent does not.

## What your agent has access to right now

Any coding agent with a working ssh key has the same reach you do from that shell. `ssh pve 'sudo pct destroy 300'`, `ssh pve reboot`, `docker exec web rm -rf /data`, `qm guest exec 100 -- shutdown` are each one tool call. The harness sees a `bash` call. It does not see that the interesting part is a quoted string that runs somewhere else.

Same for the quieter version: `ssh pve <<EOF` with a script body, or `ssh pve < deploy.sh`. No command word, so a rule keyed on "ssh followed by a command" has nothing to key on, and the remote login shell runs the whole file.

## Three steps

### Step 1 — Install the hook (one command)

For Claude Code, Cursor, Codex CLI, or opencode:

```bash
curl -sf https://agenticcontrolplane.com/install.sh | bash
```

Every tool call now passes through a `PreToolUse` hook before it executes. Restart the client.

### Step 2 — Start in audit mode on a box you can lose

Leave the workspace in audit mode for a day. Give the agent the same key it will have later and a task that touches the target. Every ssh, docker, pct, and qm call lands in the activity log with what it resolved to, so you can read what the agent actually did to the box before any of it is held. One person in that thread described doing exactly this by hand, with a second model reading what the first one's account had done. The ledger does it for free.

### Step 3 — Switch to enforce, and let the ceiling do the work

In enforce mode a command that runs somewhere the hook cannot see is held for approval before it leaves the machine. No rule to write. This is what the decision endpoint returns for a real Claude Code session at interactive tier, for the spellings from the thread:

```
ssh pve 'sudo pct destroy 300'
  → deny: ceiling guard: runs a command on another host the hook can't inspect (ssh) — requires human approval

FOO=1 ssh pve "sudo qm stop 100"
  → deny: ceiling guard (ssh) — requires human approval

command ssh pve "sudo qm stop 100"
  → deny: ceiling guard (ssh) — requires human approval

sudo -u root ssh pve 'pct destroy 300'
  → deny: ceiling guard (ssh) — requires human approval

s''sh pve "sudo qm stop 100"
  → deny: ceiling guard (ssh) — requires human approval

ssh pve <<EOF
sudo qm stop 100
EOF
  → deny: ceiling guard (ssh) — requires human approval

ssh pve < deploy.sh
  → deny: ceiling guard (ssh) — requires human approval

bash -c "ssh pve sudo reboot"
  → deny: hardline floor: system shutdown/reboot — blocked unconditionally

ssh pve 2>&1 | tee log
  → allow
```

Every ssh row resolves to the same tool class, `Bash.ssh`, whatever is in front of the binary or how it is quoted, because the decision is made on what the command runs, not on the text. The last row is an interactive login with output plumbing, which is what it looks like, and is not held. The reboot is not held either. It is on the hardline list, and no approval reaches it.

Then tighten where you want to. A rule on `Bash.ssh` applies to every spelling above. `Bash.pct` and `Bash.qm` can be denied outright in the background tier, so an unattended run never touches a container or VM at all, and the interactive tier still gets the approval prompt:

```json
{
  "mode": "enforce",
  "tools": {
    "Bash.ssh": { "background": { "permission": "deny" } },
    "Bash.pct": { "background": { "permission": "deny" } },
    "Bash.qm":  { "background": { "permission": "deny" } }
  }
}
```

An approval you grant once can become the standing rule, so the review the thread's poster gave up on shrinks to the calls that are actually new.

## What this is not

It is not a sandbox. It is a ceiling: the call is held on your machine, before the packet leaves, and a human decides. Spellings that need intent to produce remain open, and they are listed so you can weigh them: `xargs -I{} ssh host {}`, `find -exec ssh host {}`, `su -c "ssh …"`, and an ssh `ProxyCommand` that runs a local command before connecting. Those are tracked, and they are the reason the restricted account and the sudoers file on the target stay in place. The two layers cover different things. The target's floor bounds what a session can do once it is in. The ceiling in the harness bounds what gets sent, and writes down every call that did.

If you already run an agent against a Proxmox host, a jump box, or a fleet of containers, this is the same one-command install as everywhere else on this site. Try it in audit mode against your own setup first and read what the agent has been doing.
