diff --git a/skills/agent-ops/scoped-privileged-command-key/SKILL.md b/skills/agent-ops/scoped-privileged-command-key/SKILL.md new file mode 100644 index 0000000..4d7f248 --- /dev/null +++ b/skills/agent-ops/scoped-privileged-command-key/SKILL.md @@ -0,0 +1,89 @@ +--- +name: scoped-privileged-command-key +description: "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." +version: 1.0.0 +author: Claude Code (Agent Alpha, agent-ops session) +license: MIT +platforms: [linux] +prerequisites: + env_vars: [] + commands: [ssh, ssh-keygen, qm] +metadata: + hermes: + tags: [agent-ops, SAIOP, security, least-privilege, proxmox, ssh, forced-command, wrapper] +--- + +# 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 + +The wrapper is the security boundary. It reads the request from +`$SSH_ORIGINAL_COMMAND`, **never `eval`s it**, and permits ONLY an explicit +allowlist of command *shapes*, validated argument-by-argument. Fail-closed; +every call (allowed and refused) is logged. + +## Procedure + +1. Generate a dedicated keypair; private key 0600 root on the caller + (`/etc/agent-ops/keys/`), never on a laptop, never in git. +2. 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 "${a[@]:1}"` (array — no re-splitting, no eval). +3. Pin the forced command in `authorized_keys` on the target host(s) only + (exclude unstable/quorum-only nodes, e.g. pve3). +4. Keep an explicit ID/target allowlist (e.g. one VMID). Adding an entry later + is itself a reviewed change. + +## Gotchas / guardrails + +1. **Never include destructive shapes** (`destroy`, `delete`, `--force`) in the + allowlist. Prefer a constrained `clone` from a fixed template over a + free-form create. +2. **Parse arg-by-arg, never substring-match** the command — substring checks + are trivially bypassable. +3. **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:20` is 5 + tokens, not 4). Bugs here fail *open* if you're not fail-closed. +4. **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.