generated from atu/saiop-infrastructure
Add skill: scoped-privileged-command-key (agent-ops, from CR-015)
This commit is contained in:
@@ -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 <pubkey>
|
||||||
|
|
||||||
|
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/<name>`), 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 <realcmd> "${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.
|
||||||
Reference in New Issue
Block a user