diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c9874c..58aae6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/docs/operator-guide.md b/docs/operator-guide.md index fb8b0cc..7bc0cde 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -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 @@ -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. diff --git a/sdk/README.md b/sdk/README.md index 644c573..dedbbe7 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -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 diff --git a/sdk/src/cloudfall/agent_server.py b/sdk/src/cloudfall/agent_server.py index 7b69e8f..70d3abf 100644 --- a/sdk/src/cloudfall/agent_server.py +++ b/sdk/src/cloudfall/agent_server.py @@ -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: @@ -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( { @@ -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, @@ -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"""\ @@ -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. """ diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index 70d2b9b..11abd98 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -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=[ diff --git a/sdk/tests/test_agent_server.py b/sdk/tests/test_agent_server.py index 4a76c9d..068a856 100644 --- a/sdk/tests/test_agent_server.py +++ b/sdk/tests/test_agent_server.py @@ -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", @@ -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", @@ -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" @@ -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: