Skip to content
Draft
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <plan_fingerprint>` 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 —
Expand Down
8 changes: 8 additions & 0 deletions bambu_cli/cliparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions bambu_cli/contracts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions bambu_cli/job/orchestrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
74 changes: 74 additions & 0 deletions bambu_cli/job/plan.py
Original file line number Diff line number Diff line change
@@ -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()
9 changes: 9 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions docs/manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,15 @@ plate --sim status --json
plate job <url> --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 <plan_fingerprint>
```

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.
Expand Down
3 changes: 3 additions & 0 deletions docs/schemas/job_error.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@
},
"copies_ignored": {
"type": "boolean"
},
"plan_fingerprint": {
"type": "string"
}
},
"additionalProperties": true
Expand Down
4 changes: 4 additions & 0 deletions docs/schemas/job_ok.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions tests/test_job_plan.py
Original file line number Diff line number Diff line change
@@ -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())
Loading