v0.38.0: the arm gates the ssh path it never reached
The arm did not gate the one path it most needed to.
arm and disarm swap the PVE API token, so the boundary they create is enforced by Proxmox's own permission check. That check only ever sees the REST API. But ct_exec and ct_psql do not use the API at all: Proxmox exposes no REST endpoint for container exec, so they run ssh <target> pct exec ... as root on the host. That authority comes from an ssh key and never touches the token, which means swapping the token had no effect on them. A fully disarmed caller could run arbitrary in-container commands with every other gate satisfied. Found on our own estate by noticing that a read-only session was still executing.
The new armgate leg closes it. With PROXIMO_ARM_SOURCE set, a confirmed ct_exec or ct_psql is refused unless the served token's bytes equal the arm source's, and the refusal is recorded as blocked:not_armed before it raises. ExecBackend.run repeats the check independently, so a future caller reaching the backend directly cannot ride in on the allowlist alone. The lease control could not have caught this and structurally cannot: it proves the served token is fresh, never that it is the write one, and disarm deliberately stamps a fresh mtime too. Freshness and authority are different questions.
Then an adversarial pass took the gate apart, and four things around it needed fixing.
Four independent lenses and two refute passes, the refuters told to kill each finding rather than confirm it. The gate itself held: no path to the exec surface skips the check, and seven of eight mutations to the gate were caught by the test suite. Everything below sat around it.
- The gate now judges the box a command is aimed at, not the process environment. It read the arm source and token path from the process env while the exec backend resolved per target, so with the target registry in use, armed on one box read as armed on every target. The shipped
packaging/targets.example.tomlalready promised per-target arming, so this was a promise the code broke rather than a gap it left.arm_sourceandreadonly_sourceare now per-target registry fields, never inherited from the environment, and a target with exec enabled but no arm source of its own is refused with a message naming the field to set. - An arm source pointing at the served token is refused. One file in both roles made the check compare a file to itself and report armed forever, silently, with no way for
disarmto change the answer. ct_diagnosejoinsct_logsas available while disarmed. Its read-only probe battery ran the gated path, so while disarmed every probe failed and the reasons were discarded. You diagnose a box precisely when it is broken and you are not armed, so a diagnostic that goes dark on disarm is the wrong shape. The battery now lives beside the one method allowed to run it ungated, reached by a keyed call that takes a probe name and never a command, so "the argv is fixed here" is a property of the code rather than a promise made from another module. The exec opt-in, CTID validation and the allowlist all still apply.- Failing probes report why. A bare exception class name is not a diagnosis when the allowlist, a disabled exec opt-in and an unreachable host all render identically.
- The gate order is pinned by a test. The arm check runs before the lease check so an operator is told to re-arm rather than to renew. Swapping the two left the entire suite green, because no test had ever configured a disarmed token and an expired lease at the same time. Neither order runs the command, so this was never a bypass, only the wrong remedy in the ledger and in the message.
Be clear about what kind of boundary this is. It is local machinery, which SECURITY.md otherwise warns you not to mistake for a boundary. It closes drift: a caller that believes it is disarmed, an agent that forgot, a session that was never armed. It does not constrain an attacker who already has code execution in the Proximo process and can reach the ssh key directly. The stronger posture is named in that document and is not built: give the exec path its own ssh identity and swap the key, not just the token.
Dormant unless you use the arm pattern. With no PROXIMO_ARM_SOURCE there is no enforcement and no behavior change, which is correct for mint-and-revoke deployments where no write token exists at rest to gate.
Full detail in CHANGELOG.md.
Where to read more
- What each control honestly holds, and what it does not: SECURITY.md
- Setting up the arm pattern, including per-target arming: docs/SETUP.md
- Every tool with typed inputs: docs/TOOLS.md
- Verifying the artifacts you just pulled (image signature, SBOM, PEP 740 provenance): VERIFY.md
- Full change detail: CHANGELOG.md
Install: uvx proximo-proxmox · pip install proximo-proxmox · ghcr.io/john-broadway/proximo:0.38.0