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
23 changes: 16 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,18 +241,19 @@ Notable changes to Cloudfall. The format follows
fixed for the run: they leave the tool schemas, and a call that passes
one is refused, so no call reaches another fleet or engine or writes
outside the project. So are the Render API URL `import_render-api`
sends its key file to, the env file `secrets_render` writes, and the
alert gateway `operator_approve` verifies against. Files a tool reads
as input (a blueprint, a key file, an env file) stay arguments
sends its key file to and the env file `secrets_render` writes. Files
a tool reads as input (a blueprint, a key file, an env file) stay
arguments
- On a project, `deploy`, `rollback`, `restart`, `migrate` and
`data_migrate` return their plan until called with `yes: true`, as on
the CLI; the old `confirm=true` handshake is gone. `build_artifact` and
the `converge_baseline`, `converge_services` and `converge_domains`
tools stay, the converges destructive and run only with
`confirm_destructive: true`. `operator_approve`, `backup_run` and
`backup_verify` run in one call, as their commands do (they were
confirm-gated in `cloudfall-mcp`), and `operator_watch` is gone: the
watch loop runs as a service
`confirm_destructive: true`. `backup_run` and `backup_verify` run in
one call, as their commands do (they were confirm-gated in
`cloudfall-mcp`), and `operator_watch` and `operator_approve` are gone:
the watch loop runs as a service, and a person approves a proposal from
a shell
- On a repository, the tools are the read-only fleet commands and one
`operation_*` tool per declared operation, which runs check mode,
records a proposal and answers with the approval command in
Expand Down Expand Up @@ -456,6 +457,14 @@ Notable changes to Cloudfall. The format follows
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)
- `operator approve` is no longer an MCP tool. On a project,
`cloudfall mcp serve` served it as `operator_approve`, so an agent could
run and verify its own proposal with no person in the loop. A person now
approves from a shell with `cloudfall operator approve`, as with
`decisions approve`; `operator_list` and `operator_show` stay tools. The
`--gateway-url`, `--gateway-ca`, `--gateway-cert` and `--gateway-key`
flags of `cloudfall mcp serve`, which only that tool used, are removed
(#35)

## [0.5.1] - 2026-09-22

Expand Down
32 changes: 12 additions & 20 deletions docs/operator-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ operator do that" without anyone writing a note:

- **What it saw**: the alert labels or the drifted checks that triggered it
- **What it proposed**: the diagnosis and the exact engine operation
- **Who approved**: a human through `operator approve`, an agent through
the `operator_approve` tool, or a declared `OperatorPolicy`
(`approval.mode: autonomous` with the policy id)
- **Who approved**: a person running `operator approve` from a shell, or a
declared `OperatorPolicy` (`approval.mode: autonomous` with the policy
id)
- **What happened**: `verified` or `failed`, with timestamps, from the
verify step below, never from the playbook's exit code

Expand Down Expand Up @@ -172,26 +172,18 @@ proposals it executed and which it withheld, with reasons.

## Through an agent

`cloudfall mcp serve` exposes the record and the approval; start it with
the gateway material so an alert-triggered approval can verify:

```sh
uv run cloudfall mcp serve \
--gateway-ca certs/ca.crt --gateway-cert certs/operator.crt \
--gateway-key certs/operator.key
```
`cloudfall mcp serve` exposes the record, not the approval:

- `operator_list` and `operator_show` (read-only): the receipts with
trigger, diagnosis, and outcome
- `operator_approve`: executes one proposal and verifies its trigger
resolved, in one call, as `cloudfall operator approve` does

The watch loop itself is not a tool: it runs until stopped, as the service
above.
Approval is not a tool. A person approves a proposal by running
`cloudfall operator approve` from a shell, as in
[Reviewing and approving](#reviewing-and-approving); an agent that could
call it would run and verify the change it proposed itself. The watch
loop is not a tool either: it runs until stopped, as the service above.

The agent is the brain here and the operator is the record. The agent can
watch, read, relay the diagnosis and decide to approve; the tools are
annotated so the client gates the mutating one with its own permission
mode; the server adds no confirm step of its own. Whichever
way a proposal is approved, the receipt is the same, so an agent-approved
remediation is as explainable afterwards as a human-approved one.
read the proposals, relay the diagnosis and recommend one; a person
decides, or a declared `OperatorPolicy` does for the operations it
licenses. Whichever way a proposal is approved, the receipt is the same.
7 changes: 4 additions & 3 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,10 @@ only with `confirm_destructive: true`.
The fleet-declaring tools `add_ssh-key`, `add_server-type` and `add_server`
are `cloudfall add`: they write schema-validated resource files into the
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`, `operator approve`, `operator run` and
`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
speed bump against an agent with a shell, not a boundary: a process running
Expand Down
35 changes: 8 additions & 27 deletions sdk/src/cloudfall/agent_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,18 +144,6 @@ class McpServeArgs:
f"Decision records in the repository (default: {DECISION_DIRECTORY})"
),
)
gateway_url: str | None = Flag(
default=None,
description="Alerts endpoint (default: derived from the declared gateway)",
)
gateway_ca: Path | None = Flag(default=None, description="Gateway CA file")
gateway_cert: Path | None = Flag(
default=None, description="Client certificate for the gateway"
)
gateway_key: Path | None = Flag(
default=None, description="Client key for the gateway", secret=False
)

