diff --git a/docs/runbooks/adapter-1c-mcp.md b/docs/runbooks/adapter-1c-mcp.md index 86b6454..f07eaef 100644 --- a/docs/runbooks/adapter-1c-mcp.md +++ b/docs/runbooks/adapter-1c-mcp.md @@ -780,6 +780,9 @@ concrete read-only `metadata.write.plan`. The same strict mode also requires the write-preflight smoke to discover an extension form by public `extension/ref/form/member` names, compose an allowed plan, and prove that the repository and support gates use the same resolved `extension:` layer. +It then submits the same public selector with a deliberately different GUID +and requires a read-only `blocked / extension_selector_conflict` result, so a +legacy permissive repository profile cannot authorize a mismatched layer. Run that check directly with `scripts/smoke_1c_write_preflight.py --require-name-first-extension-form`. diff --git a/plugins/1c/connector/adapter_1c_server.py b/plugins/1c/connector/adapter_1c_server.py index 4978fef..408e0fc 100644 --- a/plugins/1c/connector/adapter_1c_server.py +++ b/plugins/1c/connector/adapter_1c_server.py @@ -47447,6 +47447,18 @@ def repository_apply_gate(payload: dict[str, Any], method: str, mode: str) -> di "schema": "onec_repository_write_gate.v1", "method": method, "base_id": payload.get("base_id"), **context_error, } + layer_block = development_layer_write_block(payload) + if layer_block: + return { + "schema": "onec_repository_write_gate.v1", + "method": method, + "status": "blocked", + "base_id": payload.get("base_id"), + **layer_block, + "diagnostics": { + "message": "Saved-state apply is blocked because the public extension selector did not resolve to one matching development layer.", + }, + } gate = repository_control.write_gate(payload) support = live_sql_support_gate(payload) if gate.get("allowed") is True and support.get("allowed") is True: @@ -47491,9 +47503,12 @@ def metadata_write_preflight(payload: dict[str, Any]) -> dict[str, Any]: } repository_gate = repository_control.write_gate(gate_payload) support_gate = live_sql_support_gate(gate_payload) + layer_block = development_layer_write_block(payload) schedule_status = str(schedule_plan.get("status") or "blocked") status = "ready" if schedule_status in {"planned", "unchanged"} else schedule_status - if repository_gate.get("allowed") is not True: + if layer_block: + status = "blocked" + elif repository_gate.get("allowed") is not True: status = str(repository_gate.get("status") or "blocked") elif support_gate.get("allowed") is not True: status = str(support_gate.get("status") or "blocked") @@ -47526,6 +47541,11 @@ def metadata_write_preflight(payload: dict[str, Any]) -> dict[str, Any]: }, "repository": repository_gate, "support": support_gate, + **( + {"development_layer_resolution": payload["development_layer_resolution"]} + if isinstance(payload.get("development_layer_resolution"), dict) + else {} + ), "guards": { "required": ["allow_saved_state_write", "expected_sha1", "backup", "readback_verification"], "requires_saved_state_prepare": schedule_status == "needs_prepare", @@ -47649,7 +47669,10 @@ def metadata_write_preflight(payload: dict[str, Any]) -> dict[str, Any]: gate_payload["target"] = {**caller_target, **plan_target} repository_gate = repository_control.write_gate(gate_payload) support_gate = live_sql_support_gate(gate_payload) - if repository_gate.get("allowed") is not True: + layer_block = development_layer_write_block(payload) + if layer_block: + status = "blocked" + elif repository_gate.get("allowed") is not True: status = str(repository_gate.get("status") or "blocked") elif support_gate.get("allowed") is not True: status = str(support_gate.get("status") or "blocked") @@ -47682,6 +47705,11 @@ def metadata_write_preflight(payload: dict[str, Any]) -> dict[str, Any]: "saved_state": saved_state, "repository": repository_gate, "support": support_gate, + **( + {"development_layer_resolution": payload["development_layer_resolution"]} + if isinstance(payload.get("development_layer_resolution"), dict) + else {} + ), **({"owner_resolution": payload["owner_resolution"]} if isinstance(payload.get("owner_resolution"), dict) else {}), "guards": guards, "plan": { @@ -54672,6 +54700,20 @@ def repository_resolve_form_guid_sql(*, method: str, base_id: str, form_guid: st _FORM_MODULE_FILE_RE = re.compile(r"^(?:(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})__)?(?P
[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.0$", re.IGNORECASE) +def development_layer_write_block(payload: dict[str, Any]) -> dict[str, Any] | None: + resolution = payload.get("development_layer_resolution") if isinstance(payload.get("development_layer_resolution"), dict) else {} + status = str(resolution.get("status") or "").strip().casefold() + if not status or status == "resolved": + return None + diagnostics = resolution.get("diagnostics") if isinstance(resolution.get("diagnostics"), dict) else {} + error = str(diagnostics.get("error") or "extension_layer_not_resolved") + return { + "error": error, + "layer_id": resolution.get("layer_id") or "extension:unresolved", + "development_layer_resolution": resolution, + } + + def resolve_write_gate_context(payload: dict[str, Any]) -> dict[str, Any]: """Derive the exact development layer and form ownership before gates. @@ -54705,16 +54747,47 @@ def resolve_write_gate_context(payload: dict[str, Any]) -> dict[str, Any]: else: requested_extension = str(requested_extension_value or "").strip() base_id = str(payload.get("base_id") or "").strip() - resolved_extension_guid = explicit_extension_guid + resolved_extension_guid = "" extension_error: dict[str, Any] | None = None - if not resolved_extension_guid and requested_extension and base_id: - resolved_extension_guid, extension_error = extension_filter_to_guid( + selector_conflict: dict[str, Any] | None = None + if explicit_extension_guid and not is_guid_text(explicit_extension_guid): + selector_conflict = { + "status": "invalid_argument", + "error": "invalid_extension_guid", + "extension_guid": explicit_extension_guid, + } + resolved_name_guid: str | None = None + if requested_extension and base_id and selector_conflict is None: + resolved_name_guid, extension_error = extension_filter_to_guid( base_id, requested_extension, method="write.gate_context", ) + if extension_error: + selector_conflict = extension_error + elif explicit_extension_guid and resolved_name_guid != explicit_extension_guid: + selector_conflict = { + "status": "conflict", + "error": "extension_selector_conflict", + "base_id": base_id, + "query": { + "extension": requested_extension, + "extension_guid": explicit_extension_guid, + }, + "resolved_extension_guid": resolved_name_guid, + "diagnostics": { + "message": "The public extension name resolves to a different GUID than the supplied write context.", + }, + } + elif requested_extension and not base_id and selector_conflict is None: + selector_conflict = { + "status": "not_resolved", + "error": "extension_resolution_requires_base_id", + "base_id": None, + } + if selector_conflict is None: + resolved_extension_guid = str(resolved_name_guid or explicit_extension_guid or "").lower() if resolved_extension_guid: - resolved_extension_guid = resolved_extension_guid.lower() result["extension_guid"] = resolved_extension_guid target["extension_guid"] = resolved_extension_guid target["origin"] = { @@ -54736,18 +54809,24 @@ def resolve_write_gate_context(payload: dict[str, Any]) -> dict[str, Any]: }, "layer_id": f"extension:{resolved_extension_guid}", } - elif requested_extension: - # Never let an unresolved public extension selector inherit the base - # repository/support policy. The synthetic layer cannot match a - # configured GUID layer and therefore keeps every write gate closed. + elif requested_extension or selector_conflict: + # An explicit preflight/apply block consumes this resolution. The + # synthetic layer also prevents accidental inheritance of base policy. + result.pop("extension_guid", None) + target.pop("extension_guid", None) result["layer_id"] = "extension:unresolved" target["layer_id"] = "extension:unresolved" + unresolved_status = "conflict" if str((selector_conflict or {}).get("error") or "") == "extension_selector_conflict" else "not_resolved" result["development_layer_resolution"] = { - "status": "not_resolved", + "status": unresolved_status, "source": "extension_name", - "extension": {"name": requested_extension}, + "extension": { + **({"name": requested_extension} if requested_extension else {}), + **({"supplied_guid": explicit_extension_guid} if explicit_extension_guid else {}), + }, "layer_id": "extension:unresolved", - "diagnostics": extension_error + "diagnostics": selector_conflict + or extension_error or { "status": "not_resolved", "error": "extension_resolution_requires_base_id", @@ -54756,6 +54835,8 @@ def resolve_write_gate_context(payload: dict[str, Any]) -> dict[str, Any]: } if target: result["target"] = target + if development_layer_write_block(result): + return result existing = payload.get("owner_resolution") if isinstance(payload.get("owner_resolution"), dict) else {} if existing.get("status") == "resolved" and str(existing.get("repository_object") or "").strip(): return result diff --git a/scripts/check_1c_verify_reports.py b/scripts/check_1c_verify_reports.py index 6ccc517..59f3f76 100644 --- a/scripts/check_1c_verify_reports.py +++ b/scripts/check_1c_verify_reports.py @@ -401,7 +401,7 @@ def validate_write_preflight( failures.append({"code": "write_preflight_not_ok", "label": label, "path": str(path), "status": report.get("status")}) if report.get("failures"): failures.append({"code": "write_preflight_failures_present", "label": label, "path": str(path), "failures": report.get("failures")}) - for check in ("method_exposed", "effective_path_preflight", "concrete_saved_state_preflight", "name_first_extension_form_preflight"): + for check in ("method_exposed", "effective_path_preflight", "concrete_saved_state_preflight", "name_first_extension_form_preflight", "conflicting_extension_selector_preflight"): if check not in checks: failures.append({"code": "write_preflight_check_missing", "label": label, "check": check, "path": str(path)}) expect_check(checks, failures, label, path, "method_exposed", {"status": "ok"}, failure_code="write_preflight_check_field_unexpected") @@ -470,6 +470,31 @@ def validate_write_preflight( "repository_layer_id": repository_layer or None, "support_layer_id": support_layer or None, }) + conflicting_extension = checks.get("conflicting_extension_selector_preflight") if isinstance(checks.get("conflicting_extension_selector_preflight"), dict) else {} + if conflicting_extension.get("status") == "skipped_no_public_extension_form_target": + if require_name_first_form: + failures.append({ + "code": "write_preflight_extension_conflict_check_required", + "label": label, + "path": str(path), + }) + else: + expect_check( + checks, + failures, + label, + path, + "conflicting_extension_selector_preflight", + { + "schema": "onec_metadata_write_preflight.v1", + "status": "blocked", + "allowed": False, + "resolution_status": "conflict", + "error": "extension_selector_conflict", + "layer_id": "extension:unresolved", + }, + failure_code="write_preflight_check_field_unexpected", + ) if require_mcp_initialize: if "mcp.initialize" not in checks: failures.append({"code": "write_preflight_check_missing", "label": label, "check": "mcp.initialize", "path": str(path)}) @@ -1633,6 +1658,14 @@ def write_self_test_reports(report_dir: Path, *, base_id: str, composed: bool, s "name_first": True, "extension": "test2", }, + "conflicting_extension_selector_preflight": { + "schema": "onec_metadata_write_preflight.v1", + "status": "blocked", + "allowed": False, + "resolution_status": "conflict", + "error": "extension_selector_conflict", + "layer_id": "extension:unresolved", + }, }, "failures": [], } diff --git a/scripts/smoke_1c_write_preflight.py b/scripts/smoke_1c_write_preflight.py index bbc1f98..9656aa0 100644 --- a/scripts/smoke_1c_write_preflight.py +++ b/scripts/smoke_1c_write_preflight.py @@ -207,6 +207,14 @@ def first_public_extension_form_candidate(result: dict[str, Any]) -> dict[str, A return candidate +def different_extension_guid(layer_id: str) -> str | None: + if not EXTENSION_GUID_LAYER_RE.fullmatch(str(layer_id or "")): + return None + guid = str(layer_id).split(":", 1)[1].lower() + replacement = "0" if guid[-1] != "0" else "1" + return f"{guid[:-1]}{replacement}" + + def run_smoke( endpoint_url: str, base_id: str, @@ -312,6 +320,9 @@ def run_smoke( "search_status": forms.get("status"), "forms": int((forms.get("counts") or {}).get("forms") or 0), } + checks["conflicting_extension_selector_preflight"] = { + "status": "skipped_no_public_extension_form_target", + } if require_name_first_extension_form: failures.append("name-first extension form preflight target is required but was not discovered") else: @@ -355,6 +366,49 @@ def run_smoke( require(name_first, "extension form preflight input must remain name-first", failures) require(bool(EXTENSION_GUID_LAYER_RE.fullmatch(repository_layer)), "repository gate must use a resolved extension GUID layer", failures) require(repository_layer == support_layer, "repository and support gates must use the same extension layer", failures) + conflicting_guid = different_extension_guid(repository_layer) + if not conflicting_guid: + checks["conflicting_extension_selector_preflight"] = { + "status": "skipped_no_resolved_extension_layer", + } + else: + conflicting = rpc_call( + endpoint_url, + "metadata.write.preflight", + { + "base_id": base_id, + "extension_guid": conflicting_guid, + "target": form_candidate["target"], + "edits": [form_candidate["edit"]], + "resolve_origin": False, + "timeout_seconds": int(timeout), + }, + timeout, + transport=transport, + session_id=session_id, + ) + layer_resolution = ( + conflicting.get("development_layer_resolution") + if isinstance(conflicting.get("development_layer_resolution"), dict) + else {} + ) + layer_diagnostics = ( + layer_resolution.get("diagnostics") + if isinstance(layer_resolution.get("diagnostics"), dict) + else {} + ) + checks["conflicting_extension_selector_preflight"] = { + "schema": conflicting.get("schema"), + "status": conflicting.get("status"), + "allowed": conflicting.get("allowed"), + "resolution_status": layer_resolution.get("status"), + "error": layer_diagnostics.get("error"), + "layer_id": layer_resolution.get("layer_id"), + } + require(conflicting.get("schema") == "onec_metadata_write_preflight.v1", "conflicting extension selector preflight must return expected schema", failures) + require(conflicting.get("status") == "blocked" and conflicting.get("allowed") is False, "conflicting extension name/GUID must block preflight", failures) + require(layer_resolution.get("status") == "conflict", "conflicting extension selector must report conflict resolution status", failures) + require(layer_diagnostics.get("error") == "extension_selector_conflict", "conflicting extension selector must report extension_selector_conflict", failures) return { "schema": "onec_write_preflight_smoke.v1", diff --git a/scripts/verify_1c_adapter_deployment.ps1 b/scripts/verify_1c_adapter_deployment.ps1 index 471b7a0..137c9d3 100644 --- a/scripts/verify_1c_adapter_deployment.ps1 +++ b/scripts/verify_1c_adapter_deployment.ps1 @@ -206,7 +206,7 @@ function Assert-WritePreflightReport { if ($report.failures -and $report.failures.Count -gt 0) { throw "$Label report contains failures: $Path" } - foreach ($check in @("method_exposed", "effective_path_preflight", "concrete_saved_state_preflight", "name_first_extension_form_preflight")) { + foreach ($check in @("method_exposed", "effective_path_preflight", "concrete_saved_state_preflight", "name_first_extension_form_preflight", "conflicting_extension_selector_preflight")) { if ($report.checks.PSObject.Properties.Name -notcontains $check) { throw "$Label report is missing check '$check': $Path" } diff --git a/tests/1c/test_payload_codec.py b/tests/1c/test_payload_codec.py index f3f5b28..56611ba 100644 --- a/tests/1c/test_payload_codec.py +++ b/tests/1c/test_payload_codec.py @@ -20992,6 +20992,14 @@ def test_metadata_write_active_form_target_auto_prepares_when_apply_allowed(monk def test_metadata_write_missing_form_target_auto_prepares_and_retries(monkeypatch: pytest.MonkeyPatch) -> None: calls: dict[str, int] = {"apply": 0} seen: dict[str, Any] = {} + monkeypatch.setattr( + adapter_server, + "extension_filter_to_guid", + lambda base_id, extension, method: ( + "fb26cf42-7609-11f1-828f-005056b0d483", + None, + ), + ) def fake_apply(payload: dict[str, Any]) -> dict[str, Any]: calls["apply"] += 1 @@ -21856,6 +21864,104 @@ def test_write_gate_context_does_not_fall_back_to_base_for_unknown_extension( assert adapter_server.repository_control.development_layer_id(result) == "extension:unresolved" +def test_write_gate_context_rejects_conflicting_extension_name_and_guid( + monkeypatch: pytest.MonkeyPatch, +) -> None: + name_guid = "fb26cf42-7609-11f1-828f-005056b0d483" + supplied_guid = "11111111-1111-1111-1111-111111111111" + monkeypatch.setattr( + adapter_server, + "extension_filter_to_guid", + lambda base_id, extension, method: (name_guid, None), + ) + + result = adapter_server.resolve_write_gate_context( + { + "base_id": "upo_test", + "extension_guid": supplied_guid, + "target": { + "kind": "form", + "extension": "test2", + "ref": "Catalog.test2", + "form": "t_Форма", + }, + } + ) + + resolution = result["development_layer_resolution"] + assert "extension_guid" not in result + assert "extension_guid" not in result["target"] + assert resolution["status"] == "conflict" + assert resolution["layer_id"] == "extension:unresolved" + assert resolution["extension"] == { + "name": "test2", + "supplied_guid": supplied_guid, + } + assert resolution["diagnostics"]["error"] == "extension_selector_conflict" + assert resolution["diagnostics"]["resolved_extension_guid"] == name_guid + + +def test_repository_apply_gate_blocks_unresolved_extension_before_policy_gates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def must_not_run(_payload: dict[str, Any]) -> dict[str, Any]: + raise AssertionError("repository/support policy gates must not authorize an unresolved extension layer") + + monkeypatch.setattr(adapter_server.repository_control, "write_gate", must_not_run) + monkeypatch.setattr(adapter_server, "live_sql_support_gate", must_not_run) + + result = adapter_server.repository_apply_gate( + { + "base_id": "upo_test", + "development_layer_resolution": { + "status": "conflict", + "layer_id": "extension:unresolved", + "diagnostics": {"error": "extension_selector_conflict"}, + }, + }, + "metadata.write", + "apply", + ) + + assert result["status"] == "blocked" + assert result["error"] == "extension_selector_conflict" + assert result["layer_id"] == "extension:unresolved" + + +def test_metadata_write_apply_blocks_conflicting_extension_name_and_guid( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + adapter_server, + "extension_filter_to_guid", + lambda base_id, extension, method: ( + "fb26cf42-7609-11f1-828f-005056b0d483", + None, + ), + ) + + result = adapter_server.metadata_write( + { + "base_id": "upo_test", + "extension_guid": "11111111-1111-1111-1111-111111111111", + "target": { + "kind": "form", + "extension": "test2", + "ref": "Catalog.test2", + "form": "t_Форма", + "command": "ЗаменаДомена", + }, + "edits": [{"property": "Заголовок", "value": "Новое имя"}], + "mode": "apply", + } + ) + + assert result["schema"] == "onec_repository_write_gate.v1" + assert result["status"] == "blocked" + assert result["error"] == "extension_selector_conflict" + assert result["development_layer_resolution"]["status"] == "conflict" + + def test_metadata_write_preflight_routes_public_extension_form_gates_to_extension_layer( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -21928,6 +22034,84 @@ def test_metadata_write_preflight_routes_public_extension_form_gates_to_extensio assert captured_layers == [expected_layer, expected_layer] +def test_metadata_write_preflight_blocks_conflicting_extension_name_and_guid( + monkeypatch: pytest.MonkeyPatch, +) -> None: + name_guid = "fb26cf42-7609-11f1-828f-005056b0d483" + supplied_guid = "11111111-1111-1111-1111-111111111111" + monkeypatch.setattr( + adapter_server, + "extension_filter_to_guid", + lambda base_id, extension, method: (name_guid, None), + ) + monkeypatch.setattr( + adapter_server, + "metadata_form_write_target_resolve", + lambda payload: { + "status": "ok", + "source": { + "table": "ConfigCASSave", + "file_name": f"{name_guid}__form-guid.0", + }, + "counts": {"matches": 1}, + }, + ) + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_diff", + lambda payload: { + "status": "changed", + "target": {"table": payload["table"], "file_name": payload["file_name"]}, + "freshness": { + "source": "live_sql", + "status": "live_sql_verified", + "verified_against_sql": True, + }, + }, + ) + monkeypatch.setattr( + adapter_server.repository_control, + "write_gate", + lambda payload: { + "status": "not_configured", + "allowed": True, + "layer_id": adapter_server.repository_control.development_layer_id(payload), + }, + ) + monkeypatch.setattr( + adapter_server, + "live_sql_support_gate", + lambda payload: { + "status": "support_not_configured_legacy", + "allowed": True, + "layer_id": adapter_server.repository_control.development_layer_id(payload), + }, + ) + + result = adapter_server.metadata_write_preflight( + { + "base_id": "upo_test", + "extension_guid": supplied_guid, + "target": { + "kind": "form", + "extension": "test2", + "ref": "Catalog.test2", + "form": "t_Форма", + "command": "ЗаменаДомена", + }, + "edits": [{"property": "Заголовок", "value": "Новое имя"}], + "resolve_origin": False, + } + ) + + assert result["status"] == "blocked" + assert result["allowed"] is False + assert result["repository"]["allowed"] is True + assert result["support"]["allowed"] is True + assert result["development_layer_resolution"]["status"] == "conflict" + assert result["development_layer_resolution"]["diagnostics"]["error"] == "extension_selector_conflict" + + def test_metadata_write_plan_help_describes_name_first_form_and_module_targets() -> None: methods = { row["name"]: row @@ -26459,6 +26643,14 @@ def test_metadata_write_missing_module_target_exposes_prepare_plan(monkeypatch: def test_metadata_write_missing_module_target_auto_prepares_and_retries(monkeypatch: pytest.MonkeyPatch) -> None: calls: dict[str, int] = {"search": 0} seen: dict[str, Any] = {} + monkeypatch.setattr( + adapter_server, + "extension_filter_to_guid", + lambda base_id, extension, method: ( + "fb26cf42-7609-11f1-828f-005056b0d483", + None, + ), + ) def fake_search(payload: dict[str, Any]) -> dict[str, Any]: calls["search"] += 1 diff --git a/tests/1c/test_write_preflight_smoke.py b/tests/1c/test_write_preflight_smoke.py index 1c8f27e..717fd6e 100644 --- a/tests/1c/test_write_preflight_smoke.py +++ b/tests/1c/test_write_preflight_smoke.py @@ -112,6 +112,22 @@ def test_public_extension_form_candidate_is_name_first_and_prefers_title() -> No } +def test_different_extension_guid_changes_only_last_hex_digit() -> None: + assert ( + smoke.different_extension_guid( + "extension:fb26cf42-7609-11f1-828f-005056b0d483" + ) + == "fb26cf42-7609-11f1-828f-005056b0d480" + ) + assert ( + smoke.different_extension_guid( + "extension:fb26cf42-7609-11f1-828f-005056b0d480" + ) + == "fb26cf42-7609-11f1-828f-005056b0d481" + ) + assert smoke.different_extension_guid("base") is None + + def test_name_first_extension_form_preflight_rejects_base_gate_layer(monkeypatch) -> None: def fake_rpc_call( _endpoint_url: str,