diff --git a/.gitignore b/.gitignore index f1e45db..4f678d1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ .pytest_cache/ dist/ lab-artifacts/ +operations/ .devops-skill-backups/ # Operator-specific bootstrap helpers are intentionally local-only. Keep the diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd3acd..d572c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable platform changes are recorded here. The project follows Semantic Versioning for the platform catalog; individual modules retain their own versions. +## Unreleased + +- Required a least-privilege `allowed-tools` declaration in every module manifest and `SKILL.md` frontmatter; validation now fails on missing, malformed, or mismatched declarations. +- Added `tools/devops_exec.py`, a wrapper that executes exactly one approved command: canonical argv digest must equal the approved plan digest, the operation gate re-runs immediately before launch, and every attempt is recorded in a secret-redacted execution ledger. +- Added `tools/hooks/pretooluse_gate.py`, a fail-closed PreToolUse hook that denies mutating, obfuscated, or unclassifiable shell commands without a fresh gate PASS bound to the exact command digest, with setup documentation in `docs/hooks-setup.md`. +- Migrated the portfolio demo to gated wrapper execution, including a blocked command-drift path. +- Split README safety properties into enforced and advisory guarantees. + ## 0.3.0 - 2026-08-17 (release candidate 1) - Added the remaining roadmap modules for Cloudflare, infrastructure as code, delivery pipelines, data resilience, generic and named cloud providers, Kubernetes, enterprise networking, secrets/access, and evidence-led security compliance work. diff --git a/README.md b/README.md index 8a199f9..22d9004 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,20 @@ This project demonstrates system administration and DevOps engineering practices ## Safety properties -- Read-only discovery is the default; sensitive or cross-tenant reads are separately governed. -- Every R2-R4 mutation is bound to an operation ID, exact target/profile digest, immutable plan digest, execution identity, window, lock, approval evidence, recovery proof, and acceptance criteria. -- Repository text, tickets, logs, web pages, command output, and tool responses are untrusted data. They cannot grant authority, choose privileged credentials, or weaken policy. -- Missing modules, stale provider knowledge, ambiguous ownership, changed plans, expired approvals, unproven recovery, and incomplete verification fail closed. -- Local ledgers and release manifests are integrity evidence, not external identity, signatures, immutable storage, SLSA provenance, or certification. +Safety claims are split by how they are guaranteed. **Enforced** properties are backed by a mechanism that blocks the violating action and cannot be skipped by a cooperative-but-careless agent. **Advisory** properties are documented contracts that depend on the agent following them; they add depth but should not be counted as technical guarantees. -Production use still requires organization-owned identity, short-lived credential brokerage, protected source control and CI, signed provenance, change management, immutable audit storage, data governance, accountable owners, and independent assessment. +| Property | Type | Mechanism | +|---|---|---| +| An R2-R4 operation request without exact policy binding, target/profile digest, immutable plan digest, an open execution window, and unexpired identity-backed approvals is refused | Enforced | `operation_gate.py` fails closed on schema, digest, TTL, separation-of-duties, lock, and recovery-evidence violations | +| A wrapped command runs only if the canonical digest of its exact argv equals the approved `change.plan_digest`, re-verified by a gate re-run immediately before launch; drift exits non-zero | Enforced | `tools/devops_exec.py` digest binding plus a secret-redacted execution ledger | +| A mutating, obfuscated, or unclassifiable shell command without a fresh gate PASS bound to its digest cannot execute; `bash -c`, `eval`, `base64`, substitution, variable expansion, redirection, and unknown executables are denied | Enforced once the PreToolUse hook is installed ([docs/hooks-setup.md](docs/hooks-setup.md)) | `tools/hooks/pretooluse_gate.py` fail-closed decision before every shell command | +| Every module declares a least-privilege `allowed-tools` set, identical in `SKILL.md` frontmatter and its manifest; control-plane and provider modules receive no unrestricted shell | Enforced | `validate_platform.py` fails validation on missing, malformed, or mismatched declarations | +| Catalog compatibility, dependency-closed profiles, hash-locked dependencies, deterministic releases, and archive path safety | Enforced | platform validator, release builder, and `verify_release.py` | +| Read-only discovery is the default; sensitive or cross-tenant reads are separately governed | Advisory | documented workflow in `devops-core` | +| Repository text, tickets, logs, web pages, command output, and tool responses are untrusted data and cannot grant authority, choose privileged credentials, or weaken policy | Advisory | untrusted-content boundary references consumed by every module | +| Risk classification, minimal module routing, honest `partially_verified` reporting, and evidence redaction outside the wrapper ledger | Advisory | module contracts, templates, and evaluation scenarios | + +Local ledgers and release manifests are integrity evidence, not external identity, signatures, immutable storage, SLSA provenance, or certification. Production use still requires organization-owned identity, short-lived credential brokerage, protected source control and CI, signed provenance, change management, immutable audit storage, data governance, accountable owners, and independent assessment. ## Architecture at a glance diff --git a/cicd-operations/SKILL.md b/cicd-operations/SKILL.md index a5f5ca6..f10ff49 100644 --- a/cicd-operations/SKILL.md +++ b/cicd-operations/SKILL.md @@ -1,6 +1,7 @@ --- name: cicd-operations description: Safely audit, design, change, and verify GitHub Actions and generic delivery pipelines under the devops-core contract. Use for workflow security, OIDC and permissions, protected deployments, immutable artifacts and attestations, runner trust, releases, or rollback design. +allowed-tools: Read, Grep, Glob, Write, Edit, Bash --- # CI/CD Operations diff --git a/cicd-operations/module.yaml b/cicd-operations/module.yaml index 0f3e417..cfa449e 100644 --- a/cicd-operations/module.yaml +++ b/cicd-operations/module.yaml @@ -1,6 +1,13 @@ name: cicd-operations version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Write + - Edit + - Bash requires: - devops-platform-contracts >= 0.3.0 - devops-core >= 0.3.0 diff --git a/cloud-aws/SKILL.md b/cloud-aws/SKILL.md index c45fdb9..0306b76 100644 --- a/cloud-aws/SKILL.md +++ b/cloud-aws/SKILL.md @@ -1,6 +1,7 @@ --- name: cloud-aws description: Assess, plan, execute, and verify bounded AWS control-plane operations under contract v2. Use for AWS account and regional discovery, IAM, VPC and security controls, EC2, managed container control planes, managed database routing, cost and quota impact, rollback, and evidence-driven production change handoff. +allowed-tools: Read, Grep, Glob, Bash(aws:*) --- # AWS Cloud Operations diff --git a/cloud-aws/module.yaml b/cloud-aws/module.yaml index ca296a9..a956a68 100644 --- a/cloud-aws/module.yaml +++ b/cloud-aws/module.yaml @@ -1,6 +1,11 @@ name: cloud-aws version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Bash(aws:*) requires: - devops-platform-contracts >= 0.3.0 - devops-core >= 0.3.0 diff --git a/cloud-azure/SKILL.md b/cloud-azure/SKILL.md index dcb8462..10052de 100644 --- a/cloud-azure/SKILL.md +++ b/cloud-azure/SKILL.md @@ -1,6 +1,7 @@ --- name: cloud-azure description: Assess, plan, execute, and verify bounded Microsoft Azure control-plane operations under contract v2. Use for tenant and subscription discovery, Azure RBAC, virtual networking, virtual machines, managed container control planes, managed database routing, cost and quota impact, rollback, and evidence-driven production change handoff. +allowed-tools: Read, Grep, Glob, Bash(az:*) --- # Azure Cloud Operations diff --git a/cloud-azure/module.yaml b/cloud-azure/module.yaml index 192ce1b..3ba6765 100644 --- a/cloud-azure/module.yaml +++ b/cloud-azure/module.yaml @@ -1,6 +1,11 @@ name: cloud-azure version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Bash(az:*) requires: - devops-platform-contracts >= 0.3.0 - devops-core >= 0.3.0 diff --git a/cloud-gcp/SKILL.md b/cloud-gcp/SKILL.md index 369657b..9a4ad5a 100644 --- a/cloud-gcp/SKILL.md +++ b/cloud-gcp/SKILL.md @@ -1,6 +1,7 @@ --- name: cloud-gcp description: Assess, plan, execute, and verify bounded Google Cloud control-plane operations under contract v2. Use for organization and project discovery, IAM, VPC and firewall controls, Compute Engine, managed container control planes, managed database routing, cost and quota impact, rollback, and evidence-driven production change handoff. +allowed-tools: Read, Grep, Glob, Bash(gcloud:*), Bash(gsutil:*) --- # Google Cloud Operations diff --git a/cloud-gcp/module.yaml b/cloud-gcp/module.yaml index 3f09a70..4d60c10 100644 --- a/cloud-gcp/module.yaml +++ b/cloud-gcp/module.yaml @@ -1,6 +1,12 @@ name: cloud-gcp version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Bash(gcloud:*) + - Bash(gsutil:*) requires: - devops-platform-contracts >= 0.3.0 - devops-core >= 0.3.0 diff --git a/cloud-generic/SKILL.md b/cloud-generic/SKILL.md index 73cf72c..556f97d 100644 --- a/cloud-generic/SKILL.md +++ b/cloud-generic/SKILL.md @@ -1,6 +1,7 @@ --- name: cloud-generic description: Assess, bound, and route cloud operations without assuming a provider implementation. Use for unfamiliar or unsupported clouds, multi-cloud intake, provider identification, read-only scope inventory, contract-v2 change planning, and explicit handoff when no installed provider pack safely owns the requested mutation. +allowed-tools: Read, Grep, Glob --- # Generic Cloud Operations diff --git a/cloud-generic/module.yaml b/cloud-generic/module.yaml index 07c0fdb..2e2e894 100644 --- a/cloud-generic/module.yaml +++ b/cloud-generic/module.yaml @@ -1,6 +1,10 @@ name: cloud-generic version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob requires: - devops-platform-contracts >= 0.3.0 - devops-core >= 0.3.0 diff --git a/cloud-selectel/SKILL.md b/cloud-selectel/SKILL.md index 5e7b55c..b83931c 100644 --- a/cloud-selectel/SKILL.md +++ b/cloud-selectel/SKILL.md @@ -1,6 +1,7 @@ --- name: cloud-selectel description: Assess, plan, execute, and verify bounded Selectel cloud control-plane operations under contract v2. Use for account and project discovery, IAM, cloud-server networking, compute, Managed Kubernetes control planes, Managed Databases routing, cost and quota impact, rollback, and evidence-driven production change handoff. +allowed-tools: Read, Grep, Glob, Bash(openstack:*) --- # Selectel Cloud Operations diff --git a/cloud-selectel/module.yaml b/cloud-selectel/module.yaml index 1da51c2..a2ef5ff 100644 --- a/cloud-selectel/module.yaml +++ b/cloud-selectel/module.yaml @@ -1,6 +1,11 @@ name: cloud-selectel version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Bash(openstack:*) requires: - devops-platform-contracts >= 0.3.0 - devops-core >= 0.3.0 diff --git a/cloudflare-operations/SKILL.md b/cloudflare-operations/SKILL.md index 0857f5d..7dee753 100644 --- a/cloudflare-operations/SKILL.md +++ b/cloudflare-operations/SKILL.md @@ -1,6 +1,7 @@ --- name: cloudflare-operations description: Safely audit and operate Cloudflare DNS, cache, WAF, rate limiting, Access, Tunnel, and Workers under the devops-core contract. Use for Cloudflare incident diagnosis, configuration review, origin protection, or bounded edge changes. +allowed-tools: Read, Grep, Glob, Bash(curl:*) --- # Cloudflare Operations diff --git a/cloudflare-operations/module.yaml b/cloudflare-operations/module.yaml index 3c1f8a9..d407fed 100644 --- a/cloudflare-operations/module.yaml +++ b/cloudflare-operations/module.yaml @@ -1,6 +1,11 @@ name: cloudflare-operations version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Bash(curl:*) requires: - devops-platform-contracts >= 0.3.0 - devops-core >= 0.3.0 diff --git a/data-resilience-operations/SKILL.md b/data-resilience-operations/SKILL.md index 9c2c318..ac883ba 100644 --- a/data-resilience-operations/SKILL.md +++ b/data-resilience-operations/SKILL.md @@ -1,6 +1,7 @@ --- name: data-resilience-operations description: Safely assess, plan, and execute bounded PostgreSQL and Redis backup, restore, PITR, migration, failover, retention, and data-masking operations. Use for database recoverability, RPO/RTO validation, isolated restore testing, production data changes, and stateful recovery workflows. +allowed-tools: Read, Grep, Glob, Write, Edit, Bash --- # Data Resilience Operations diff --git a/data-resilience-operations/module.yaml b/data-resilience-operations/module.yaml index f4edb87..a841a72 100644 --- a/data-resilience-operations/module.yaml +++ b/data-resilience-operations/module.yaml @@ -1,6 +1,13 @@ name: data-resilience-operations version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Write + - Edit + - Bash requires: - devops-platform-contracts >= 0.3.0 - devops-core >= 0.3.0 diff --git a/devops-core/SKILL.md b/devops-core/SKILL.md index 3d9985d..6b84347 100644 --- a/devops-core/SKILL.md +++ b/devops-core/SKILL.md @@ -1,6 +1,7 @@ --- name: devops-core description: Coordinate safe, evidence-driven infrastructure work across modular DevOps skills. Use for any request to assess, design, deploy, change, troubleshoot, or operate servers, cloud resources, containers, networking, DNS/TLS, CI/CD, observability, backups, or production infrastructure—especially when the task needs risk classification, module routing, approvals, rollback, or verification. +allowed-tools: Read, Grep, Glob, Bash(python devops-core/scripts/profile_digest.py:*), Bash(python devops-core/scripts/validate_contracts.py:*), Bash(python devops-platform-contracts/scripts/operation_gate.py:*), Bash(python devops-platform-contracts/scripts/resolve_capabilities.py:*) --- # DevOps Core diff --git a/devops-core/module.yaml b/devops-core/module.yaml index 82c3ae7..4386d27 100644 --- a/devops-core/module.yaml +++ b/devops-core/module.yaml @@ -1,6 +1,14 @@ name: devops-core version: 0.3.0 kind: coordinator +allowed_tools: + - Read + - Grep + - Glob + - Bash(python devops-core/scripts/profile_digest.py:*) + - Bash(python devops-core/scripts/validate_contracts.py:*) + - Bash(python devops-platform-contracts/scripts/operation_gate.py:*) + - Bash(python devops-platform-contracts/scripts/resolve_capabilities.py:*) capabilities: - task-normalization - risk-classification diff --git a/devops-platform-contracts/SKILL.md b/devops-platform-contracts/SKILL.md index f21a5db..a68470d 100644 --- a/devops-platform-contracts/SKILL.md +++ b/devops-platform-contracts/SKILL.md @@ -1,6 +1,7 @@ --- name: devops-platform-contracts description: Maintain shared DevOps skill-platform policies, capability contracts, compatibility rules, schemas, and evaluation scenarios. Use when creating, validating, versioning, packaging, installing, or reviewing DevOps modules and their cross-module safety behavior. +allowed-tools: Read, Grep, Glob, Bash(python devops-platform-contracts/scripts/validate_platform.py:*), Bash(python devops-platform-contracts/scripts/operation_gate.py:*), Bash(python devops-platform-contracts/scripts/resolve_capabilities.py:*), Bash(python devops-platform-contracts/scripts/ledger_chain.py:*) --- # DevOps Platform Contracts diff --git a/devops-platform-contracts/module.yaml b/devops-platform-contracts/module.yaml index abd8818..902f152 100644 --- a/devops-platform-contracts/module.yaml +++ b/devops-platform-contracts/module.yaml @@ -1,6 +1,14 @@ name: devops-platform-contracts version: 0.3.0 kind: policy-and-validation +allowed_tools: + - Read + - Grep + - Glob + - Bash(python devops-platform-contracts/scripts/validate_platform.py:*) + - Bash(python devops-platform-contracts/scripts/operation_gate.py:*) + - Bash(python devops-platform-contracts/scripts/resolve_capabilities.py:*) + - Bash(python devops-platform-contracts/scripts/ledger_chain.py:*) capabilities: - module-registry - compatibility-validation diff --git a/devops-platform-contracts/schemas/module-manifest.schema.json b/devops-platform-contracts/schemas/module-manifest.schema.json index 0879598..e7d7739 100644 --- a/devops-platform-contracts/schemas/module-manifest.schema.json +++ b/devops-platform-contracts/schemas/module-manifest.schema.json @@ -3,13 +3,14 @@ "title": "DevOps module manifest", "type": "object", "additionalProperties": false, - "required": ["name", "version", "kind", "capabilities"], + "required": ["name", "version", "kind", "capabilities", "allowed_tools"], "properties": { "name": {"type": "string", "pattern": "^[a-z0-9-]{1,63}$"}, "version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"}, "kind": {"type": "string", "enum": ["coordinator", "executor", "policy-and-validation"]}, "requires": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[a-z0-9-]+( (>=|==) [0-9]+\\.[0-9]+\\.[0-9]+)?$"}}, "capabilities": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[a-z0-9-]+$"}}, + "allowed_tools": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Z][A-Za-z]*(\\([^(),]+\\))?$"}}, "risk_domains": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[a-z0-9-]+$"}}, "platforms": {"type": "array", "uniqueItems": true, "items": {"type": "string"}}, "provides": {"type": "array", "uniqueItems": true, "items": {"type": "string"}}, diff --git a/devops-platform-contracts/scripts/validate_platform.py b/devops-platform-contracts/scripts/validate_platform.py index 37749b2..1c5bf81 100644 --- a/devops-platform-contracts/scripts/validate_platform.py +++ b/devops-platform-contracts/scripts/validate_platform.py @@ -11,16 +11,17 @@ SOURCE_LAYOUT = (ROOT / "catalog.json").is_file() CATALOG_PATH = ROOT / "catalog.json" if SOURCE_LAYOUT else PACKAGE_ROOT / "catalog.json" SECRET_KEYS = {"token", "password", "secret", "private_key", "private-key", "api_key", "api-key"} -REQUIRED = {"name": str, "version": str, "kind": str, "capabilities": list} +REQUIRED = {"name": str, "version": str, "kind": str, "capabilities": list, "allowed_tools": list} SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") REQ = re.compile(r"^([a-z0-9-]+)(?:\s*(>=|==)\s*(\d+\.\d+\.\d+))?$") NAME = re.compile(r"^[a-z0-9-]{1,63}$") TOKEN = re.compile(r"^[a-z0-9-]+$") +TOOL = re.compile(r"^[A-Z][A-Za-z]*(\([^(),]+\))?$") POLICY_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{2,79}$") LOCKED_REQUIREMENT = re.compile(r"^[A-Za-z0-9_.-]+==[^\s]+(?:\s+--hash=sha256:[0-9a-f]{64})+$") FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n", re.S) RESOURCE_LINK = re.compile(r"`((?:references|scripts|templates)/[^`\s]+)`") -MANIFEST_KEYS = {"name", "version", "kind", "requires", "capabilities", "risk_domains", "platforms", "provides", "source_freshness"} +MANIFEST_KEYS = {"name", "version", "kind", "requires", "capabilities", "allowed_tools", "risk_domains", "platforms", "provides", "source_freshness"} CATALOG_KEYS = {"name", "version", "contract_version", "skills", "profiles"} SKILL_META_KEYS = {"version", "role"} KINDS = {"coordinator", "executor", "policy-and-validation"} @@ -83,15 +84,18 @@ def validate_freshness(name, data): if any(source not in sources for source in mapped_sources): return f"{name} capability {capability} references an undeclared official source" return None -def validate_skill(name, folder): +def validate_skill(name, folder, allowed_tools): skill_path = folder / "SKILL.md" text = skill_path.read_text(encoding="utf-8-sig") if "\r" in text: return f"{name} SKILL.md contains stray CR characters" match = FRONTMATTER.match(text) if not match: return f"{name} SKILL.md has invalid frontmatter" metadata = yaml.safe_load(match.group(1)) - if not isinstance(metadata, dict) or set(metadata) != {"name", "description"}: return f"{name} SKILL.md frontmatter must contain only name and description" + if not isinstance(metadata, dict) or set(metadata) != {"name", "description", "allowed-tools"}: return f"{name} SKILL.md frontmatter must contain only name, description, and allowed-tools" if metadata.get("name") != name or not isinstance(metadata.get("description"), str): return f"{name} SKILL.md metadata mismatch" + declared = metadata.get("allowed-tools") + if not isinstance(declared, str) or not declared.strip(): return f"{name} SKILL.md must declare a non-empty allowed-tools list" + if [item.strip() for item in declared.split(",")] != allowed_tools: return f"{name} SKILL.md allowed-tools must match the manifest allowed_tools exactly" if len(text.splitlines()) > 500: return f"{name} SKILL.md exceeds 500 lines" for relative in RESOURCE_LINK.findall(text): clean = relative.rstrip(".,;:)") @@ -182,7 +186,10 @@ def main() -> int: if data["name"] != name or not NAME.fullmatch(data["name"]) or version is None: return fail(f"invalid name/version for {name}") if data["kind"] not in KINDS or data["kind"] != meta["role"]: return fail(f"catalog/module role mismatch for {name}") if data["version"] != meta.get("version"): return fail(f"catalog/module version mismatch for {name}") - skill_error = validate_skill(name, folder) + tools = data["allowed_tools"] + if not tools or any(not isinstance(value, str) or not TOOL.fullmatch(value) for value in tools): return fail(f"{name} allowed_tools must be non-empty valid tool declarations") + if len(tools) != len(set(tools)): return fail(f"{name} allowed_tools contains duplicates") + skill_error = validate_skill(name, folder, tools) if skill_error: return fail(skill_error) freshness_error = validate_freshness(name, data) if freshness_error: return fail(freshness_error) diff --git a/docker-operations/SKILL.md b/docker-operations/SKILL.md index f61c092..8795a0c 100644 --- a/docker-operations/SKILL.md +++ b/docker-operations/SKILL.md @@ -1,6 +1,7 @@ --- name: docker-operations description: Safely audit, design, deploy, verify, and roll back Docker and Docker Compose workloads under the devops-core safety contract. Use for container images, registries, Compose files, containers, networks, volumes, image security, service rollout, or container rollback. +allowed-tools: Read, Grep, Glob, Write, Edit, Bash --- # Docker Operations diff --git a/docker-operations/module.yaml b/docker-operations/module.yaml index 9826d09..711b471 100644 --- a/docker-operations/module.yaml +++ b/docker-operations/module.yaml @@ -1,6 +1,13 @@ name: docker-operations version: 0.2.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Write + - Edit + - Bash requires: - devops-platform-contracts >= 0.2.0 - devops-core >= 0.2.0 diff --git a/docs/hooks-setup.md b/docs/hooks-setup.md new file mode 100644 index 0000000..d801c81 --- /dev/null +++ b/docs/hooks-setup.md @@ -0,0 +1,61 @@ +# Enforcement hook setup + +`tools/hooks/pretooluse_gate.py` turns the operation gate from a convention into a +mechanism. Without it, `operation_gate.py` is advisory: an agent that skips the gate +can still execute a mutating command. With the hook installed, the agent runtime +consults the hook before every shell command, and the hook denies anything that is +not provably read-only and not bound to a fresh gate PASS. + +## Install + +Add the hook to the Claude Code settings of the workspace that operates infrastructure +(`.claude/settings.json` in the checkout, or the user-level settings file): + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python tools/hooks/pretooluse_gate.py" + } + ] + } + ] + } +} +``` + +The hook reads the standard PreToolUse JSON payload on stdin and answers with a +`permissionDecision` of `allow` or `deny`. A deny also exits with status 2 so that +runtimes that ignore the JSON body still block the call. + +## Decision rules + +| Command class | Decision | +|---|---| +| Provably read-only segments (`ls`, `cat`, `grep`, `systemctl status`, `kubectl get/describe/logs`, `terraform plan/validate/show`, `docker ps/inspect/logs`, `git status/log/diff`, `aws/gcloud/az/openstack` describe/list/get/show, plain `curl` GET probes, ...) | allow | +| Registered platform scripts, verified by resolved path (validators, `operation_gate.py`, `resolve_capabilities.py`, `ledger_chain.py`, digest tools, preflight and verification scripts, the portfolio demo runner) | allow | +| `python tools/devops_exec.py --operation -- ` | allow only after the hook re-verifies that `change.plan_digest` equals the canonical digest of the exact wrapped command, the execution window is open, and `operation_gate.py` returns a fresh PASS for that request | +| Mutating verbs (`terraform apply/destroy`, `kubectl apply/delete/patch/scale`, `docker compose up/down`, `systemctl restart/stop/disable`, `rm`, `dd`, `mkfs`, package installs, firewall changes, cloud create/update/delete, ...) | deny with the exact remediation | +| Obfuscation: `bash -c`, `sh -c`, `eval`, `xargs`, `env` wrappers, `base64`, command substitution `$(...)`, backticks, variable expansion `$VAR`, multi-line commands, redirections, unknown executables | deny (fail closed) | + +The canonical command digest is `sha256` over the JSON encoding of the exact argv +list, computed identically by the hook and by `tools/devops_exec.py`. Approving a +plan therefore approves one exact command, not a family of similar commands. + +## Properties and limits + +- Fail closed: a command the hook cannot classify is treated as mutating; a hook + crash denies the call. +- The wrapper re-runs the gate itself immediately before launch, so editing the + operation file between the hook check and execution does not help an attacker. +- The hook governs shell commands. File edits and other tools are constrained by + the per-module `allowed-tools` declarations validated by + `devops-platform-contracts/scripts/validate_platform.py`. +- The hook only protects sessions where it is installed. CI and development + sessions that run the repository's own test suite typically omit it; operator + sessions that can reach real infrastructure must not. diff --git a/enterprise-networking/SKILL.md b/enterprise-networking/SKILL.md index c546179..d452780 100644 --- a/enterprise-networking/SKILL.md +++ b/enterprise-networking/SKILL.md @@ -1,6 +1,7 @@ --- name: enterprise-networking description: Safely assess, design, and change enterprise VPN, BGP, routing, segmentation, and hybrid-connectivity paths. Use for route or ACL changes, tunnel and peering operations, asymmetric-routing or MTU diagnosis, staged network cutovers, and out-of-band recovery planning. +allowed-tools: Read, Grep, Glob, Write, Edit, Bash --- # Enterprise Networking diff --git a/enterprise-networking/module.yaml b/enterprise-networking/module.yaml index e34efdb..64f0218 100644 --- a/enterprise-networking/module.yaml +++ b/enterprise-networking/module.yaml @@ -1,6 +1,13 @@ name: enterprise-networking version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Write + - Edit + - Bash requires: - devops-platform-contracts >= 0.3.0 - devops-core >= 0.3.0 diff --git a/examples/portfolio-demo/README.md b/examples/portfolio-demo/README.md index 8e4d11a..009eecd 100644 --- a/examples/portfolio-demo/README.md +++ b/examples/portfolio-demo/README.md @@ -2,7 +2,7 @@ This example demonstrates the platform workflow without contacting a server, cloud API, DNS provider, container runtime, or network endpoint: -`synthetic audit -> immutable plan digest -> exact approval gate -> local simulation -> verification -> rollback drill` +`synthetic audit -> immutable plan digest -> exact approval gate -> gated wrapper execution -> verification -> rollback drill` The approval identity and evidence reference are fixtures. They demonstrate contract binding only and cannot authorize a real operation. The R2 request models the controls required for an externally impactful rollout; the runner itself performs only local, reversible file operations in an automatically removed temporary directory. @@ -29,6 +29,7 @@ The runner refuses to overwrite an existing evidence file; choose a new path for - A target profile passes the shared secret-field contract validator. - A synthetic approval with the wrong plan digest is rejected. - An approval bound to the exact target, plan, policy, and execution window is accepted. +- The approved plan digest is the canonical digest of the exact command; `tools/devops_exec.py` re-runs the gate immediately before launch, blocks any command that drifts from that digest with a non-zero exit code, and records both outcomes in a secret-redacted execution ledger. - Execution cannot start unless the fixture declares `simulation_only: true` and the full prohibited-capability boundary. - Verification checks the desired immutable release and health state. - A deliberately injected health failure triggers a rollback drill and restores the exact pre-change state. diff --git a/examples/portfolio-demo/run_demo.py b/examples/portfolio-demo/run_demo.py index 09becd9..9320cac 100644 --- a/examples/portfolio-demo/run_demo.py +++ b/examples/portfolio-demo/run_demo.py @@ -18,6 +18,7 @@ REPO_ROOT = DEMO_DIR.parents[1] FIXTURES = DEMO_DIR / "fixtures" GATE = REPO_ROOT / "devops-platform-contracts" / "scripts" / "operation_gate.py" +WRAPPER = REPO_ROOT / "tools" / "devops_exec.py" CONTRACT_VALIDATOR = REPO_ROOT / "devops-core" / "scripts" / "validate_contracts.py" POLICY_DIGEST_RE = re.compile(r"digest=(sha256:[0-9a-f]{64})") PLAIN_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") @@ -131,6 +132,23 @@ def gate_request(request: dict[str, Any], directory: Path, name: str) -> subproc ) +def run_wrapper(request_path: Path, command: list[str], ledger_path: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, str(WRAPPER), + "--operation", str(request_path), + "--policy", "default-policy.json", + "--at", AT, + "--ledger", str(ledger_path), + "--", *command, + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + + def write_evidence(path: Path, evidence: dict[str, Any]) -> None: if path.exists(): raise RuntimeError(f"refusing to overwrite existing evidence: {path}") @@ -152,12 +170,31 @@ def run_demo(output: Path | None) -> dict[str, Any]: contract = run_checked([sys.executable, str(CONTRACT_VALIDATOR), str(profile_path)]) selected_policy_digest = policy_digest() - selected_plan_digest = canonical_digest(plan) selected_profile_digest = profile_digest(profile_path) - request = make_request(selected_plan_digest, selected_profile_digest, selected_policy_digest) with tempfile.TemporaryDirectory(prefix="devops-portfolio-demo-") as temporary: workspace = Path(temporary) + state_path = workspace / "simulated-state.json" + ledger_path = workspace / "execution-ledger.jsonl" + + before = { + "release": audit["observed_release"], + "health": audit["health"], + "target": audit["target"], + } + state_path.write_text(json.dumps(before, sort_keys=True), encoding="utf-8") + + rollout_step = ( + "import json, sys\n" + "from pathlib import Path\n" + "path = Path(sys.argv[1])\n" + "state = json.loads(path.read_text(encoding='utf-8'))\n" + "state['release'] = sys.argv[2]\n" + "path.write_text(json.dumps(state, sort_keys=True), encoding='utf-8')\n" + ) + approved_command = [sys.executable, "-c", rollout_step, str(state_path), str(plan["desired_release"])] + selected_plan_digest = canonical_digest(approved_command) + request = make_request(selected_plan_digest, selected_profile_digest, selected_policy_digest) mismatched = copy.deepcopy(request) mismatched["approvals"][0]["plan_digest"] = "sha256:" + "0" * 64 @@ -172,18 +209,18 @@ def run_demo(output: Path | None) -> dict[str, Any]: allowed = gate_request(request, workspace, "request-exact.json") if allowed.returncode != 0 or not allowed.stdout.startswith("ALLOWED:"): raise RuntimeError("gate rejected the exact synthetic approval: " + (allowed.stdout + allowed.stderr).strip()) - - before = { - "release": audit["observed_release"], - "health": audit["health"], - "target": audit["target"], - } - state_path = workspace / "simulated-state.json" - state_path.write_text(json.dumps(before, sort_keys=True), encoding="utf-8") - - after = dict(before) - after["release"] = plan["desired_release"] - state_path.write_text(json.dumps(after, sort_keys=True), encoding="utf-8") + request_path = workspace / "request-exact.json" + + tampered_command = approved_command[:-1] + ["attacker-release"] + drift = run_wrapper(request_path, tampered_command, ledger_path) + if drift.returncode == 0 or "BLOCKED:" not in drift.stdout: + raise RuntimeError("wrapper failed to block a command that drifted from the approved plan digest") + if read_json(state_path) != before: + raise RuntimeError("blocked command must not mutate the simulated state") + + executed = run_wrapper(request_path, approved_command, ledger_path) + if executed.returncode != 0: + raise RuntimeError("wrapper rejected the exactly approved command: " + (executed.stdout + executed.stderr).strip()) observed = read_json(state_path) verified = observed["release"] == plan["desired_release"] and observed["health"] == "healthy" if not verified: @@ -200,6 +237,10 @@ def run_demo(output: Path | None) -> dict[str, Any]: if not rollback_verified: raise RuntimeError("rollback drill failed to restore the pre-change state") + ledger_records = [json.loads(line) for line in ledger_path.read_text(encoding="utf-8").splitlines()] + if [record["status"] for record in ledger_records] != ["blocked_digest_mismatch", "executed"]: + raise RuntimeError("execution ledger does not record the blocked drift and the gated execution") + evidence = { "operation_id": request["operation_id"], "status": "verified", @@ -212,7 +253,15 @@ def run_demo(output: Path | None) -> dict[str, Any]: "contract_validation": contract.stdout.strip(), "approval_negative_test": blocked.stdout.strip(), "approval_exact_test": allowed.stdout.strip(), - "execution": {"simulated": True, "temporary_state_only": True}, + "wrapper_drift_test": drift.stdout.strip().splitlines()[0], + "execution": { + "simulated": True, + "temporary_state_only": True, + "wrapper": "tools/devops_exec.py", + "command_digest": selected_plan_digest, + "exit_code": executed.returncode, + "ledger_statuses": [record["status"] for record in ledger_records], + }, "verification": {"post_change": verified, "rollback_triggered": rollback_triggered, "rollback_verified": rollback_verified}, "redaction_status": "no-sensitive-input", } @@ -236,7 +285,8 @@ def main() -> int: print(f"PLAN: {evidence['plan_digest']}") print("GATE NEGATIVE: mismatched approval blocked") print("GATE EXACT: exact target, plan, policy, and time-bound approval allowed") - print("EXECUTION: simulated in a temporary local state file") + print("WRAPPER DRIFT: command that diverged from the approved digest blocked before execution") + print("EXECUTION: approved command executed through tools/devops_exec.py into a temporary local state file") print("VERIFY: desired release and synthetic health verified") print("ROLLBACK DRILL: injected failure detected and pre-change state restored") print("RESULT: verified (simulation only; no live target contacted)") diff --git a/iac-operations/SKILL.md b/iac-operations/SKILL.md index d900ebf..14ddcaf 100644 --- a/iac-operations/SKILL.md +++ b/iac-operations/SKILL.md @@ -1,6 +1,7 @@ --- name: iac-operations description: Safely audit, plan, apply, and recover infrastructure-as-code under the devops-core contract. Use for Terraform or OpenTofu plans/applies, state and backend work, drift/import review, or bounded Ansible and cloud-init changes. +allowed-tools: Read, Grep, Glob, Write, Edit, Bash --- # IaC Operations diff --git a/iac-operations/module.yaml b/iac-operations/module.yaml index 3ffc884..9179d65 100644 --- a/iac-operations/module.yaml +++ b/iac-operations/module.yaml @@ -1,6 +1,13 @@ name: iac-operations version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Write + - Edit + - Bash requires: - devops-platform-contracts >= 0.3.0 - devops-core >= 0.3.0 diff --git a/identity-directory-operations/SKILL.md b/identity-directory-operations/SKILL.md index 964ee9e..965a19b 100644 --- a/identity-directory-operations/SKILL.md +++ b/identity-directory-operations/SKILL.md @@ -1,6 +1,7 @@ --- name: identity-directory-operations description: Safely assess, plan, provision, change, and recover on-premises Active Directory Domain Services and Group Policy. Use for AD DS discovery, OUs, users, computers, groups, delegated administration, privileged-group review, GPO inventory, GPO backup, security filtering, linking, staged policy rollout, and policy rollback. Do not use for Entra, cloud IAM, local Windows accounts, literal secrets, or unbounded directory changes. +allowed-tools: Read, Grep, Glob, Write, Edit, Bash --- # Directory Identity Operations diff --git a/identity-directory-operations/module.yaml b/identity-directory-operations/module.yaml index 01f5dfb..dc932ad 100644 --- a/identity-directory-operations/module.yaml +++ b/identity-directory-operations/module.yaml @@ -1,6 +1,13 @@ name: identity-directory-operations version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Write + - Edit + - Bash requires: - devops-platform-contracts >= 0.3.0 - devops-core >= 0.3.0 diff --git a/kubernetes-operations/SKILL.md b/kubernetes-operations/SKILL.md index 123f375..fd4e601 100644 --- a/kubernetes-operations/SKILL.md +++ b/kubernetes-operations/SKILL.md @@ -1,6 +1,7 @@ --- name: kubernetes-operations description: Safely audit and change confirmed Kubernetes clusters and workloads under the devops-core contract. Use for cluster/workload inventory, manifests and server-side apply, rollout diagnosis, RBAC, Pod Security Standards, NetworkPolicy, or Kubernetes storage/control-plane coordination; never select Kubernetes without confirming it is the target platform. +allowed-tools: Read, Grep, Glob, Write, Edit, Bash --- # Kubernetes Operations diff --git a/kubernetes-operations/module.yaml b/kubernetes-operations/module.yaml index fc3e939..0c7166a 100644 --- a/kubernetes-operations/module.yaml +++ b/kubernetes-operations/module.yaml @@ -1,6 +1,13 @@ name: kubernetes-operations version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Write + - Edit + - Bash requires: - devops-platform-contracts >= 0.3.0 - devops-core >= 0.3.0 diff --git a/linux-operations/SKILL.md b/linux-operations/SKILL.md index cb30831..cf74cec 100644 --- a/linux-operations/SKILL.md +++ b/linux-operations/SKILL.md @@ -1,6 +1,7 @@ --- name: linux-operations description: Safely audit, bootstrap, operate, diagnose, and recover Debian/Ubuntu Linux servers under the devops-core safety contract. Use for Linux VPS/VM work involving SSH, users and sudo, systemd services, packages, firewall, disks, memory, processes, logs, network basics, host hardening, or controlled recovery. +allowed-tools: Read, Grep, Glob, Write, Edit, Bash --- # Linux Operations diff --git a/linux-operations/module.yaml b/linux-operations/module.yaml index abffc3c..d3a7f8f 100644 --- a/linux-operations/module.yaml +++ b/linux-operations/module.yaml @@ -1,6 +1,13 @@ name: linux-operations version: 0.2.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Write + - Edit + - Bash requires: - devops-platform-contracts >= 0.2.0 - devops-core >= 0.2.0 diff --git a/network-edge-operations/SKILL.md b/network-edge-operations/SKILL.md index f78132e..898d1ee 100644 --- a/network-edge-operations/SKILL.md +++ b/network-edge-operations/SKILL.md @@ -1,6 +1,7 @@ --- name: network-edge-operations description: Safely diagnose, design, change, verify, and roll back service paths involving DNS, TLS, HTTP(S), public ports, reverse proxies, and upstream connectivity. Use for Caddy, Nginx, Traefik, redirects, certificates, DNS resolution, 502/504 errors, or origin exposure. +allowed-tools: Read, Grep, Glob, Write, Edit, Bash --- # Network Edge Operations diff --git a/network-edge-operations/module.yaml b/network-edge-operations/module.yaml index efe3aef..5ceb733 100644 --- a/network-edge-operations/module.yaml +++ b/network-edge-operations/module.yaml @@ -1,6 +1,13 @@ name: network-edge-operations version: 0.2.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Write + - Edit + - Bash requires: - devops-platform-contracts >= 0.2.0 - devops-core >= 0.2.0 diff --git a/reliability-operations/SKILL.md b/reliability-operations/SKILL.md index 4387b4f..6499422 100644 --- a/reliability-operations/SKILL.md +++ b/reliability-operations/SKILL.md @@ -1,6 +1,7 @@ --- name: reliability-operations description: Design and verify observability, service health, SLI/SLOs, actionable alerts, incident flow, and evidence-driven release validation. Use for metrics, logs, traces, dashboards, alerting, uptime checks, error/latency analysis, incident triage, or post-deploy verification. +allowed-tools: Read, Grep, Glob, Write, Edit, Bash --- # Reliability Operations diff --git a/reliability-operations/module.yaml b/reliability-operations/module.yaml index 3967265..bc7e324 100644 --- a/reliability-operations/module.yaml +++ b/reliability-operations/module.yaml @@ -1,6 +1,13 @@ name: reliability-operations version: 0.2.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Write + - Edit + - Bash requires: - devops-platform-contracts >= 0.2.0 - devops-core >= 0.2.0 diff --git a/secrets-access-operations/SKILL.md b/secrets-access-operations/SKILL.md index 9a7a7e7..f66297c 100644 --- a/secrets-access-operations/SKILL.md +++ b/secrets-access-operations/SKILL.md @@ -1,6 +1,7 @@ --- name: secrets-access-operations description: Govern secret references, workload identities, privileged access, JIT/JEA elevation, credential rotation and revocation, and break-glass workflows. Use for production access changes, secret lifecycle operations, least-privilege design, dual-control approval, and provider-specific access handoffs. +allowed-tools: Read, Grep, Glob, Write, Edit, Bash --- # Secrets and Access Operations diff --git a/secrets-access-operations/module.yaml b/secrets-access-operations/module.yaml index e381a48..a7b6bef 100644 --- a/secrets-access-operations/module.yaml +++ b/secrets-access-operations/module.yaml @@ -1,6 +1,13 @@ name: secrets-access-operations version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Write + - Edit + - Bash requires: - devops-platform-contracts >= 0.3.0 - devops-core >= 0.3.0 diff --git a/security-compliance-operations/SKILL.md b/security-compliance-operations/SKILL.md index fae2303..bc1b114 100644 --- a/security-compliance-operations/SKILL.md +++ b/security-compliance-operations/SKILL.md @@ -1,6 +1,7 @@ --- name: security-compliance-operations description: Build evidence-led security governance for threat assessment, control mapping, vulnerability ownership, findings, and time-bounded exceptions. Use for security reviews, audit evidence plans, control effectiveness checks, remediation ownership, compensating controls, and privacy-aware reporting without claiming certification or legal compliance. +allowed-tools: Read, Grep, Glob, Write, Edit --- # Security Governance Operations diff --git a/security-compliance-operations/module.yaml b/security-compliance-operations/module.yaml index 765e6a4..93f7820 100644 --- a/security-compliance-operations/module.yaml +++ b/security-compliance-operations/module.yaml @@ -1,6 +1,12 @@ name: security-compliance-operations version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Write + - Edit requires: - devops-platform-contracts >= 0.3.0 - devops-core >= 0.3.0 diff --git a/tests/test_enforcement.py b/tests/test_enforcement.py new file mode 100644 index 0000000..50b2257 --- /dev/null +++ b/tests/test_enforcement.py @@ -0,0 +1,227 @@ +from __future__ import annotations +import hashlib, json, subprocess, sys, tempfile, unittest +from pathlib import Path +from test_platform import NOW, operation + +ROOT = Path(__file__).resolve().parents[1] +PYTHON = sys.executable +WRAPPER = ROOT / "tools" / "devops_exec.py" +HOOK = ROOT / "tools" / "hooks" / "pretooluse_gate.py" + + +def command_digest(argv): + payload = json.dumps(list(argv), ensure_ascii=False, separators=(",", ":")).encode("utf-8") + return "sha256:" + hashlib.sha256(payload).hexdigest() + + +def bound_operation(argv): + request = operation() + digest = command_digest(argv) + request["change"]["plan_digest"] = digest + for approval in request["approvals"]: + approval["plan_digest"] = digest + return request + + +class WrapperTests(unittest.TestCase): + def run_wrapper(self, request, directory, command, at=NOW): + request_path = Path(directory) / "operation.json" + request_path.write_text(json.dumps(request), encoding="utf-8") + ledger_path = Path(directory) / "ledger.jsonl" + arguments = [ + PYTHON, str(WRAPPER), + "--operation", str(request_path), + "--policy", "default-policy.json", + "--ledger", str(ledger_path), + ] + if at: + arguments += ["--at", at] + arguments += ["--", *command] + result = subprocess.run(arguments, capture_output=True, text=True, check=False) + records = [] + if ledger_path.is_file(): + records = [json.loads(line) for line in ledger_path.read_text(encoding="utf-8").splitlines()] + return result, records + + def test_wrapper_executes_command_bound_to_plan_digest(self): + command = [PYTHON, "-c", "print('wrapper-executed')"] + with tempfile.TemporaryDirectory() as directory: + result, records = self.run_wrapper(bound_operation(command), directory, command) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("ALLOWED:", result.stdout) + self.assertIn("wrapper-executed", result.stdout) + self.assertEqual([record["status"] for record in records], ["executed"]) + self.assertEqual(records[0]["exit_code"], 0) + self.assertEqual(records[0]["command_digest"], command_digest(command)) + + def test_wrapper_blocks_command_digest_drift(self): + approved = [PYTHON, "-c", "print('approved-command')"] + with tempfile.TemporaryDirectory() as directory: + marker = Path(directory) / "must-not-exist.txt" + tampered = [PYTHON, "-c", f"open(r'{marker}', 'w').close()"] + result, records = self.run_wrapper(bound_operation(approved), directory, tampered) + self.assertFalse(marker.exists()) + self.assertNotEqual(result.returncode, 0) + self.assertIn("BLOCKED:", result.stdout) + self.assertIn("does not match approved plan digest", result.stdout) + self.assertEqual([record["status"] for record in records], ["blocked_digest_mismatch"]) + + def test_wrapper_reruns_gate_and_blocks_expired_approval(self): + command = [PYTHON, "-c", "print('should-not-run')"] + request = bound_operation(command) + for approval in request["approvals"]: + approval["expires_at"] = "2026-08-17T10:05:00Z" + with tempfile.TemporaryDirectory() as directory: + result, records = self.run_wrapper(request, directory, command) + self.assertNotEqual(result.returncode, 0) + self.assertIn("BLOCKED", result.stdout) + self.assertNotIn("should-not-run", result.stdout) + self.assertEqual([record["status"] for record in records], ["blocked_gate"]) + + def test_wrapper_redacts_secrets_in_ledger_and_output(self): + command = [PYTHON, "-c", "print('password=hunter2-literal'); print('api_key: k-1234567890')"] + with tempfile.TemporaryDirectory() as directory: + result, records = self.run_wrapper(bound_operation(command), directory, command) + ledger_text = (Path(directory) / "ledger.jsonl").read_text(encoding="utf-8") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("hunter2-literal", ledger_text) + self.assertNotIn("k-1234567890", ledger_text) + self.assertIn("[REDACTED]", ledger_text) + self.assertNotIn("hunter2-literal", result.stdout) + + def test_wrapper_fails_closed_on_malformed_request(self): + command = [PYTHON, "-c", "print('should-not-run')"] + request = {"schema_version": "2.0", "change": {}} + with tempfile.TemporaryDirectory() as directory: + result, records = self.run_wrapper(request, directory, command) + self.assertNotEqual(result.returncode, 0) + self.assertIn("BLOCKED", result.stdout) + self.assertNotIn("should-not-run", result.stdout) + + +class HookTests(unittest.TestCase): + def run_hook(self, command=None, payload=None, cwd=None): + if payload is None: + payload = {"tool_name": "Bash", "tool_input": {"command": command}, "cwd": str(cwd or ROOT)} + result = subprocess.run([PYTHON, str(HOOK)], input=json.dumps(payload), capture_output=True, text=True, check=False) + body = json.loads(result.stdout)["hookSpecificOutput"] + return result.returncode, body["permissionDecision"], body["permissionDecisionReason"] + + def assert_blocked(self, command): + returncode, decision, reason = self.run_hook(command) + self.assertEqual(decision, "deny", command) + self.assertNotEqual(returncode, 0, command) + self.assertIn("BLOCKED", reason, command) + return reason + + def test_hook_allows_read_only_commands(self): + for command in ( + "ls -la", + "cat /etc/os-release", + "systemctl status nginx", + "kubectl get pods -A", + "kubectl describe deployment api", + "terraform plan -input=false", + "docker ps", + "git status", + "aws ec2 describe-instances --region eu-central-1", + "gcloud compute instances list", + "az vm show --name web-1", + "cat access.log | grep 503 | wc -l", + ): + returncode, decision, reason = self.run_hook(command) + self.assertEqual(decision, "allow", f"{command}: {reason}") + self.assertEqual(returncode, 0, 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) + + def test_hook_blocks_lookalike_platform_script(self): + with tempfile.TemporaryDirectory() as directory: + fake = Path(directory) / "validate_platform.py" + fake.write_text("print('fake')\n", encoding="utf-8") + returncode, decision, reason = self.run_hook("python validate_platform.py", cwd=directory) + self.assertEqual(decision, "deny", reason) + + def test_hook_blocks_mutation_without_gate(self): + for command in ( + "terraform apply -auto-approve", + "kubectl delete pod api-0", + "kubectl apply -f deployment.yaml", + "systemctl restart nginx", + "docker compose up -d", + "rm -rf /srv/data", + "apt-get install -y nginx", + "aws ec2 terminate-instances --instance-ids i-1", + ): + reason = self.assert_blocked(command) + self.assertIn("devops_exec.py", reason, command) + + def test_hook_blocks_obfuscated_mutation(self): + for command in ( + "bash -c 'terraform apply -auto-approve'", + "eval terraform apply", + "echo dGVycmFmb3JtIGFwcGx5 | base64 -d | sh", + "CMD='kubectl delete pod api-0'; $CMD", + "kubectl $VERB pod api-0", + "sh -c \"$(cat payload.txt)\"", + ): + self.assert_blocked(command) + + def test_hook_blocks_unknown_and_redirected_commands(self): + self.assert_blocked("frobnicate --all") + self.assert_blocked("echo data > /etc/hosts") + self.assert_blocked("cat plan.txt\nterraform apply") + + def test_hook_fails_closed_on_malformed_payload(self): + returncode, decision, reason = self.run_hook(payload={"tool_name": "Bash", "tool_input": {}}) + self.assertEqual(decision, "deny", reason) + self.assertNotEqual(returncode, 0) + + def test_hook_ignores_non_shell_tools(self): + returncode, decision, reason = self.run_hook(payload={"tool_name": "Read", "tool_input": {"file_path": "x"}}) + self.assertEqual(decision, "allow", reason) + self.assertEqual(returncode, 0) + + def wrapper_command(self, request, directory, inner): + request_path = Path(directory) / "operation.json" + request_path.write_text(json.dumps(request), encoding="utf-8") + return ( + f'python "{WRAPPER.as_posix()}" --operation "{request_path.as_posix()}" ' + f"--policy default-policy.json --at {NOW} -- " + " ".join(inner) + ) + + def test_hook_allows_wrapper_with_fresh_gate_pass(self): + inner = ["hypothetical-mutator", "--switch", "release-2"] + with tempfile.TemporaryDirectory() as directory: + command = self.wrapper_command(bound_operation(inner), directory, inner) + returncode, decision, reason = self.run_hook(command) + self.assertEqual(decision, "allow", reason) + self.assertEqual(returncode, 0) + self.assertIn("gate PASS", reason) + + def test_hook_blocks_wrapper_with_digest_mismatch(self): + approved = ["hypothetical-mutator", "--switch", "release-2"] + tampered = ["hypothetical-mutator", "--switch", "release-3"] + with tempfile.TemporaryDirectory() as directory: + command = self.wrapper_command(bound_operation(approved), directory, tampered) + returncode, decision, reason = self.run_hook(command) + self.assertEqual(decision, "deny", reason) + self.assertIn("digest", reason) + + def test_hook_blocks_wrapper_when_gate_refuses(self): + inner = ["hypothetical-mutator", "--switch", "release-2"] + request = bound_operation(inner) + for approval in request["approvals"]: + approval["expires_at"] = "2026-08-17T10:05:00Z" + with tempfile.TemporaryDirectory() as directory: + command = self.wrapper_command(request, directory, inner) + returncode, decision, reason = self.run_hook(command) + self.assertEqual(decision, "deny", reason) + self.assertIn("gate", reason) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_platform.py b/tests/test_platform.py index 3962dcb..ec66350 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -63,6 +63,34 @@ def test_catalog_is_complete_dependency_closed_and_unambiguous(self): manifest = yaml.safe_load((ROOT / name / "module.yaml").read_text(encoding="utf-8-sig")) dependencies = {item.split()[0] for item in manifest.get("requires", [])} self.assertTrue(dependencies <= set(names), f"{profile} omits dependencies for {name}") + def test_every_module_declares_allowed_tools(self): + validator_path = ROOT / "devops-platform-contracts/scripts/validate_platform.py" + spec = importlib.util.spec_from_file_location("platform_validator_tools", validator_path) + module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + catalog = json.loads((ROOT / "catalog.json").read_text(encoding="utf-8-sig")) + read_only_modules = { + "devops-platform-contracts", "devops-core", "cloud-generic", "cloud-aws", + "cloud-gcp", "cloud-azure", "cloud-selectel", "cloudflare-operations", + } + for name in catalog["skills"]: + manifest = yaml.safe_load((ROOT / name / "module.yaml").read_text(encoding="utf-8-sig")) + tools = manifest.get("allowed_tools") + self.assertIsInstance(tools, list, name) + self.assertTrue(tools, name) + self.assertEqual(len(tools), len(set(tools)), name) + self.assertTrue(all(module.TOOL.fullmatch(tool) for tool in tools), name) + metadata = yaml.safe_load(module.FRONTMATTER.match((ROOT / name / "SKILL.md").read_text(encoding="utf-8-sig")).group(1)) + self.assertEqual([item.strip() for item in metadata["allowed-tools"].split(",")], tools, name) + if name in read_only_modules: + for unbounded in ("Bash", "Write", "Edit"): + self.assertNotIn(unbounded, tools, f"{name} must not grant unrestricted {unbounded}") + self.assertIn("allowed_tools", module.REQUIRED) + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) + (folder / "SKILL.md").write_text("---\nname: sample\ndescription: sample module\n---\n\n# Sample\n", encoding="utf-8") + error = module.validate_skill("sample", folder, ["Read"]) + self.assertIsNotNone(error) + self.assertIn("allowed-tools", error) def test_fast_moving_modules_declare_current_official_sources(self): expected = { "cloudflare-operations": {"developers.cloudflare.com"}, diff --git a/tools/build_public_source.py b/tools/build_public_source.py index 68d9f2e..22c73fe 100644 --- a/tools/build_public_source.py +++ b/tools/build_public_source.py @@ -31,6 +31,8 @@ PUBLIC_TOOLS = { "tools/build_public_source.py", "tools/build_release.py", + "tools/devops_exec.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 b698d77..e62ac55 100644 --- a/tools/build_release.py +++ b/tools/build_release.py @@ -27,6 +27,8 @@ "CHANGELOG.md", } TOOL_FILES = { + "tools/devops_exec.py", + "tools/hooks/pretooluse_gate.py", "tools/install.py", "tools/verify_release.py", } diff --git a/tools/devops_exec.py b/tools/devops_exec.py new file mode 100644 index 0000000..2b0f94b --- /dev/null +++ b/tools/devops_exec.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Execute exactly one approved command through the fail-closed operation gate. + +Usage: + python tools/devops_exec.py --operation operation.json [--policy NAME] \ + [--at RFC3339] [--ledger PATH] -- [args...] + +The wrapper recomputes the canonical digest of the actual command, requires it +to equal the approved ``change.plan_digest``, re-runs the operation gate +immediately before execution, executes without a shell, and appends a +secret-redacted record to a local execution ledger. Any mismatch, gate refusal, +or internal error blocks execution with a non-zero exit code. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GATE = ROOT / "devops-platform-contracts" / "scripts" / "operation_gate.py" +DEFAULT_LEDGER = ROOT / "operations" / "execution-ledger.jsonl" +BLOCKED_EXIT = 3 +OUTPUT_TAIL_CHARS = 4000 +REDACTED = "[REDACTED]" +KEY_VALUE_SECRET = re.compile(r"(?i)\b(password|passwd|secret|token|api[_-]?key|access[_-]?key|authorization)\b(\s*[=:]\s*)\S+") +VALUE_SECRETS = ( + re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{8,}"), + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + re.compile(r"-----BEGIN[A-Z ]+KEY-----[\s\S]+?-----END[A-Z ]+KEY-----"), +) + + +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 redact(text: str) -> str: + text = KEY_VALUE_SECRET.sub(lambda match: f"{match.group(1)}{match.group(2)}{REDACTED}", text) + for pattern in VALUE_SECRETS: + text = pattern.sub(REDACTED, text) + return text + + +def tail(text: str) -> str: + return text if len(text) <= OUTPUT_TAIL_CHARS else text[-OUTPUT_TAIL_CHARS:] + + +def append_ledger(ledger_path: Path, entry: dict) -> None: + ledger_path.parent.mkdir(parents=True, exist_ok=True) + with ledger_path.open("a", encoding="utf-8", newline="\n") as handle: + handle.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n") + + +def blocked(reason: str, ledger_path: Path, entry: dict, status: str) -> int: + print(f"BLOCKED: {reason}") + entry.update({"status": status, "reason": redact(reason), "exit_code": None}) + try: + append_ledger(ledger_path, entry) + except OSError as error: + print(f"BLOCKED: execution ledger is unavailable: {error}") + return BLOCKED_EXIT + + +def main() -> int: + arguments = sys.argv[1:] + if "--" not in arguments: + print("BLOCKED: no command was provided after --") + return BLOCKED_EXIT + split = arguments.index("--") + command = arguments[split + 1 :] + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--operation", type=Path, required=True, help="Secret-free JSON operation request v2.") + parser.add_argument("--policy", default="default-policy.json", help="Registered policy basename.") + parser.add_argument("--at", help="RFC3339 evaluation time for deterministic testing; defaults to now.") + parser.add_argument("--ledger", type=Path, default=DEFAULT_LEDGER, help="Append-only execution ledger path.") + parser.add_argument("--timeout-seconds", type=int, default=None, help="Optional command timeout.") + args = parser.parse_args(arguments[:split]) + + entry = { + "recorded_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "operation_id": None, + "policy": args.policy, + "command": None, + "command_digest": None, + } + try: + if not command: + return blocked("no command was provided after --", args.ledger, entry, "blocked_usage") + if any(not isinstance(item, str) for item in command): + return blocked("command arguments must be strings", args.ledger, entry, "blocked_usage") + entry["command"] = redact(subprocess.list2cmdline(command)) + digest = canonical_command_digest(command) + entry["command_digest"] = digest + + request = json.loads(args.operation.read_text(encoding="utf-8-sig")) + if not isinstance(request, dict): + return blocked("operation request must be a JSON object", args.ledger, entry, "blocked_request") + entry["operation_id"] = request.get("operation_id") if isinstance(request.get("operation_id"), str) else None + change = request.get("change") + plan_digest = change.get("plan_digest") if isinstance(change, dict) else None + if not isinstance(plan_digest, str): + return blocked("operation request does not declare change.plan_digest", args.ledger, entry, "blocked_request") + if plan_digest != digest: + return blocked( + f"command digest {digest} does not match approved plan digest {plan_digest}", + args.ledger, entry, "blocked_digest_mismatch", + ) + + gate_arguments = [sys.executable, str(GATE), "--request", str(args.operation), "--policy", args.policy] + if args.at: + gate_arguments += ["--at", args.at] + gate = subprocess.run(gate_arguments, capture_output=True, text=True, check=False) + gate_output = (gate.stdout + gate.stderr).strip() + print(gate_output) + if gate.returncode != 0 or not gate.stdout.startswith("ALLOWED:"): + return blocked("operation gate refused execution immediately before launch", args.ledger, entry, "blocked_gate") + + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=args.timeout_seconds, check=False) + except subprocess.TimeoutExpired: + return blocked("command execution timed out before completion", args.ledger, entry, "blocked_timeout") + stdout = redact(tail(result.stdout)) + stderr = redact(tail(result.stderr)) + if stdout: + print(stdout, end="" if stdout.endswith("\n") else "\n") + if stderr: + print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr) + entry.update({"status": "executed", "reason": None, "exit_code": result.returncode, "stdout_tail": stdout, "stderr_tail": stderr}) + append_ledger(args.ledger, entry) + print(f"EXECUTED: {entry['operation_id']} exit={result.returncode} digest={digest}") + return result.returncode + except (OSError, json.JSONDecodeError, ValueError) as error: + return blocked(f"invalid execution input: {error}", args.ledger, entry, "blocked_error") + except Exception as error: # fail closed on anything unexpected + return blocked(f"internal wrapper error ({type(error).__name__})", args.ledger, entry, "blocked_error") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hooks/pretooluse_gate.py b/tools/hooks/pretooluse_gate.py new file mode 100644 index 0000000..275fe39 --- /dev/null +++ b/tools/hooks/pretooluse_gate.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +"""Fail-closed PreToolUse hook that makes ungated mutation technically impossible. + +The hook reads one Claude Code PreToolUse payload from stdin and decides whether +the proposed shell command may run: + +- provably read-only commands are allowed; +- registered platform scripts are allowed after resolved-path verification; +- an invocation of ``tools/devops_exec.py`` is allowed only when the referenced + operation request binds ``change.plan_digest`` to the canonical digest of the + wrapped command, the execution window is currently open, and the registered + operation gate returns a fresh PASS; +- every other command, including obfuscated or unclassifiable ones, is denied. + +Unknown means mutating. Any parsing failure or internal error denies. +""" +from __future__ import annotations + +import hashlib +import json +import re +import shlex +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +GATE = ROOT / "devops-platform-contracts" / "scripts" / "operation_gate.py" +WRAPPER = ROOT / "tools" / "devops_exec.py" +READ_ONLY_PLATFORM_SCRIPTS = { + (ROOT / relative).resolve() + for relative in ( + "devops-platform-contracts/scripts/validate_platform.py", + "devops-platform-contracts/scripts/operation_gate.py", + "devops-platform-contracts/scripts/resolve_capabilities.py", + "devops-platform-contracts/scripts/ledger_chain.py", + "devops-core/scripts/profile_digest.py", + "devops-core/scripts/validate_contracts.py", + "docker-operations/scripts/compose-preflight.py", + "network-edge-operations/scripts/http-path-check.py", + "reliability-operations/scripts/deploy-verify.py", + "examples/portfolio-demo/run_demo.py", + ) +} +OBFUSCATION_MARKERS = ("$(", "`", "${", "<(", ">(", "$") +SEPARATORS = {";", "&&", "||", "|", "&"} +PYTHON_NAMES = ("python", "python3", "python.exe", "python3.exe", "py", "py.exe") +SHELL_WRAPPERS = { + "bash", "sh", "zsh", "dash", "ksh", "fish", "pwsh", "powershell", "cmd", + "eval", "exec", "source", "xargs", "env", "nohup", "setsid", "watch", + "base64", "perl", "ruby", "node", "awk", "sed", +} +SIMPLE_READ_ONLY = { + "ls", "cat", "head", "tail", "pwd", "whoami", "id", "uname", "hostname", + "date", "uptime", "df", "du", "free", "ps", "stat", "file", "wc", "which", + "printenv", "echo", "grep", "sort", "uniq", "cut", "tr", "jq", "dig", + "nslookup", "journalctl", "ss", "netstat", "findmnt", "lsblk", "true", +} +FIND_MUTATING_FLAGS = {"-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprintf", "-fprint"} +SYSTEMCTL_READ_ONLY = { + "status", "show", "cat", "is-active", "is-enabled", "is-failed", "is-system-running", + "list-units", "list-unit-files", "list-timers", "list-dependencies", "list-sockets", +} +KUBECTL_READ_ONLY = { + "get", "describe", "logs", "top", "version", "explain", "diff", + "api-resources", "api-versions", "cluster-info", +} +TERRAFORM_READ_ONLY = {"plan", "validate", "show", "output", "version", "providers", "graph"} +DOCKER_READ_ONLY = {"ps", "images", "inspect", "logs", "version", "info", "stats", "port", "top"} +DOCKER_READ_ONLY_SUB = { + "image": {"ls", "inspect", "history"}, + "container": {"ls", "inspect", "logs", "top", "stats", "port"}, + "network": {"ls", "inspect"}, + "volume": {"ls", "inspect"}, + "system": {"df", "events", "info"}, + "compose": {"ps", "config", "logs", "version"}, +} +GIT_READ_ONLY = {"status", "log", "diff", "show", "rev-parse", "ls-files", "blame", "describe", "shortlog", "grep"} +HELM_READ_ONLY = {"list", "status", "get", "history", "show", "version"} +PACKAGE_READ_ONLY = {"list", "show", "search", "info", "check-update"} +AWS_READ_ONLY_OPERATION = re.compile(r"^(describe|list|get)-[a-z0-9-]+$") +AWS_MUTATING_OPERATION = re.compile( + r"^(create|delete|update|put|attach|detach|modify|run|start|stop|terminate|reboot|associate|disassociate|" + r"authorize|revoke|enable|disable|set|add|remove|tag|untag|import|restore|replace|register|deregister|" + r"apply|cancel|execute|invoke|publish|purge|release|copy|move|reset|rotate|assume)(-[a-z0-9-]+)?$" +) +CLOUD_READ_ONLY_VERBS = {"describe", "list", "show", "get"} +CLOUD_MUTATING_VERBS = { + "create", "delete", "update", "set", "add", "remove", "deploy", "apply", "patch", "enable", + "disable", "start", "stop", "restart", "resize", "attach", "detach", "import", "export", + "run", "submit", "rollout", "promote", "migrate", "reset", "rotate", "upgrade", "scale", +} +CURL_MUTATING_FLAGS = { + "-d", "--data", "--data-raw", "--data-binary", "--data-urlencode", "-F", "--form", + "-T", "--upload-file", "-o", "-O", "--output", "--remote-name", "-K", "--config", +} +REMEDIATION = ( + "run mutating work through 'python tools/devops_exec.py --operation " + "--policy -- ' where change.plan_digest equals the canonical " + "digest of the exact command argv and approvals plus the execution window are currently valid" +) + + +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 decide(allow: bool, reason: str) -> int: + decision = "allow" if allow else "deny" + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": decision, + "permissionDecisionReason": reason, + } + })) + if not allow: + print(reason, file=sys.stderr) + return 0 if allow else 2 + + +def command_name(token: str) -> str: + name = token.replace("\\", "/").rsplit("/", 1)[-1].lower() + return name[:-4] if name.endswith(".exe") else name + + +def parse_rfc3339(value: str) -> datetime: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("timestamp must carry a timezone") + return parsed.astimezone(timezone.utc) + + +def resolve_script(token: str, cwd: Path) -> Path | None: + candidate = Path(token.replace("\\", "/")) + if not candidate.is_absolute(): + candidate = cwd / candidate + try: + return candidate.resolve(strict=True) + except OSError: + return None + + +def classify_python(argv: list[str], cwd: Path) -> tuple[bool, str]: + if len(argv) < 2 or argv[1].startswith("-"): + return False, "python may only run registered read-only platform scripts" + resolved = resolve_script(argv[1], cwd) + if resolved is not None and resolved in READ_ONLY_PLATFORM_SCRIPTS: + return True, f"registered platform script {resolved.name}" + return False, "python may only run registered read-only platform scripts" + + +def classify_segment(argv: list[str], cwd: Path) -> tuple[bool, str]: + name = command_name(argv[0]) + rest = argv[1:] + positionals = [token for token in rest if not token.startswith("-")] + if name in SHELL_WRAPPERS: + return False, f"'{name}' hides or transforms the real command" + if name in SIMPLE_READ_ONLY: + return True, name + if name == "find": + if any(token in FIND_MUTATING_FLAGS for token in rest): + return False, "find with a mutating action flag" + return True, "find" + if name == "rg": + if "--pre" in rest: + return False, "rg --pre executes an external preprocessor" + return True, "rg" + if name in PYTHON_NAMES: + return classify_python(argv, cwd) + if name == "systemctl": + verb = positionals[0] if positionals else "" + return (verb in SYSTEMCTL_READ_ONLY, f"systemctl {verb}".strip()) + if name == "kubectl": + verb = positionals[0] if positionals else "" + if verb == "config": + sub = positionals[1] if len(positionals) > 1 else "" + return (sub in {"view", "get-contexts", "current-context", "get-clusters", "get-users"}, f"kubectl config {sub}") + if verb == "auth": + sub = positionals[1] if len(positionals) > 1 else "" + return (sub == "can-i", f"kubectl auth {sub}") + return (verb in KUBECTL_READ_ONLY, f"kubectl {verb}".strip()) + if name in {"terraform", "tofu"}: + verb = positionals[0] if positionals else "" + if verb == "fmt": + return ("-check" in rest, f"{name} fmt") + if verb == "state": + sub = positionals[1] if len(positionals) > 1 else "" + return (sub in {"list", "show"}, f"{name} state {sub}") + return (verb in TERRAFORM_READ_ONLY, f"{name} {verb}".strip()) + if name == "docker": + verb = positionals[0] if positionals else "" + if verb in DOCKER_READ_ONLY_SUB: + sub = positionals[1] if len(positionals) > 1 else "" + return (sub in DOCKER_READ_ONLY_SUB[verb], f"docker {verb} {sub}") + return (verb in DOCKER_READ_ONLY, f"docker {verb}".strip()) + if name == "git": + verb = positionals[0] if positionals else "" + return (verb in GIT_READ_ONLY, f"git {verb}".strip()) + if name == "helm": + verb = positionals[0] if positionals else "" + return (verb in HELM_READ_ONLY, f"helm {verb}".strip()) + if name in {"apt", "apt-get", "dnf", "yum", "apk", "zypper"}: + verb = positionals[0] if positionals else "" + return (verb in PACKAGE_READ_ONLY, f"{name} {verb}".strip()) + if name == "ip": + if any(token in {"add", "del", "delete", "set", "flush", "replace", "change"} for token in rest): + return False, "ip with a mutating subcommand" + return True, "ip" + if name == "aws": + if any(AWS_MUTATING_OPERATION.fullmatch(token) for token in positionals): + return False, "aws with a mutating operation" + if any(AWS_READ_ONLY_OPERATION.fullmatch(token) for token in positionals): + return True, "aws read-only operation" + return False, "aws operation is not provably read-only" + if name in {"gcloud", "gsutil", "az", "openstack"}: + if any(token in CLOUD_MUTATING_VERBS for token in positionals): + return False, f"{name} with a mutating verb" + if any(token in CLOUD_READ_ONLY_VERBS for token in positionals): + return True, f"{name} read-only verb" + return False, f"{name} verb is not provably read-only" + if name == "curl": + request_value = "" + for index, token in enumerate(rest): + if token in {"-X", "--request"}: + request_value = rest[index + 1].upper() if index + 1 < len(rest) else "?" + if request_value not in {"", "GET", "HEAD"}: + return False, "curl with a non-GET request method" + if any(token in CURL_MUTATING_FLAGS or token.startswith("--data") for token in rest): + return False, "curl with upload, data, or output flags" + return True, "curl read-only probe" + return False, f"'{name}' is not classifiable as read-only" + + +def split_segments(tokens: list[str]) -> tuple[list[list[str]], str | None]: + segments: list[list[str]] = [[]] + for token in tokens: + if token in SEPARATORS: + segments.append([]) + continue + if ">" in token or "<" in token: + return [], f"redirection token '{token}' can write outside the approved plan" + segments[-1].append(token) + return [segment for segment in segments if segment], None + + +def is_wrapper(argv: list[str], cwd: Path) -> bool: + if len(argv) < 2 or command_name(argv[0]) not in PYTHON_NAMES: + return False + resolved = resolve_script(argv[1], cwd) + return resolved is not None and resolved == WRAPPER.resolve() + + +def verify_wrapper(argv: list[str], cwd: Path) -> tuple[bool, str]: + arguments = argv[2:] + if "--" not in arguments: + return False, "wrapper invocation is missing the -- command separator" + split = arguments.index("--") + inner = arguments[split + 1 :] + flags = arguments[:split] + values: dict[str, str] = {} + index = 0 + while index < len(flags): + flag = flags[index] + if not flag.startswith("--") or index + 1 >= len(flags): + return False, f"wrapper flag '{flag}' is malformed" + values[flag] = flags[index + 1] + index += 2 + unknown = set(values) - {"--operation", "--policy", "--at", "--ledger", "--timeout-seconds"} + if unknown: + return False, "wrapper invocation carries unknown flags: " + ", ".join(sorted(unknown)) + if "--operation" not in values: + return False, "wrapper invocation does not name an operation request" + if not inner: + return False, "wrapper invocation carries no command" + operation_path = resolve_script(values["--operation"], cwd) + if operation_path is None: + return False, "wrapper operation request file does not exist" + request = json.loads(operation_path.read_text(encoding="utf-8-sig")) + if not isinstance(request, dict): + return False, "wrapper operation request must be a JSON object" + change = request.get("change") + plan_digest = change.get("plan_digest") if isinstance(change, dict) else None + digest = canonical_command_digest(inner) + if plan_digest != digest: + return False, f"approved plan digest does not match the canonical digest {digest} of the wrapped command" + execution = request.get("execution") + if not isinstance(execution, dict): + return False, "wrapper operation request has no execution window" + now = parse_rfc3339(values["--at"]) if "--at" in values else datetime.now(timezone.utc) + window_start = parse_rfc3339(str(execution.get("window_start"))) + window_end = parse_rfc3339(str(execution.get("window_end"))) + if not window_start <= now <= window_end: + return False, "execution window is not currently open" + gate_arguments = [sys.executable, str(GATE), "--request", str(operation_path), "--policy", values.get("--policy", "default-policy.json")] + if "--at" in values: + gate_arguments += ["--at", values["--at"]] + gate = subprocess.run(gate_arguments, capture_output=True, text=True, check=False) + if gate.returncode != 0 or not gate.stdout.startswith("ALLOWED:"): + return False, "operation gate did not return a fresh PASS: " + (gate.stdout + gate.stderr).strip()[:400] + return True, "fresh gate PASS is bound to the exact wrapped command digest" + + +def evaluate(payload: dict) -> tuple[bool, str]: + tool_name = payload.get("tool_name") + if tool_name != "Bash": + return True, f"tool {tool_name!r} is governed by allowed-tools declarations, not by the command gate" + tool_input = payload.get("tool_input") + command = tool_input.get("command") if isinstance(tool_input, dict) else None + if not isinstance(command, str) or not command.strip(): + return False, "BLOCKED: no shell command was provided" + cwd_value = payload.get("cwd") + cwd = Path(cwd_value) if isinstance(cwd_value, str) and cwd_value else ROOT + if "\n" in command or "\r" in command: + return False, "BLOCKED: multi-line commands cannot be verified against an approved plan" + for marker in OBFUSCATION_MARKERS: + if marker in command: + return False, f"BLOCKED: '{marker}' can substitute or expand a hidden command; approve the literal command instead" + tokens = shlex.split(command, posix=True) + if not tokens: + return False, "BLOCKED: empty command" + segments, split_error = split_segments(tokens) + if split_error: + return False, f"BLOCKED: {split_error}" + if not segments: + return False, "BLOCKED: empty command" + if len(segments) == 1 and is_wrapper(segments[0], cwd): + verified, detail = verify_wrapper(segments[0], cwd) + return (verified, ("ALLOWED: " if verified else "BLOCKED: ") + detail) + for segment in segments: + read_only, detail = classify_segment(segment, cwd) + if not read_only: + return False, f"BLOCKED: {detail}; {REMEDIATION}" + return True, "ALLOWED: every command segment is provably read-only" + + +def main() -> int: + try: + payload = json.loads(sys.stdin.read()) + if not isinstance(payload, dict): + return decide(False, "BLOCKED: hook payload must be a JSON object") + allow, reason = evaluate(payload) + return decide(allow, reason) + except Exception as error: # fail closed on anything unexpected + return decide(False, f"BLOCKED: hook could not verify the command ({type(error).__name__}: {error})") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify_release.py b/tools/verify_release.py index 16ea3a6..8344988 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/install.py", "tools/verify_release.py"} +REQUIRED_TOOL_FILES = {"tools/devops_exec.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"} diff --git a/windows-server-operations/SKILL.md b/windows-server-operations/SKILL.md index a790544..4cb93b3 100644 --- a/windows-server-operations/SKILL.md +++ b/windows-server-operations/SKILL.md @@ -1,6 +1,7 @@ --- name: windows-server-operations description: Safely audit, bootstrap, operate, diagnose, and recover Windows Server hosts under the devops-core safety contract. Use for PowerShell remoting, WinRM, RDP, local users/groups, Windows services, Event Logs, Windows Firewall, disks, updates, reboot planning, Windows networking, or host hardening. +allowed-tools: Read, Grep, Glob, Write, Edit, Bash --- # Windows Server Operations diff --git a/windows-server-operations/module.yaml b/windows-server-operations/module.yaml index f7fb8d9..281aea5 100644 --- a/windows-server-operations/module.yaml +++ b/windows-server-operations/module.yaml @@ -1,6 +1,13 @@ name: windows-server-operations version: 0.3.0 kind: executor +allowed_tools: + - Read + - Grep + - Glob + - Write + - Edit + - Bash requires: - devops-platform-contracts >= 0.2.0 - devops-core >= 0.2.0