diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index e62201b..5949096 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `e1fd00a3172eaaec29bf8f1e6e47f96d175c31ff`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `9ec6419178e8ddc76a698e84bde72d5ff4472411`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index 1604022..2ef1509 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "e1fd00a3172eaaec29bf8f1e6e47f96d175c31ff", + "sourceRevision": "9ec6419178e8ddc76a698e84bde72d5ff4472411", "files": [ { "path": ".dockerignore", @@ -509,8 +509,8 @@ }, { "path": "catalog/policies/041-appops-governed-operations.yaml", - "sha256": "1426694df3ec9f8d4691bce055abc21bd991d2a0041ba4b3b0e6619de7e747c1", - "bytes": 1024 + "sha256": "915a080783aaf99145699b30d1556f16fd799d4c267f588d1d4dc06fac2ee047", + "bytes": 1068 }, { "path": "catalog/policies/042-stitch-governed-design-writes.yaml", @@ -584,8 +584,8 @@ }, { "path": "catalog/tools/gitea-branch-protection-ensure.yaml", - "sha256": "b0750a7de2b10c8c0403444e96bf9a9ef6c60c3c184f02bb8322de5d732f2bff", - "bytes": 822 + "sha256": "bb072d1f18a17d7bbe9f7e628fd9c93ac7d8af7be2065b40d6195898e42502ff", + "bytes": 968 }, { "path": "catalog/tools/gitea-branch-protection-get.yaml", @@ -594,8 +594,8 @@ }, { "path": "catalog/tools/gitea-branch-protection-preview.yaml", - "sha256": "a31c94a963c8d384bfbd67706dec8a82ddf18a889f8749ac6f79d41b99e6670d", - "bytes": 735 + "sha256": "e975e08cc47d619f61489cb1eb78ff6d8c0eca2fc281301f21bca83dc74e14ea", + "bytes": 787 }, { "path": "catalog/tools/gitea-capabilities-get.yaml", @@ -709,18 +709,18 @@ }, { "path": "catalog/tools/gitea-portfolio-governance-apply.yaml", - "sha256": "7b4f20d29390a005e13ed4d4a3726dadb0d19d15814ad61d4c265b717ce06153", - "bytes": 960 + "sha256": "6c096efb328d513b7b7995c79e9cb03caf3ba85017d3af4941101baaae43f7b2", + "bytes": 1183 }, { "path": "catalog/tools/gitea-portfolio-governance-preview.yaml", - "sha256": "dcf866336af236d81dde3476988f066d1b86bfb2e8ae3432af30a068cab18a4b", - "bytes": 810 + "sha256": "1ecb3aa9ae7b60871b48066221ae02b76c156422d96f4e86526196d8b9914d72", + "bytes": 819 }, { "path": "catalog/tools/gitea-portfolio-governance-verify.yaml", - "sha256": "59acb31c46829ac985ec9b1291b08c5eadd03d657be3d42b6c23fa9cfe12f86e", - "bytes": 805 + "sha256": "425ce50c7d8fda4ea745fb631da869fe2023a8f1f846a4c7cece5d472f00416a", + "bytes": 814 }, { "path": "catalog/tools/gitea-portfolio-health.yaml", @@ -1402,6 +1402,26 @@ "sha256": "e90091e39c5e63285954f9f322f8afe2c2c35575152d13f7b31e29166c519648", "bytes": 659 }, + { + "path": "catalog/tools/server-appops-deployment-status.yaml", + "sha256": "d766ff2982f8b3232d931160f3e1c369e94eb1ac01b4a44b6b15ff0d1d7ad658", + "bytes": 661 + }, + { + "path": "catalog/tools/server-appops-self-upgrade-apply.yaml", + "sha256": "85f3d7f7cfb09312704881b48154ba0af71e82da22985ed7cf31ccb4b3fbe56c", + "bytes": 1235 + }, + { + "path": "catalog/tools/server-appops-self-upgrade-preflight.yaml", + "sha256": "9eae687b97913f99642161c9c8bd5a1f1627671e1150fcf180366d1a77d18e6f", + "bytes": 1011 + }, + { + "path": "catalog/tools/server-appops-self-upgrade-status.yaml", + "sha256": "500515ffee25623be12e847b4f81edb43edc13cee294c7174bf97be2a7cee8c5", + "bytes": 766 + }, { "path": "catalog/tools/server-audit-search.yaml", "sha256": "d43c06ce0b1d423a0ce13262b9189ffd645d3ea6cf2c3e9ea3fd4740c7fa293e", @@ -1634,8 +1654,8 @@ }, { "path": "connectors/gitea/server.py", - "sha256": "7da7c3be6d259ef2c897f52c82771716a602559f7671c794ec6380634ffdc91e", - "bytes": 209422 + "sha256": "d8c5be1efed22d99dc287a83fd78156e306afa731f6b233b4d076bb5736152b4", + "bytes": 227648 }, { "path": "connectors/knowledge/__init__.py", @@ -2554,8 +2574,8 @@ }, { "path": "services/control_api/contextforge/fake.py", - "sha256": "74cf3f097ea40c761d6bed05ee457d9fb0eea5bc603eba565da3e2da9b7d8023", - "bytes": 18589 + "sha256": "7cd684a569d5df1624af6f449e4f13199b8b113c1244dd4afec0de2121421fc4", + "bytes": 20697 }, { "path": "services/control_api/contextforge/models.py", @@ -2734,13 +2754,13 @@ }, { "path": "services/runtime/connector_appops.py", - "sha256": "976f81079e1deb7b0eea2445e80da21bc5057ed788058b646f0afb8834316dfd", - "bytes": 9190 + "sha256": "22360e7ac393d2a72cacd8309a6e68f2d232603271649992706a7f6c5ee3a83a", + "bytes": 9474 }, { "path": "services/runtime/connector_gitea.py", - "sha256": "144b4e7d8fc1a8fb37dc011dab2bdd169fc869382a361e35aee435ddc7f59a34", - "bytes": 37459 + "sha256": "c89874089d1bdde3020864e9222052d2470970ad7c4aa5677201cbfbc4a88eea", + "bytes": 37958 }, { "path": "services/runtime/connector_ingestion.py", @@ -2887,6 +2907,11 @@ "sha256": "006648857d313b8f7fcaa62b86d5f453aa565a75a348aefe7b8557e3b4ba8620", "bytes": 63 }, + { + "path": "tests/fixtures/projectbrain-managed-validation.yml", + "sha256": "2f78d37ff51f38f0a3b0c9e222eb2dbf83d5108aee3615eb53f2c01283f37f34", + "bytes": 9185 + }, { "path": "tests/fixtures/security/wp160-untrusted-inputs.json", "sha256": "779ab274237df2dfd4d23f4b0fe2c21621594ca5a3f64aeef586ba448f702a5c", @@ -2899,8 +2924,8 @@ }, { "path": "tests/test_appops_connector.py", - "sha256": "5520aa212d9a175da22cb4bb584f8c36811e923003f3d13d468ebfd224f334f4", - "bytes": 5257 + "sha256": "4e088d187b239cd1e8b81b7c5d72aed27eddae78491678564bfe2776d37913be", + "bytes": 9448 }, { "path": "tests/test_auth.py", @@ -2914,7 +2939,7 @@ }, { "path": "tests/test_catalog.py", - "sha256": "e591b502f2dd07ae165b5cafa8f0a0e42a84530b8a352c963116cb803e865cb8", + "sha256": "adbe584172170fa17fe21c0e6a1953729e9b83190845d8e9d053b0d2cef4b1c6", "bytes": 16467 }, { @@ -3039,8 +3064,8 @@ }, { "path": "tests/test_gitea_connector.py", - "sha256": "62c25f2953cd6d0d02c59d07925430886f87b5f04f6f995ef4c8168a77e59483", - "bytes": 97203 + "sha256": "c6389e924471d392f1a19cae48e9c409b349223b76faa34c20b9ae4c7894ede2", + "bytes": 128684 }, { "path": "tests/test_gitea_merge_eligibility.py", diff --git a/catalog/policies/041-appops-governed-operations.yaml b/catalog/policies/041-appops-governed-operations.yaml index 7b98788..fb71209 100644 --- a/catalog/policies/041-appops-governed-operations.yaml +++ b/catalog/policies/041-appops-governed-operations.yaml @@ -17,7 +17,8 @@ "server.project.reconcile", "server.deployment.target.enroll", "server.deployment.apply", - "server.deployment.rollback" + "server.deployment.rollback", + "server.appops.self_upgrade.apply" ] }, "conditions": {"requireEnabledClient": true, "requireBinding": true, "maxArgumentBytes": 65536}, diff --git a/catalog/tools/gitea-branch-protection-ensure.yaml b/catalog/tools/gitea-branch-protection-ensure.yaml index 7ef8e89..2c5aeb1 100644 --- a/catalog/tools/gitea-branch-protection-ensure.yaml +++ b/catalog/tools/gitea-branch-protection-ensure.yaml @@ -1 +1 @@ -{"apiVersion":"hub.example.com/v1alpha1","kind":"ToolManifest","metadata":{"name":"Gitea Branch Protection Ensure","slug":"gitea-branch-protection-ensure"},"spec":{"canonicalName":"gitea.branchprotection.ensure","version":"1.8.0","connectorType":"gitea","description":"Apply one unchanged governance preview and verify managed validation plus default-branch protection.","riskClass":"R2","readOnly":false,"idempotent":true,"inputSchema":{"type":"object","additionalProperties":false,"properties":{"repositoryRef":{"type":"string"},"expectedPlanHash":{"type":"string","pattern":"^[0-9a-f]{64}$"}},"required":["repositoryRef","expectedPlanHash"]},"outputSchema":{"type":"object"},"limits":{"timeoutMs":30000,"maxOutputBytes":262144,"maxItems":1},"redactionProfile":"documents","requiredScopes":["project:{project}:write"]}} +{"apiVersion":"hub.example.com/v1alpha1","kind":"ToolManifest","metadata":{"name":"Gitea Branch Protection Ensure","slug":"gitea-branch-protection-ensure"},"spec":{"canonicalName":"gitea.branchprotection.ensure","version":"1.9.0","connectorType":"gitea","description":"Apply one unchanged governance preview with an explicitly hashed scope; ProjectBrain protection-only scope adds required status checks without rewriting its workflow.","riskClass":"R2","readOnly":false,"idempotent":true,"inputSchema":{"type":"object","additionalProperties":false,"properties":{"repositoryRef":{"type":"string"},"expectedPlanHash":{"type":"string","pattern":"^[0-9a-f]{64}$"},"applyScope":{"type":"string","enum":["all","protection_only"],"default":"all"}},"required":["repositoryRef","expectedPlanHash"]},"outputSchema":{"type":"object"},"limits":{"timeoutMs":30000,"maxOutputBytes":262144,"maxItems":1},"redactionProfile":"documents","requiredScopes":["project:{project}:write"]}} diff --git a/catalog/tools/gitea-branch-protection-preview.yaml b/catalog/tools/gitea-branch-protection-preview.yaml index 18c365b..a362e45 100644 --- a/catalog/tools/gitea-branch-protection-preview.yaml +++ b/catalog/tools/gitea-branch-protection-preview.yaml @@ -1 +1 @@ -{"apiVersion":"hub.example.com/v1alpha1","kind":"ToolManifest","metadata":{"name":"Gitea Branch Protection Preview","slug":"gitea-branch-protection-preview"},"spec":{"canonicalName":"gitea.branchprotection.preview","version":"1.8.0","connectorType":"gitea","description":"Preview the exact managed-validation and default-branch protection diff and return a plan hash.","riskClass":"R1","readOnly":true,"idempotent":true,"inputSchema":{"type":"object","additionalProperties":false,"properties":{"repositoryRef":{"type":"string"}},"required":["repositoryRef"]},"outputSchema":{"type":"object"},"limits":{"timeoutMs":20000,"maxOutputBytes":262144,"maxItems":1},"redactionProfile":"documents","requiredScopes":["project:{project}:read"]}} +{"apiVersion":"hub.example.com/v1alpha1","kind":"ToolManifest","metadata":{"name":"Gitea Branch Protection Preview","slug":"gitea-branch-protection-preview"},"spec":{"canonicalName":"gitea.branchprotection.preview","version":"1.9.0","connectorType":"gitea","description":"Preview the exact managed-validation profile and default-branch protection diff, including a separately hashed ProjectBrain protection-only option.","riskClass":"R1","readOnly":true,"idempotent":true,"inputSchema":{"type":"object","additionalProperties":false,"properties":{"repositoryRef":{"type":"string"}},"required":["repositoryRef"]},"outputSchema":{"type":"object"},"limits":{"timeoutMs":20000,"maxOutputBytes":262144,"maxItems":1},"redactionProfile":"documents","requiredScopes":["project:{project}:read"]}} diff --git a/catalog/tools/gitea-portfolio-governance-apply.yaml b/catalog/tools/gitea-portfolio-governance-apply.yaml index 3e8fd56..e3c2d55 100644 --- a/catalog/tools/gitea-portfolio-governance-apply.yaml +++ b/catalog/tools/gitea-portfolio-governance-apply.yaml @@ -1 +1 @@ -{"apiVersion":"hub.example.com/v1alpha1","kind":"ToolManifest","metadata":{"name":"Gitea Portfolio Governance Apply","slug":"gitea-portfolio-governance-apply"},"spec":{"canonicalName":"gitea.portfolio.governance.apply","version":"1.8.0","connectorType":"gitea","description":"Apply and verify at most five exact repository governance plans in one bounded batch.","riskClass":"R2","readOnly":false,"idempotent":true,"inputSchema":{"type":"object","additionalProperties":false,"properties":{"plans":{"type":"array","minItems":1,"maxItems":5,"uniqueItems":true,"items":{"type":"object","additionalProperties":false,"properties":{"repositoryRef":{"type":"string"},"planHash":{"type":"string","pattern":"^[0-9a-f]{64}$"}},"required":["repositoryRef","planHash"]}}},"required":["plans"]},"outputSchema":{"type":"object"},"limits":{"timeoutMs":120000,"maxOutputBytes":524288,"maxItems":5},"redactionProfile":"documents","requiredScopes":["project:{project}:write"]}} +{"apiVersion":"hub.example.com/v1alpha1","kind":"ToolManifest","metadata":{"name":"Gitea Portfolio Governance Apply","slug":"gitea-portfolio-governance-apply"},"spec":{"canonicalName":"gitea.portfolio.governance.apply","version":"1.9.0","connectorType":"gitea","description":"Apply and verify at most five exact repository governance plans, including the separately hashed ProjectBrain protection-only scope.","riskClass":"R2","readOnly":false,"idempotent":true,"inputSchema":{"type":"object","additionalProperties":false,"properties":{"plans":{"type":"array","minItems":1,"maxItems":5,"uniqueItems":true,"items":{"type":"object","additionalProperties":false,"properties":{"repositoryRef":{"type":"string"},"planHash":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"Use planHash for all scope or protectionOnlyPlanHash for protection_only scope."},"applyScope":{"type":"string","enum":["all","protection_only"],"default":"all"}},"required":["repositoryRef","planHash"]}}},"required":["plans"]},"outputSchema":{"type":"object"},"limits":{"timeoutMs":120000,"maxOutputBytes":524288,"maxItems":5},"redactionProfile":"documents","requiredScopes":["project:{project}:write"]}} diff --git a/catalog/tools/gitea-portfolio-governance-preview.yaml b/catalog/tools/gitea-portfolio-governance-preview.yaml index 7ca49c9..65659d4 100644 --- a/catalog/tools/gitea-portfolio-governance-preview.yaml +++ b/catalog/tools/gitea-portfolio-governance-preview.yaml @@ -1 +1 @@ -{"apiVersion":"hub.example.com/v1alpha1","kind":"ToolManifest","metadata":{"name":"Gitea Portfolio Governance Preview","slug":"gitea-portfolio-governance-preview"},"spec":{"canonicalName":"gitea.portfolio.governance.preview","version":"1.8.0","connectorType":"gitea","description":"Preview managed validation and branch protection for at most 25 explicit repositories.","riskClass":"R1","readOnly":true,"idempotent":true,"inputSchema":{"type":"object","additionalProperties":false,"properties":{"repositoryRefs":{"type":"array","minItems":1,"maxItems":25,"uniqueItems":true,"items":{"type":"string"}}},"required":["repositoryRefs"]},"outputSchema":{"type":"object"},"limits":{"timeoutMs":60000,"maxOutputBytes":524288,"maxItems":25},"redactionProfile":"documents","requiredScopes":["project:{project}:read"]}} +{"apiVersion":"hub.example.com/v1alpha1","kind":"ToolManifest","metadata":{"name":"Gitea Portfolio Governance Preview","slug":"gitea-portfolio-governance-preview"},"spec":{"canonicalName":"gitea.portfolio.governance.preview","version":"1.9.0","connectorType":"gitea","description":"Preview managed-validation profiles and branch protection for at most 25 explicit repositories.","riskClass":"R1","readOnly":true,"idempotent":true,"inputSchema":{"type":"object","additionalProperties":false,"properties":{"repositoryRefs":{"type":"array","minItems":1,"maxItems":25,"uniqueItems":true,"items":{"type":"string"}}},"required":["repositoryRefs"]},"outputSchema":{"type":"object"},"limits":{"timeoutMs":60000,"maxOutputBytes":524288,"maxItems":25},"redactionProfile":"documents","requiredScopes":["project:{project}:read"]}} diff --git a/catalog/tools/gitea-portfolio-governance-verify.yaml b/catalog/tools/gitea-portfolio-governance-verify.yaml index 272c8de..9715fc7 100644 --- a/catalog/tools/gitea-portfolio-governance-verify.yaml +++ b/catalog/tools/gitea-portfolio-governance-verify.yaml @@ -1 +1 @@ -{"apiVersion":"hub.example.com/v1alpha1","kind":"ToolManifest","metadata":{"name":"Gitea Portfolio Governance Verify","slug":"gitea-portfolio-governance-verify"},"spec":{"canonicalName":"gitea.portfolio.governance.verify","version":"1.8.0","connectorType":"gitea","description":"Verify managed validation and default-branch protection for at most 25 repositories.","riskClass":"R1","readOnly":true,"idempotent":true,"inputSchema":{"type":"object","additionalProperties":false,"properties":{"repositoryRefs":{"type":"array","minItems":1,"maxItems":25,"uniqueItems":true,"items":{"type":"string"}}},"required":["repositoryRefs"]},"outputSchema":{"type":"object"},"limits":{"timeoutMs":60000,"maxOutputBytes":524288,"maxItems":25},"redactionProfile":"documents","requiredScopes":["project:{project}:read"]}} +{"apiVersion":"hub.example.com/v1alpha1","kind":"ToolManifest","metadata":{"name":"Gitea Portfolio Governance Verify","slug":"gitea-portfolio-governance-verify"},"spec":{"canonicalName":"gitea.portfolio.governance.verify","version":"1.9.0","connectorType":"gitea","description":"Verify managed-validation profiles and default-branch protection for at most 25 repositories.","riskClass":"R1","readOnly":true,"idempotent":true,"inputSchema":{"type":"object","additionalProperties":false,"properties":{"repositoryRefs":{"type":"array","minItems":1,"maxItems":25,"uniqueItems":true,"items":{"type":"string"}}},"required":["repositoryRefs"]},"outputSchema":{"type":"object"},"limits":{"timeoutMs":60000,"maxOutputBytes":524288,"maxItems":25},"redactionProfile":"documents","requiredScopes":["project:{project}:read"]}} diff --git a/catalog/tools/server-appops-deployment-status.yaml b/catalog/tools/server-appops-deployment-status.yaml new file mode 100644 index 0000000..8720493 --- /dev/null +++ b/catalog/tools/server-appops-deployment-status.yaml @@ -0,0 +1 @@ +{"apiVersion":"hub.example.com/v1alpha1","kind":"ToolManifest","metadata":{"name":"Server AppOps Deployment Status","slug":"server-appops-deployment-status"},"spec":{"canonicalName":"server.appops.deployment.status","version":"1.4.0","connectorType":"appops","description":"Read the exact source repository, revision, source tree and health of the running AppOps gateway image.","riskClass":"R1","readOnly":true,"idempotent":true,"inputSchema":{"type":"object","additionalProperties":false},"outputSchema":{"type":"object"},"limits":{"timeoutMs":30000,"maxOutputBytes":65536,"maxItems":1},"redactionProfile":"logs","requiredScopes":["project:{project}:read"]}} diff --git a/catalog/tools/server-appops-self-upgrade-apply.yaml b/catalog/tools/server-appops-self-upgrade-apply.yaml new file mode 100644 index 0000000..a0d27fc --- /dev/null +++ b/catalog/tools/server-appops-self-upgrade-apply.yaml @@ -0,0 +1 @@ +{"apiVersion":"hub.example.com/v1alpha1","kind":"ToolManifest","metadata":{"name":"Server AppOps Self-Upgrade Apply","slug":"server-appops-self-upgrade-apply"},"spec":{"canonicalName":"server.appops.self_upgrade.apply","version":"1.4.0","connectorType":"appops","description":"Request only the preflighted exact AppOps revision through its private single-instance controller, bound to the preflight fingerprint and idempotency key.","riskClass":"R3","readOnly":false,"idempotent":true,"inputSchema":{"type":"object","additionalProperties":false,"properties":{"repository":{"type":"string","const":"jens/unraid-appops-gateway"},"revision":{"type":"string","pattern":"^[0-9a-f]{40}$"},"reason":{"type":"string","minLength":8,"maxLength":500},"expected_preflight_fingerprint":{"type":"string","pattern":"^[0-9a-f]{64}$"},"idempotency_key":{"type":"string","pattern":"^[A-Za-z0-9._:-]{8,128}$"},"expected_current_revision":{"type":"string","pattern":"^[0-9a-f]{40}$"}},"required":["repository","revision","reason","expected_preflight_fingerprint","idempotency_key"]},"outputSchema":{"type":"object"},"limits":{"timeoutMs":60000,"maxOutputBytes":262144,"maxItems":1},"redactionProfile":"logs","requiredScopes":["project:{project}:write"]}} diff --git a/catalog/tools/server-appops-self-upgrade-preflight.yaml b/catalog/tools/server-appops-self-upgrade-preflight.yaml new file mode 100644 index 0000000..e6d78e3 --- /dev/null +++ b/catalog/tools/server-appops-self-upgrade-preflight.yaml @@ -0,0 +1 @@ +{"apiVersion":"hub.example.com/v1alpha1","kind":"ToolManifest","metadata":{"name":"Server AppOps Self-Upgrade Preflight","slug":"server-appops-self-upgrade-preflight"},"spec":{"canonicalName":"server.appops.self_upgrade.preflight","version":"1.4.0","connectorType":"appops","description":"Check one exact protected-main AppOps revision and current-revision expectation through the private deployment controller.","riskClass":"R1","readOnly":true,"idempotent":true,"inputSchema":{"type":"object","additionalProperties":false,"properties":{"repository":{"type":"string","const":"jens/unraid-appops-gateway"},"revision":{"type":"string","pattern":"^[0-9a-f]{40}$"},"reason":{"type":"string","minLength":8,"maxLength":500},"expected_current_revision":{"type":"string","pattern":"^[0-9a-f]{40}$"}},"required":["repository","revision","reason"]},"outputSchema":{"type":"object"},"limits":{"timeoutMs":30000,"maxOutputBytes":262144,"maxItems":1},"redactionProfile":"logs","requiredScopes":["project:{project}:read"]}} diff --git a/catalog/tools/server-appops-self-upgrade-status.yaml b/catalog/tools/server-appops-self-upgrade-status.yaml new file mode 100644 index 0000000..cfdf788 --- /dev/null +++ b/catalog/tools/server-appops-self-upgrade-status.yaml @@ -0,0 +1 @@ +{"apiVersion":"hub.example.com/v1alpha1","kind":"ToolManifest","metadata":{"name":"Server AppOps Self-Upgrade Status","slug":"server-appops-self-upgrade-status"},"spec":{"canonicalName":"server.appops.self_upgrade.status","version":"1.4.0","connectorType":"appops","description":"Read durable status and exact deployment receipt for one AppOps self-upgrade operation identifier.","riskClass":"R1","readOnly":true,"idempotent":true,"inputSchema":{"type":"object","additionalProperties":false,"properties":{"operation_id":{"type":"string","pattern":"^[0-9a-f]{64}$"}},"required":["operation_id"]},"outputSchema":{"type":"object"},"limits":{"timeoutMs":30000,"maxOutputBytes":262144,"maxItems":1},"redactionProfile":"logs","requiredScopes":["project:{project}:read"]}} diff --git a/connectors/gitea/server.py b/connectors/gitea/server.py index 34d3a82..f9da3f2 100644 --- a/connectors/gitea/server.py +++ b/connectors/gitea/server.py @@ -13,9 +13,11 @@ import time from collections.abc import Callable from datetime import UTC, datetime from threading import Lock -from typing import Any +from typing import Any, Literal from urllib.parse import quote, unquote, urlparse +import yaml + from packages.connector_kit import ( ConnectorContext, GiteaClient, @@ -72,6 +74,208 @@ _REPOSITORY_CONTEXT_DOCUMENT_LIMIT = 4 _REPOSITORY_CONTEXT_DOCUMENT_BYTES = 4_096 _REPOSITORY_CONTEXT_DOCUMENT_TOTAL_BYTES = 8_192 +_PROJECTBRAIN_MANAGED_VALIDATION_REPOSITORY = "jens/projectbrain" +_PROJECTBRAIN_QUALITY_VALIDATION_CONTEXT = "Pull request / quality (pull_request)" +_PROJECTBRAIN_REQUIRED_VALIDATION_CONTEXTS = ( + MANAGED_VALIDATION_CONTEXT, + _PROJECTBRAIN_QUALITY_VALIDATION_CONTEXT, +) +_PROJECTBRAIN_REQUIRED_PROTECTION_LOCKS = { + "enable_push": False, + "enable_push_whitelist": False, + "enable_force_push": False, + "enable_force_push_allowlist": False, + "enable_bypass_allowlist": False, + "enable_status_check": True, + "block_admin_merge_override": True, +} +# Original source commit for the ProjectBrain-specific profile lineage. +_PROJECTBRAIN_VALIDATION_PROFILE_SOURCE_SHA = "57157b1b24d3679018b54970c934fc35d1735df7" +# SHA-256 of JSON-dumped jobs.full (sort_keys, compact separators), parsed with +# BaseLoader. It pins the reviewed ProjectBrain workflow copy below. +# Frozen workflow source and copied fixture raw SHA-256: +# 2f78d37ff51f38f0a3b0c9e222eb2dbf83d5108aee3615eb53f2c01283f37f34. +# Refresh only after review. +_PROJECTBRAIN_VALIDATION_FULL_JOB_DIGEST = ( + "a3fefb8865f0bd0d4a8c10af79eeb5e22367c5e82e9333a2fe04bdaf97d0193b" +) +_PROJECTBRAIN_VALIDATION_MAX_YAML_CHARS = 200_000 +_PROJECTBRAIN_VALIDATION_MAX_YAML_TOKENS = 10_000 +_PROJECTBRAIN_VALIDATION_MAX_YAML_DEPTH = 64 +_PROJECTBRAIN_VALIDATION_RUN_NAME = "Managed validation / ${{ inputs.profile || 'full' }}" +_PROJECTBRAIN_VALIDATION_DISPATCH_OPTIONS = [ + "test", + "lint", + "typecheck", + "build", + "security", + "full", +] +_PROJECTBRAIN_VALIDATION_TOP_LEVEL_KEYS = frozenset( + {"name", "run-name", "on", "permissions", "concurrency", "jobs"} +) +_PROJECTBRAIN_VALIDATION_CONCURRENCY = { + "group": "managed-validation-${{ gitea.repository }}-${{ gitea.ref }}", + "cancel-in-progress": "true", +} + + +class _UniqueWorkflowLoader(yaml.BaseLoader): + """Parse workflow YAML without YAML 1.1's special treatment of the `on` key.""" + + +def _construct_unique_workflow_mapping(loader: Any, node: Any) -> dict[str, Any]: + mapping: dict[str, Any] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node) + if not isinstance(key, str) or key in mapping: + raise yaml.constructor.ConstructorError( + "while constructing a workflow mapping", + node.start_mark, + "workflow mapping keys must be unique strings", + key_node.start_mark, + ) + mapping[key] = loader.construct_object(value_node) + return mapping + + +_UniqueWorkflowLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, + _construct_unique_workflow_mapping, +) + + +def _workflow_mapping(value: Any) -> dict[str, Any] | None: + return value if isinstance(value, dict) else None + + +def _workflow_yaml_is_bounded_and_alias_free(workflow_content: str) -> bool: + if len(workflow_content) > _PROJECTBRAIN_VALIDATION_MAX_YAML_CHARS: + return False + container_starts = ( + yaml.tokens.BlockMappingStartToken, + yaml.tokens.BlockSequenceStartToken, + yaml.tokens.FlowMappingStartToken, + yaml.tokens.FlowSequenceStartToken, + ) + container_ends = ( + yaml.tokens.BlockEndToken, + yaml.tokens.FlowMappingEndToken, + yaml.tokens.FlowSequenceEndToken, + ) + depth = 0 + try: + for token_count, token in enumerate( + yaml.scan(workflow_content, Loader=_UniqueWorkflowLoader), start=1 + ): + if token_count > _PROJECTBRAIN_VALIDATION_MAX_YAML_TOKENS: + return False + if isinstance(token, yaml.tokens.AnchorToken | yaml.tokens.AliasToken): + return False + if isinstance(token, container_starts): + depth += 1 + if depth > _PROJECTBRAIN_VALIDATION_MAX_YAML_DEPTH: + return False + elif isinstance(token, container_ends): + depth -= 1 + if depth < 0: + return False + except (yaml.YAMLError, RecursionError, TypeError, ValueError): + return False + return depth == 0 + + +def _projectbrain_managed_validation_is_strong(workflow_content: str) -> bool: + if not _workflow_yaml_is_bounded_and_alias_free(workflow_content): + return False + try: + # BaseLoader constructs only strings/lists/maps; it never creates Python objects. + workflow = yaml.load(workflow_content, Loader=_UniqueWorkflowLoader) # noqa: S506 + except (yaml.YAMLError, RecursionError, TypeError, ValueError): + return False + document = _workflow_mapping(workflow) + if document is None or document.get("name") != "Managed validation": + return False + if ( + not set(document).issubset(_PROJECTBRAIN_VALIDATION_TOP_LEVEL_KEYS) + or document.get("run-name") != _PROJECTBRAIN_VALIDATION_RUN_NAME + or document.get("permissions") != {"contents": "read"} + or document.get("concurrency") != _PROJECTBRAIN_VALIDATION_CONCURRENCY + ): + return False + + triggers = _workflow_mapping(document.get("on")) + if ( + triggers is None + or set(triggers) != {"push", "pull_request", "workflow_dispatch"} + ): + return False + dispatch = _workflow_mapping(triggers.get("workflow_dispatch")) + dispatch_inputs = _workflow_mapping(dispatch.get("inputs")) if dispatch is not None else None + profile_input = ( + _workflow_mapping(dispatch_inputs.get("profile")) + if dispatch_inputs is not None + else None + ) + if ( + dispatch_inputs is None + or set(dispatch) != {"inputs"} + or set(dispatch_inputs) != {"profile"} + or profile_input is None + or set(profile_input) != {"description", "required", "default", "type", "options"} + or not isinstance(profile_input.get("description"), str) + or profile_input.get("required") != "true" + or profile_input.get("default") != "full" + or profile_input.get("type") != "choice" + or profile_input.get("options") != _PROJECTBRAIN_VALIDATION_DISPATCH_OPTIONS + ): + return False + push = _workflow_mapping(triggers.get("push")) + if ( + push is None + or set(push) != {"branches"} + or push.get("branches") != ["main"] + ): + return False + pull_request = triggers.get("pull_request") + if pull_request not in (None, ""): + pull_request_mapping = _workflow_mapping(pull_request) + if pull_request_mapping is None: + return False + if set(pull_request_mapping) - {"branches"}: + return False + branches = pull_request_mapping.get("branches") + if branches is not None and branches != ["main"]: + return False + + jobs = _workflow_mapping(document.get("jobs")) + if jobs is None or set(jobs) != {"full"}: + return False + full_job = _workflow_mapping(jobs.get("full")) + if full_job is None: + return False + try: + full_job_digest = hashlib.sha256( + json.dumps(full_job, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + except (RecursionError, TypeError, ValueError): + return False + return full_job_digest == _PROJECTBRAIN_VALIDATION_FULL_JOB_DIGEST + + +def _managed_validation_profile( + repository_qualified_name: str, workflow_content: str | None +) -> str | None: + """Accept only the canonical workflow, except for ProjectBrain's stronger bounded profile.""" + if workflow_content is None: + return None + if repository_qualified_name.casefold() == _PROJECTBRAIN_MANAGED_VALIDATION_REPOSITORY: + if _projectbrain_managed_validation_is_strong(workflow_content): + return "projectbrain" + return None + digest = hashlib.sha256(workflow_content.encode("utf-8")).hexdigest() + return "canonical" if digest == MANAGED_VALIDATION_DIGEST else None + def _select_validation_workflow(workflows: list[dict[str, Any]]) -> dict[str, Any] | None: """Prefer the bounded managed contract over legacy validation workflows.""" @@ -177,6 +381,19 @@ def _branch_protection_projection(payload: dict[str, Any] | None) -> dict[str, A return {key: payload.get(key) for key in desired_keys} +def _branch_protection_effective_projection( + payload: dict[str, Any] | None, +) -> dict[str, Any] | None: + if payload is None: + return None + volatile_metadata = {"id", "created_at", "updated_at"} + return { + key: value + for key, value in payload.items() + if key not in volatile_metadata and key != "status_check_contexts" + } + + def _validate_ref(value: str, *, change_branch: bool = False) -> str: pattern = _SAFE_CHANGE_BRANCH if change_branch else _SAFE_REF if ( @@ -800,15 +1017,20 @@ class GiteaConnector: None, ) desired = _desired_branch_protection(default_branch or "main") - if current is not None: - existing_contexts = current.get("status_check_contexts") - if isinstance(existing_contexts, list): - desired["status_check_contexts"] = sorted( - { - MANAGED_VALIDATION_CONTEXT, - *(str(item) for item in existing_contexts if str(item).strip()), - } - ) + required_contexts = ( + _PROJECTBRAIN_REQUIRED_VALIDATION_CONTEXTS + if repository.qualified_name.casefold() == _PROJECTBRAIN_MANAGED_VALIDATION_REPOSITORY + else (MANAGED_VALIDATION_CONTEXT,) + ) + existing_contexts = current.get("status_check_contexts") if current is not None else None + preserved_contexts = ( + {str(item) for item in existing_contexts if str(item).strip()} + if isinstance(existing_contexts, list) + else set() + ) + desired["status_check_contexts"] = sorted( + set(required_contexts).union(preserved_contexts) + ) current_projection = _branch_protection_projection(current) protection_diff = { key: { @@ -839,13 +1061,51 @@ class GiteaConnector: if workflow_content is not None else None ) - workflow_action = ( - "create" - if workflow_content is None - else "none" - if workflow_digest == MANAGED_VALIDATION_DIGEST - else "update" + workflow_profile = _managed_validation_profile( + repository.qualified_name, workflow_content ) + is_projectbrain = ( + repository.qualified_name.casefold() + == _PROJECTBRAIN_MANAGED_VALIDATION_REPOSITORY + ) + if is_projectbrain and workflow_profile is None: + blockers.append("managed-validation-projectbrain-profile-invalid") + # Fail closed: governance must never replace ProjectBrain's + # repository-specific database/E2E gate with the generic baseline. + workflow_action = "none" + desired_workflow_digest = None + else: + workflow_action = ( + "create" + if workflow_content is None + else "none" + if workflow_profile is not None + else "update" + ) + desired_workflow_digest = ( + workflow_digest + if workflow_profile == "projectbrain" + else MANAGED_VALIDATION_DIGEST + ) + if is_projectbrain and current_projection is not None: + if any( + current_projection.get(key) != expected + for key, expected in _PROJECTBRAIN_REQUIRED_PROTECTION_LOCKS.items() + ): + blockers.append("managed-validation-projectbrain-protection-safety-locks-invalid") + if workflow_profile == "projectbrain": + desired = dict(current_projection) + desired["status_check_contexts"] = sorted( + set(required_contexts).union(preserved_contexts) + ) + protection_diff = { + key: { + "current": None if current_projection is None else current_projection.get(key), + "desired": value, + } + for key, value in desired.items() + if current_projection is None or current_projection.get(key) != value + } protection_action = "create" if current is None else "update" if protection_diff else "none" plan_payload = { "repositoryRef": repository_ref, @@ -853,7 +1113,9 @@ class GiteaConnector: "workflow": { "path": MANAGED_VALIDATION_PATH, "currentDigest": workflow_digest, - "desiredDigest": MANAGED_VALIDATION_DIGEST, + "desiredDigest": desired_workflow_digest, + "profile": workflow_profile + or ("projectbrain-required" if is_projectbrain else "canonical"), "action": workflow_action, }, "branchProtection": { @@ -864,17 +1126,50 @@ class GiteaConnector: }, "blockers": blockers, } + effective_protection = _branch_protection_effective_projection(current) + plan_hash_source: dict[str, Any] = plan_payload + if is_projectbrain: + plan_hash_source = { + "plan": plan_payload, + "effectiveProtection": effective_protection, + } plan_hash = hashlib.sha256( - json.dumps(plan_payload, sort_keys=True, separators=(",", ":")).encode() + json.dumps(plan_hash_source, sort_keys=True, separators=(",", ":")).encode() ).hexdigest() + protection_only_available = ( + is_projectbrain + and current is not None + and current.get("enable_status_check") is True + and default_branch == "main" + and isinstance(desired.get("status_check_contexts"), list) + and set(blockers) == {"managed-validation-projectbrain-profile-invalid"} + ) + protection_only_plan_hash = ( + hashlib.sha256( + json.dumps( + { + "applyScope": "protection_only", + "plan": plan_payload, + "effectiveProtection": effective_protection, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest() + if protection_only_available + else None + ) return { **plan_payload, "planHash": plan_hash, + "protectionOnlyPlanHash": protection_only_plan_hash, + "protectionOnlyAvailable": protection_only_available, "compliant": not blockers and workflow_action == "none" and protection_action == "none", "_repository": repository, "_workflowContent": workflow_content, "_workflowSha": workflow_sha, "_currentProtection": current, + "_protectionOnlyAvailable": protection_only_available, } @governed_tool("gitea.branchprotection.get") @@ -946,17 +1241,103 @@ class GiteaConnector: @governed_tool("gitea.branchprotection.ensure") def branch_protection_ensure( - self, context: ConnectorContext, repository_ref: str, expected_plan_hash: str + self, + context: ConnectorContext, + repository_ref: str, + expected_plan_hash: str, + apply_scope: Literal["all", "protection_only"] = "all", ) -> dict[str, Any]: + if apply_scope not in {"all", "protection_only"}: + raise GiteaError("GITEA_INPUT_INVALID", "governance apply scope is not allowlisted") plan = self._governance_plan_data(context, repository_ref) + expected_hash = ( + plan["planHash"] + if apply_scope == "all" + else plan["protectionOnlyPlanHash"] + ) if ( - not re.fullmatch(r"[0-9a-f]{64}", expected_plan_hash) - or expected_plan_hash != plan["planHash"] + not isinstance(expected_hash, str) + or not re.fullmatch(r"[0-9a-f]{64}", expected_plan_hash) + or expected_plan_hash != expected_hash ): raise GiteaError( "GITEA_PLAN_STALE", "governance state changed; preview the repository again before applying", ) + + if apply_scope == "protection_only": + allowed_blockers = {"managed-validation-projectbrain-profile-invalid"} + if ( + not plan["_protectionOnlyAvailable"] + or any(blocker not in allowed_blockers for blocker in plan["blockers"]) + ): + raise GiteaError( + "GITEA_GOVERNANCE_BLOCKED", + "ProjectBrain protection-only apply requires an existing " + "enabled status-check rule", + ) + current_protection = plan["_currentProtection"] + if current_protection is None: + raise GiteaError( + "GITEA_GOVERNANCE_BLOCKED", + "ProjectBrain protection-only apply requires an existing " + "enabled status-check rule", + ) + current_contexts = current_protection.get("status_check_contexts") + expected_contexts = list(plan["branchProtection"]["desired"]["status_check_contexts"]) + protection_changed = current_contexts != expected_contexts + if not protection_changed: + return self._envelope( + context, + { + **self._public_governance_plan(plan), + "applyScope": apply_scope, + "changed": False, + "verified": True, + "temporaryPushAllowlistUsed": False, + }, + source=f"gitea:{repository_ref}", + ) + self._writer().update_branch_protection( + plan["_repository"], + str(plan["defaultBranch"]), + {"status_check_contexts": expected_contexts}, + ) + verified_plan = self._governance_plan_data(context, repository_ref) + verified_protection = verified_plan["branchProtection"]["current"] + expected_effective_protection = _branch_protection_effective_projection( + current_protection + ) + actual_effective_protection = _branch_protection_effective_projection( + verified_plan["_currentProtection"] + ) + actual_contexts = set( + verified_protection.get("status_check_contexts") or [] + ) if verified_protection is not None else set() + if ( + verified_protection is None + or verified_protection.get("enable_status_check") is not True + or actual_contexts != set(expected_contexts) + or actual_effective_protection != expected_effective_protection + or verified_plan["workflow"]["currentDigest"] + != plan["workflow"]["currentDigest"] + ): + raise GiteaError( + "GITEA_GOVERNANCE_VERIFY_FAILED", + "Gitea did not retain the requested ProjectBrain protection-only contract", + ) + return self._envelope( + context, + { + **self._public_governance_plan(verified_plan), + "applyScope": apply_scope, + "changed": True, + "verified": True, + "temporaryPushAllowlistUsed": False, + }, + source=f"gitea:{repository_ref}", + ) + if plan["blockers"]: raise GiteaError( "GITEA_GOVERNANCE_BLOCKED", @@ -965,7 +1346,12 @@ class GiteaConnector: if plan["compliant"]: return self._envelope( context, - {**self._public_governance_plan(plan), "changed": False, "verified": True}, + { + **self._public_governance_plan(plan), + "applyScope": apply_scope, + "changed": False, + "verified": True, + }, source=f"gitea:{repository_ref}", ) @@ -992,11 +1378,16 @@ class GiteaConnector: if protection_action == "create": writer.create_branch_protection(repository, desired) elif protection_action == "update": - edit_payload = { - key: value - for key, value in desired.items() - if key not in {"rule_name", "branch_name"} - } + if plan["workflow"]["profile"] == "projectbrain": + edit_payload = { + "status_check_contexts": desired["status_check_contexts"] + } + else: + edit_payload = { + key: value + for key, value in desired.items() + if key not in {"rule_name", "branch_name"} + } writer.update_branch_protection(repository, default_branch, edit_payload) except GiteaError: if workflow_changed: @@ -1027,7 +1418,36 @@ class GiteaConnector: raise verified_plan = self._governance_plan_data(context, repository_ref) - if not verified_plan["compliant"]: + is_projectbrain_profile = plan["workflow"]["profile"] == "projectbrain" + verified_contexts = set( + (verified_plan["branchProtection"]["current"] or {}).get( + "status_check_contexts" + ) + or [] + ) + expected_contexts = set( + plan["branchProtection"]["desired"].get("status_check_contexts") or [] + ) + projectbrain_protection_changed = is_projectbrain_profile and ( + verified_contexts != expected_contexts + or ( + plan["_currentProtection"] is not None + and _branch_protection_effective_projection( + verified_plan["_currentProtection"] + ) + != _branch_protection_effective_projection(plan["_currentProtection"]) + ) + ) + projectbrain_workflow_changed = ( + is_projectbrain_profile + and verified_plan["workflow"]["currentDigest"] + != plan["workflow"]["currentDigest"] + ) + if ( + not verified_plan["compliant"] + or projectbrain_protection_changed + or projectbrain_workflow_changed + ): raise GiteaError( "GITEA_GOVERNANCE_VERIFY_FAILED", "Gitea did not retain the requested governance contract", @@ -1036,6 +1456,7 @@ class GiteaConnector: context, { **self._public_governance_plan(verified_plan), + "applyScope": apply_scope, "changed": True, "verified": True, "temporaryPushAllowlistUsed": temporary_push_allowlist_used, @@ -1100,8 +1521,12 @@ class GiteaConnector: for item in plans: repository_ref = str(item.get("repositoryRef", "")) try: + apply_scope = str(item.get("applyScope", "all")) result = self.branch_protection_ensure( - context, repository_ref, str(item.get("planHash", "")) + context, + repository_ref, + str(item.get("planHash", "")), + apply_scope=apply_scope, # type: ignore[arg-type] )["data"] items.append( { @@ -1109,6 +1534,7 @@ class GiteaConnector: "applied": True, "changed": bool(result.get("changed")), "verified": bool(result.get("verified")), + "applyScope": str(result.get("applyScope", "all")), } ) except GiteaError as exc: @@ -4519,13 +4945,13 @@ GITEA_READ_TOOLS = tuple( "gitea.change.status", "gitea.change.review", "gitea.workspace.status", + "gitea.branchprotection.preview", + "gitea.portfolio.governance.preview", + "gitea.portfolio.governance.verify", } - else ( - "1.8.0" - if name.startswith("gitea.branchprotection.") - or name.startswith("gitea.portfolio.governance.") - else "1.5.0" - ), + else "1.8.0" + if name == "gitea.branchprotection.get" + else "1.5.0", connector_type="gitea", risk_class="R1", input_schema=_tool_input_schema(name), @@ -4620,6 +5046,11 @@ def _write_tool_input_schema(name: str) -> dict[str, Any]: } if name == "gitea.branchprotection.ensure": properties["expectedPlanHash"] = {"type": "string", "pattern": "^[0-9a-f]{64}$"} + properties["applyScope"] = { + "type": "string", + "enum": ["all", "protection_only"], + "default": "all", + } required.append("expectedPlanHash") elif name == "gitea.portfolio.governance.apply": return { @@ -4639,7 +5070,19 @@ def _write_tool_input_schema(name: str) -> dict[str, Any]: "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,127}$", }, - "planHash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "planHash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": ( + "Use planHash for all scope or protectionOnlyPlanHash " + "for protection_only scope." + ), + }, + "applyScope": { + "type": "string", + "enum": ["all", "protection_only"], + "default": "all", + }, }, "required": ["repositoryRef", "planHash"], }, @@ -4827,13 +5270,10 @@ GITEA_PR_WRITE_TOOLS = tuple( "gitea.pullrequest.update", "gitea.changeset.apply", } + else "1.9.0" + if name in {"gitea.branchprotection.ensure", "gitea.portfolio.governance.apply"} else "1.8.0" - if name - in { - "gitea.repository.create", - "gitea.branchprotection.ensure", - "gitea.portfolio.governance.apply", - } + if name == "gitea.repository.create" else "1.5.0" ), connector_type="gitea", diff --git a/services/control_api/contextforge/fake.py b/services/control_api/contextforge/fake.py index 7c62796..993604e 100644 --- a/services/control_api/contextforge/fake.py +++ b/services/control_api/contextforge/fake.py @@ -9,6 +9,49 @@ from uuid import UUID, uuid4 import httpx +APPOPS_INPUT_SCHEMAS: dict[str, dict[str, Any]] = { + "server.appops.deployment.status": { + "type": "object", + "additionalProperties": False, + }, + "server.appops.self_upgrade.preflight": { + "type": "object", + "additionalProperties": False, + "properties": { + "repository": {"type": "string", "const": "jens/unraid-appops-gateway"}, + "revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "reason": {"type": "string", "minLength": 8, "maxLength": 500}, + "expected_current_revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + }, + "required": ["repository", "revision", "reason"], + }, + "server.appops.self_upgrade.apply": { + "type": "object", + "additionalProperties": False, + "properties": { + "repository": {"type": "string", "const": "jens/unraid-appops-gateway"}, + "revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "reason": {"type": "string", "minLength": 8, "maxLength": 500}, + "expected_preflight_fingerprint": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "idempotency_key": {"type": "string", "pattern": "^[A-Za-z0-9._:-]{8,128}$"}, + "expected_current_revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + }, + "required": [ + "repository", + "revision", + "reason", + "expected_preflight_fingerprint", + "idempotency_key", + ], + }, + "server.appops.self_upgrade.status": { + "type": "object", + "additionalProperties": False, + "properties": {"operation_id": {"type": "string", "pattern": "^[0-9a-f]{64}$"}}, + "required": ["operation_id"], + }, +} + class FakeContextForge: """Fake REST gateway for offline adapter and reconciliation tests.""" @@ -201,6 +244,10 @@ class FakeContextForge: "server.deployment.status", "server.deployment.verify", "server.deployment.rollback", + "server.appops.deployment.status", + "server.appops.self_upgrade.preflight", + "server.appops.self_upgrade.apply", + "server.appops.self_upgrade.status", ), "itworx-opencode": ( "opencode.capabilities.get", @@ -251,7 +298,9 @@ class FakeContextForge: @staticmethod def _tool_payload(tool_name: str) -> dict[str, Any]: payload: dict[str, Any] = {"id": str(uuid4()), "name": tool_name} - if tool_name == "gitea.repository.create": + if tool_name in APPOPS_INPUT_SCHEMAS: + payload["inputSchema"] = deepcopy(APPOPS_INPUT_SCHEMAS[tool_name]) + elif tool_name == "gitea.repository.create": payload["inputSchema"] = { "type": "object", "properties": { diff --git a/services/runtime/connector_appops.py b/services/runtime/connector_appops.py index e866ec4..5e6068e 100644 --- a/services/runtime/connector_appops.py +++ b/services/runtime/connector_appops.py @@ -45,6 +45,10 @@ UPSTREAM_TO_PUBLIC = { "deployment.status": "server.deployment.status", "deployment.verify": "server.deployment.verify", "deployment.rollback": "server.deployment.rollback", + "appops.deployment.status": "server.appops.deployment.status", + "appops.self_upgrade.preflight": "server.appops.self_upgrade.preflight", + "appops.self_upgrade.apply": "server.appops.self_upgrade.apply", + "appops.self_upgrade.status": "server.appops.self_upgrade.status", } PUBLIC_TO_UPSTREAM = {value: key for key, value in UPSTREAM_TO_PUBLIC.items()} MAX_BODY_BYTES = 1024 * 1024 diff --git a/services/runtime/connector_gitea.py b/services/runtime/connector_gitea.py index d23396e..8ef7204 100644 --- a/services/runtime/connector_gitea.py +++ b/services/runtime/connector_gitea.py @@ -3,7 +3,7 @@ from __future__ import annotations import os import re from collections.abc import Callable -from typing import Any, Literal, ParamSpec, TypeVar, cast +from typing import Annotated, Any, Literal, ParamSpec, TypeVar, cast from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver.context import Context @@ -87,6 +87,14 @@ class ResearchFetchOutput(BaseModel): metadata: dict[str, Any] | None = None +class PortfolioGovernanceApplyPlan(BaseModel): + model_config = ConfigDict(extra="forbid") + + repositoryRef: str + planHash: str = Field(pattern=r"^[0-9a-f]{64}$") + applyScope: Literal["all", "protection_only"] = "all" + + def _repository_ref(name: str) -> str: value = re.sub(r"[^a-z0-9]+", "-", name.casefold()).strip("-") if not value: @@ -655,9 +663,12 @@ def build_server() -> MCPServer[Any]: repositoryRef: str, expectedPlanHash: str, context: Context[Any, Any], + applyScope: Literal["all", "protection_only"] = "all", ) -> dict[str, Any]: - """Apply one unchanged governance preview and verify the stored result.""" - return connector.branch_protection_ensure(current(context), repositoryRef, expectedPlanHash) + """Apply one unchanged governance preview with its explicitly hashed scope.""" + return connector.branch_protection_ensure( + current(context), repositoryRef, expectedPlanHash, apply_scope=applyScope + ) @write_tool( name="gitea.portfolio.governance.apply", @@ -665,10 +676,15 @@ def build_server() -> MCPServer[Any]: structured_output=True, ) async def portfolio_governance_apply( - plans: list[dict[str, str]], context: Context[Any, Any] + context: Context[Any, Any], + plans: Annotated[ + list[PortfolioGovernanceApplyPlan], Field(min_length=1, max_length=5) + ], ) -> dict[str, Any]: """Apply at most five exact governance plans; every item is independently verified.""" - return connector.portfolio_governance_apply(current(context), plans) + return connector.portfolio_governance_apply( + current(context), [plan.model_dump() for plan in plans] + ) @write_tool(name="gitea.branch.create", annotations=SAFE_WRITE_TOOL, structured_output=True) async def branch_create( diff --git a/tests/fixtures/projectbrain-managed-validation.yml b/tests/fixtures/projectbrain-managed-validation.yml new file mode 100644 index 0000000..d6abbed --- /dev/null +++ b/tests/fixtures/projectbrain-managed-validation.yml @@ -0,0 +1,210 @@ +name: Managed validation +run-name: "Managed validation / ${{ inputs.profile || 'full' }}" + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + inputs: + profile: + description: Allowlisted validation profile + required: true + default: full + type: choice + options: [test, lint, typecheck, build, security, full] + +permissions: + contents: read + +concurrency: + group: managed-validation-${{ gitea.repository }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + full: + name: full + runs-on: linux-validation + timeout-minutes: 30 + env: + CI: "true" + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: projectbrain_integration + POSTGRES_USER: projectbrain + POSTGRES_PASSWORD: projectbrain-test-password + options: >- + --health-cmd "pg_isready -U projectbrain -d projectbrain_integration" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Validate repository with a bounded profile + shell: bash + env: + REQUESTED_PROFILE: ${{ inputs.profile }} + run: | + set -euo pipefail + profile="${REQUESTED_PROFILE:-full}" + case "${profile}" in + test|lint|typecheck|build|security|full) ;; + *) echo "Profile is not allowlisted" >&2; exit 2 ;; + esac + if [[ "${profile}" == security && ( ! -f package.json || ! -f pnpm-lock.yaml ) ]]; then + echo "Security profile requires the repository's pinned pnpm audit:security command" >&2 + exit 2 + fi + + git diff --check + if git grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then + echo "Unresolved merge markers detected" >&2 + exit 1 + fi + + # MANAGED_FAST_PATH: only documentation may skip product tests. + # Include deletions and renames; workflow and runner changes must + # always execute validation. + if [[ -n "${GITHUB_BASE_REF:-}" ]]; then + git fetch --no-tags origin "+refs/heads/${GITHUB_BASE_REF}:refs/remotes/origin/${GITHUB_BASE_REF}" + managed_base="origin/${GITHUB_BASE_REF}" + git diff --check "${managed_base}..HEAD" + mapfile -t managed_changed_files < <( + git diff --name-only --no-renames "${managed_base}..HEAD" + ) + managed_runtime_change=0 + for managed_path in "${managed_changed_files[@]}"; do + case "${managed_path}" in + *.md|*.mdx) + ;; + *) + managed_runtime_change=1 + break + ;; + esac + done + if [[ "${#managed_changed_files[@]}" -gt 0 && "${managed_runtime_change}" -eq 0 ]]; then + printf 'Managed validation fast path: %s non-runtime file(s); full product suite skipped.\n' \ + "${#managed_changed_files[@]}" + if [[ "${profile}" == full ]]; then + node scripts/check-doc-links.mjs + node scripts/check-unraid-template-readiness.mjs + fi + exit 0 + fi + fi + if [[ -f pyproject.toml || -f requirements.txt ]]; then + # Compile only tracked Python sources. Running compileall after a + # Node install would otherwise traverse node_modules and turn a + # lightweight baseline into a large runner workload. + git ls-files -z '*.py' | xargs -0 -r python -m py_compile + if [[ -f uv.lock ]]; then + python -m venv "${RUNNER_TEMP}/managed-uv" + uv_python="${RUNNER_TEMP}/managed-uv/bin/python" + "${uv_python}" -m pip install --disable-pip-version-check uv==0.10.0 + managed_uv="${RUNNER_TEMP}/managed-uv/bin/uv" + export UV_PROJECT_ENVIRONMENT="${RUNNER_TEMP}/managed-project-venv" + "${managed_uv}" sync --locked + export PATH="${UV_PROJECT_ENVIRONMENT}/bin:${PATH}" + if [[ "${profile}" == test || "${profile}" == full ]]; then + if "${managed_uv}" run python -c 'import pytest' 2>/dev/null; then + "${managed_uv}" run python -m pytest + fi + fi + if [[ "${profile}" == lint || "${profile}" == full ]]; then + if "${managed_uv}" run python -c 'import ruff' 2>/dev/null; then + "${managed_uv}" run python -m ruff check . + fi + fi + elif [[ -f requirements.txt ]]; then + python -m venv "${RUNNER_TEMP}/managed-python" + managed_python="${RUNNER_TEMP}/managed-python/bin/python" + "${managed_python}" -m pip install --disable-pip-version-check -r requirements.txt + export PATH="${RUNNER_TEMP}/managed-python/bin:${PATH}" + if [[ "${profile}" == test || "${profile}" == full ]]; then + if "${managed_python}" -c 'import pytest' 2>/dev/null; then + "${managed_python}" -m pytest + fi + fi + fi + fi + + # Keep ordinary unit/contract validation database-independent. The + # real PostgreSQL suite is enabled only after migrations are applied + # in the deployment-authoritative full profile below. + if [[ -f package.json ]]; then + corepack enable + if [[ -f pnpm-lock.yaml ]]; then + pnpm install --frozen-lockfile + if [[ "${profile}" == test ]]; then + pnpm --if-present test + [[ -f backend/package.json ]] && pnpm --filter projectbrain-api run test + elif [[ "${profile}" == full ]]; then + pnpm run test:coverage + [[ -f backend/package.json ]] && pnpm --filter projectbrain-api run test:coverage + fi + if [[ "${profile}" == security ]]; then + pnpm run audit:security + fi + if [[ "${profile}" == lint || "${profile}" == full ]]; then + pnpm --if-present lint + [[ -f backend/package.json ]] && pnpm --filter projectbrain-api run lint + fi + if [[ "${profile}" == typecheck || "${profile}" == full ]]; then + pnpm --if-present typecheck + [[ -f backend/package.json ]] && pnpm --filter projectbrain-api run typecheck + fi + if [[ "${profile}" == build || "${profile}" == full ]]; then + pnpm --if-present build + [[ -f backend/package.json ]] && pnpm --filter projectbrain-api run build + fi + if [[ "${profile}" == full ]]; then + pnpm run audit:openapi + pnpm run audit:architecture + pnpm run audit:docs + pnpm run audit:v1-freeze + pnpm run audit:unraid-template + fi + if [[ "${profile}" == full && -f backend/package.json ]]; then + export DATABASE_URL="postgresql://projectbrain:projectbrain-test-password@postgres:5432/projectbrain_integration?schema=public" + export TEST_DATABASE_URL="${DATABASE_URL}" + pnpm --dir backend prisma:generate + pnpm --dir backend db:deploy + pnpm --dir backend db:seed + pnpm --dir backend test:database + pnpm --dir backend exec prisma migrate reset --schema prisma/schema.prisma --force --skip-seed + pnpm --dir backend db:seed + fi + if [[ "${profile}" == full && -f playwright.config.ts ]]; then + pnpm exec playwright install --with-deps --force chromium chromium-headless-shell + pnpm test:e2e + pnpm test:e2e:real + fi + elif [[ -f package-lock.json ]]; then + npm ci + [[ "${profile}" == test || "${profile}" == full ]] && npm run --if-present test + [[ "${profile}" == lint || "${profile}" == full ]] && npm run --if-present lint + if [[ "${profile}" == typecheck || "${profile}" == full ]]; then + npm run --if-present typecheck + fi + [[ "${profile}" == build || "${profile}" == full ]] && npm run --if-present build + fi + fi + + if [[ -f go.mod ]]; then + if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then + go test ./... + fi + fi + if [[ -f Cargo.toml ]]; then + if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then + cargo test --locked + fi + fi + if compgen -G '*.sln' >/dev/null; then + if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then + dotnet test --configuration Release + fi + fi diff --git a/tests/test_appops_connector.py b/tests/test_appops_connector.py index fcfd576..ccaef53 100644 --- a/tests/test_appops_connector.py +++ b/tests/test_appops_connector.py @@ -18,7 +18,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_appops_tool_catalog_is_bijective_and_server_namespaced() -> None: - assert len(UPSTREAM_TO_PUBLIC) == len(PUBLIC_TO_UPSTREAM) == 25 + assert len(UPSTREAM_TO_PUBLIC) == len(PUBLIC_TO_UPSTREAM) == 29 assert all(name.startswith("server.") for name in PUBLIC_TO_UPSTREAM) assert PUBLIC_TO_UPSTREAM["server.summary.get"] == "get_system_summary" assert PUBLIC_TO_UPSTREAM["server.operation.execute"] == "server.operation.execute" @@ -28,6 +28,19 @@ def test_appops_tool_catalog_is_bijective_and_server_namespaced() -> None: assert PUBLIC_TO_UPSTREAM["server.deployment.rollback"] == "deployment.rollback" assert PUBLIC_TO_UPSTREAM["server.deployment.target.enroll"] == "target.enroll" assert PUBLIC_TO_UPSTREAM["server.ci.runners.status"] == "gitea.actions.runners.status" + assert PUBLIC_TO_UPSTREAM["server.appops.deployment.status"] == "appops.deployment.status" + assert ( + PUBLIC_TO_UPSTREAM["server.appops.self_upgrade.preflight"] + == "appops.self_upgrade.preflight" + ) + assert ( + PUBLIC_TO_UPSTREAM["server.appops.self_upgrade.apply"] + == "appops.self_upgrade.apply" + ) + assert ( + PUBLIC_TO_UPSTREAM["server.appops.self_upgrade.status"] + == "appops.self_upgrade.status" + ) def test_every_public_appops_tool_has_governance_metadata() -> None: @@ -42,6 +55,50 @@ def test_every_public_appops_tool_has_governance_metadata() -> None: assert set(PUBLIC_TO_UPSTREAM) <= canonical_names +def test_appops_self_upgrade_manifests_are_fixed_target_and_fingerprint_bound() -> None: + specs = {} + for path in (ROOT / "catalog" / "tools").glob("*.yaml"): + document = yaml.safe_load(path.read_text(encoding="utf-8")) + spec = document.get("spec") if isinstance(document, dict) else None + if isinstance(spec, dict) and spec.get("canonicalName", "").startswith( + "server.appops." + ): + specs[spec["canonicalName"]] = spec + + assert set(specs) == { + "server.appops.deployment.status", + "server.appops.self_upgrade.preflight", + "server.appops.self_upgrade.apply", + "server.appops.self_upgrade.status", + } + for name in ( + "server.appops.self_upgrade.preflight", + "server.appops.self_upgrade.apply", + ): + schema = specs[name]["inputSchema"] + assert schema["properties"]["repository"]["const"] == "jens/unraid-appops-gateway" + assert schema["properties"]["revision"]["pattern"] == "^[0-9a-f]{40}$" + assert schema["properties"]["reason"]["minLength"] == 8 + assert schema["properties"]["reason"]["maxLength"] == 500 + assert schema["properties"]["expected_current_revision"]["pattern"] == "^[0-9a-f]{40}$" + apply = specs["server.appops.self_upgrade.apply"] + assert apply["riskClass"] == "R3" + assert apply["readOnly"] is False + assert apply["inputSchema"]["properties"]["expected_preflight_fingerprint"][ + "pattern" + ] == "^[0-9a-f]{64}$" + assert { + "expected_preflight_fingerprint", + "idempotency_key", + } <= set(apply["inputSchema"]["required"]) + policy = yaml.safe_load( + (ROOT / "catalog" / "policies" / "041-appops-governed-operations.yaml").read_text( + encoding="utf-8" + ) + ) + assert "server.appops.self_upgrade.apply" in policy["spec"]["match"]["toolPatterns"] + + def test_appops_proxy_rewrites_only_registered_tool_calls() -> None: request = { "jsonrpc": "2.0", @@ -73,6 +130,35 @@ def test_appops_proxy_rewrites_only_registered_tool_calls() -> None: with pytest.raises(ValueError, match="not registered"): rewrite_request({"method": "tools/call", "params": {"name": "shell.execute"}}) + upgrade_request = { + "jsonrpc": "2.0", + "id": 10, + "method": "tools/call", + "params": { + "name": "server.appops.self_upgrade.apply", + "arguments": { + "repository": "jens/unraid-appops-gateway", + "revision": "a" * 40, + "reason": "approved release", + "expected_preflight_fingerprint": "b" * 64, + "idempotency_key": "upgrade-2026-09-30", + "expected_current_revision": "c" * 40, + }, + }, + } + upgrade_rewritten = rewrite_request(upgrade_request) + assert upgrade_rewritten["params"]["name"] == "appops.self_upgrade.apply" + assert upgrade_rewritten["params"]["arguments"] == upgrade_request["params"]["arguments"] + + for upstream_name in ( + "appops.deployment.status", + "appops.self_upgrade.preflight", + "appops.self_upgrade.apply", + "appops.self_upgrade.status", + ): + with pytest.raises(ValueError, match="not registered"): + rewrite_request({"method": "tools/call", "params": {"name": upstream_name}}) + def test_appops_proxy_rewrites_discovery_and_drops_unknown_tools() -> None: response = { @@ -84,6 +170,10 @@ def test_appops_proxy_rewrites_discovery_and_drops_unknown_tools() -> None: {"name": "gitea.projects.catalog", "description": "projects"}, {"name": "deployment.preflight", "description": "deployment preflight"}, {"name": "deployment.apply", "description": "typed deployment"}, + {"name": "appops.deployment.status", "description": "AppOps revision"}, + {"name": "appops.self_upgrade.preflight", "description": "upgrade preflight"}, + {"name": "appops.self_upgrade.apply", "description": "upgrade apply"}, + {"name": "appops.self_upgrade.status", "description": "upgrade status"}, {"name": "future_unsafe_tool", "description": "unknown"}, ] }, @@ -93,6 +183,10 @@ def test_appops_proxy_rewrites_discovery_and_drops_unknown_tools() -> None: {"name": "server.project.list", "description": "projects"}, {"name": "server.deployment.preflight", "description": "deployment preflight"}, {"name": "server.deployment.apply", "description": "typed deployment"}, + {"name": "server.appops.deployment.status", "description": "AppOps revision"}, + {"name": "server.appops.self_upgrade.preflight", "description": "upgrade preflight"}, + {"name": "server.appops.self_upgrade.apply", "description": "upgrade apply"}, + {"name": "server.appops.self_upgrade.status", "description": "upgrade status"}, ] diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 90a57c0..16871b9 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -33,7 +33,7 @@ class CatalogLoaderTests(unittest.TestCase): def test_committed_catalog_validates_without_network_or_secret_values(self) -> None: report = CatalogLoader(ROOT / "catalog").load() self.assertTrue(report.valid, report.issues) - self.assertEqual(len(report.documents), 277) + self.assertEqual(len(report.documents), 281) self.assertEqual(len(report.issues), 0) self.assertIsNotNone(report.desired_hash) diff --git a/tests/test_gitea_connector.py b/tests/test_gitea_connector.py index bf7f88e..e98132e 100644 --- a/tests/test_gitea_connector.py +++ b/tests/test_gitea_connector.py @@ -24,7 +24,7 @@ from connectors.gitea.changesets import ( validate_operations, ) from connectors.gitea.managed_validation import MANAGED_VALIDATION_WORKFLOW -from connectors.gitea.server import _select_validation_workflow +from connectors.gitea.server import _managed_validation_profile, _select_validation_workflow from connectors.knowledge import KnowledgeConnector from packages.connector_kit import ( ConnectorContext, @@ -43,6 +43,49 @@ from services.control_api.gitea.registry import registration_from_project PROJECT_ID = UUID("11111111-1111-1111-1111-111111111111") OTHER_PROJECT_ID = UUID("22222222-2222-2222-2222-222222222222") ROOT = Path(__file__).resolve().parents[1] +PROJECTBRAIN_VALIDATION_WORKFLOW = ( + ROOT / "tests" / "fixtures" / "projectbrain-managed-validation.yml" +).read_text(encoding="utf-8") + + +def projectbrain_connector( + server: FakeGiteaServer | None = None, +) -> tuple[GiteaConnector, FakeGiteaServer]: + fixture = server or FakeGiteaServer() + fixture._repos["Jens/projectbrain"] = { + "owner": {"login": "Jens"}, + "name": "projectbrain", + "full_name": "Jens/projectbrain", + "description": "ProjectBrain", + "private": True, + "default_branch": "main", + } + fixture._managed_workflows["Jens/projectbrain"] = PROJECTBRAIN_VALIDATION_WORKFLOW + protection = copy.deepcopy(fixture._branch_protections["acme/hub"][0]) + protection["status_check_contexts"] = ["Pull request / quality (pull_request)"] + protection["required_approvals"] = 2 + fixture._branch_protections["Jens/projectbrain"] = [protection] + settings = GiteaSettings( + base_url="http://gitea.test", + token_reference="docker-secret://gitea_mcp_token", # noqa: S106 + allow_insecure_http_for_tests=True, + ) + client = GiteaClient( + settings, StaticTokenProvider("fixture-token-must-not-leak"), transport=fixture.transport() + ) + registry = GiteaRepositoryRegistry( + ( + RegisteredRepository( + repository_ref="projectbrain-repository", + project_id=PROJECT_ID, + owner="Jens", + name="projectbrain", + source_uri="itworx-git://projectbrain/projectbrain-repository", + private=True, + ), + ) + ) + return GiteaConnector(client, registry, write_client=client), fixture def context(project_id: UUID = PROJECT_ID, slug: str = "hub") -> ConnectorContext: @@ -114,6 +157,262 @@ class GiteaConnectorTests(unittest.TestCase): self.assertIs(_select_validation_workflow([legacy, managed]), managed) self.assertIs(_select_validation_workflow([managed, legacy]), managed) + def test_projectbrain_managed_validation_profile_is_pinned_and_repository_scoped(self) -> None: + self.assertEqual( + _managed_validation_profile("Jens/projectbrain", PROJECTBRAIN_VALIDATION_WORKFLOW), + "projectbrain", + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace("pnpm test:e2e:real", ""), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + "pnpm test:e2e:real", "# pnpm test:e2e:real" + ), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + "pnpm test:e2e:real", 'echo "pnpm test:e2e:real"' + ), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + 'export TEST_DATABASE_URL="${DATABASE_URL}"', + '# export TEST_DATABASE_URL="${DATABASE_URL}"', + ), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " image: postgres:16", " image: postgres:15" + ), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace(" pull_request:\n", ""), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " pull_request:\n", ' pull_request:\n paths: ["backend/**"]\n' + ), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " pull_request:\n", " pull_request:\n types: [edited]\n" + ), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace("branches: [main]", "branches: [master]"), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + "branches: [main]", "branches: [main, '!main']" + ), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " pull_request:\n", + " pull_request:\n branches: [main, '!main']\n", + ), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " name: full\n", " name: full\n if: false\n" + ), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " name: full\n", " name: full\n needs: [conditional-setup]\n" + ), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " - name: Validate repository with a bounded profile\n", + " - name: Validate repository with a bounded profile\n" + " if: false\n", + ), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " name: full\n", " name: full\n continue-on-error: true\n" + ), + ) + ) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " - name: Validate repository with a bounded profile\n", + " - name: Validate repository with a bounded profile\n" + " continue-on-error: true\n", + ), + ) + ) + bypass_mutations = ( + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " pnpm test:e2e:real\n", + " cat <<'EOF'\n" + " pnpm test:e2e:real\n" + " EOF\n", + ), + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " pnpm --dir backend test:database\n", + " exit 0\n" + " pnpm --dir backend test:database\n", + ), + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " pnpm test:e2e:real\n", + " if false; then\n" + " pnpm test:e2e:real\n" + " fi\n", + ), + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " pnpm test:e2e:real\n", + " export PATH=/tmp/untrusted:$PATH\n" + " pnpm test:e2e:real\n", + ), + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 " + "# v4.2.2\n", + " - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 " + "# v4.2.2\n" + " - run: echo injected-action\n", + ), + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + "run-name: \"Managed validation / ${{ inputs.profile || 'full' }}\"", + 'run-name: "Managed validation / full"', + ), + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " default: full\n", " default: test\n" + ), + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + "permissions:\n", "env:\n PATH: /tmp/untrusted\n\npermissions:\n" + ), + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + "permissions:\n", "defaults:\n run:\n shell: sh\n\npermissions:\n" + ), + PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + "jobs:\n", "jobs:\n extra:\n runs-on: linux-validation\n steps: []\n" + ), + ) + for modified_workflow in bypass_mutations: + with self.subTest(modified_workflow=modified_workflow[-160:]): + self.assertIsNone( + _managed_validation_profile("Jens/projectbrain", modified_workflow) + ) + + alias_chain = ["level0: &level0 [marker]"] + for level in range(1, 23): + alias_chain.append( + f"level{level}: &level{level} [*level{level - 1}, *level{level - 1}]" + ) + alias_bomb = PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + "jobs:\n", "\n".join(alias_chain) + "\njobs:\n" + ) + self.assertIsNone(_managed_validation_profile("Jens/projectbrain", alias_bomb)) + self.assertIsNone( + _managed_validation_profile( + "Jens/projectbrain", PROJECTBRAIN_VALIDATION_WORKFLOW + "\njobs: {}\n" + ) + ) + self.assertIsNone( + _managed_validation_profile("Acme/hub", PROJECTBRAIN_VALIDATION_WORKFLOW) + ) + self.assertEqual( + _managed_validation_profile("Acme/hub", MANAGED_VALIDATION_WORKFLOW), + "canonical", + ) + self.assertIsNone( + _managed_validation_profile( + "Acme/hub", MANAGED_VALIDATION_WORKFLOW + "\n# custom project workflow\n" + ) + ) + + def test_projectbrain_profile_pins_coverage_audits_security_and_doc_checks(self) -> None: + required_commands = ( + ("root coverage gate", "pnpm run test:coverage", "pnpm --if-present test"), + ( + "backend coverage gate", + "pnpm --filter projectbrain-api run test:coverage", + "pnpm --filter projectbrain-api run test", + ), + ("security audit", "pnpm run audit:security", "pnpm --if-present audit:security"), + ("OpenAPI audit", "pnpm run audit:openapi", "echo skipped-openapi-audit"), + ( + "architecture audit", + "pnpm run audit:architecture", + "echo skipped-architecture-audit", + ), + ("documentation audit", "pnpm run audit:docs", "echo skipped-docs-audit"), + ("v1 freeze audit", "pnpm run audit:v1-freeze", "echo skipped-v1-freeze-audit"), + ( + "Unraid template audit", + "pnpm run audit:unraid-template", + "echo skipped-unraid-template-audit", + ), + ( + "documentation fast-path check", + "node scripts/check-doc-links.mjs", + "echo skipped-doc-link-check", + ), + ( + "Unraid documentation fast-path check", + "node scripts/check-unraid-template-readiness.mjs", + "echo skipped-unraid-readiness-check", + ), + ) + for label, command, replacement in required_commands: + with self.subTest(gate=label): + self.assertIn(command, PROJECTBRAIN_VALIDATION_WORKFLOW) + modified_workflow = PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + command, replacement, 1 + ) + self.assertIsNone( + _managed_validation_profile("Jens/projectbrain", modified_workflow) + ) + def test_native_skills_are_complete_digest_verified_and_bounded(self) -> None: from services.runtime.mcp_skills import SkillGetParams, SkillListParams, StaticSkills @@ -653,6 +952,16 @@ class GiteaConnectorTests(unittest.TestCase): "main", ) self.assertIn("expectedPlanHash", schemas["gitea.branchprotection.ensure"]["required"]) + self.assertEqual( + schemas["gitea.branchprotection.ensure"]["properties"]["applyScope"]["enum"], + ["all", "protection_only"], + ) + portfolio_plan_properties = schemas["gitea.portfolio.governance.apply"]["properties"][ + "plans" + ]["items"]["properties"] + self.assertEqual( + portfolio_plan_properties["applyScope"]["enum"], ["all", "protection_only"] + ) self.assertEqual( schemas["gitea.portfolio.governance.apply"]["properties"]["plans"]["maxItems"], 5, @@ -755,6 +1064,24 @@ class GiteaConnectorTests(unittest.TestCase): self.assertEqual(server.version, "1.14.0") self.assertIn("plan-locked governance tools", server.instructions) names = {tool.name for tool in tools} + ensure_tool = next( + tool for tool in tools if tool.name == "gitea.branchprotection.ensure" + ) + portfolio_apply_tool = next( + tool for tool in tools if tool.name == "gitea.portfolio.governance.apply" + ) + self.assertEqual( + ensure_tool.input_schema["properties"]["applyScope"]["enum"], + ["all", "protection_only"], + ) + portfolio_schema = portfolio_apply_tool.input_schema + plan_ref = portfolio_schema["properties"]["plans"]["items"]["$ref"] + plan_schema_name = plan_ref.rsplit("/", maxsplit=1)[-1] + plan_schema = portfolio_schema["$defs"][plan_schema_name] + self.assertEqual( + plan_schema["properties"]["applyScope"]["enum"], ["all", "protection_only"] + ) + self.assertEqual(portfolio_schema["properties"]["plans"]["maxItems"], 5) self.assertTrue( { "gitea.repository.create", @@ -1065,6 +1392,391 @@ class GiteaConnectorTests(unittest.TestCase): finally: connector_instance.client.close() + def test_projectbrain_custom_workflow_is_preserved_while_full_and_quality_are_added( + self, + ) -> None: + connector_instance, fixture = projectbrain_connector() + original_workflow = fixture._managed_workflows["Jens/projectbrain"] + fixture._branch_protections["Jens/projectbrain"][0]["status_check_contexts"] = [ + "Pull request / quality (pull_request)" + ] + original_protection = copy.deepcopy( + fixture._branch_protections["Jens/projectbrain"][0] + ) + try: + current = context() + preview = connector_instance.branch_protection_preview( + current, "projectbrain-repository" + )["data"] + self.assertEqual(preview["workflow"]["profile"], "projectbrain") + self.assertEqual(preview["workflow"]["action"], "none") + self.assertNotEqual( + preview["workflow"]["currentDigest"], + hashlib.sha256(MANAGED_VALIDATION_WORKFLOW.encode()).hexdigest(), + ) + self.assertEqual( + preview["branchProtection"]["desired"]["status_check_contexts"], + [ + "Managed validation / full (pull_request)", + "Pull request / quality (pull_request)", + ], + ) + self.assertEqual(preview["branchProtection"]["action"], "update") + + applied = connector_instance.branch_protection_ensure( + current, "projectbrain-repository", preview["planHash"] + )["data"] + + self.assertTrue(applied["changed"]) + self.assertTrue(applied["verified"]) + self.assertTrue(applied["compliant"]) + self.assertEqual(fixture._managed_workflows["Jens/projectbrain"], original_workflow) + self.assertEqual( + fixture._branch_protections["Jens/projectbrain"][0]["status_check_contexts"], + [ + "Managed validation / full (pull_request)", + "Pull request / quality (pull_request)", + ], + ) + self.assertEqual( + fixture._branch_protections["Jens/projectbrain"][0]["required_approvals"], + original_protection["required_approvals"], + ) + workflow_writes = [ + (method, path) + for method, path in zip( + fixture.recorded_methods, fixture.recorded_paths, strict=True + ) + if path.endswith("/.gitea/workflows/managed-validation.yml") + and method in {"POST", "PUT", "DELETE"} + ] + self.assertEqual(workflow_writes, []) + finally: + connector_instance.client.close() + + def test_projectbrain_all_scope_fails_if_gitea_drops_an_extra_required_context(self) -> None: + connector_instance, fixture = projectbrain_connector() + fixture._branch_protections["Jens/projectbrain"][0]["status_check_contexts"] = [ + "Pull request / quality (pull_request)", + "Additional required check", + ] + original_read = connector_instance.client.branch_protections + read_count = 0 + + def drop_extra_context(repository: RegisteredRepository) -> list[dict[str, object]]: + nonlocal read_count + read_count += 1 + if read_count == 3: + fixture._branch_protections["Jens/projectbrain"][0][ + "status_check_contexts" + ].remove("Additional required check") + return original_read(repository) + + try: + current = context() + with patch.object( + connector_instance.client, + "branch_protections", + side_effect=drop_extra_context, + ): + preview = connector_instance.branch_protection_preview( + current, "projectbrain-repository" + )["data"] + self.assertIn( + "Additional required check", + preview["branchProtection"]["desired"]["status_check_contexts"], + ) + with self.assertRaisesRegex(GiteaError, "did not retain"): + connector_instance.branch_protection_ensure( + current, "projectbrain-repository", preview["planHash"] + ) + self.assertNotIn( + "Additional required check", + fixture._branch_protections["Jens/projectbrain"][0]["status_check_contexts"], + ) + finally: + connector_instance.client.close() + + def test_projectbrain_all_scope_rejects_an_accepted_workflow_digest_change(self) -> None: + connector_instance, fixture = projectbrain_connector() + fixture._branch_protections["Jens/projectbrain"][0]["status_check_contexts"] = [ + "Pull request / quality (pull_request)" + ] + modified_workflow = PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " pull_request:\n", " pull_request:\n branches: [main]\n" + ) + original_update = connector_instance.client.update_branch_protection + + def update_protection_then_change_workflow( + repository: RegisteredRepository, + branch: str, + payload: dict[str, object], + ) -> object: + response = original_update(repository, branch, payload) + fixture._managed_workflows["Jens/projectbrain"] = modified_workflow + return response + + try: + current = context() + preview = connector_instance.branch_protection_preview( + current, "projectbrain-repository" + )["data"] + self.assertEqual(preview["workflow"]["profile"], "projectbrain") + with patch.object( + connector_instance.client, + "update_branch_protection", + side_effect=update_protection_then_change_workflow, + ): + with self.assertRaisesRegex(GiteaError, "did not retain"): + connector_instance.branch_protection_ensure( + current, "projectbrain-repository", preview["planHash"] + ) + self.assertEqual( + _managed_validation_profile("Jens/projectbrain", modified_workflow), + "projectbrain", + ) + self.assertEqual(fixture._managed_workflows["Jens/projectbrain"], modified_workflow) + finally: + connector_instance.client.close() + + def test_projectbrain_custom_profile_blocks_unsafe_existing_protection(self) -> None: + connector_instance, fixture = projectbrain_connector() + protection = fixture._branch_protections["Jens/projectbrain"][0] + protection["enable_push"] = True + protection["block_admin_merge_override"] = False + original_protection = copy.deepcopy(protection) + try: + current = context() + preview = connector_instance.branch_protection_preview( + current, "projectbrain-repository" + )["data"] + self.assertEqual(preview["workflow"]["profile"], "projectbrain") + self.assertIn( + "managed-validation-projectbrain-protection-safety-locks-invalid", + preview["blockers"], + ) + with self.assertRaisesRegex(GiteaError, "unresolved blockers"): + connector_instance.branch_protection_ensure( + current, "projectbrain-repository", preview["planHash"] + ) + self.assertEqual( + fixture._branch_protections["Jens/projectbrain"][0], original_protection + ) + finally: + connector_instance.client.close() + + def test_projectbrain_all_scope_creates_safe_protection_without_rewriting_profile(self) -> None: + connector_instance, fixture = projectbrain_connector() + original_workflow = fixture._managed_workflows["Jens/projectbrain"] + fixture._branch_protections["Jens/projectbrain"] = [] + try: + current = context() + preview = connector_instance.branch_protection_preview( + current, "projectbrain-repository" + )["data"] + self.assertEqual(preview["workflow"]["profile"], "projectbrain") + self.assertEqual(preview["branchProtection"]["action"], "create") + applied = connector_instance.branch_protection_ensure( + current, "projectbrain-repository", preview["planHash"] + )["data"] + self.assertTrue(applied["compliant"]) + self.assertTrue(applied["verified"]) + self.assertEqual(fixture._managed_workflows["Jens/projectbrain"], original_workflow) + created_protection = fixture._branch_protections["Jens/projectbrain"][0] + self.assertEqual( + created_protection["status_check_contexts"], + [ + "Managed validation / full (pull_request)", + "Pull request / quality (pull_request)", + ], + ) + self.assertTrue(created_protection["block_admin_merge_override"]) + finally: + connector_instance.client.close() + + def test_projectbrain_protection_only_is_hash_bound_and_preserves_workflow( + self, + ) -> None: + connector_instance, fixture = projectbrain_connector() + invalid_workflow = PROJECTBRAIN_VALIDATION_WORKFLOW.replace(" pull_request:\n", "") + fixture._managed_workflows["Jens/projectbrain"] = invalid_workflow + protection = fixture._branch_protections["Jens/projectbrain"][0] + protection["status_check_contexts"] = [ + "Pull request / quality (pull_request)", + "Additional required check", + ] + protection["required_approvals"] = 2 + protection["require_signed_commits"] = True + original_protection = copy.deepcopy(protection) + try: + current = context() + preview = connector_instance.branch_protection_preview( + current, "projectbrain-repository" + )["data"] + self.assertEqual(preview["workflow"]["profile"], "projectbrain-required") + self.assertEqual(preview["workflow"]["action"], "none") + self.assertIn("managed-validation-projectbrain-profile-invalid", preview["blockers"]) + self.assertFalse(preview["compliant"]) + self.assertTrue(preview["protectionOnlyAvailable"]) + self.assertNotEqual(preview["planHash"], preview["protectionOnlyPlanHash"]) + with self.assertRaisesRegex(GiteaError, "unresolved blockers"): + connector_instance.branch_protection_ensure( + current, "projectbrain-repository", preview["planHash"] + ) + with self.assertRaisesRegex(GiteaError, "preview the repository again"): + connector_instance.branch_protection_ensure( + current, + "projectbrain-repository", + preview["planHash"], + apply_scope="protection_only", + ) + self.assertEqual(fixture._managed_workflows["Jens/projectbrain"], invalid_workflow) + self.assertEqual( + fixture._branch_protections["Jens/projectbrain"][0], original_protection + ) + + portfolio_result = connector_instance.portfolio_governance_apply( + current, + [ + { + "repositoryRef": "projectbrain-repository", + "planHash": preview["protectionOnlyPlanHash"], + "applyScope": "protection_only", + } + ], + )["data"] + self.assertEqual(portfolio_result["summary"], {"total": 1, "applied": 1, "failed": 0}) + self.assertEqual(portfolio_result["items"][0]["applyScope"], "protection_only") + updated_protection = fixture._branch_protections["Jens/projectbrain"][0] + self.assertEqual( + updated_protection["status_check_contexts"], + [ + "Additional required check", + "Managed validation / full (pull_request)", + "Pull request / quality (pull_request)", + ], + ) + self.assertEqual(updated_protection["required_approvals"], 2) + self.assertTrue(updated_protection["require_signed_commits"]) + self.assertEqual(fixture._managed_workflows["Jens/projectbrain"], invalid_workflow) + self.assertEqual( + fixture.recorded_bodies[-1], + { + "status_check_contexts": [ + "Additional required check", + "Managed validation / full (pull_request)", + "Pull request / quality (pull_request)", + ] + }, + ) + finally: + connector_instance.client.close() + + def test_projectbrain_protection_only_requires_an_existing_enabled_rule(self) -> None: + connector_instance, fixture = projectbrain_connector() + fixture._managed_workflows["Jens/projectbrain"] = PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " pull_request:\n", "" + ) + fixture._branch_protections["Jens/projectbrain"] = [] + try: + preview = connector_instance.branch_protection_preview( + context(), "projectbrain-repository" + )["data"] + self.assertFalse(preview["protectionOnlyAvailable"]) + self.assertIsNone(preview["protectionOnlyPlanHash"]) + self.assertEqual(preview["branchProtection"]["action"], "create") + finally: + connector_instance.client.close() + + def test_projectbrain_protection_only_fails_if_gitea_drops_an_extra_context(self) -> None: + connector_instance, fixture = projectbrain_connector() + fixture._managed_workflows["Jens/projectbrain"] = PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " pull_request:\n", "" + ) + fixture._branch_protections["Jens/projectbrain"][0]["status_check_contexts"] = [ + "Pull request / quality (pull_request)", + "Additional required check", + ] + original_read = connector_instance.client.branch_protections + read_count = 0 + + def drop_extra_context(repository: RegisteredRepository) -> list[dict[str, object]]: + nonlocal read_count + read_count += 1 + if read_count == 3: + fixture._branch_protections["Jens/projectbrain"][0][ + "status_check_contexts" + ].remove("Additional required check") + return original_read(repository) + + try: + current = context() + with patch.object( + connector_instance.client, + "branch_protections", + side_effect=drop_extra_context, + ): + preview = connector_instance.branch_protection_preview( + current, "projectbrain-repository" + )["data"] + self.assertTrue(preview["protectionOnlyAvailable"]) + with self.assertRaisesRegex(GiteaError, "did not retain"): + connector_instance.branch_protection_ensure( + current, + "projectbrain-repository", + preview["protectionOnlyPlanHash"], + apply_scope="protection_only", + ) + self.assertNotIn( + "Additional required check", + fixture._branch_protections["Jens/projectbrain"][0]["status_check_contexts"], + ) + finally: + connector_instance.client.close() + + def test_projectbrain_protection_only_rejects_other_protection_changes(self) -> None: + connector_instance, fixture = projectbrain_connector() + fixture._managed_workflows["Jens/projectbrain"] = PROJECTBRAIN_VALIDATION_WORKFLOW.replace( + " pull_request:\n", "" + ) + original_read = connector_instance.client.branch_protections + read_count = 0 + + def changed_protection_response( + repository: RegisteredRepository, + ) -> list[dict[str, object]]: + nonlocal read_count + protections = original_read(repository) + read_count += 1 + if read_count == 3: + protections[0]["required_approvals"] = 0 + protections[0]["block_admin_merge_override"] = False + return protections + + try: + current = context() + with patch.object( + connector_instance.client, + "branch_protections", + side_effect=changed_protection_response, + ): + preview = connector_instance.branch_protection_preview( + current, "projectbrain-repository" + )["data"] + with self.assertRaisesRegex(GiteaError, "did not retain"): + connector_instance.branch_protection_ensure( + current, + "projectbrain-repository", + preview["protectionOnlyPlanHash"], + apply_scope="protection_only", + ) + self.assertEqual( + fixture._managed_workflows["Jens/projectbrain"], + PROJECTBRAIN_VALIDATION_WORKFLOW.replace(" pull_request:\n", ""), + ) + finally: + connector_instance.client.close() + def test_governance_update_never_relaxes_protected_main_push( self, ) -> None: