From 3bc6c3d7bf20395f184ca0bca9d06da302277a2b Mon Sep 17 00:00:00 2001 From: Romamo Date: Thu, 8 Oct 2026 18:56:28 +0300 Subject: [PATCH] Return a read operation's output from operations propose A read operation runs for real when it is proposed, but its play output lived only in the decision's .diff file, so an agent on the CLI or MCP could not read what it had just run. data.output now carries it, marked untrusted, and the read tool stops suggesting an approval. Closes #24 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01APTdZZw8Jw9PQF4TD55wMv --- CHANGELOG.md | 6 +++++ sdk/src/cloudfall/app.py | 21 +++++++++++++-- sdk/tests/test_decision.py | 52 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfb0f2e..4ea846d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ Notable changes to Cloudfall. The format follows ### Added +- `operations propose` returns a read operation's play output in + `data.output`, so an agent acts on what the operation reported without + opening the decision's `.diff` file; the record keeps the file as before. + The output is the hosts' text, so `data` carries `_trusted: false`. + A proposed change answers `output: null`, and the read operation's MCP + tool no longer suggests approving a run that needs no approval (#24) - `cloudfall why` answers "why did the agent do that" from the decision records alone, for a host (`--host`), an operation (`--operation`) or a time window (`--since`, `--until`, ISO 8601 or a bare date), as JSON or diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index 07fcb17..cc22ee9 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -105,6 +105,7 @@ DecisionStatus, DecisionStore, ProposalRequest, + Requirement, Targets, approve, propose, @@ -1947,12 +1948,17 @@ class ApproveDecisionArgs(DecisionsArgs): @dataclass(frozen=True, slots=True) class Decided: - """One decision record, and what to do next when it waits for approval.""" + """One decision record, and what to do next when it waits for approval. + + A read operation has run by the time it is recorded, so its play output + comes back with the record; it is the hosts' text, never trusted. + """ effect: str status: str decision: dict[str, object] = Out(ordered=True) next: list[str] = Out(ordered=True) + output: str | None = Out(default=None, external=True) def _decision_failed(error: DecisionError) -> Exception: @@ -2003,8 +2009,17 @@ def operations_propose(args: ProposeArgs, ctx: Ctx) -> Decided: decision = propose(request, store, run=_check_runner(ctx, args.root)) except DecisionError as error: raise _decision_failed(error) from error + output = ( + (args.root / decision.check.diff.path).read_text(encoding="utf-8") + if decision.requirement is Requirement.RUNS_FREELY + else None + ) decided = Decided( - effect="created", status="ok", decision=decision.as_document(), next=[] + effect="created", + status="ok", + decision=decision.as_document(), + next=[], + output=output, ) if decision.check.exit_code != 0: message = ( @@ -3462,6 +3477,8 @@ def propose_operation(arguments: Mapping[str, object], ctx: Ctx) -> Decided: ), ctx, ) + 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" return dataclasses.replace(decided, next=[approve]) diff --git a/sdk/tests/test_decision.py b/sdk/tests/test_decision.py index ceae79f..bc508d3 100644 --- a/sdk/tests/test_decision.py +++ b/sdk/tests/test_decision.py @@ -394,6 +394,58 @@ def test_the_cli_proposes_and_then_lists_the_record( assert payload["data"]["decisions"][0]["spec"]["operation"]["id"] == "deploy" +def test_the_cli_returns_what_a_read_operation_reported( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + (repository / "playbooks" / "facts.yml").write_text( + "---\n- hosts: localhost\n connection: local\n gather_facts: false\n" + " tasks:\n - name: Report\n ansible.builtin.debug:\n" + " msg: /var/log/shop holds 36G\n", + encoding="utf-8", + ) + + exit_code = main( + ["operations", "propose", "facts", "--repository", str(repository)] + ) + + payload = json.loads(capsys.readouterr().out) + assert exit_code == 0 + assert "/var/log/shop holds 36G" in payload["data"]["output"] + assert payload["data"]["_trusted"] is False + assert payload["data"]["next"] == [] + + +def test_the_cli_returns_no_output_for_a_proposed_change( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + _observations(repository, "web-1") + (repository / "playbooks" / "deploy.yml").write_text( + "---\n- hosts: localhost\n connection: local\n gather_facts: false\n" + " tasks:\n - name: Nothing\n ansible.builtin.debug:\n" + " msg: nothing\n", + encoding="utf-8", + ) + + main( + [ + "operations", + "propose", + "deploy", + "--repository", + str(repository), + "--target", + "localhost", + "--input", + "version=1.4.0", + ] + ) + + payload = json.loads(capsys.readouterr().out) + assert payload["data"]["output"] is None + + def test_the_cli_reports_a_gate_refusal_as_json( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: