diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..c1972a3 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "manacost-devops", + "owner": { + "name": "Manacost Labs", + "url": "https://github.com/Manacost-Labs" + }, + "description": "Bounded, evidence-driven DevOps skills for agent-operated infrastructure work.", + "plugins": [ + { + "name": "devops-skill-platform", + "source": "./", + "description": "Composable DevOps skills with a fail-closed change-control contract. Command execution enforcement is opt-in: see docs/hooks-setup.md.", + "version": "0.4.0", + "author": { + "name": "Manacost Labs" + } + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..a103b2b --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "name": "devops-skill-platform", + "displayName": "DevOps Skill Platform", + "version": "0.4.0", + "description": "Composable DevOps skills with a fail-closed change-control contract: risk classification, digest-bound approvals, recovery proof, and verification evidence for hosts, containers, Kubernetes, IaC, delivery, GitHub, data, edge, and cloud work.", + "author": { + "name": "Manacost Labs", + "url": "https://github.com/Manacost-Labs" + }, + "homepage": "https://github.com/Manacost-Labs/devops-skill", + "repository": "https://github.com/Manacost-Labs/devops-skill", + "license": "Apache-2.0", + "keywords": [ + "devops", + "infrastructure", + "change-control", + "kubernetes", + "terraform", + "docker", + "cloud", + "github", + "safety" + ], + "skills": "." +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4040ae2..148ac7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable platform changes are recorded here. The project follows Semantic Ver ## Unreleased +- Added `tools/devops_plan.py`, which builds a contract-v2 operation request bound to one exact command: it computes the canonical command, policy, and target-profile digests, derives the minimum risk class the policy implies and refuses to understate it, requires acceptance criteria at R2 and above, and names every remaining human obligation. Generated requests are structurally unauthorized until a real approver fills them, so planning never grants authority. +- Packaged the repository as a Claude Code plugin and marketplace (`.claude-plugin/`), installable with `/plugin marketplace add Manacost-Labs/devops-skill`. The plugin ships skills only; the fail-closed command gate stays opt-in through `docs/hooks-setup.md`. - Added `github-operations`, a bounded GitHub control-plane executor (catalog 0.4.0, 22 skills): branch protection and rulesets, deployment environments and reviewer gates, Actions run and runner administration, releases, and token-permission scope, with a permission-model reference, verified failure modes, a change-card template, and a read-only repository-protection audit script; joined the `delivery` and `all` profiles with docs.github.com freshness validation. - Taught the PreToolUse gate to classify the `gh` CLI: view/list/checks subcommands and body-less `gh api` GET calls pass as read-only; every other `gh` invocation is denied and routed through the gated wrapper. - Added six GitHub prompt-injection scenarios (PR-comment merge pressure, log-embedded protection rollback, bypass-list requests, fake API approvals, release re-tagging, fork access to privileged runners) to the adversarial evaluation suite. diff --git a/README.md b/README.md index ef6af1e..16dadf9 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,15 @@ A modular, Codex-first platform for bounded, evidence-driven infrastructure work This project demonstrates system administration and DevOps engineering practices: decomposing operational ownership, classifying risk, planning recovery, constraining privileged changes, validating packages, and collecting verification evidence. It is not a certification, a managed service, or an autonomous administrator. +## Install as a Claude Code plugin + +``` +/plugin marketplace add Manacost-Labs/devops-skill +/plugin install devops-skill-platform@manacost-devops +``` + +This installs the skills only. Command-execution enforcement is deliberately opt-in: the fail-closed `PreToolUse` gate denies mutating shell commands session-wide, so you enable it yourself when you want that boundary — see [docs/hooks-setup.md](docs/hooks-setup.md). For a source checkout instead, see the [5-minute safe evaluation](#5-minute-safe-evaluation). + ## Start here - [5-minute safe evaluation](#5-minute-safe-evaluation) — validate the platform and preview an install without changing a host or cloud account. @@ -115,6 +124,22 @@ A successful validation reports `22/22 compatible installed skills`. The install `tools/install.py` is dry-run by default. `--apply` writes to the selected skills directory, and `--apply --force` can replace existing skills; neither option is part of this safe evaluation. +## Running one real change + +The contract binds an approval to one exact command. `tools/devops_plan.py` computes the bindings a human cannot compute by hand (canonical command digest, policy digest, validated target-profile digest) and derives the minimum risk class the policy implies: + +```bash +python tools/devops_plan.py --target-profile my-target.yaml --action container_rollout --risk R2 --objective "Roll out the approved release" --scope service:api --verify "health endpoint returns 2xx" --external-side-effects --output change.json -- docker compose up -d +``` + +The generated request is deliberately **not** authorized: approval slots are empty, and required change locks or recovery evidence are left blank. The builder prints exactly what a human must supply. After a real approver fills those fields, execute through the wrapper: + +```bash +python tools/devops_exec.py --operation change.json -- docker compose up -d +``` + +The wrapper re-runs the gate immediately before launch and refuses if the command no longer matches the approved digest. Planning never grants authority; only a filled, unexpired, identity-backed approval does. + ## Safe operation flow 1. Normalize the objective, target owner, environment, data class, constraints, and measurable acceptance criteria. diff --git a/tests/test_enforcement.py b/tests/test_enforcement.py index 125906d..c4ca1b8 100644 --- a/tests/test_enforcement.py +++ b/tests/test_enforcement.py @@ -99,6 +99,124 @@ def test_wrapper_fails_closed_on_malformed_request(self): self.assertNotIn("should-not-run", result.stdout) +class PlanBuilderTests(unittest.TestCase): + PROFILE = ROOT / "devops-platform-contracts/templates/target-profile.yaml" + BUILDER = ROOT / "tools" / "devops_plan.py" + + def build(self, command, directory=None, extra=()): + arguments = [ + PYTHON, str(self.BUILDER), + "--target-profile", str(self.PROFILE), + "--action", "container_rollout", + "--risk", "R2", + "--objective", "Roll out the approved immutable release", + "--scope", "service:api", + "--verify", "health endpoint returns 2xx", + "--external-side-effects", + "--at", NOW, + *extra, + ] + if directory is not None: + arguments += ["--output", str(Path(directory) / "request.json")] + arguments += ["--", *command] + return subprocess.run(arguments, capture_output=True, text=True, check=False) + + def test_builder_binds_the_exact_command_policy_and_profile(self): + command = [PYTHON, "-c", "print('planned')"] + result = self.build(command) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + request = json.loads(result.stdout) + self.assertEqual(request["change"]["plan_digest"], command_digest(command)) + self.assertEqual(request["policy"]["digest"], request["approvals"][0]["policy_digest"]) + self.assertTrue(request["target"]["profile_digest"].startswith("sha256:")) + self.assertEqual(request["execution"]["window_start"], "2026-08-17T10:15:00Z") + self.assertIn("NOT YET AUTHORIZED", result.stderr) + + def test_generated_request_is_not_authorized_until_a_human_fills_approvals(self): + command = [PYTHON, "-c", "print('must-not-run')"] + with tempfile.TemporaryDirectory() as directory: + self.assertEqual(self.build(command, directory).returncode, 0) + request_path = Path(directory) / "request.json" + gate = subprocess.run( + [PYTHON, str(ROOT / "devops-platform-contracts/scripts/operation_gate.py"), + "--request", str(request_path), "--at", NOW], + capture_output=True, text=True, check=False, + ) + self.assertEqual(gate.returncode, 1, gate.stdout) + self.assertIn("distinct valid approval(s) required", gate.stdout) + wrapper = subprocess.run( + [PYTHON, str(WRAPPER), "--operation", str(request_path), "--at", NOW, + "--ledger", str(Path(directory) / "ledger.jsonl"), "--", *command], + capture_output=True, text=True, check=False, + ) + self.assertNotEqual(wrapper.returncode, 0) + self.assertNotIn("must-not-run", wrapper.stdout) + + def test_planned_request_executes_after_approval(self): + command = [PYTHON, "-c", "print('gated-execution')"] + with tempfile.TemporaryDirectory() as directory: + self.assertEqual(self.build(command, directory).returncode, 0) + request_path = Path(directory) / "request.json" + request = json.loads(request_path.read_text(encoding="utf-8")) + for approval in request["approvals"]: + approval.update({ + "approver": "user:service-owner", + "role": "service-owner", + "evidence_ref": "ticket:CHG-9001", + "approved_at": "2026-08-17T10:15:00Z", + "expires_at": "2026-08-17T11:00:00Z", + }) + request_path.write_text(json.dumps(request), encoding="utf-8") + wrapper = subprocess.run( + [PYTHON, str(WRAPPER), "--operation", str(request_path), "--at", NOW, + "--ledger", str(Path(directory) / "ledger.jsonl"), "--", *command], + capture_output=True, text=True, check=False, + ) + self.assertEqual(wrapper.returncode, 0, wrapper.stdout + wrapper.stderr) + self.assertIn("ALLOWED:", wrapper.stdout) + self.assertIn("gated-execution", wrapper.stdout) + + def test_builder_refuses_to_understate_risk(self): + result = self.build([PYTHON, "-c", "print(1)"], extra=["--destructive"]) + self.assertEqual(result.returncode, 2) + self.assertIn("at least R4", result.stdout) + arguments = [ + PYTHON, str(self.BUILDER), "--target-profile", str(self.PROFILE), + "--action", "dns_change", "--risk", "R2", "--objective", "Change a DNS record", + "--scope", "zone:example", "--verify", "resolver returns the new value", + "--external-side-effects", "--at", NOW, "--", "echo", "x", + ] + result = subprocess.run(arguments, capture_output=True, text=True, check=False) + self.assertEqual(result.returncode, 2) + self.assertIn("at least R3", result.stdout) + + def test_builder_requires_acceptance_criteria_at_r2(self): + arguments = [ + PYTHON, str(self.BUILDER), "--target-profile", str(self.PROFILE), + "--action", "container_rollout", "--risk", "R2", "--objective", "Roll out a release", + "--scope", "service:api", "--external-side-effects", "--at", NOW, "--", "echo", "x", + ] + result = subprocess.run(arguments, capture_output=True, text=True, check=False) + self.assertEqual(result.returncode, 2) + self.assertIn("--verify is required", result.stdout) + + def test_builder_reports_recovery_and_lock_obligations(self): + arguments = [ + PYTHON, str(self.BUILDER), "--target-profile", str(self.PROFILE), + "--action", "database_migration", "--risk", "R4", "--objective", "Migrate the primary schema", + "--scope", "database:primary", "--verify", "row counts match", "--stateful", + "--at", NOW, "--", "echo", "migrate", + ] + result = subprocess.run(arguments, capture_output=True, text=True, check=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + request = json.loads(result.stdout) + self.assertTrue(request["recovery"]["required"]) + self.assertEqual(request["execution"]["change_lock_ref"], "") + self.assertIn("change lock", result.stderr) + self.assertIn("prove recovery", result.stderr) + self.assertIn("separation of duties", result.stderr) + + class HookTests(unittest.TestCase): def run_hook(self, command=None, payload=None, cwd=None): if payload is None: @@ -165,9 +283,13 @@ def test_hook_blocks_mutating_gh_commands(self): self.assert_blocked(command) def test_hook_allows_registered_platform_scripts_by_resolved_path(self): - returncode, decision, reason = self.run_hook("python devops-platform-contracts/scripts/validate_platform.py") - self.assertEqual(decision, "allow", reason) - self.assertEqual(returncode, 0) + for command in ( + "python devops-platform-contracts/scripts/validate_platform.py", + "python tools/devops_plan.py --target-profile p.yaml --action container_rollout --risk R2 -- docker compose up -d", + ): + returncode, decision, reason = self.run_hook(command) + self.assertEqual(decision, "allow", f"{command}: {reason}") + self.assertEqual(returncode, 0, command) def test_hook_blocks_lookalike_platform_script(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_platform.py b/tests/test_platform.py index 707e342..11b9c61 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -146,6 +146,28 @@ def test_compose_preflight_rejects_unsafe_workload(self): compose.write_text("services:\n api:\n image: demo:latest\n privileged: true\n environment:\n API_KEY: literal\n", encoding="utf-8") result = self.command(ROOT / "docker-operations/scripts/compose-preflight.py", compose) self.assertNotEqual(result.returncode, 0); self.assertIn("literal sensitive", result.stdout) + def test_plugin_manifests_match_the_catalog_and_keep_enforcement_opt_in(self): + catalog = json.loads((ROOT / "catalog.json").read_text(encoding="utf-8-sig")) + plugin = json.loads((ROOT / ".claude-plugin/plugin.json").read_text(encoding="utf-8-sig")) + marketplace = json.loads((ROOT / ".claude-plugin/marketplace.json").read_text(encoding="utf-8-sig")) + self.assertEqual(plugin["name"], "devops-skill-platform") + self.assertEqual(plugin["version"], catalog["version"]) + self.assertEqual(plugin["license"], "Apache-2.0") + self.assertEqual(plugin["skills"], ".") + for name in catalog["skills"]: + self.assertTrue((ROOT / name / "SKILL.md").is_file(), name) + self.assertNotIn("hooks", plugin, "installing the plugin must not silently enable the command gate") + self.assertTrue(marketplace["name"] and marketplace["owner"]["name"]) + self.assertNotIn(marketplace["name"], { + "claude-code-marketplace", "claude-code-plugins", "claude-plugins-official", + "claude-plugins-community", "claude-community", "anthropic-marketplace", + "anthropic-plugins", "agent-skills", "anthropic-agent-skills", + }) + entries = {entry["name"]: entry for entry in marketplace["plugins"]} + self.assertIn(plugin["name"], entries) + entry = entries[plugin["name"]] + self.assertEqual(entry["version"], catalog["version"]) + self.assertTrue((ROOT / entry["source"]).is_dir()) def test_repo_protection_audit_flags_weak_protection(self): snapshot = { "repository": {"full_name": "example/repo", "default_branch": "main"}, diff --git a/tools/build_public_source.py b/tools/build_public_source.py index 22c73fe..3c33de8 100644 --- a/tools/build_public_source.py +++ b/tools/build_public_source.py @@ -27,11 +27,12 @@ "catalog.json", "requirements.txt", } -PUBLIC_TREES = {".github", "docs", "evaluations", "examples", "tests"} +PUBLIC_TREES = {".claude-plugin", ".github", "docs", "evaluations", "examples", "tests"} PUBLIC_TOOLS = { "tools/build_public_source.py", "tools/build_release.py", "tools/devops_exec.py", + "tools/devops_plan.py", "tools/hooks/pretooluse_gate.py", "tools/install.py", "tools/verify_release.py", diff --git a/tools/build_release.py b/tools/build_release.py index e62ac55..f29d741 100644 --- a/tools/build_release.py +++ b/tools/build_release.py @@ -28,6 +28,7 @@ } TOOL_FILES = { "tools/devops_exec.py", + "tools/devops_plan.py", "tools/hooks/pretooluse_gate.py", "tools/install.py", "tools/verify_release.py", diff --git a/tools/devops_plan.py b/tools/devops_plan.py new file mode 100644 index 0000000..1712db3 --- /dev/null +++ b/tools/devops_plan.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Build a contract-v2 operation request bound to one exact command. + +Usage: + python tools/devops_plan.py --target-profile profile.yaml \ + --action container_rollout --risk R2 \ + --objective "Roll out the approved immutable release" \ + --scope service:api --verify "health endpoint returns 2xx" \ + -- docker compose up -d + +The builder computes the three bindings that cannot reasonably be produced by +hand: the canonical digest of the exact command argv, the canonical digest of +the selected registered policy, and the validated target-profile digest. It +also derives the minimum risk class the policy implies and refuses to emit a +request that understates it. + +It never manufactures authorization. Approvals are emitted as structurally +incomplete slots that the operation gate rejects until a real approver, role, +evidence reference, and time window replace them, and required recovery +evidence is left empty rather than invented. Passing the unedited output to +tools/devops_exec.py is expected to be BLOCKED. +""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import re +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +GATE = ROOT / "devops-platform-contracts" / "scripts" / "operation_gate.py" +PROFILE_DIGEST_TOOL = ROOT / "devops-core" / "scripts" / "profile_digest.py" +RISK_ORDER = ("R0", "R1", "R2", "R3", "R4") +RISK_RANK = {name: index for index, name in enumerate(RISK_ORDER)} +DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +UNSAFE_ID = re.compile(r"[^A-Za-z0-9._-]+") + + +def canonical_command_digest(argv: list[str]) -> str: + payload = json.dumps(list(argv), ensure_ascii=False, separators=(",", ":")).encode("utf-8") + return "sha256:" + hashlib.sha256(payload).hexdigest() + + +def load_gate_module(): + spec = importlib.util.spec_from_file_location("operation_gate_for_planning", GATE) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def target_profile_digest(path: Path) -> str: + result = subprocess.run([sys.executable, str(PROFILE_DIGEST_TOOL), str(path)], capture_output=True, text=True, check=False) + value = result.stdout.strip() + if result.returncode != 0 or not DIGEST.fullmatch(value): + raise ValueError(f"target profile is invalid or undigestible: {(result.stdout + result.stderr).strip()}") + return value + + +def minimum_risk(policy: dict[str, Any], action: str, environment: str, stateful: bool, destructive: bool, external: bool) -> tuple[str, list[str]]: + minimum = "R0" + reasons: list[str] = [] + + def raise_to(level: str, reason: str) -> None: + nonlocal minimum + if RISK_RANK[level] > RISK_RANK[minimum]: + minimum = level + reasons.append(reason) + + if external: + raise_to("R2", "external side effects require at least R2") + if environment == "production" and external: + raise_to("R3", "production mutation requires at least R3") + if action in set(policy["always_require_approval"]): + raise_to("R3", f"policy lists '{action}' as always requiring approval, which needs at least R3") + if destructive: + raise_to("R4", "destructive work must be classified R4") + if stateful and not destructive: + reasons.append("stateful work at R4 or destructive work requires proven recovery") + return minimum, reasons + + +def build_request(args: argparse.Namespace, command: list[str], gate: Any) -> tuple[dict[str, Any], list[str]]: + policy, policy_digest = gate.load_registered_policy(args.policy) + profile_path = Path(args.target_profile) + profile = yaml.safe_load(profile_path.read_text(encoding="utf-8-sig")) + if not isinstance(profile, dict): + raise ValueError("target profile must be a YAML mapping") + profile_digest = target_profile_digest(profile_path) + + name = str(profile.get("name", "")).strip() + environment = str(profile.get("environment", "")).strip() + owner = str(profile.get("owner", "")).strip() + classification = args.data_classification or str(profile.get("data_classification", "")).strip() + if not name or not environment or not owner: + raise ValueError("target profile must declare name, environment, and owner") + if classification not in set(policy["data"]["allowed_classifications"]): + raise ValueError(f"data classification '{classification}' is not allowed by {policy['policy_id']}") + + plan_digest = canonical_command_digest(command) + floor, notes = minimum_risk(policy, args.action, environment, args.stateful, args.destructive, args.external_side_effects) + if RISK_RANK[args.risk] < RISK_RANK[floor]: + raise ValueError(f"--risk {args.risk} understates this change; policy and flags require at least {floor}: " + "; ".join(notes)) + if args.destructive and args.risk != "R4": + raise ValueError("destructive work must be classified exactly R4") + if RISK_RANK[args.risk] >= RISK_RANK["R2"] and not args.verify: + raise ValueError("--verify is required at R2 and above: acceptance criteria must exist before approval is requested") + + now = gate.parse_time(args.at, "--at", []) if args.at else datetime.now(timezone.utc) + if now is None: + raise ValueError("--at must be an RFC3339 timestamp with timezone") + window_end = now + timedelta(minutes=args.window_minutes) + ttl = policy["approval_ttl_minutes"] + if args.window_minutes > ttl: + raise ValueError(f"--window-minutes {args.window_minutes} exceeds the {ttl}-minute approval TTL of {policy['policy_id']}") + + slug = UNSAFE_ID.sub("-", name).strip("-")[:60] or "target" + short = plan_digest[7:15] + required_approvals = policy["minimum_approvals"][args.risk] + if args.action in set(policy["always_require_approval"]) or args.external_side_effects: + required_approvals = max(required_approvals, 1) + needs_lock = args.risk in set(policy["require_change_lock"]) + needs_recovery = args.action in set(policy["require_recovery_evidence"]) or (args.stateful and (args.risk == "R4" or args.destructive)) + + approval_slot = { + "approver": "", + "role": "", + "target": name, + "plan_digest": plan_digest, + "policy_digest": policy_digest, + "approved_at": None, + "expires_at": None, + "evidence_ref": "", + } + request = { + "schema_version": "2.0", + "operation_id": args.operation_id or f"ops-{slug}-{short}", + "objective": args.objective, + "data_classification": classification, + "policy": {"id": policy["policy_id"], "version": policy["version"], "digest": policy_digest}, + "target": {"name": name, "environment": environment, "owner": owner, "profile_digest": profile_digest}, + "change": { + "action": args.action, + "risk": args.risk, + "scope": list(args.scope), + "plan_digest": plan_digest, + "stateful": args.stateful, + "destructive": args.destructive, + "external_side_effects": args.external_side_effects, + }, + "execution": { + "executor": args.executor, + "requested_at": now.isoformat().replace("+00:00", "Z"), + "window_start": now.isoformat().replace("+00:00", "Z"), + "window_end": window_end.isoformat().replace("+00:00", "Z"), + "idempotency_key": f"{slug}-{short}", + "change_lock_ref": args.change_lock if args.change_lock else ("" if needs_lock else None), + }, + "approvals": [dict(approval_slot) for _ in range(required_approvals)], + "recovery": { + "required": needs_recovery, + "method": args.recovery_method or ("" if needs_recovery else None), + "artifact_ref": args.recovery_artifact or ("" if needs_recovery else None), + "restore_tested_at": args.restore_tested_at, + "rpo_minutes": args.rpo_minutes, + "rto_minutes": args.rto_minutes, + }, + "verification": {"criteria": list(args.verify), "observation_window_minutes": args.observation_minutes}, + "exception": None, + } + + todo: list[str] = [] + if required_approvals: + distinct = " from distinct approvers with distinct roles" if required_approvals > 1 else "" + todo.append(f"fill {required_approvals} approval slot(s){distinct}: approver, role, evidence_ref, approved_at, expires_at (within {ttl} minutes)") + if policy["require_separation_of_duties"][args.risk]: + todo.append(f"separation of duties applies at {args.risk}: the executor '{args.executor}' must not appear as an approver") + if needs_lock and not args.change_lock: + todo.append(f"acquire a target-scoped change lock and set execution.change_lock_ref ({args.risk} requires it)") + if needs_recovery: + missing = [field for field, value in (("method", args.recovery_method), ("artifact_ref", args.recovery_artifact), ("restore_tested_at", args.restore_tested_at), ("rpo_minutes", args.rpo_minutes), ("rto_minutes", args.rto_minutes)) if value in (None, "")] + if missing: + todo.append("prove recovery before execution and set recovery." + ", recovery.".join(missing)) + return request, todo + + +def main() -> int: + arguments = sys.argv[1:] + if "--" not in arguments: + print("BLOCKED: no command was provided after --") + return 2 + split = arguments.index("--") + command = arguments[split + 1 :] + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--target-profile", required=True, help="Validated non-secret target profile YAML.") + parser.add_argument("--action", required=True, help="Action identifier, [a-z0-9_-]{2,80}.") + parser.add_argument("--risk", required=True, choices=list(RISK_ORDER), help="Declared risk class; must not understate policy.") + parser.add_argument("--objective", required=True, help="What this operation achieves, 8-1000 characters.") + parser.add_argument("--scope", action="append", default=[], help="Affected scope entry; repeatable.") + parser.add_argument("--verify", action="append", default=[], help="Acceptance criterion; repeatable, required at R2+.") + parser.add_argument("--policy", default="default-policy.json", help="Registered policy basename.") + parser.add_argument("--executor", default="service:codex", help="Execution identity.") + parser.add_argument("--operation-id", help="Override the generated operation ID.") + parser.add_argument("--data-classification", help="Override the profile data classification.") + parser.add_argument("--window-minutes", type=int, default=60, help="Execution window length from now.") + parser.add_argument("--observation-minutes", type=int, default=10, help="Post-change observation window.") + parser.add_argument("--stateful", action="store_true", help="The change alters persistent state.") + parser.add_argument("--destructive", action="store_true", help="The change destroys data or resources (forces R4).") + parser.add_argument("--external-side-effects", action="store_true", help="The change is observable outside the target.") + parser.add_argument("--change-lock", help="Target-scoped change lock reference.") + parser.add_argument("--recovery-method", help="How the change is reversed.") + parser.add_argument("--recovery-artifact", help="Reference to the recovery artifact.") + parser.add_argument("--restore-tested-at", help="RFC3339 time an isolated restore was proven.") + parser.add_argument("--rpo-minutes", type=int, help="Measured recovery point objective.") + parser.add_argument("--rto-minutes", type=int, help="Measured recovery time objective.") + parser.add_argument("--at", help="RFC3339 build time for deterministic output; defaults to now.") + parser.add_argument("--output", type=Path, help="Write the request here instead of stdout.") + args = parser.parse_args(arguments[:split]) + + try: + if not command: + raise ValueError("no command was provided after --") + if not args.scope: + raise ValueError("--scope is required at least once") + gate = load_gate_module() + request, todo = build_request(args, command, gate) + except (OSError, ValueError, TypeError, yaml.YAMLError, json.JSONDecodeError) as error: + print(f"BLOCKED: {error}") + return 2 + + payload = json.dumps(request, indent=2, ensure_ascii=False) + "\n" + if args.output is not None: + if args.output.exists(): + print(f"BLOCKED: refusing to overwrite an existing request: {args.output}") + return 2 + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(payload, encoding="utf-8", newline="\n") + print(f"WROTE: {args.output}") + else: + print(payload, end="") + + summary = [ + "", + f"PLAN DIGEST: {request['change']['plan_digest']} bound to: {subprocess.list2cmdline(command)}", + f"POLICY: {request['policy']['id']} {request['policy']['digest']}", + f"TARGET: {request['target']['name']} ({request['target']['environment']}) {request['target']['profile_digest']}", + f"RISK: {request['change']['risk']}", + "", + "NOT YET AUTHORIZED. This request is deliberately incomplete:", + ] + summary += [f" - {item}" for item in todo] or [" - nothing further is required by policy for this risk class"] + summary += [ + "", + "Then execute exactly this command through the gate:", + f" python tools/devops_exec.py --operation {args.output or ''} --policy {args.policy} -- {subprocess.list2cmdline(command)}", + ] + print("\n".join(summary), file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hooks/pretooluse_gate.py b/tools/hooks/pretooluse_gate.py index 24320d2..9e3f221 100644 --- a/tools/hooks/pretooluse_gate.py +++ b/tools/hooks/pretooluse_gate.py @@ -41,6 +41,7 @@ "network-edge-operations/scripts/http-path-check.py", "reliability-operations/scripts/deploy-verify.py", "github-operations/scripts/repo-protection-audit.py", + "tools/devops_plan.py", "examples/portfolio-demo/run_demo.py", ) } diff --git a/tools/verify_release.py b/tools/verify_release.py index 8344988..b7ffd59 100644 --- a/tools/verify_release.py +++ b/tools/verify_release.py @@ -14,7 +14,7 @@ MANIFEST_KEYS = {"schema_version", "name", "version", "contract_version", "license", "files", "excluded_source_classes"} FILE_KEYS = {"path", "sha256", "size"} REQUIRED_ROOT_FILES = {"catalog.json", "requirements.txt", "README.md", "SECURITY.md", "LICENSE", "CONTRIBUTING.md", "GOVERNANCE.md", "SUPPORT.md", "CHANGELOG.md"} -REQUIRED_TOOL_FILES = {"tools/devops_exec.py", "tools/hooks/pretooluse_gate.py", "tools/install.py", "tools/verify_release.py"} +REQUIRED_TOOL_FILES = {"tools/devops_exec.py", "tools/devops_plan.py", "tools/hooks/pretooluse_gate.py", "tools/install.py", "tools/verify_release.py"} ALLOWED_SKILL_SUFFIXES = {".md", ".yaml", ".yml", ".json", ".py", ".ps1", ".txt"} SPECIAL_SKILL_FILES = {"host-audit"} CATALOG_KEYS = {"name", "version", "contract_version", "skills", "profiles"}