Skip to content

Commit e8a2195

Browse files
Merge pull request #38 from cloudfall-dev/fix/35-operator-approve-off-mcp
Take operator approve off the MCP server
2 parents ef7b4b5 + f668590 commit e8a2195

6 files changed

Lines changed: 60 additions & 64 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -241,18 +241,19 @@ Notable changes to Cloudfall. The format follows
241241
fixed for the run: they leave the tool schemas, and a call that passes
242242
one is refused, so no call reaches another fleet or engine or writes
243243
outside the project. So are the Render API URL `import_render-api`
244-
sends its key file to, the env file `secrets_render` writes, and the
245-
alert gateway `operator_approve` verifies against. Files a tool reads
246-
as input (a blueprint, a key file, an env file) stay arguments
244+
sends its key file to and the env file `secrets_render` writes. Files
245+
a tool reads as input (a blueprint, a key file, an env file) stay
246+
arguments
247247
- On a project, `deploy`, `rollback`, `restart`, `migrate` and
248248
`data_migrate` return their plan until called with `yes: true`, as on
249249
the CLI; the old `confirm=true` handshake is gone. `build_artifact` and
250250
the `converge_baseline`, `converge_services` and `converge_domains`
251251
tools stay, the converges destructive and run only with
252-
`confirm_destructive: true`. `operator_approve`, `backup_run` and
253-
`backup_verify` run in one call, as their commands do (they were
254-
confirm-gated in `cloudfall-mcp`), and `operator_watch` is gone: the
255-
watch loop runs as a service
252+
`confirm_destructive: true`. `backup_run` and `backup_verify` run in
253+
one call, as their commands do (they were confirm-gated in
254+
`cloudfall-mcp`), and `operator_watch` and `operator_approve` are gone:
255+
the watch loop runs as a service, and a person approves a proposal from
256+
a shell
256257
- On a repository, the tools are the read-only fleet commands and one
257258
`operation_*` tool per declared operation, which runs check mode,
258259
records a proposal and answers with the approval command in
@@ -456,6 +457,14 @@ Notable changes to Cloudfall. The format follows
456457
proposal: `decisions show` does. Scripted or CI approvals stop working.
457458
This is a speed bump, not a boundary: a process running as the same OS
458459
user can fake a terminal (#25)
460+
- `operator approve` is no longer an MCP tool. On a project,
461+
`cloudfall mcp serve` served it as `operator_approve`, so an agent could
462+
run and verify its own proposal with no person in the loop. A person now
463+
approves from a shell with `cloudfall operator approve`, as with
464+
`decisions approve`; `operator_list` and `operator_show` stay tools. The
465+
`--gateway-url`, `--gateway-ca`, `--gateway-cert` and `--gateway-key`
466+
flags of `cloudfall mcp serve`, which only that tool used, are removed
467+
(#35)
459468

460469
## [0.5.1] - 2026-09-22
461470

‎docs/operator-guide.md‎

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ operator do that" without anyone writing a note:
6969

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

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

173173
## Through an agent
174174

175-
`cloudfall mcp serve` exposes the record and the approval; start it with
176-
the gateway material so an alert-triggered approval can verify:
177-
178-
```sh
179-
uv run cloudfall mcp serve \
180-
--gateway-ca certs/ca.crt --gateway-cert certs/operator.crt \
181-
--gateway-key certs/operator.key
182-
```
175+
`cloudfall mcp serve` exposes the record, not the approval:
183176

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

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

192186
The agent is the brain here and the operator is the record. The agent can
193-
watch, read, relay the diagnosis and decide to approve; the tools are
194-
annotated so the client gates the mutating one with its own permission
195-
mode; the server adds no confirm step of its own. Whichever
196-
way a proposal is approved, the receipt is the same, so an agent-approved
197-
remediation is as explainable afterwards as a human-approved one.
187+
read the proposals, relay the diagnosis and recommend one; a person
188+
decides, or a declared `OperatorPolicy` does for the operations it
189+
licenses. Whichever way a proposal is approved, the receipt is the same.

‎sdk/README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,10 @@ only with `confirm_destructive: true`.
9090
The fleet-declaring tools `add_ssh-key`, `add_server-type` and `add_server`
9191
are `cloudfall add`: they write schema-validated resource files into the
9292
project and never overwrite an existing resource. `cloudfall init`,
93-
`decisions approve`, `operator run` and `dashboard serve` are never tools:
94-
the server starts inside an existing project, approving a recorded
95-
operation is a person's command, and the loops run until stopped.
93+
`decisions approve`, `operator approve`, `operator run` and
94+
`dashboard serve` are never tools: the server starts inside an existing
95+
project, approving a recorded operation or an operator proposal is a
96+
command a person runs from a shell, and the loops run until stopped.
9697
`decisions approve` asks the person to type the decision id at a terminal;
9798
off a terminal it exits 4 with `PERSON_REQUIRED`, `--yes` or not. That is a
9899
speed bump against an agent with a shell, not a boundary: a process running

‎sdk/src/cloudfall/agent_server.py‎

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -144,18 +144,6 @@ class McpServeArgs:
144144
f"Decision records in the repository (default: {DECISION_DIRECTORY})"
145145
),
146146
)
147-
gateway_url: str | None = Flag(
148-
default=None,
149-
description="Alerts endpoint (default: derived from the declared gateway)",
150-
)
151-
gateway_ca: Path | None = Flag(default=None, description="Gateway CA file")
152-
gateway_cert: Path | None = Flag(
153-
default=None, description="Client certificate for the gateway"
154-
)
155-
gateway_key: Path | None = Flag(
156-
default=None, description="Client key for the gateway", secret=False
157-
)
158-
159147
def __post_init__(self) -> None:
160148
"""Refuse two fleets: a server serves a project or a repository."""
161149
if self.project is not None and self.repository is not None:
@@ -308,10 +296,12 @@ def operation_tool_description(operation: Operation) -> str:
308296
"backup.verify",
309297
"operator.list",
310298
"operator.show",
311-
"operator.approve",
312299
}
313300
)
314-
"""The commands an agent runs on a project; ``init`` and the loops are not tools."""
301+
"""The commands an agent runs on a project.
302+
303+
``init``, the loops and ``operator approve`` are not tools: a person approves.
304+
"""
315305

316306
FLEET_COMMANDS = frozenset(
317307
{
@@ -366,7 +356,7 @@ def bound_arguments(args: McpServeArgs) -> dict[str, object]:
366356
"operations": str(args.operations),
367357
"decisions": str(args.decisions),
368358
}
369-
bound: dict[str, object] = {
359+
return {
370360
**shared,
371361
"project": str(root.directory),
372362
"inventory": None,
@@ -390,19 +380,7 @@ def bound_arguments(args: McpServeArgs) -> dict[str, object]:
390380
"api_url": RENDER_API_URL,
391381
# Decrypted values are written where the server puts them, in the project.
392382
"output_file": None,
393-
# A chosen gateway would answer the verification of its own approval.
394-
"gateway_url": args.gateway_url,
395-
}
396-
gateway = {
397-
"gateway_ca": args.gateway_ca,
398-
"gateway_cert": args.gateway_cert,
399-
"gateway_key": args.gateway_key,
400383
}
401-
# TLS material is bound only when the server was given it; otherwise an
402-
# alert-triggered approval names the flags it needs, and the gateway it
403-
# reaches is still the declared one (or the server's --gateway-url).
404-
bound.update({name: str(value) for name, value in gateway.items() if value})
405-
return bound
406384

407385

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

‎sdk/src/cloudfall/app.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2619,6 +2619,8 @@ class Approved:
26192619

26202620
@operator.command(
26212621
"approve",
2622+
# A person approves; no agent tool stands in for it.
2623+
mcp=False,
26222624
description="Execute a proposal and verify its trigger resolves",
26232625
danger_level="mutating",
26242626
exit_codes=[

‎sdk/tests/test_agent_server.py‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,11 @@ def test_a_project_serves_its_commands_with_the_project_bound(tmp_path: Path) ->
237237
("converge_baseline", {}),
238238
)
239239

240-
assert {"deploy", "audit", "operator_approve", "build_artifact"} <= set(tools)
240+
assert {"deploy", "audit", "operator_show", "build_artifact"} <= set(tools)
241241
assert not {
242242
"init",
243243
"decisions_approve",
244+
"operator_approve",
244245
"operator_run",
245246
"dashboard_serve",
246247
"cleanup",
@@ -253,17 +254,27 @@ def test_a_project_serves_its_commands_with_the_project_bound(tmp_path: Path) ->
253254
assert converge["error"]["code"] == "CONFIRMATION_REQUIRED"
254255

255256

257+
def test_a_project_call_cannot_approve_its_own_proposal(tmp_path: Path) -> None:
258+
# A person approves a proposal from a shell; an agent that could call
259+
# operator approve would run and verify the change it proposed itself.
260+
_, (approved,) = _talk(
261+
_serve("--project", str(EXAMPLES), cwd=tmp_path),
262+
("operator_approve", {"proposal": "anything"}),
263+
)
264+
265+
assert approved["error"]["code"] == "UNKNOWN_TOOL"
266+
267+
256268
def test_a_project_call_cannot_send_a_file_elsewhere_or_write_secrets_out(
257269
tmp_path: Path,
258270
) -> None:
259271
# An agent that chose the Render API URL could send any controller file
260272
# it names as the bearer key to a host of its choosing; one that chose
261-
# the env file could write decrypted values outside the project; one
262-
# that chose the gateway URL could answer its own verification.
273+
# the env file could write decrypted values outside the project.
263274
secret = tmp_path / "outside.txt"
264275
secret.write_text("not-a-render-key\n", encoding="utf-8")
265276

266-
tools, (sent, rendered, approved) = _talk(
277+
tools, (sent, rendered) = _talk(
267278
_serve("--project", str(EXAMPLES), cwd=tmp_path),
268279
(
269280
"import_render-api",
@@ -275,13 +286,11 @@ def test_a_project_call_cannot_send_a_file_elsewhere_or_write_secrets_out(
275286
},
276287
),
277288
("secrets_render", {"component": "crm", "output_file": str(secret)}),
278-
("operator_approve", {"proposal": "p", "gateway_url": "http://127.0.0.1:9"}),
279289
)
280290

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

313324

314325
def test_a_project_call_cannot_write_outside_the_project(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)