diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index 88472ef..e5058ec 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 `402a0ef2e7d14e98325504f79ab7d7651ffc88b1`. 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 `6d175c3333218e2b580a310496fed6c407f28114`. 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 6bfe293..26eb448 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "402a0ef2e7d14e98325504f79ab7d7651ffc88b1", + "sourceRevision": "6d175c3333218e2b580a310496fed6c407f28114", "files": [ { "path": ".dockerignore", @@ -404,8 +404,8 @@ }, { "path": "backend/app/mcp_server.py", - "sha256": "feac22ee7a1c755f0fafd6890500b83442b5f13b51e6a6cde3b0227c5b5ab25b", - "bytes": 49536 + "sha256": "598e7f7a5020d92becfb143d31dbd096f16bf79ae73b26e6ecae710661f73ca1", + "bytes": 50519 }, { "path": "backend/app/runner_statistics_mcp.py", @@ -899,8 +899,8 @@ }, { "path": "backend/tests/test_appops_recovery_deploy_binding.py", - "sha256": "4902f2e16ed35b8bd52d8610ab61b9752aa10a4147e5aafc27404bbbfef61e0a", - "bytes": 50460 + "sha256": "274e401e81eda762ff4cae0b64ecdefd322972c72a004fdd84f02b93a2e8b44d", + "bytes": 52001 }, { "path": "backend/tests/test_appops_self_upgrade_controller.py", @@ -909,8 +909,8 @@ }, { "path": "backend/tests/test_appops_self_upgrade_recovery_boundaries.py", - "sha256": "07c3a38c360f5b70b6f3efbc8ba62356d4937170667d07d31d82a6810ed58a0e", - "bytes": 21578 + "sha256": "676a465700296b081cc07eaeab3d2669f3047001b28b614e8a8a82088010f3f0", + "bytes": 29130 }, { "path": "backend/tests/test_appops_self_upgrade_runtime_model.py", @@ -1004,8 +1004,8 @@ }, { "path": "backend/tests/test_deploy_broker.py", - "sha256": "b0e96b1cd2954dc2a6452f5f7c50c5fbae541dc0b4de1e60af11349654594902", - "bytes": 438851 + "sha256": "40c290925c0569192973d858e5df36b91e3c5dd38ce638b9f19b0ca60e61903a", + "bytes": 442136 }, { "path": "backend/tests/test_deployment_auto_rollback.py", @@ -1284,8 +1284,8 @@ }, { "path": "backend/tests/test_mcp_rag_n8n_integrations.py", - "sha256": "0c90431bb77eab27f0da21afd02dd5fb232141483062f7a19c3340d1d91f6c9d", - "bytes": 52814 + "sha256": "1d72e95529863d3263a178d71d4c7729c8b624b13e734460fb9303a3f6f7416d", + "bytes": 54507 }, { "path": "backend/tests/test_media_codec_dashboard_deployment_reconciliation.py", diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 67ef5df..384fc4d 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -15,7 +15,7 @@ from typing import Any from mcp.server import MCPServer from mcp.server.transport_security import TransportSecuritySettings from mcp.types import ToolAnnotations -from pydantic import StrictBool +from pydantic import StrictBool, ValidationError from sqlmodel import Session, select from .api.routes_actions import action_registry as build_action_registry @@ -894,6 +894,9 @@ def deployment_preflight(plan: dict[str, Any], idempotencyKey: str) -> dict[str, operation="preflight", ) ) + except ValidationError: + # Pydantic errors may contain arbitrary keys, input values and validator context. + return {"status": "invalid", "executed": False, "reason": "invalid_typed_plan"} except (ValueError, DeploymentControlError) as exc: return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))} @@ -918,6 +921,8 @@ def forge_bootstrap_apply(profile: dict[str, Any], idempotencyKey: str) -> dict[ idempotency_key=idempotencyKey, ) ) + except ValidationError: + return {"status": "invalid", "executed": False, "reason": "invalid_typed_plan"} except ValueError as exc: return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))} finally: @@ -967,6 +972,8 @@ def enrollment_preflight(plan: dict[str, Any]) -> dict[str, Any]: typed_plan = EnrollmentPlanV1.model_validate(plan) with Session(engine) as session: return _json(EnrollmentPlane().preflight(session, typed_plan)) + except ValidationError: + return {"status": "invalid", "executed": False, "reason": "invalid_typed_plan"} except (ValueError, EnrollmentError) as exc: return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))} @@ -981,6 +988,8 @@ def target_enroll(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]: typed_plan = EnrollmentPlanV1.model_validate(plan) with Session(engine) as session: return _json(EnrollmentPlane().enroll(session, typed_plan, idempotency_key=idempotencyKey)) + except ValidationError: + return {"status": "invalid", "executed": False, "reason": "invalid_typed_plan"} except (ValueError, EnrollmentError) as exc: return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))} finally: @@ -1015,6 +1024,9 @@ def deployment_apply(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any operation="apply", ) ) + except ValidationError: + # Pydantic errors may contain arbitrary keys, input values and validator context. + return {"status": "invalid", "executed": False, "reason": "invalid_typed_plan"} except (ValueError, DeploymentControlError) as exc: return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))} finally: @@ -1063,6 +1075,9 @@ def deployment_rollback(plan: dict[str, Any], idempotencyKey: str) -> dict[str, operation="rollback", ) ) + except ValidationError: + # Pydantic errors may contain arbitrary keys, input values and validator context. + return {"status": "invalid", "executed": False, "reason": "invalid_typed_plan"} except (ValueError, DeploymentControlError) as exc: return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))} finally: diff --git a/backend/tests/test_appops_recovery_deploy_binding.py b/backend/tests/test_appops_recovery_deploy_binding.py index b51d8cf..86c966e 100644 --- a/backend/tests/test_appops_recovery_deploy_binding.py +++ b/backend/tests/test_appops_recovery_deploy_binding.py @@ -1135,3 +1135,36 @@ def test_followup_transaction_journal_failure_precedes_all_source_runtime_and_st assert [event[0] for event in events] == ["green", "snapshot", "backup", "journal"] assert marker.read_text() == "prior source remains\n" assert state["live_revision"] == prior_revision + + + +def test_prepare_restored_recovery_rechecks_signed_live_state_and_recaptures_under_new_id( + deploy, monkeypatch, tmp_path, +): + case = _appops_prepare_recovery_case(deploy, monkeypatch, tmp_path) + calls = [] + + def verify(_project, container, image, **kwargs): + calls.append(kwargs) + assert container["Id"] != deploy.APPOPS_LEGACY_OLD_CONTAINER_ID + + monkeypatch.setattr(deploy, "verify_appops_legacy_runtime", verify) + deploy._appops_prepare_recovery( + case.project, APP_OPS_REPOSITORY, case.repo, case.revision, case.metadata, case.state, + ) + assert calls == [{"require_original_container": False, "recovery_state": case.state}] + assert case.calls["models"][0][0] == RECOVERY_ID + assert case.calls["models"][0][2]["services"]["appops-gateway"]["environment"] == case.values + assert case.project["_compose_rollback"]["containers"][0]["container_id"] == case.container["Id"] + + +def test_prepare_recovery_rejects_environment_drift_before_model_backup_or_transaction( + deploy, monkeypatch, tmp_path, +): + case = _appops_prepare_recovery_case(deploy, monkeypatch, tmp_path) + case.container["Config"]["Env"].append("UNEXPECTED_SETTING=private-sentinel") + with pytest.raises(deploy.DeployError, match="complete runtime environment changed"): + deploy._appops_prepare_recovery( + case.project, APP_OPS_REPOSITORY, case.repo, case.revision, case.metadata, case.state, + ) + assert not any(case.calls[name] for name in ("models", "capture", "backup", "begin")) diff --git a/backend/tests/test_appops_self_upgrade_recovery_boundaries.py b/backend/tests/test_appops_self_upgrade_recovery_boundaries.py index 5c4380a..e3e5f7f 100644 --- a/backend/tests/test_appops_self_upgrade_recovery_boundaries.py +++ b/backend/tests/test_appops_self_upgrade_recovery_boundaries.py @@ -556,3 +556,166 @@ def test_original_failed_delivery_rejects_modified_legacy_record(controller, mon assert controller._self_upgrade_original_failed_delivery( semantic, revision, policy, ) is None + + + +@pytest.mark.parametrize(("status", "extra", "valid"), [ + ("deploying", {}, True), + ("failed", {"reason": "deployment_failed"}, True), + ("deployed", {"receipt_sha256": "f" * 64}, True), + ("deployed", {"receipt_sha256": "invalid"}, False), + ("deploying", {"reason": "deployment_failed"}, False), + ("failed", {"reason": "private-secret-value"}, False), + ("failed", {"receipt_sha256": "f" * 64}, False), + ("failed", {"unknown": "value"}, False), + ("failed", {"reason": ["deployment_failed"]}, False), + ("awaiting_validation", {"reason": "gitea_validation_unavailable"}, True), + ("failed", {"reason": "recovery_preimage_changed"}, True), +]) +def test_signed_recovery_reader_accepts_only_typed_worker_outcome_fields( + controller, monkeypatch, tmp_path, status, extra, valid, +): + record = _recovery_delivery(controller, secret="sentinel") + record.pop("runtimeEnvironment") + record.pop("privateCredential") + record.update(status=status, **extra) + secret = b"fixture-recovery-signing-key" + journal = { + "recoveryId": record["recovery_id"], "revision": record["revision"], + "idempotencyKeySha256": record["idempotency_key_sha256"], + "preflightSha256": record["preflight_sha256"], + "semanticSha256": record["semantic_sha256"], + "originalRawSha256": record["recovery_of_sha256"], + } + payload = { + "operationId": record["semantic_sha256"], "revision": record["revision"], + "preflightSha256": record["preflight_sha256"], + "idempotencyKeySha256": record["idempotency_key_sha256"], + "recoveryId": record["recovery_id"], + "recoveryOfSha256": record["recovery_of_sha256"], + } + record["body_sha256"] = controller.hashlib.sha256(controller._self_upgrade_canonical_json({ + "semanticSha256": record["semantic_sha256"], "recoveryId": record["recovery_id"], + "idempotencyKeySha256": record["idempotency_key_sha256"], + })).hexdigest() + record["recovery_hmac"] = controller._self_upgrade_public_recovery_signature(record, secret) + monkeypatch.setattr(controller, "_self_upgrade_secret", lambda: secret) + monkeypatch.setattr(controller, "_self_upgrade_recovery_journal", lambda _id: journal) + monkeypatch.setattr(controller, "_load_projects", lambda: {controller.SELF_UPGRADE_REPOSITORY: {}}) + monkeypatch.setattr(controller, "_validation_policy", lambda *_a: {}) + monkeypatch.setattr(controller, "_validation_snapshot_matches", lambda *_a: True) + monkeypatch.setattr(controller, "_self_upgrade_request_root", lambda **_k: tmp_path) + monkeypatch.setattr(controller, "_self_upgrade_read_private_json", lambda _path: (b"", {})) + monkeypatch.setattr(controller, "_self_upgrade_recovery_request_payload", lambda _record: payload) + + def verify(): + return controller._self_upgrade_recovery_record_valid( + record, semantic_digest=record["semantic_sha256"], + original_raw_sha256=record["recovery_of_sha256"], + ) + + assert verify() is valid + record["updated_at"] += 1 + assert verify() is False # Even permitted outcome fields require the original complete HMAC. + + + +@pytest.mark.parametrize(("uid", "gid", "mode"), [(99, 100, 0o777), (0, 0, 0o755)]) +@pytest.mark.parametrize("drift", ["none", "owner", "mode", "symlink", "foreign_cache", "inode"]) +def test_legacy_cache_reader_and_state_cas_bind_both_exact_appdata_profiles( + controller, monkeypatch, tmp_path, uid, gid, mode, drift, +): + if os.name == "nt" or os.geteuid() != 0: + pytest.skip("Pinned dirfd ownership contract requires root POSIX") + root = tmp_path / "virtual-host" + root.mkdir(mode=0o755) + dirs = [ + ("mnt", 0, 0, 0o755), ("mnt/cache", 99, 100, 0o777), + ("mnt/cache/appdata", uid, gid, mode), + ("mnt/cache/appdata/gitea-deploy-runners", 0, 0, 0o755), + ("mnt/cache/appdata/gitea-deploy-runners/state", 0, 0, 0o755), + ] + for name, owner, group, permissions in dirs: + path = root / name + path.mkdir() + os.chown(path, owner, group) + path.chmod(permissions) + appdata = root / "mnt/cache/appdata" + state_root = appdata / "gitea-deploy-runners/state" + state_path = state_root / "unraid-appops-gateway.json" + before = controller._self_upgrade_canonical_json({ + "schema_version": 3, "repository": controller.SELF_UPGRADE_REPOSITORY, "marker": "old", + }) + b"\n" + state_path.write_bytes(before) + state_path.chmod(0o600) + after = before.replace(b'"old"', b'"new"') + if drift == "owner": + os.chown(appdata, 1000, 1000) + elif drift == "mode": + appdata.chmod(0o775) + elif drift == "foreign_cache": + os.chown(root / "mnt/cache", 0, 0) + (root / "mnt/cache").chmod(0o755) + elif drift == "inode": + replacement = appdata.with_name("different-inode") + replacement.mkdir() + os.chown(replacement, uid, gid) + replacement.chmod(mode) + elif drift == "symlink": + renamed = appdata.with_name("appdata-elsewhere") + appdata.rename(renamed) + appdata.symlink_to(renamed, target_is_directory=True) + monkeypatch.setattr(controller, "STATE_ROOT", Path("/mnt/cache/appdata/gitea-deploy-runners/state")) + real_open = os.open + + def virtual_root_open(path, flags, *args, **kwargs): + if drift == "inode" and str(path) == "appdata": + return real_open(replacement, flags) + return real_open(root if str(path) == "/" else path, flags, *args, **kwargs) + + monkeypatch.setattr(controller.os, "open", virtual_root_open) + requested = controller.STATE_ROOT / state_path.name + if drift == "none": + assert controller._self_upgrade_read_legacy_cache_file( + requested, maximum_bytes=65536, allowed_modes=frozenset({0o600}), + ) == before + assert not controller._self_upgrade_write_legacy_state_cas("0" * 64, after) + assert state_path.read_bytes() == before + assert controller._self_upgrade_write_legacy_state_cas( + controller.hashlib.sha256(before).hexdigest(), after, + ) + assert state_path.read_bytes() == after + else: + with pytest.raises(OSError): + controller._self_upgrade_read_legacy_cache_file( + requested, maximum_bytes=65536, allowed_modes=frozenset({0o600}), + ) + assert not controller._self_upgrade_write_legacy_state_cas( + controller.hashlib.sha256(before).hexdigest(), after, + ) + + +@pytest.mark.parametrize(("profile", "valid"), [ + ((99, 100, 0o777), True), ((0, 0, 0o755), True), + ((0, 0, 0o775), False), ((99, 100, 0o755), False), +]) +def test_source_root_allows_only_legacy_or_root_owned_appdata_anchor( + controller, monkeypatch, profile, valid, +): + from types import SimpleNamespace + root = Path("/srv/example/appdata/unraid-appops-gateway/source") + profiles = { + Path("/mnt"): (0, 0, 0o755), Path("/mnt/user"): (99, 100, 0o777), + Path("/srv/example/appdata"): profile, + root.parent: (0, 0, 0o755), root: (100, 101, 0o755), + } + + def lstat(path): + uid, gid, mode = profiles[path] + return SimpleNamespace(st_uid=uid, st_gid=gid, st_mode=stat.S_IFDIR | mode) + + monkeypatch.setattr(Path, "lstat", lstat) + monkeypatch.setattr(Path, "is_symlink", lambda _path: False) + assert controller._self_upgrade_source_root_safe(root) is valid + monkeypatch.setattr(Path, "is_symlink", lambda path: path == root.parent) + assert controller._self_upgrade_source_root_safe(root) is False diff --git a/backend/tests/test_deploy_broker.py b/backend/tests/test_deploy_broker.py index 600de5d..0c2ddaf 100644 --- a/backend/tests/test_deploy_broker.py +++ b/backend/tests/test_deploy_broker.py @@ -379,9 +379,7 @@ def test_appops_legacy_state_anchor_accepts_only_known_preimage_or_current_green ) -def test_appops_legacy_runtime_requires_pinned_source_image_and_resolved_model( - deploy_module, tmp_path -): +def _fixed_appops_legacy_runtime_case(deploy_module, tmp_path): project = _project(tmp_path) project.update({ "branch": "main", "gitea_repository_id": 9, @@ -435,6 +433,15 @@ def test_appops_legacy_runtime_requires_pinned_source_image_and_resolved_model( "Config": {"Labels": container["Config"]["Labels"]}, } + return project, container, image + + +def test_appops_legacy_runtime_requires_pinned_source_image_and_resolved_model( + deploy_module, tmp_path +): + project, container, image = _fixed_appops_legacy_runtime_case(deploy_module, tmp_path) + image_id = deploy_module.APPOPS_LEGACY_OLD_IMAGE_ID + source = "https://gitea.example.com/Jens/Unraid-AppOps-Gateway" observed = deploy_module.verify_appops_legacy_runtime(project, container, image) assert observed["imageId"] == image_id assert observed["imageRef"] == "unraid-appops-gateway:platform-5553c04ce497" @@ -10604,3 +10611,67 @@ def test_managed_appdata_initializer_http_prerequisite_and_refusal_are_sanitized server.shutdown() server.server_close() serving.join(timeout=2) + + + +@pytest.mark.parametrize("proof", ["signed", "missing", "foreign", "candidate"]) +def test_recovery_verifies_restored_container_only_with_signed_live_555_model( + deploy_module, monkeypatch, tmp_path, proof, +): + project, container, image = _fixed_appops_legacy_runtime_case(deploy_module, tmp_path) + container["Id"] = "a" * 64 + model_path = "/private/models/signed-555.compose.json" + container["Config"]["Labels"]["com.docker.compose.project.config_files"] = model_path + state = {"live_revision": deploy_module.APPOPS_LEGACY_OLD_REVISION} + active = {"legacy": True, "path": model_path} + if proof == "missing": + active = None + elif proof == "foreign": + active["path"] = "/private/models/foreign.compose.json" + elif proof == "candidate": + active["legacy"] = False + calls = [] + + def verify_active(actual_state, actual_container, *, project): + calls.append((actual_state, actual_container, project)) + return active + + reader = types.SimpleNamespace(_self_upgrade_active_compose_model=verify_active) + monkeypatch.setattr(deploy_module, "_appops_status_reader", lambda: reader) + with pytest.raises(deploy_module.DeployError, match="fixed AppOps legacy runtime"): + deploy_module.verify_appops_legacy_runtime(project, container, image) + if proof == "signed": + observed = deploy_module.verify_appops_legacy_runtime( + project, container, image, require_original_container=False, recovery_state=state, + ) + assert observed["containerId"] == container["Id"] + else: + with pytest.raises(deploy_module.DeployError): + deploy_module.verify_appops_legacy_runtime( + project, container, image, require_original_container=False, recovery_state=state, + ) + assert calls == [(state, container, project)] + + +@pytest.mark.parametrize("drift", ["image", "ports", "mounts", "health"]) +def test_signed_restored_555_proof_preserves_fixed_runtime_checks( + deploy_module, monkeypatch, tmp_path, drift, +): + project, container, image = _fixed_appops_legacy_runtime_case(deploy_module, tmp_path) + container["Id"] = "a" * 64 + active = {"legacy": True, "path": "/private/models/signed-555.compose.json"} + monkeypatch.setattr(deploy_module, "_appops_status_reader", lambda: types.SimpleNamespace( + _self_upgrade_active_compose_model=lambda *_a, **_k: active, + )) + if drift == "image": + image["Id"] = "sha256:" + "0" * 64 + elif drift == "ports": + container["NetworkSettings"]["Ports"]["1216/tcp"][0]["HostIp"] = "127.0.0.1" + elif drift == "mounts": + container["Mounts"][0]["RW"] = False + elif drift == "health": + container["State"]["Health"]["Status"] = "unhealthy" + with pytest.raises(deploy_module.DeployError): + deploy_module.verify_appops_legacy_runtime( + project, container, image, require_original_container=False, recovery_state={}, + ) diff --git a/backend/tests/test_mcp_rag_n8n_integrations.py b/backend/tests/test_mcp_rag_n8n_integrations.py index a278c4f..5765e46 100644 --- a/backend/tests/test_mcp_rag_n8n_integrations.py +++ b/backend/tests/test_mcp_rag_n8n_integrations.py @@ -1369,3 +1369,44 @@ def test_controller_preflight_changed_identity_expires_approval_without_unknown_ promotion = session.get(WorkMeshPromotion, promotion_id) assert approval is not None and approval.status == "expired" assert promotion is not None and promotion.status == "preflight_changed" + + + +@pytest.mark.parametrize(("name", "argument", "discriminator"), [ + ("enrollment.preflight", "plan", None), + ("target.enroll", "plan", None), + ("deployment.preflight", "plan", None), + ("deployment.apply", "plan", None), + ("deployment.rollback", "plan", None), + ("deployment.preflight", "plan", "itworx.forge-bootstrap-preflight/v1"), + ("forge.bootstrap.apply", "profile", None), +]) +def test_mcp_typed_schema_rejection_never_echoes_opaque_input( + integration_client, monkeypatch, name, argument, discriminator, +): + from backend.app import mcp_server + canary = "syntheticOpaqueInput_9Z7XQ" + payload = {"api_key": canary, canary: {"nested": canary}, "application_id": canary} + if discriminator: + payload["api_version"] = discriminator + arguments = {argument: payload} + if name != "enrollment.preflight": + arguments["idempotencyKey"] = "schema-redaction-fixture" + + def forbidden_service(*_args, **_kwargs): + pytest.fail("invalid schema must not reach deployment/enrollment services") + + monkeypatch.setattr(mcp_server, "EnrollmentPlane", forbidden_service) + monkeypatch.setattr(mcp_server, "DeploymentControlPlane", forbidden_service) + + async def exercise(): + async with Client(mcp_server.appops_mcp) as client: + result = await client.call_tool(name, arguments) + assert result.is_error is False + assert result.structured_content == { + "status": "invalid", "executed": False, "reason": "invalid_typed_plan", + } + assert canary not in json.dumps(result.model_dump(mode="json")) + + asyncio.run(exercise()) + assert mcp_server._MCP_EXECUTION_LOCK.locked() is False