Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,17 @@ Notable changes to Cloudfall. The format follows
`--gateway-url`, `--gateway-ca`, `--gateway-cert` and `--gateway-key`
flags of `cloudfall mcp serve`, which only that tool used, are removed
(#35)
- `cloudfall operator approve <id>` asks the person to type the proposal id
at a terminal before it runs the playbook or the verify step, and no flag
answers that, `--yes` or `"yes": true` in `--raw-payload` included. Off a
terminal it exits 4 with `PERSON_REQUIRED`; a mistyped id exits 4 with
`ATTESTATION_MISMATCH`; either way the proposal stays `proposed`. Before,
any shell call ran it, so an agent could approve its own proposal.
`operator show` previews it. The receipt's approval records
`via: terminal`; a person's approval written before reads as
`via: unknown`, and a policy's has none. The field is optional in
`operator-proposal.schema.json`. Scripted approvals stop working; a
declared `OperatorPolicy` still licenses autonomous runs (#35)

## [0.5.1] - 2026-09-22

Expand Down
8 changes: 8 additions & 0 deletions config/schemas/v1/operator-proposal.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,14 @@
},
"policy": {
"$ref": "common.schema.json#/$defs/resourceId"
},
"via": {
"description": "How a person's approval arrived: terminal when a person typed the proposal id at a terminal. A human approval written before this field reads as unknown; an autonomous one has none.",
"type": "string",
"enum": [
"terminal",
"unknown"
]
}
},
"required": [
Expand Down
10 changes: 10 additions & 0 deletions docs/operator-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,16 @@ uv run cloudfall operator approve op-... \
--gateway-key certs/operator.key
```

`operator show` is the preview. `operator approve` asks you to type the
proposal id at your terminal before it runs anything, and no flag answers
that, `--yes` included. Off a terminal, as from an agent's shell, a script
or a pipe, it exits 4 with `PERSON_REQUIRED`; a mistyped id exits 4 with
`ATTESTATION_MISMATCH`. Either way the proposal stays `proposed`. The
receipt records the approval as `approval.via: terminal`; a person's
approval written before reads as `via: unknown`. This is a speed bump
against an agent with a shell, not a boundary: a process running as the
same OS user can fake a terminal.

Approval executes the proposal's engine playbook and then verifies the
trigger actually resolved: an alert-triggered proposal must see its alert
stop firing, a drift-triggered proposal must see its drifted checks audit
Expand Down
5 changes: 3 additions & 2 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,9 @@ project and never overwrite an existing resource. `cloudfall init`,
`dashboard serve` are never tools: the server starts inside an existing
project, approving a recorded operation or an operator proposal is a
command a person runs from a shell, and the loops run until stopped.
`decisions approve` asks the person to type the decision id at a terminal;
off a terminal it exits 4 with `PERSON_REQUIRED`, `--yes` or not. That is a
`decisions approve` and `operator approve` ask the person to type the
decision or proposal id at a terminal; off a terminal they exit 4 with
`PERSON_REQUIRED`, `--yes` or not. That is a
speed bump against an agent with a shell, not a boundary: a process running
as the same OS user can fake a terminal.

Expand Down
31 changes: 26 additions & 5 deletions sdk/src/cloudfall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2619,9 +2619,16 @@ class Approved:

@operator.command(
"approve",
# A person approves; no agent tool stands in for it.
# A person approves; no agent tool stands in for it. As with decisions
# approve, they type the proposal id at a terminal, which no flag
# answers, --yes included. requires_person keeps it off MCP too; mcp=False
# says so here as well.
mcp=False,
description="Execute a proposal and verify its trigger resolves",
requires_person=True,
description=(
"Execute a proposal and verify its trigger resolves; a person types "
"the proposal id at a terminal, and `operator show` previews it"
),
danger_level="mutating",
exit_codes=[
"PROJECT_INVALID",
Expand All @@ -2638,22 +2645,36 @@ class Approved:
subprocess=Subprocess("ansible-playbook"),
required_tools={"ansible-playbook": "2.21.0"},
examples=[
("Approve one proposal", "cloudfall operator approve nginx-down-20260101"),
(
"Approve and run one proposal, typing its id at a terminal",
"cloudfall operator approve nginx-down-20260101",
),
],
)
def operator_approve(
args: OperatorApproveArgs, ctx: Ctx, fleet: Fleet
) -> Approved:
"""Run the proposal's operation, then wait for its trigger to resolve."""
"""Run the proposal's operation once a person typed its id, then verify it."""
store = args.store(fleet)
verifier_for = args.verifier_for(fleet, ctx, partial(args.feed, fleet))
try:
recorded = store.load(args.proposal)
verifier = verifier_for(recorded)
# Off a terminal this ends with PERSON_REQUIRED, and a mistyped id with
# ATTESTATION_MISMATCH, before anything runs: --yes never approves.
attestation = ctx.attest(
f"{recorded.diagnosis_summary}. It runs "
f"{' '.join(recorded.command)}. Type the proposal id to approve "
f"{recorded.resource_id.value} and run it",
expected=recorded.resource_id.value,
)
proposal = approve_proposal(
store,
args.proposal,
engine_executor(args.context(fleet, ctx)),
verifier_for(store.load(args.proposal)),
verifier,
ApproveOptions(verify_timeout_seconds=args.verify_timeout),
via=ApprovalChannel(attestation.channel),
)
except OperatorError as error:
raise _operator_failed(error) from error
Expand Down
6 changes: 5 additions & 1 deletion sdk/src/cloudfall/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,11 @@ def _plan(subject: str, *extra: str) -> OutputShape:
"operator approve",
CommandEffect.SERVERS,
"execute one proposal and verify that its trigger resolves",
gate="the proposal id names a receipt a human has reviewed",
gate=(
"a person types the proposal id at a terminal, which no flag answers "
"(`--yes` included); off a terminal it exits 4 with `PERSON_REQUIRED`, "
"and `operator show` previews the proposal"
),
output=_PROPOSAL,
),
)
Expand Down
48 changes: 43 additions & 5 deletions sdk/src/cloudfall/operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from typing import TYPE_CHECKING, Protocol, cast

from cloudfall.audit import AuditStatus, audit_inventory
from cloudfall.decision import ApprovalChannel
from cloudfall.domain import AlertSeverity, ResourceId
from cloudfall.lifecycle import LifecycleError, run_engine_playbook
from cloudfall.observation import load_observations
Expand All @@ -43,6 +44,7 @@
ERROR_PROPOSAL_MISSING = "operator_proposal_missing"
"""Code of the error ``ProposalStore`` raises for an id it holds no receipt for."""
_ERROR_PROPOSAL_NOT_OPEN = "operator_proposal_not_open"
_ERROR_CHANNEL_UNKNOWN = "operator_approval_channel_unknown"
_ERROR_OPERATION_UNSUPPORTED = "operator_operation_unsupported"
ERROR_GATEWAY_UNDECLARED = "operator_gateway_undeclared"
_SERVICE_CHECK_PREFIXES = ("services.bind[", "alerting.rules[")
Expand Down Expand Up @@ -144,16 +146,33 @@ def as_dict(self) -> dict[str, object]:

@dataclass(frozen=True, slots=True)
class ApprovalRecord:
"""Who licensed an execution: a human confirm or a declared policy."""
"""Who licensed an execution: a person or a declared policy.

A person's approval says how it arrived (``via``); a policy's has no
channel, since no one typed anything.
"""

mode: str
policy: ResourceId | None
via: ApprovalChannel | None = None

def __post_init__(self) -> None:
"""Reject a person's approval without a channel, or a policy's with one."""
if (self.via is None) is (self.mode == "human"):
message = (
f"a {self.mode} approval "
f"{'names' if self.mode == 'human' else 'has no'} the channel it "
"arrived through"
)
raise ValueError(message)

def as_dict(self) -> dict[str, object]:
"""Serialize the approval for the proposal receipt."""
result: dict[str, object] = {"mode": self.mode}
if self.policy is not None:
result["policy"] = self.policy.value
if self.via is not None:
result["via"] = self.via.value
return result


Expand Down Expand Up @@ -567,13 +586,20 @@ def _proposal_from_document(document: Mapping[str, object]) -> OperatorProposal:
approval = None
if isinstance(raw_approval, dict):
raw_policy = raw_approval.get("policy")
mode = cast("str", raw_approval["mode"])
approval = ApprovalRecord(
mode=cast("str", raw_approval["mode"]),
mode=mode,
policy=(
ResourceId.from_boundary(raw_policy)
if raw_policy is not None
else None
),
# A person's approval written before the channel reads as unknown.
via=(
ApprovalChannel.from_record(raw_approval.get("via"))
if mode == "human"
else None
),
)
raw_outcome = spec.get("outcome")
outcome = None
Expand Down Expand Up @@ -803,14 +829,26 @@ class ApproveOptions:
sleep: Callable[[float], None] = field(default=time.sleep)


def approve(
def approve( # noqa: PLR0913 - the run's contract plus how the approval came.
store: ProposalStore,
proposal_id: ResourceId,
executor: Callable[[OperatorProposal], None],
verifier: Callable[[OperatorProposal], bool],
options: ApproveOptions | None = None,
*,
via: ApprovalChannel,
) -> OperatorProposal:
"""Execute a human-approved proposal and verify its trigger resolves."""
"""Execute a human-approved proposal and verify its trigger resolves.

``via`` is how the person's approval arrived; a new approval cannot be
of an unknown channel.
"""
if via is ApprovalChannel.UNKNOWN:
message = (
"an approval records how it arrived; a new one cannot be of an "
"unknown channel"
)
raise OperatorError(_ERROR_CHANNEL_UNKNOWN, message)
proposal = store.load(proposal_id)
if proposal.status is not ProposalStatus.PROPOSED:
message = (
Expand All @@ -823,7 +861,7 @@ def approve(
proposal,
executor,
verifier,
ApprovalRecord(mode="human", policy=None),
ApprovalRecord(mode="human", policy=None, via=via),
options,
)

Expand Down
Loading
Loading