Skip to content

Commit de3e544

Browse files
committed
Add approval gates
1 parent eadd564 commit de3e544

10 files changed

Lines changed: 552 additions & 15 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,15 @@ fix the host manually, then restart from the next checkpoint with `automax
232232
resume --from` or `automax run --from`. See
233233
`docs/guides/job-inspection-and-recovery.md` for the full operator workflow.
234234

235+
Approval gates let operators bind a reviewed, secret-free plan to a JSON approval file before execution:
236+
237+
```bash
238+
automax approval create --job job.yaml --inventory inventory.yaml --approved-by change-1234 --output approval.json
239+
automax approval verify --job job.yaml --inventory inventory.yaml --approval approval.json
240+
automax run --job job.yaml --inventory inventory.yaml --secrets secrets.yaml --approval approval.json
241+
```
242+
243+
235244
## External files
236245

237246
Job, inventory, variable and secret files can live anywhere. They do not need to

docs/guides/approval-gates.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<!--
2+
Copyright (C) 2026 Marco Fortina
3+
SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
# Approval gates
7+
8+
Approval gates bind a reviewed Automax plan to a JSON approval file. Operators can create an approval after reviewing the same job, inventory, vars and selector inputs that will later be executed.
9+
10+
Approval files are intentionally secret-free. Create them without `--secrets`; runs can still pass `--secrets` later because approval verification compares the secret-free review surface.
11+
12+
## Create an approval
13+
14+
```bash
15+
automax approval create \
16+
--job job.yaml \
17+
--inventory inventory.yaml \
18+
--vars vars.yaml \
19+
--approved-by change-1234 \
20+
--reason "Approved maintenance window" \
21+
--expires-at 2026-06-30T23:00:00Z \
22+
--output approvals/change-1234.json
23+
```
24+
25+
The approval file records:
26+
27+
- approver identity;
28+
- approval timestamp;
29+
- optional expiry timestamp;
30+
- optional reason or ticket reference;
31+
- SHA-256 digest of the secret-free operator review;
32+
- short review summary.
33+
34+
## Verify an approval
35+
36+
```bash
37+
automax approval verify \
38+
--job job.yaml \
39+
--inventory inventory.yaml \
40+
--vars vars.yaml \
41+
--approval approvals/change-1234.json
42+
```
43+
44+
Verification fails when the selected plan, inventory, vars, filters or tags no longer match the reviewed content. It also fails after `expires_at`.
45+
46+
## Run with an approval gate
47+
48+
```bash
49+
automax run \
50+
--job job.yaml \
51+
--inventory inventory.yaml \
52+
--vars vars.yaml \
53+
--secrets secrets.yaml \
54+
--approval approvals/change-1234.json
55+
```
56+
57+
The run verifies the approval file before execution. Dry runs do not require approval verification, so operators can still preview changes before asking for sign-off.
58+
59+
## Operator workflow
60+
61+
A typical change workflow is:
62+
63+
```bash
64+
automax review --job job.yaml --inventory inventory.yaml --vars vars.yaml --output review.md
65+
automax approval create --job job.yaml --inventory inventory.yaml --vars vars.yaml --approved-by change-1234 --output approval.json
66+
automax approval verify --job job.yaml --inventory inventory.yaml --vars vars.yaml --approval approval.json
67+
automax run --job job.yaml --inventory inventory.yaml --vars vars.yaml --secrets secrets.yaml --approval approval.json
68+
```
69+
70+
Keep approval files with the release or change record when the environment requires auditable operator sign-off.

docs/reference/cli.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ coverage, resume points and ready-to-copy operator commands. Review reports
3535
that use `--secrets` are printed to stdout only; `--output` is accepted only
3636
when the review does not load a secrets file.
3737

38+
## Approval gates
39+
40+
```bash
41+
automax approval create --job job.yaml --inventory inventory.yaml --approved-by change-1234 --output approval.json
42+
automax approval verify --job job.yaml --inventory inventory.yaml --approval approval.json
43+
automax run --job job.yaml --inventory inventory.yaml --approval approval.json
44+
```
45+
46+
Approval files are secret-free JSON gates for reviewed plans. Create and verify
47+
them without `--secrets`; execution may still pass `--secrets` because the gate
48+
compares the secret-free review digest.
49+
3850
## Plan
3951

4052
```bash

docs/reference/future-work.md

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,20 +55,6 @@ high: os.package.upgrade, identity.user.remove, fs.dir.remove recursive=true
5555
This would give operators a quick review surface before running jobs on real
5656
targets.
5757

58-
## Approval gates
59-
60-
Automax may support explicit operator approval points in interactive sessions:
61-
62-
```yaml
63-
- id: approve_restart
64-
use: operator.approval
65-
with:
66-
message: Restart production web services?
67-
```
68-
69-
CI and unattended runs would need a non-interactive approval policy such as
70-
`--approve` or a signed approval file.
71-
7258
## Event stream contract
7359

7460
Run state already records events. A future contract may formalize an append-only

docs/reference/release.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ Keep the following with the release record:
7979
- `dist/runtime-evidence-index.json` generated with checksum verification;
8080
- runtime validation pack output from `scripts/runtime-validation-packs.sh`;
8181
- policy/risk preflight output from `scripts/policy-risk-preflight.sh`;
82+
- approval gate files for release operations that require sign-off;
8283
- `automax plugins coverage --strict` output for builtin and release-loaded
8384
plugins;
8485
- package smoke output for the built sdist and wheel.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ nav:
7171
- Runtime Evidence Suite: guides/runtime-evidence.md
7272
- Runtime Validation Packs: guides/runtime-validation-packs.md
7373
- Policy and Risk Preflight: guides/policy-risk-preflight.md
74+
- Approval Gates: guides/approval-gates.md
7475
- Runtime Evidence Index: guides/runtime-evidence-index.md
7576
- Publishing Documentation: guides/publishing-docs.md
7677
- Reference:

src/automax/cli/cli.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from automax.core.engine import AutomaxEngine, AutomaxError
2323
from automax.core.evidence import build_run_evidence, render_run_evidence_markdown, write_run_evidence_bundle
2424
from automax.core.review import build_operator_review, build_saved_operator_review, render_operator_review_markdown
25+
from automax.core.approvals import ApprovalError, build_approval_payload, verify_approval_file, write_approval_file
2526
from automax.core.capabilities import package_for_tool
2627
from automax.core.inventory import Inventory, InventoryError, load_inventory_document
2728
from automax.core.known_hosts import KnownHostEntry, KnownHostsError, scan_known_hosts, write_known_hosts
@@ -455,6 +456,7 @@ def _apply_common_options(function):
455456
@click.option("--lock-timeout", type=float, default=0.0, show_default=True, help="Seconds to wait for locks.")
456457
@click.option("--preflight-capabilities", is_flag=True, help="Compatibility flag; capability preflight is implicit for normal runs.")
457458
@click.option("--sudo-password-env", help="Environment variable containing the sudo password for sudo-enabled remote substeps.")
459+
@click.option("--approval", "approval_path", type=click.Path(exists=True, dir_okay=False), help="Approval gate JSON file to verify before execution.")
458460
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True, help="Output format for the final run summary.")
459461
def run(
460462
job_path: str,
@@ -477,6 +479,7 @@ def run(
477479
lock_timeout: float,
478480
preflight_capabilities: bool,
479481
sudo_password_env: str | None,
482+
approval_path: str | None,
480483
output_format: str,
481484
) -> None:
482485
"""Run a job from external YAML definitions."""
@@ -498,6 +501,19 @@ def run(
498501
if not payload["ok"]:
499502
raise click.ClickException("check-mode preview found errors")
500503
return
504+
if approval_path and not dry_run:
505+
verify_approval_file(
506+
approval_path,
507+
engine,
508+
job_path=job_path,
509+
inventory_path=inventory_path,
510+
vars_path=vars_path,
511+
limit=_split_selectors(limit),
512+
exclude=_split_selectors(exclude),
513+
tags=_split_selectors(tags),
514+
skip_tags=_split_selectors(skip_tags),
515+
cli_vars=_parse_vars(cli_vars),
516+
)
501517
rc = engine.run(
502518
job_path=job_path,
503519
inventory_path=inventory_path,
@@ -518,7 +534,7 @@ def run(
518534
preflight_capabilities=preflight_capabilities,
519535
sudo_password_env=sudo_password_env,
520536
)
521-
except (AutomaxError, ValueError, RuntimeError) as exc:
537+
except (AutomaxError, ApprovalError, ValueError, RuntimeError) as exc:
522538
raise click.ClickException(str(exc)) from exc
523539
sys.exit(rc)
524540

@@ -792,6 +808,110 @@ def _render_operator_review_payload(payload: Dict[str, Any], output_format: str)
792808
return render_operator_review_markdown(payload)
793809

794810

811+
@cli.group()
812+
def approval() -> None:
813+
"""Create and verify approval gate files."""
814+
815+
816+
@approval.command("create")
817+
@_apply_common_options
818+
@click.option("--limit", multiple=True, help="Limit targets. Accepts server, group or group:name.")
819+
@click.option("--exclude", multiple=True, help="Exclude targets. Accepts server, group or group:name.")
820+
@click.option("--tags", multiple=True, help="Approve only substeps matching one of these tags.")
821+
@click.option("--skip-tags", multiple=True, help="Exclude substeps matching one of these tags.")
822+
@click.option("--plugin-path", multiple=True, help="External plugin file, directory or ZIP package.")
823+
@click.option("--approved-by", required=True, help="Operator, ticket or approval identity.")
824+
@click.option("--reason", default="", help="Short approval reason or ticket reference.")
825+
@click.option("--expires-at", help="Optional ISO datetime after which the approval is invalid.")
826+
@click.option("--output", "output_path", required=True, type=click.Path(dir_okay=False), help="Approval JSON file to write.")
827+
def approval_create(
828+
job_path: str,
829+
inventory_path: str,
830+
vars_path: str | None,
831+
secrets_path: str | None,
832+
cli_vars: tuple[str, ...],
833+
limit: tuple[str, ...],
834+
exclude: tuple[str, ...],
835+
tags: tuple[str, ...],
836+
skip_tags: tuple[str, ...],
837+
plugin_path: tuple[str, ...],
838+
approved_by: str,
839+
reason: str,
840+
expires_at: str | None,
841+
output_path: str,
842+
) -> None:
843+
"""Create an approval gate file from the current secret-free review."""
844+
if secrets_path:
845+
raise click.ClickException("approval files are secret-free; omit --secrets when creating approvals")
846+
try:
847+
payload = build_approval_payload(
848+
_engine(plugin_path),
849+
approved_by=approved_by,
850+
reason=reason,
851+
expires_at=expires_at,
852+
job_path=job_path,
853+
inventory_path=inventory_path,
854+
vars_path=vars_path,
855+
limit=_split_selectors(limit),
856+
exclude=_split_selectors(exclude),
857+
tags=_split_selectors(tags),
858+
skip_tags=_split_selectors(skip_tags),
859+
cli_vars=_parse_vars(cli_vars),
860+
)
861+
output = write_approval_file(payload, output_path)
862+
except (AutomaxError, ApprovalError, ValueError, RuntimeError) as exc:
863+
raise click.ClickException(str(exc)) from exc
864+
click.echo(f"Wrote approval gate {output}")
865+
866+
867+
@approval.command("verify")
868+
@_apply_common_options
869+
@click.option("--limit", multiple=True, help="Limit targets. Accepts server, group or group:name.")
870+
@click.option("--exclude", multiple=True, help="Exclude targets. Accepts server, group or group:name.")
871+
@click.option("--tags", multiple=True, help="Verify only substeps matching one of these tags.")
872+
@click.option("--skip-tags", multiple=True, help="Exclude substeps matching one of these tags.")
873+
@click.option("--plugin-path", multiple=True, help="External plugin file, directory or ZIP package.")
874+
@click.option("--approval", "approval_path", required=True, type=click.Path(exists=True, dir_okay=False), help="Approval JSON file to verify.")
875+
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True, help="Output format.")
876+
def approval_verify(
877+
job_path: str,
878+
inventory_path: str,
879+
vars_path: str | None,
880+
secrets_path: str | None,
881+
cli_vars: tuple[str, ...],
882+
limit: tuple[str, ...],
883+
exclude: tuple[str, ...],
884+
tags: tuple[str, ...],
885+
skip_tags: tuple[str, ...],
886+
plugin_path: tuple[str, ...],
887+
approval_path: str,
888+
output_format: str,
889+
) -> None:
890+
"""Verify that an approval gate file matches the current review."""
891+
if secrets_path:
892+
raise click.ClickException("approval verification is secret-free; omit --secrets")
893+
try:
894+
result = verify_approval_file(
895+
approval_path,
896+
_engine(plugin_path),
897+
job_path=job_path,
898+
inventory_path=inventory_path,
899+
vars_path=vars_path,
900+
limit=_split_selectors(limit),
901+
exclude=_split_selectors(exclude),
902+
tags=_split_selectors(tags),
903+
skip_tags=_split_selectors(skip_tags),
904+
cli_vars=_parse_vars(cli_vars),
905+
)
906+
except (AutomaxError, ApprovalError, ValueError, RuntimeError) as exc:
907+
raise click.ClickException(str(exc)) from exc
908+
if output_format == "json":
909+
click.echo(json.dumps(result, indent=2, sort_keys=True))
910+
return
911+
click.echo(f"Approval gate OK: {result['review_sha256']}")
912+
click.echo(f"Approved by: {result['approved_by']}")
913+
914+
795915
@cli.group()
796916
def ssh() -> None:
797917
"""SSH helper commands."""

0 commit comments

Comments
 (0)