def __post_init__(self) -> None:
"""Refuse two fleets: a server serves a project or a repository."""
if self.project is not None and self.repository is not None:
Expand Down Expand Up @@ -308,10 +296,12 @@ def operation_tool_description(operation: Operation) -> str:
"backup.verify",
"operator.list",
"operator.show",
"operator.approve",
}
)
"""The commands an agent runs on a project; ``init`` and the loops are not tools."""
"""The commands an agent runs on a project.

``init``, the loops and ``operator approve`` are not tools: a person approves.
"""

FLEET_COMMANDS = frozenset(
{
Expand Down Expand Up @@ -366,7 +356,7 @@ def bound_arguments(args: McpServeArgs) -> dict[str, object]:
"operations": str(args.operations),
"decisions": str(args.decisions),
}
bound: dict[str, object] = {
return {
**shared,
"project": str(root.directory),
"inventory": None,
Expand All @@ -390,19 +380,7 @@ def bound_arguments(args: McpServeArgs) -> dict[str, object]:
"api_url": RENDER_API_URL,
# Decrypted values are written where the server puts them, in the project.
"output_file": None,
# A chosen gateway would answer the verification of its own approval.
"gateway_url": args.gateway_url,
}
gateway = {
"gateway_ca": args.gateway_ca,
"gateway_cert": args.gateway_cert,
"gateway_key": args.gateway_key,
}
# TLS material is bound only when the server was given it; otherwise an
# alert-triggered approval names the flags it needs, and the gateway it
# reaches is still the declared one (or the server's --gateway-url).
bound.update({name: str(value) for name, value in gateway.items() if value})
return bound


_FLEET_INSTRUCTIONS = f"""\
Expand Down Expand Up @@ -441,6 +419,9 @@ def bound_arguments(args: McpServeArgs) -> dict[str, object]:
schema-validated resource files into the project, never onto a server, and
never overwrite an existing resource. The project itself is laid out with
`cloudfall init` on the CLI, and the operator's watch loop runs there too.
operator_list and operator_show read the proposals that loop records.
Approving one is `cloudfall operator approve`, a command a person runs
from a shell; no tool here can do it, whatever reason is given for asking.
Every result is an envelope: read ok, then data, else error.
"""

Expand Down
2 changes: 2 additions & 0 deletions sdk/src/cloudfall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2619,6 +2619,8 @@ class Approved:

@operator.command(
"approve",
# A person approves; no agent tool stands in for it.
mcp=False,
description="Execute a proposal and verify its trigger resolves",
danger_level="mutating",
exit_codes=[
Expand Down
25 changes: 18 additions & 7 deletions sdk/tests/test_agent_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,11 @@ def test_a_project_serves_its_commands_with_the_project_bound(tmp_path: Path) ->
("converge_baseline", {}),
)

assert {"deploy", "audit", "operator_approve", "build_artifact"} <= set(tools)
assert {"deploy", "audit", "operator_show", "build_artifact"} <= set(tools)
assert not {
"init",
"decisions_approve",
"operator_approve",
"operator_run",
"dashboard_serve",
"cleanup",
Expand All @@ -253,17 +254,27 @@ def test_a_project_serves_its_commands_with_the_project_bound(tmp_path: Path) ->
assert converge["error"]["code"] == "CONFIRMATION_REQUIRED"


def test_a_project_call_cannot_approve_its_own_proposal(tmp_path: Path) -> None:
# A person approves a proposal from a shell; an agent that could call
# operator approve would run and verify the change it proposed itself.
_, (approved,) = _talk(
_serve("--project", str(EXAMPLES), cwd=tmp_path),
("operator_approve", {"proposal": "anything"}),
)

assert approved["error"]["code"] == "UNKNOWN_TOOL"


def test_a_project_call_cannot_send_a_file_elsewhere_or_write_secrets_out(
tmp_path: Path,
) -> None:
# An agent that chose the Render API URL could send any controller file
# it names as the bearer key to a host of its choosing; one that chose
# the env file could write decrypted values outside the project; one
# that chose the gateway URL could answer its own verification.
# the env file could write decrypted values outside the project.
secret = tmp_path / "outside.txt"
secret.write_text("not-a-render-key\n", encoding="utf-8")

tools, (sent, rendered, approved) = _talk(
tools, (sent, rendered) = _talk(
_serve("--project", str(EXAMPLES), cwd=tmp_path),
(
"import_render-api",
Expand All @@ -275,13 +286,11 @@ def test_a_project_call_cannot_send_a_file_elsewhere_or_write_secrets_out(
},
),
("secrets_render", {"component": "crm", "output_file": str(secret)}),
("operator_approve", {"proposal": "p", "gateway_url": "http://127.0.0.1:9"}),
)

assert "api_url" not in tools["import_render-api"].input_schema["properties"]
assert "output_file" not in tools["secrets_render"].input_schema["properties"]
assert "gateway_url" not in tools["operator_approve"].input_schema["properties"]
for refused in (sent, rendered, approved):
for refused in (sent, rendered):
assert refused["ok"] is False
assert refused["error"]["code"] == "ARG_ERROR"
assert secret.read_text(encoding="utf-8") == "not-a-render-key\n"
Expand Down Expand Up @@ -309,6 +318,8 @@ def test_a_project_server_takes_its_secrets_directory(tmp_path: Path) -> None:
assert done.returncode == 0, done.stderr
tools = {tool["name"] for tool in json.loads(done.stdout)["tools"]}
assert "secrets_render" in tools
assert {"operator_list", "operator_show"} <= tools
assert "operator_approve" not in tools


def test_a_project_call_cannot_write_outside_the_project(tmp_path: Path) -> None:
Expand Down
Loading