diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d7ac76..ae4d302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### Agent safety + +- **`job`/`send` now report a `plan_fingerprint` and can enforce it.** Every `job`/`send` + envelope, dry-run included, carries a SHA-256 of what the run is about to do: the + printer, the source, the content of a local source file, and every slicing and + print-start option. Run `--dry-run`, review it, then pass the fingerprint back as + `--expect-plan`; if anything changed the run exits `5` before it downloads, slices, + uploads or prints. It proves the run is the one that was reviewed, not that a person + reviewed it, and a URL source is bound to the URL, not the bytes it serves later. + ### Changed - **`plate tui` has a new look.** It has its own dark theme, with a light one diff --git a/SECURITY.md b/SECURITY.md index 5294f7b..1a29d9f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -85,6 +85,10 @@ and (b) explicit model downloads you request. Key properties: only the print step is withheld. `--confirm` is a deliberate-action gate against accidents, not an authorization boundary: the CLI cannot distinguish a human's flag from an agent's. Agents must not invent confirm flags without user approval. + `job` / `send --expect-plan ` narrows the gap: it refuses (exit `5`) + unless the run matches the `--dry-run` that was reviewed. It does not make the gate + an authorization boundary, because anything that can run `plate` can run the dry run + and read its fingerprint. - **In the interactive front-ends the gate is a dialog, not the flag.** `plate go` (wizard) and `plate tui` (full-screen UI) never require the user to type `--confirm`; the equivalent deliberate action is an explicit confirmation step — diff --git a/bambu_cli/cliparse.py b/bambu_cli/cliparse.py index 9aa163e..8e1ff5b 100644 --- a/bambu_cli/cliparse.py +++ b/bambu_cli/cliparse.py @@ -211,6 +211,14 @@ def _add_job_arguments(parser): parser.add_argument( "--dry-run", action="store_true", help="No-side-effect validation; skip download/slice/upload/print" ) + parser.add_argument( + "--expect-plan", + metavar="FINGERPRINT", + help=( + "Run only if this job's plan_fingerprint (from a reviewed --dry-run) matches; otherwise exit 5 " + "before anything is downloaded, sliced, uploaded or printed" + ), + ) parser.add_argument("--upload-only", action="store_true", help="Upload the printable but do not start the print") parser.add_argument( "--plate", diff --git a/bambu_cli/contracts/models.py b/bambu_cli/contracts/models.py index e698c6e..72d370b 100644 --- a/bambu_cli/contracts/models.py +++ b/bambu_cli/contracts/models.py @@ -511,6 +511,13 @@ class JobOk(Contract): dry_run: bool | None = None copies_ignored: bool | None = None plate: int | None = spec(default=None, description="Plate of a pre-sliced 3MF chosen for printing.") + plan_fingerprint: str | None = spec( + default=None, + description=( + "SHA-256 of the canonical plan (printer, source, local file content, slice and print options). " + "Pass it to --expect-plan on the real run to refuse if anything changed since review." + ), + ) @dataclass(frozen=True) @@ -566,6 +573,7 @@ class JobError(Contract): would_upload: bool | None = None would_print: bool | None = None copies_ignored: bool | None = None + plan_fingerprint: str | None = None @dataclass(frozen=True) diff --git a/bambu_cli/job/orchestrate.py b/bambu_cli/job/orchestrate.py index 5583a67..b05e9af 100644 --- a/bambu_cli/job/orchestrate.py +++ b/bambu_cli/job/orchestrate.py @@ -39,6 +39,7 @@ from bambu_cli.errors import BambuError from bambu_cli.gcode_limits import file_heater_problem, zip_member_heater_problem from bambu_cli.job.payload import _parse_print_options, _print_next_command +from bambu_cli.job.plan import plan_fingerprint from bambu_cli.job.predict import ( _ext_would_slice, _predicted_download_slice_extension, @@ -162,6 +163,19 @@ def _run_job(ctx, args, steps=None): "Predicted printer filename is unsafe", ) + summary["plan_fingerprint"] = plan_fingerprint(ctx, args, source) + expected_plan = _namespace_get(args, "expect_plan") + if expected_plan is not None and str(expected_plan).strip().lower() != summary["plan_fingerprint"]: + _job_fail( + args, + summary, + "validate", + EXIT_COMMAND_ERROR, + "The plan changed since it was reviewed: nothing was downloaded, sliced, uploaded or printed. " + f"Expected plan {str(expected_plan).strip().lower()}, this run is plan {summary['plan_fingerprint']}. " + "Re-run with --dry-run, review it, and pass the new plan_fingerprint to --expect-plan.", + ) + workdir = None temp_dirs = [] # every auto-created bambu-job-* dir; ALL are cleaned up in finally, no matter how workdir is reassigned diff --git a/bambu_cli/job/plan.py b/bambu_cli/job/plan.py new file mode 100644 index 0000000..9fcfa93 --- /dev/null +++ b/bambu_cli/job/plan.py @@ -0,0 +1,74 @@ +"""Plan fingerprint: a stable digest of what a ``job``/``send`` is about to do. + +A caller reviews a ``--dry-run``, reads its ``plan_fingerprint``, and passes it +back as ``--expect-plan`` on the real run. The real run recomputes the digest +from its own arguments and refuses (exit 5) before any download, slice, upload +or print if it differs. That proves the run is the one that was reviewed; it +does not prove a person reviewed it, because anything that can run ``plate`` can +also run the dry run (see SECURITY.md). + +What the digest covers: the printer's serial and model, the source, the content +of a local source file, and every option that changes slicing or the print +start. What it cannot cover: the bytes behind a URL, which are only known after +download. A URL plan is bound to the URL, not to what the URL serves later. +""" + +from __future__ import annotations + +import hashlib +import json +import os + +from bambu_cli.argutils import namespace_get as _namespace_get +from bambu_cli.job.predict import _slice_args_for_job +from bambu_cli.paths import expand_path as _expand_path + +PLAN_VERSION = "plate-plan/1" + +# Print-start and pipeline options that change what happens (flags such as +# --confirm, --dry-run, --json and the output/work directories are deliberately +# not part of the plan: they do not change what gets printed). +_PRINT_OPTIONS = ( + "use_ams", + "ams_mapping", + "timelapse", + "skip_bed_leveling", + "skip_flow_cali", + "plate", + "upload_only", + "name", +) + +# Slice-argument fields that name a place or a resource limit, not the result. +_NOT_PLAN_SLICE_FIELDS = frozenset({"file", "output", "threads"}) + + +def _local_file_sha256(path: str) -> str | None: + try: + if not os.path.isfile(path): + return None + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + except OSError: + return None + + +def plan_fingerprint(ctx, args, source: str) -> str: + """Return the hex SHA-256 of the canonical plan for this job.""" + settings = ctx.settings + slice_options = { + k: v for k, v in vars(_slice_args_for_job(None, args, None)).items() if k not in _NOT_PLAN_SLICE_FIELDS + } + plan = { + "version": PLAN_VERSION, + "printer": {"serial": settings.serial, "model": settings.printer_model}, + "source": source, + "source_sha256": _local_file_sha256(_expand_path(source)) if "://" not in source else None, + "slice": slice_options, + "print": {name: _namespace_get(args, name) for name in _PRINT_OPTIONS}, + } + canonical = json.dumps(plan, sort_keys=True, separators=(",", ":"), default=str, ensure_ascii=True) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() diff --git a/docs/api.md b/docs/api.md index 1859211..e8e5815 100644 --- a/docs/api.md +++ b/docs/api.md @@ -319,6 +319,15 @@ Validate includes `checks[]`, `ok`, `errors`, `warnings`, `exit_code`, and `stri Success / dry-run: [`job_ok.json`](schemas/job_ok.json). Failure: [`job_error.json`](schemas/job_error.json). +Every `job` / `send` envelope (dry-run, success and failure) carries `plan_fingerprint`: +a SHA-256 of the printer serial and model, the source, the content of a local source +file, and every slicing and print-start option (not `--confirm`, `--dry-run`, `--json`, +`--output` or `--threads`). Review a `--dry-run`, then pass its value to +`--expect-plan` on the real run. If the plan differs, the run exits `5` +(`failed_step: validate`) before anything is downloaded, sliced, uploaded or printed. +A URL source is bound to the URL, not to the bytes it serves later. The fingerprint +shows that a run is the one that was reviewed; it does not show that a person reviewed it. + Print start requires `--confirm`. Without it, download → slice → upload still runs and the command exits `0` with `"status": "uploaded_not_printed"`; only the print step is withheld. For a pre-sliced 3MF, `job` reads which plates carry G-code and prints plate 1, diff --git a/docs/manual.md b/docs/manual.md index 8757338..7dd3f19 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -414,6 +414,15 @@ plate --sim status --json plate job --json --confirm ``` +To make sure the run is the one you reviewed, review a dry run and pass its fingerprint back: + +```bash +plate job model.3mf --dry-run --json # note "plan_fingerprint" in the output +plate job model.3mf --json --confirm --expect-plan +``` + +If the file, the printer or any slicing or print option changed in between, the second command exits `5` before it downloads, slices, uploads or prints. A URL is bound to the URL, not to what it serves later. + ## Features in depth - **Jobs & URL support** — Use `job` when an agent or user gives either a website URL or a local file path. It handles everything in one shot. diff --git a/docs/schemas/job_error.json b/docs/schemas/job_error.json index ba74902..e17bbfc 100644 --- a/docs/schemas/job_error.json +++ b/docs/schemas/job_error.json @@ -124,6 +124,9 @@ }, "copies_ignored": { "type": "boolean" + }, + "plan_fingerprint": { + "type": "string" } }, "additionalProperties": true diff --git a/docs/schemas/job_ok.json b/docs/schemas/job_ok.json index 84602c9..ae63e17 100644 --- a/docs/schemas/job_ok.json +++ b/docs/schemas/job_ok.json @@ -44,6 +44,10 @@ "plate": { "description": "Plate of a pre-sliced 3MF chosen for printing.", "type": "integer" + }, + "plan_fingerprint": { + "description": "SHA-256 of the canonical plan (printer, source, local file content, slice and print options). Pass it to --expect-plan on the real run to refuse if anything changed since review.", + "type": "string" } }, "additionalProperties": true diff --git a/tests/test_job_plan.py b/tests/test_job_plan.py new file mode 100644 index 0000000..8ccd6d8 --- /dev/null +++ b/tests/test_job_plan.py @@ -0,0 +1,133 @@ +"""Plan fingerprint: ``job``/``send --dry-run`` reports it, ``--expect-plan`` enforces it. + +Ground rules as in tests/test_job.py: no printer, no network, injected steps. +""" + +import json + +import pytest + +from bambu_cli.cli import build_parser +from bambu_cli.constants import EXIT_COMMAND_ERROR +from bambu_cli.context import RuntimeContext +from bambu_cli.errors import BambuError +from bambu_cli.job import JobSteps, _run_job +from bambu_cli.job.plan import PLAN_VERSION, plan_fingerprint +from tests.json_contract_base import sliced_3mf_bytes + + + +def _args(argv): + return build_parser().parse_args(argv) + + +def _fingerprint(argv): + args = _args(argv) + return plan_fingerprint(RuntimeContext(), args, args.source) + + +@pytest.fixture +def ready(tmp_path): + path = tmp_path / "part.3mf" + path.write_bytes(sliced_3mf_bytes()) + return path + + +def _dry_run(capsys, argv): + _run_job(RuntimeContext(), _args(["job", *argv, "--dry-run", "--json"]), JobSteps()) + return json.loads(capsys.readouterr().out) + + +def test_dry_run_reports_a_stable_64_hex_fingerprint(ready, capsys): + first = _dry_run(capsys, [str(ready)]) + second = _dry_run(capsys, [str(ready)]) + assert len(first["plan_fingerprint"]) == 64 + int(first["plan_fingerprint"], 16) + assert first["plan_fingerprint"] == second["plan_fingerprint"] + + +def test_send_and_job_share_one_plan(ready): + assert _fingerprint(["job", str(ready)]) == _fingerprint(["send", str(ready)]) + + +@pytest.mark.parametrize( + "extra", + [ + ["--use-ams", "--ams-mapping", "1"], + ["--timelapse"], + ["--skip-bed-leveling"], + ["--skip-flow-cali"], + ["--plate", "2"], + ["--upload-only"], + ["--filament", "PETG"], + ["--nozzle-temp", "230"], + ["--bed-temp", "70"], + ["--infill", "30"], + ["--copies", "2"], + ["--quality", "high"], + ], +) +def test_every_option_that_changes_the_result_changes_the_plan(ready, extra): + assert _fingerprint(["job", str(ready)]) != _fingerprint(["job", str(ready), *extra]) + + +@pytest.mark.parametrize("extra", [["--confirm"], ["--dry-run"], ["--json"], ["--threads", "2"]]) +def test_flags_that_do_not_change_the_result_leave_the_plan_alone(ready, extra): + assert _fingerprint(["job", str(ready)]) == _fingerprint(["job", str(ready), *extra]) + + +def test_editing_the_local_file_changes_the_plan(ready): + before = _fingerprint(["job", str(ready)]) + ready.write_bytes(sliced_3mf_bytes() + b"\0") + assert _fingerprint(["job", str(ready)]) != before + + +def test_the_printer_is_part_of_the_plan(ready): + args = _args(["job", str(ready)]) + other = RuntimeContext() + other = RuntimeContext(settings=other.settings.__class__(**{**other.settings.__dict__, "serial": "OTHER"})) + assert plan_fingerprint(RuntimeContext(), args, args.source) != plan_fingerprint(other, args, args.source) + + +def test_url_plan_is_bound_to_the_url_not_the_bytes(): + url = "https://example.com/a.stl" + assert _fingerprint(["job", url]) != _fingerprint(["job", "https://example.com/b.stl"]) + + +def test_plan_version_is_named(): + assert PLAN_VERSION == "plate-plan/1" + + +def test_matching_expect_plan_runs(ready, capsys): + fp = _dry_run(capsys, [str(ready)])["plan_fingerprint"] + payload = _dry_run(capsys, [str(ready), "--expect-plan", fp.upper()]) + assert payload["status"] == "dry_run_local_skipped" + assert payload["plan_fingerprint"] == fp + + +def test_a_changed_plan_is_refused_before_any_step_runs(ready, capsys): + fp = _dry_run(capsys, [str(ready)])["plan_fingerprint"] + calls = [] + + def boom(*a, **k): + calls.append(a) + raise AssertionError("a step ran after the plan was refused") + + steps = JobSteps(download=boom, slice=boom, upload=boom, print_=boom) + args = _args(["job", str(ready), "--use-ams", "--ams-mapping", "0", "--confirm", "--json", "--expect-plan", fp]) + with pytest.raises(BambuError) as caught: + _run_job(RuntimeContext(), args, steps) + assert caught.value.exit_code == EXIT_COMMAND_ERROR + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "error" + assert payload["failed_step"] == "validate" + assert payload["exit_code"] == EXIT_COMMAND_ERROR + assert payload["plan_fingerprint"] != fp + assert "plan changed" in payload["error"] + assert calls == [] + + +def test_a_wrong_fingerprint_text_is_refused_too(ready): + args = _args(["job", str(ready), "--json", "--expect-plan", "nope"]) + with pytest.raises(BambuError): + _run_job(RuntimeContext(), args, JobSteps())