Skip to content
Merged
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
19 changes: 19 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
25 changes: 25 additions & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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": "."
}
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
128 changes: 125 additions & 3 deletions tests/test_enforcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions tests/test_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
3 changes: 2 additions & 1 deletion tools/build_public_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tools/build_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading