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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ Notable changes to Cloudfall. The format follows

### Added

- A decision record's approval says how it arrived, as `approval.via`:
`terminal` when a person typed the decision id at a terminal. A record
written before reads as `via: unknown`. `cloudfall why` tells it in the
approval sentence. The field is optional in
`operation-decision.schema.json` (#25)
- `agent investigate --replay DIR` plays a real run back instead of running
Ansible: DIR is the decisions directory of a run on real hosts, and each
check-mode call the model makes answers with the output and per-host
Expand Down Expand Up @@ -130,6 +135,12 @@ Notable changes to Cloudfall. The format follows

### Changed

- treaty 1.0.0rc38 instead of 1.0.0rc29. `dashboard serve` and
`operator run` write each event in `json` and `jsonl` as a bare JSON line
with `_seq` instead of an envelope with `data` and `meta.seq`, and end
with one `"_summary": true` line or the error envelope. `mcp serve`
stopped by `SIGINT` or `SIGTERM` exits 130 or 143 with the `CANCELLED`
envelope instead of 0, and writes no envelope when it stops cleanly (#25)
- The package is classified as Beta on PyPI instead of Alpha
- The site's landing page is redesigned for technical founders leaving the
cloud and for teams running servers for clients: a light, illustrated
Expand Down Expand Up @@ -420,6 +431,19 @@ Notable changes to Cloudfall. The format follows
`failed` receipt, left the proposal `proposed` with no trace that it ran,
and showed the caller a traceback

### Security

- `cloudfall decisions approve <id>` asks the person to type the decision id
at a terminal before it runs anything, and no flag answers that, `--yes`
included. Off a terminal, as from an agent's shell or a script, it exits 4
with `PERSON_REQUIRED` and runs nothing; a mistyped id exits 4 with
`ATTESTATION_MISMATCH`. Before, `--yes` ran it headless and `--approver`
fell back to `$USER`, so an agent could approve its own proposal in the
person's name. Without `--yes` the command no longer previews the
proposal: `decisions show` does. Scripted or CI approvals stop working.
This is a speed bump, not a boundary: a process running as the same OS
user can fake a terminal (#25)

## [0.5.1] - 2026-09-22

### Changed
Expand Down
8 changes: 8 additions & 0 deletions config/schemas/v1/operation-decision.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,14 @@
"approvedAt": {
"type": "string",
"format": "date-time"
},
"via": {
"description": "How the approval arrived: terminal when a person typed the decision id at a terminal. A record written before this field reads as unknown.",
"type": "string",
"enum": [
"terminal",
"unknown"
]
}
},
"required": [
Expand Down
10 changes: 6 additions & 4 deletions examples/disk-full-incident/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,14 @@ cloudfall agent investigate \
```

The answer names the root cause and the decisions it recorded, in the order
to approve them. Nothing on the host has changed yet. Review and approve:
to approve them. Nothing on the host has changed yet. Review, then approve
at your terminal, where each approval asks you to type the decision id (an
agent's shell call is refused with `PERSON_REQUIRED`):

```console
cloudfall decisions approve shop-logrotate-<stamp> # shows the proposal
cloudfall decisions approve shop-logrotate-<stamp> --yes # runs it, then verifies
cloudfall decisions approve postgresql-restart-<stamp> --yes
cloudfall decisions show shop-logrotate-<stamp> # shows the proposal
cloudfall decisions approve shop-logrotate-<stamp> # type the id; runs it, then verifies
cloudfall decisions approve postgresql-restart-<stamp>
cloudfall why --host hz1 --format html > why.html
```

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ dependencies = [
"ansible-core>=2.21,<2.22",
"jsonschema[format]>=4.23,<5",
"pyyaml>=6,<7",
"treaty==1.0.0rc29",
"treaty==1.0.0rc38",
]

[project.urls]
Expand Down
4 changes: 4 additions & 0 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ project and never overwrite an existing resource. `cloudfall init`,
`decisions approve`, `operator run` and `dashboard serve` are never tools:
the server starts inside an existing project, approving a recorded
operation is a person's command, 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
speed bump against an agent with a shell, not a boundary: a process running
as the same OS user can fake a terminal.

```console
uv run cloudfall mcp serve --project config/examples
Expand Down
56 changes: 29 additions & 27 deletions sdk/src/cloudfall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
CHECK_TIMEOUT_SECONDS,
DECISION_DIRECTORY,
ERROR_DECISION_MISSING,
ApprovalChannel,
ApprovalRequest,
CheckRunner,
DecisionError,
Expand Down Expand Up @@ -1957,14 +1958,6 @@ class ApproveDecisionArgs(DecisionsArgs):
default=None,
description="Who is approving (default: the USER environment variable)",
)
yes: bool = Flag(
default=False,
confirm=True,
description=(
"Change the servers; without it the command shows the recorded "
"proposal and runs nothing"
),
)


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -2058,11 +2051,14 @@ def _propose(args: ProposeArgs, run: CheckRunner) -> Decided:

@decisions.command(
"approve",
# A person approves; no agent tool stands in for it.
# A person approves; no agent tool stands in for it. The person types the
# decision 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,
requires_person=True,
description=(
"Approve one recorded proposal, run it, and verify it; without --yes it "
"shows the proposal and runs nothing"
"Approve one recorded proposal, run it, and verify it; a person types "
"the decision id at a terminal, and `decisions show` previews it"
),
danger_level="mutating",
exit_codes=["NOT_FOUND", "PRECONDITION", "RECORD_INVALID", "NOT_VERIFIED"],
Expand All @@ -2071,12 +2067,14 @@ def _propose(args: ProposeArgs, run: CheckRunner) -> Decided:
subprocess=Subprocess("ansible-playbook"),
required_tools={"ansible-playbook": "2.21.0"},
examples=[
("Review a proposal", "cloudfall decisions approve restart-nginx-20260101"),
("Run it", "cloudfall decisions approve restart-nginx-20260101 --yes"),
(
"Approve and run a proposal, typing its id at a terminal",
"cloudfall decisions approve restart-nginx-20260101",
),
],
)
def decisions_approve(args: ApproveDecisionArgs, ctx: Ctx) -> Decided:
"""Run what was proposed, as recorded, and record how it ended."""
"""Run what was proposed, as recorded, once a person typed its id."""
store = DecisionStore(
directory=args.root / args.decisions, catalog=SchemaCatalog(args.schemas)
)
Expand All @@ -2086,21 +2084,22 @@ def decisions_approve(args: ApproveDecisionArgs, ctx: Ctx) -> Decided:
if error.code == ERROR_DECISION_MISSING:
raise Exit.NOT_FOUND(error.detail, context={"code": error.code}) from error
raise _record_invalid(error) from error
if not args.yes:
# Without --yes the run is treaty's dry run (Flag(confirm=True)).
return Decided(
effect="would_update",
status="pending",
decision=decision.as_document(),
next=[
f"review the recorded diff at {decision.check.diff.path}",
"approve with --yes to run it",
],
)
# 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"The diff is at {decision.check.diff.path}. Type the decision id to "
f"approve {decision.decision_id.value} and run it",
expected=decision.decision_id.value,
)
approver = args.approver if args.approver is not None else ctx.env.get("USER", "")
try:
approved = approve(
ApprovalRequest(decision=decision, approver=approver, repository=args.root),
ApprovalRequest(
decision=decision,
approver=approver,
repository=args.root,
via=ApprovalChannel(attestation.channel),
),
store,
run=_check_runner(ctx, args.root),
)
Expand Down Expand Up @@ -3817,7 +3816,10 @@ def propose_operation(arguments: Mapping[str, object], ctx: Ctx) -> Decided:
if operation.risk is RiskLevel.READ:
return decided
record = cast("Mapping[str, Mapping[str, object]]", decided.decision)
approve = f"{APPROVAL_COMMAND} {record['metadata']['id']} --yes"
approve = (
"a person approves it at a terminal, typing the decision id: "
f"{APPROVAL_COMMAND} {record['metadata']['id']}"
)
return dataclasses.replace(decided, next=[approve])

return McpTool(
Expand Down
9 changes: 5 additions & 4 deletions sdk/src/cloudfall/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,11 +267,12 @@ def _plan(subject: str, *extra: str) -> OutputShape:
"decisions approve",
CommandEffect.SERVERS,
"approve one recorded proposal, run it for real, and verify it",
gate=_YES,
output=(
_shape("status", "decision", "next", when="without `--yes`"),
_shape("status", "decision", when="with `--yes`"),
gate=(
"a person types the decision id at a terminal, which no flag answers "
"(`--yes` included); off a terminal it exits 4 with `PERSON_REQUIRED`, "
"and `decisions show` previews the proposal"
),
output=(_shape("status", "decision"),),
),
CommandContract(
"cloudfall",
Expand Down
65 changes: 52 additions & 13 deletions sdk/src/cloudfall/decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
ERROR_DECISION_MISSING = "decision_missing"
_ERROR_NOT_PROPOSED = "decision_not_proposed"
_ERROR_APPROVER_UNKNOWN = "decision_approver_unknown"
_ERROR_CHANNEL_UNKNOWN = "decision_approval_channel_unknown"


class DecisionError(RuntimeError):
Expand Down Expand Up @@ -223,16 +224,43 @@ def as_dict(self) -> dict[str, object]:
}


class ApprovalChannel(StrEnum):
"""How an approval reached the record.

A person typing the decision id back at a terminal is the only channel
an approval is given through. A record written before the channel was
recorded says nothing about it, and reads as unknown.
"""

TERMINAL = "terminal"
"""A person typed the decision id at a terminal."""

UNKNOWN = "unknown"
"""The record predates the channel; it cannot say how the approval came."""

@classmethod
def from_record(cls, value: object) -> ApprovalChannel:
"""Read the channel a record names; a record that names none is unknown."""
if value is None:
return cls.UNKNOWN
return cls(str(value))


@dataclass(frozen=True, slots=True)
class Approval:
"""Who let one operation through, and when."""
"""Who let one operation through, when, and how it arrived."""

approver: str
approved_at: str
via: ApprovalChannel

def as_dict(self) -> dict[str, object]:
"""Serialize the approval for the record."""
return {"approver": self.approver, "approvedAt": self.approved_at}
return {
"approver": self.approver,
"approvedAt": self.approved_at,
"via": self.via.value,
}


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -435,6 +463,7 @@ class ApprovalRequest:
decision: Decision
approver: str
repository: Path
via: ApprovalChannel


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -469,6 +498,12 @@ def approve(
if not request.approver.strip():
message = "an approval records who gave it; pass --approver or set USER"
raise DecisionError(_ERROR_APPROVER_UNKNOWN, message)
if request.via is ApprovalChannel.UNKNOWN:
message = (
"an approval records how it arrived; a new one cannot be of an "
"unknown channel"
)
raise DecisionError(_ERROR_CHANNEL_UNKNOWN, message)
clock = now or _utc_now
stage = _Stage(request=request, store=store, runner=run or _run_check)
moment = clock()
Expand All @@ -482,7 +517,9 @@ def approve(
approved = replace(
decision,
approval=Approval(
approver=request.approver.strip(), approved_at=_timestamp(moment)
approver=request.approver.strip(),
approved_at=_timestamp(moment),
via=request.via,
),
execution=execution,
verification=verification,
Expand Down Expand Up @@ -837,14 +874,16 @@ def _decision_from_document(document: Mapping[str, object]) -> Decision:
verdict=str(spec["verdict"]) if "verdict" in spec else None,
execution=_run_from_document(spec.get("execution")),
verification=_run_from_document(spec.get("verify")),
approval=(
Approval(
approver=str(cast("Mapping[str, object]", approval)["approver"]),
approved_at=str(
cast("Mapping[str, object]", approval)["approvedAt"]
),
)
if isinstance(approval, dict)
else None
),
approval=_approval_from_document(approval),
)


def _approval_from_document(approval: object) -> Approval | None:
if not isinstance(approval, dict):
return None
record = cast("Mapping[str, object]", approval)
return Approval(
approver=str(record["approver"]),
approved_at=str(record["approvedAt"]),
via=ApprovalChannel.from_record(record.get("via")),
)
19 changes: 13 additions & 6 deletions sdk/src/cloudfall/why.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@
from html import escape
from typing import TYPE_CHECKING, cast

from cloudfall.decision import DecisionStatus, Requirement
from cloudfall.decision import ApprovalChannel, DecisionStatus, Requirement
from cloudfall.domain import Hostname, ResourceId

if TYPE_CHECKING:
from collections.abc import Iterable, Mapping, Sequence

from cloudfall.decision import Decision, DecisionStore, RunRecord
from cloudfall.decision import Approval, Decision, DecisionStore, RunRecord

_ERROR_INSTANT_INVALID = "why_instant_invalid"
_ERROR_WINDOW_INVERTED = "why_window_inverted"
Expand Down Expand Up @@ -348,10 +348,7 @@ def _story(decision: Decision) -> Iterable[str]:
yield _checked(decision)
yield _gated(decision)
if decision.approval is not None:
yield (
f"{decision.approval.approver} approved it at "
f"{decision.approval.approved_at}."
)
yield _approved(decision.approval)
if decision.execution is not None:
yield _ran("The run", decision.execution, decision.execution.log.path)
if decision.verification is not None:
Expand All @@ -361,6 +358,16 @@ def _story(decision: Decision) -> Iterable[str]:
yield _ended(decision)


def _approved(approval: Approval) -> str:
said = (
f"{approval.approver} approved it at {approval.approved_at} "
f"(via {approval.via.value})"
)
if approval.via is ApprovalChannel.UNKNOWN:
return f"{said}: the record predates noting how an approval arrived."
return f"{said}: a person typed its id at a terminal."


def _saw(decision: Decision) -> str:
observed = decision.basis.observed_at
if not observed:
Expand Down
3 changes: 2 additions & 1 deletion sdk/tests/test_agent_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ def test_an_operation_tool_records_a_proposal_and_changes_nothing(
assert decision["spec"]["status"] == "proposed"
assert decision["spec"]["inputs"] == {"version": "2.0.0"}
assert proposed["data"]["next"] == [
f"cloudfall decisions approve {decision['metadata']['id']} --yes"
"a person approves it at a terminal, typing the decision id: "
f"cloudfall decisions approve {decision['metadata']['id']}"
]
assert not (repository / "marker.txt").exists()
recorded = decisions["data"]["decisions"]
Expand Down
Loading
Loading