4.0 KiB
name, description, version, author, license, platforms, prerequisites, metadata
| name | description | version | author | license | platforms | prerequisites | metadata | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| scoped-privileged-command-key | Give an agent a narrow, audited set of privileged host commands (e.g. Proxmox qm) via an SSH forced-command wrapper with an argument-by-argument allowlist — without handing over root. Two-gate model: CR approval AND wrapper both must pass. | 1.0.0 | Claude Code (Agent Alpha, agent-ops session) | MIT |
|
|
|
Scoped privileged-command key (SSH forced-command allowlist)
When to use
An agent needs to run a small, fixed set of privileged commands on a host
(e.g. clone/reconfigure/start one specific VM) but must NOT get general root.
The approval gate decides whether; this pattern bounds what the transport can
physically do, independent of the approval gate. Both gates must pass.
(Built for agentops-cr@ on pve1/pve2 — CR-012 T10 / CR-015.)
The pattern
A dedicated SSH key whose authorized_keys entry pins a forced command and
strips everything else:
command="/usr/local/sbin/cr-exec-wrapper",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty <pubkey>
The wrapper is the security boundary. It reads the request from
$SSH_ORIGINAL_COMMAND, never evals it, and permits ONLY an explicit
allowlist of command shapes, validated argument-by-argument. Fail-closed;
every call (allowed and refused) is logged.
Procedure
- Generate a dedicated keypair; private key 0600 root on the caller
(
/etc/agent-ops/keys/<name>), never on a laptop, never in git. - Write the wrapper (root:root 0755). It MUST:
- reject any char outside a strict whitelist before parsing —
case "$cmd" in *[!A-Za-z0-9\ ._:=,/+-]*) deny ;; esac— this kills injection (; | & $ backtick > < \ newline); - tokenise with
read -r -a, then match exactly one permitted shape, validating every arg against fixed allowlists (target IDs, storage, size ceilings, fixed values); - be fail-closed (unmatched → refuse, non-zero exit);
- log every call with a UTC timestamp to an append-only log;
- on match,
exec <realcmd> "${a[@]:1}"(array — no re-splitting, no eval).
- reject any char outside a strict whitelist before parsing —
- Pin the forced command in
authorized_keyson the target host(s) only (exclude unstable/quorum-only nodes, e.g. pve3). - Keep an explicit ID/target allowlist (e.g. one VMID). Adding an entry later is itself a reviewed change.
Gotchas / guardrails
- Never include destructive shapes (
destroy,delete,--force) in the allowlist. Prefer a constrainedclonefrom a fixed template over a free-form create. - Parse arg-by-arg, never substring-match the command — substring checks are trivially bypassable.
- Off-by-one arity is the common bug. The target ID at
a[2]shifts every later index; count tokens carefully (qm set 144 --scsi1 local-lvm:20is 5 tokens, not 4). Bugs here fail open if you're not fail-closed. - The key is transport, not authority. The CR-approval gate
(
cr_authorize.py) still governs whether a shape may run. Both gates must pass — the wrapper adds no authority the approval gate hasn't granted.
Verification
Run the NEGATIVE set FIRST and confirm each is refused + logged, BEFORE trusting the key:
- a destroy/delete shape → refused
- a target ID outside the allowlist → refused
- a wrong storage / oversize disk → refused
- a shell metacharacter (
... ; rm -rf /tmp/x) → refused (metachar)
Only then run the POSITIVE allowed shapes and confirm they execute and log
ALLOWED.
Provenance
Learned building the ATU app-delivery platform (CR-015, unblocking CR-014
webapp-01). agentops-cr@ permits only: clone template 9000 → VMID 144, disk
resize/add on local-lvm (≤ ceiling), ipconfig/ciuser/boot set, start, status —
and refuses everything else, fail-closed, logged to /var/log/cr-exec.log.
Author: Agent Alpha, 2026-08-14.