feat(github): validate redacted evidence refs
This commit is contained in:
@@ -340,6 +340,7 @@ from src.services.gitea_workflow_runner_health import (
|
||||
from src.services.github_target_private_backup_evidence_gate import (
|
||||
load_latest_github_target_private_backup_evidence_gate,
|
||||
preflight_github_target_owner_response_submission,
|
||||
validate_github_target_safe_credential_evidence_refs,
|
||||
)
|
||||
from src.services.host_runaway_aiops_loop_readiness import (
|
||||
load_latest_host_runaway_aiops_loop_readiness,
|
||||
@@ -1027,6 +1028,44 @@ async def preflight_github_target_owner_response_intake(
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/github-target-safe-credential-evidence-reviewer-validation/validate-redacted-refs",
|
||||
response_model=dict[str, Any],
|
||||
summary="驗證 GitHub target safe credential 脫敏 evidence refs",
|
||||
description=(
|
||||
"針對單次 owner-provided redacted safe credential evidence refs 進行 no-persist "
|
||||
"reviewer validation,回傳 accepted / needs supplement / quarantined / rejected runtime "
|
||||
"action 分流。此端點不保存 payload、不呼叫 GitHub live API、不建立 repo、不改 visibility、"
|
||||
"不同步 refs、不觸發 workflow、不收 private clone URL credential 或任何 secret value,也不更新 "
|
||||
"safe credential accepted evidence 總帳。"
|
||||
),
|
||||
)
|
||||
async def validate_github_target_safe_credential_evidence_review(
|
||||
submission: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""回傳單次 GitHub safe credential 脫敏 evidence refs 公開安全驗證結果。"""
|
||||
try:
|
||||
payload = await asyncio.to_thread(
|
||||
validate_github_target_safe_credential_evidence_refs,
|
||||
submission,
|
||||
)
|
||||
return redact_public_lan_topology(payload)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.error(
|
||||
"github_target_safe_credential_evidence_review_invalid",
|
||||
error=str(exc),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="GitHub target safe credential evidence review 無效",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent-12-agent-war-room",
|
||||
response_model=dict[str, Any],
|
||||
|
||||
@@ -25,6 +25,38 @@ _CONNECTOR_READBACK_FILE = "github-target-connector-readback.snapshot.json"
|
||||
_MISSING_SOURCE_READINESS_FILE = "github-target-missing-source-readiness.snapshot.json"
|
||||
_PREFLIGHT_SCHEMA_VERSION = "github_target_owner_response_intake_preflight_v1"
|
||||
_PREFLIGHT_MODE = "validate_owner_response_only_no_persist_no_github_write"
|
||||
_SAFE_CREDENTIAL_REVIEW_SCHEMA_VERSION = (
|
||||
"github_target_safe_credential_evidence_ref_validation_result_v1"
|
||||
)
|
||||
_SAFE_CREDENTIAL_REVIEW_MODE = (
|
||||
"validate_redacted_evidence_refs_only_no_persist_no_github_write"
|
||||
)
|
||||
_SAFE_CREDENTIAL_REVIEW_ENDPOINT = (
|
||||
"/api/v1/agents/"
|
||||
"github-target-safe-credential-evidence-reviewer-validation/validate-redacted-refs"
|
||||
)
|
||||
_SAFE_CREDENTIAL_REVIEW_ACCEPT_DECISION = (
|
||||
"accept_redacted_evidence_refs_for_reviewer_validation"
|
||||
)
|
||||
_SAFE_CREDENTIAL_REVIEW_REQUIRED_FIELDS = (
|
||||
"template_id",
|
||||
"github_repo",
|
||||
"reviewer_role_or_team",
|
||||
"decision",
|
||||
"decision_reason",
|
||||
"evidence_ref_type",
|
||||
"redacted_evidence_refs",
|
||||
)
|
||||
_SAFE_CREDENTIAL_REVIEW_OPTIONAL_FIELDS = {
|
||||
"affected_scope",
|
||||
"evidence_refs",
|
||||
"followup_owner",
|
||||
"redaction_status",
|
||||
"response_id",
|
||||
"source_system",
|
||||
"submission_mode",
|
||||
"validation_plan",
|
||||
}
|
||||
_SUBMISSION_METADATA_FIELDS = {
|
||||
"github_repo",
|
||||
"response_id",
|
||||
@@ -44,10 +76,41 @@ _FORBIDDEN_KEY_FRAGMENTS = {
|
||||
"private_key",
|
||||
"repo_archive",
|
||||
"repo_creation_command",
|
||||
"requested_action",
|
||||
"requested_actions",
|
||||
"runtime_action",
|
||||
"runtime_execution",
|
||||
"refs_sync",
|
||||
"delete_refs",
|
||||
"force_push",
|
||||
"tag_rewrite",
|
||||
"github_primary_switch",
|
||||
"secret_value",
|
||||
"session",
|
||||
"token_value",
|
||||
"visibility_change_command",
|
||||
"workflow_trigger",
|
||||
"write_or_admin_api_request",
|
||||
}
|
||||
_RUNTIME_ACTION_TERMS = {
|
||||
"change_repo_visibility",
|
||||
"create_github_repo",
|
||||
"delete_refs",
|
||||
"force_push",
|
||||
"github_primary_switch",
|
||||
"push_refs",
|
||||
"refs_sync",
|
||||
"refs_sync_or_delete_request",
|
||||
"repo_creation_command",
|
||||
"requested_action",
|
||||
"requested_actions",
|
||||
"runtime_action",
|
||||
"runtime_execution",
|
||||
"switch_github_primary",
|
||||
"tag_rewrite",
|
||||
"visibility_change_command",
|
||||
"workflow_trigger",
|
||||
"write_or_admin_api_request",
|
||||
}
|
||||
_SENSITIVE_VALUE_PATTERNS = (
|
||||
("github_token", re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b")),
|
||||
@@ -211,6 +274,181 @@ def preflight_github_target_owner_response_submission(
|
||||
}
|
||||
|
||||
|
||||
def validate_github_target_safe_credential_evidence_refs(
|
||||
submission: dict[str, Any],
|
||||
security_dir: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate redacted safe-credential evidence refs without persisting them."""
|
||||
gate = load_latest_github_target_private_backup_evidence_gate(security_dir)
|
||||
safe_intake = _dict(gate.get("safe_credential_evidence_intake_readiness"))
|
||||
owner_intake = _dict(gate.get("owner_response_intake_readiness"))
|
||||
payload = _dict(submission)
|
||||
allowed_modes = set(_strings(safe_intake.get("allowed_submission_modes")))
|
||||
allowed_ref_types = set(_strings(safe_intake.get("allowed_evidence_ref_types")))
|
||||
forbidden_payloads = (
|
||||
set(_strings(safe_intake.get("forbidden_payloads")))
|
||||
| set(_strings(owner_intake.get("still_forbidden")))
|
||||
| _RUNTIME_ACTION_TERMS
|
||||
)
|
||||
target_by_template = {
|
||||
str(target.get("owner_response_template_id")): _dict(target)
|
||||
for target in _list(gate.get("targets"))
|
||||
if target.get("owner_response_template_id")
|
||||
}
|
||||
|
||||
submission_mode = str(payload.get("submission_mode") or "")
|
||||
candidate_items = [
|
||||
_dict(row) for row in _list(payload.get("evidence_ref_submissions"))
|
||||
]
|
||||
if not candidate_items:
|
||||
candidate_items = [_dict(row) for row in _list(payload.get("responses"))]
|
||||
|
||||
mode_allowed = submission_mode in allowed_modes
|
||||
global_scan_payload = {
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key not in {"evidence_ref_submissions", "responses"}
|
||||
}
|
||||
global_hits = _forbidden_payload_hits(
|
||||
global_scan_payload,
|
||||
forbidden_payloads=forbidden_payloads,
|
||||
)
|
||||
global_runtime_hits = _runtime_action_hits(global_hits)
|
||||
item_results = [
|
||||
_validate_safe_credential_evidence_ref_item(
|
||||
item=item,
|
||||
submission_mode=submission_mode,
|
||||
mode_allowed=mode_allowed,
|
||||
target_by_template=target_by_template,
|
||||
allowed_ref_types=allowed_ref_types,
|
||||
forbidden_payloads=forbidden_payloads,
|
||||
)
|
||||
for item in candidate_items
|
||||
]
|
||||
passed_count = sum(
|
||||
1 for row in item_results if row["accepted_for_readonly_review"] is True
|
||||
)
|
||||
runtime_rejected_count = sum(
|
||||
1 for row in item_results if row["runtime_action_rejected"] is True
|
||||
) + (1 if global_runtime_hits else 0)
|
||||
quarantined_count = sum(1 for row in item_results if row["quarantined"] is True) + (
|
||||
1 if global_hits and not global_runtime_hits else 0
|
||||
)
|
||||
blocked_count = len(item_results) - passed_count
|
||||
|
||||
global_blockers: list[str] = []
|
||||
if not mode_allowed:
|
||||
global_blockers.append("submission_mode_not_allowed")
|
||||
if not candidate_items:
|
||||
global_blockers.append("redacted_evidence_ref_items_missing")
|
||||
if global_runtime_hits:
|
||||
global_blockers.append("runtime_action_request_detected")
|
||||
elif global_hits:
|
||||
global_blockers.append("forbidden_payload_detected")
|
||||
|
||||
if runtime_rejected_count:
|
||||
status_value = "reject_runtime_action_request"
|
||||
elif quarantined_count:
|
||||
status_value = "quarantine_sensitive_payload"
|
||||
elif global_blockers or blocked_count:
|
||||
status_value = "request_redacted_evidence_ref_supplement"
|
||||
else:
|
||||
status_value = "accepted_for_readonly_safe_credential_evidence_review_only"
|
||||
|
||||
accepted = (
|
||||
status_value == "accepted_for_readonly_safe_credential_evidence_review_only"
|
||||
)
|
||||
return {
|
||||
"schema_version": _SAFE_CREDENTIAL_REVIEW_SCHEMA_VERSION,
|
||||
"contract_schema_version": gate["schema_version"],
|
||||
"generated_at": gate.get("generated_at", ""),
|
||||
"status": status_value,
|
||||
"mode": _SAFE_CREDENTIAL_REVIEW_MODE,
|
||||
"outcome_lane": status_value,
|
||||
"accepted_for_readonly_review": accepted,
|
||||
"reviewer_validation_passed": accepted,
|
||||
"quarantined": status_value == "quarantine_sensitive_payload",
|
||||
"runtime_action_rejected": status_value == "reject_runtime_action_request",
|
||||
"submission_mode": submission_mode,
|
||||
"submission_mode_allowed": mode_allowed,
|
||||
"allowed_submission_modes": sorted(allowed_modes),
|
||||
"global_blockers": global_blockers,
|
||||
"global_forbidden_hits": global_hits,
|
||||
"summary": {
|
||||
"candidate_evidence_ref_item_count": len(candidate_items),
|
||||
"reviewer_validation_passed_count": passed_count,
|
||||
"reviewer_validation_blocked_count": blocked_count,
|
||||
"reviewer_validation_quarantined_count": quarantined_count,
|
||||
"runtime_action_rejected_count": runtime_rejected_count,
|
||||
"forbidden_payload_hit_count": len(global_hits)
|
||||
+ sum(len(row["forbidden_hits"]) for row in item_results),
|
||||
"unsafe_evidence_ref_hit_count": sum(
|
||||
len(row["evidence_ref_hits"]) for row in item_results
|
||||
),
|
||||
"missing_required_field_count": sum(
|
||||
len(row["missing_required_fields"]) for row in item_results
|
||||
),
|
||||
"safe_credential_evidence_submission_received_count": len(candidate_items),
|
||||
"safe_credential_evidence_submission_accepted_count": passed_count,
|
||||
"safe_credential_accepted_evidence_count": 0,
|
||||
"private_backup_verified_count": gate["summary"][
|
||||
"private_backup_verified_count"
|
||||
],
|
||||
"github_missing_target_create_private_repo_ready_count": gate["summary"][
|
||||
"github_missing_target_create_private_repo_ready_count"
|
||||
],
|
||||
"github_missing_target_refs_sync_ready_count": gate["summary"][
|
||||
"github_missing_target_refs_sync_ready_count"
|
||||
],
|
||||
"execution_ready_count": gate["summary"]["execution_ready_count"],
|
||||
"blocked_target_count": gate["summary"]["blocked_target_count"],
|
||||
"payload_persisted": False,
|
||||
"safe_credential_accepted_updated": False,
|
||||
"github_api_write_allowed": False,
|
||||
"repo_creation_authorized": False,
|
||||
"visibility_change_authorized": False,
|
||||
"refs_sync_authorized": False,
|
||||
"workflow_trigger_authorized": False,
|
||||
"private_clone_url_collection_allowed": False,
|
||||
"secret_value_collection_allowed": False,
|
||||
"execution_authorized": False,
|
||||
},
|
||||
"evidence_ref_submissions": item_results,
|
||||
"boundary_markers": [
|
||||
"github_safe_credential_evidence_review_no_persist=true",
|
||||
f"github_safe_credential_evidence_review_passed_count={passed_count}",
|
||||
f"github_safe_credential_evidence_review_quarantined_count={quarantined_count}",
|
||||
"github_safe_credential_accepted_evidence_count=0",
|
||||
"github_safe_credential_accepted_updated=false",
|
||||
"github_repo_creation_authorized=false",
|
||||
"github_visibility_change_authorized=false",
|
||||
"github_refs_sync_authorized=false",
|
||||
"github_workflow_trigger_authorized=false",
|
||||
"github_private_clone_url_collection_allowed=false",
|
||||
"github_secret_value_collection_allowed=false",
|
||||
"runtime_execution_authorized=false",
|
||||
"not_authorization=true",
|
||||
],
|
||||
"boundaries": {
|
||||
"payload_persisted": False,
|
||||
"github_api_write_allowed": False,
|
||||
"repo_creation_authorized": False,
|
||||
"visibility_change_authorized": False,
|
||||
"refs_sync_authorized": False,
|
||||
"workflow_trigger_authorized": False,
|
||||
"private_clone_url_collection_allowed": False,
|
||||
"secret_value_collection_allowed": False,
|
||||
"raw_payload_storage_allowed": False,
|
||||
"safe_credential_accepted_updated": False,
|
||||
"runtime_execution_authorized": False,
|
||||
"not_authorization": True,
|
||||
},
|
||||
"next_gate": "private_backup_post_review_readback"
|
||||
if accepted
|
||||
else "redacted_evidence_ref_fix_and_resubmit",
|
||||
}
|
||||
|
||||
|
||||
def build_github_target_private_backup_evidence_gate(
|
||||
*,
|
||||
decision: dict[str, Any],
|
||||
@@ -396,6 +634,14 @@ def build_github_target_private_backup_evidence_gate(
|
||||
"quarantine_lane_count"
|
||||
],
|
||||
"safe_credential_raw_payload_storage_allowed": False,
|
||||
"safe_credential_reviewer_validation_ready": safe_credential_evidence_intake_readiness[
|
||||
"reviewer_validation_ready"
|
||||
],
|
||||
"safe_credential_reviewer_validation_required_field_count": safe_credential_evidence_intake_readiness[
|
||||
"reviewer_validation_required_field_count"
|
||||
],
|
||||
"safe_credential_reviewer_validation_passed_count": 0,
|
||||
"safe_credential_reviewer_validation_quarantined_count": 0,
|
||||
"owner_response_received_count": _int(
|
||||
owner_summary.get("received_response_count")
|
||||
),
|
||||
@@ -559,6 +805,104 @@ def _preflight_owner_response_item(
|
||||
}
|
||||
|
||||
|
||||
def _validate_safe_credential_evidence_ref_item(
|
||||
*,
|
||||
item: dict[str, Any],
|
||||
submission_mode: str,
|
||||
mode_allowed: bool,
|
||||
target_by_template: dict[str, dict[str, Any]],
|
||||
allowed_ref_types: set[str],
|
||||
forbidden_payloads: set[str],
|
||||
) -> dict[str, Any]:
|
||||
template_id = str(item.get("template_id") or "")
|
||||
target = target_by_template.get(template_id, {})
|
||||
github_repo = str(item.get("github_repo") or target.get("github_repo") or "")
|
||||
evidence_ref_type = str(item.get("evidence_ref_type") or "")
|
||||
decision = str(item.get("decision") or "")
|
||||
response_fields = set(item)
|
||||
supported_fields = (
|
||||
set(_SAFE_CREDENTIAL_REVIEW_REQUIRED_FIELDS)
|
||||
| _SAFE_CREDENTIAL_REVIEW_OPTIONAL_FIELDS
|
||||
)
|
||||
unsupported_fields = sorted(response_fields - supported_fields)
|
||||
missing_required_fields = sorted(
|
||||
field
|
||||
for field in _SAFE_CREDENTIAL_REVIEW_REQUIRED_FIELDS
|
||||
if not _has_response_value(item.get(field))
|
||||
)
|
||||
evidence_refs = sorted(set(_response_strings(item.get("redacted_evidence_refs"))))
|
||||
blockers: list[str] = []
|
||||
if not mode_allowed:
|
||||
blockers.append("submission_mode_not_allowed")
|
||||
if not target:
|
||||
blockers.append("unknown_or_unrequested_template_id")
|
||||
if (
|
||||
target
|
||||
and item.get("github_repo")
|
||||
and str(item.get("github_repo")) != str(target.get("github_repo"))
|
||||
):
|
||||
blockers.append("github_repo_mismatch_for_template")
|
||||
if unsupported_fields:
|
||||
blockers.append("unsupported_evidence_ref_fields")
|
||||
if missing_required_fields:
|
||||
blockers.append("required_fields_missing")
|
||||
if evidence_ref_type not in allowed_ref_types:
|
||||
blockers.append("evidence_ref_type_not_allowed")
|
||||
if decision != _SAFE_CREDENTIAL_REVIEW_ACCEPT_DECISION:
|
||||
blockers.append("decision_not_allowed_for_safe_credential_review")
|
||||
if not evidence_refs:
|
||||
blockers.append("redacted_evidence_refs_missing")
|
||||
|
||||
forbidden_hits = _forbidden_payload_hits(
|
||||
item, forbidden_payloads=forbidden_payloads
|
||||
)
|
||||
runtime_hits = _runtime_action_hits(forbidden_hits)
|
||||
evidence_ref_hits = _evidence_ref_hits(evidence_refs)
|
||||
if runtime_hits:
|
||||
blockers.append("runtime_action_request_detected")
|
||||
elif forbidden_hits:
|
||||
blockers.append("forbidden_payload_detected")
|
||||
if evidence_ref_hits:
|
||||
blockers.append("unsafe_evidence_ref_detected")
|
||||
|
||||
accepted = not blockers
|
||||
return {
|
||||
"template_id": template_id,
|
||||
"github_repo": github_repo,
|
||||
"submission_mode": submission_mode,
|
||||
"status": "accepted_for_readonly_safe_credential_evidence_review_only"
|
||||
if accepted
|
||||
else "blocked_safe_credential_evidence_ref_candidate",
|
||||
"accepted_for_readonly_review": accepted,
|
||||
"reviewer_validation_passed": accepted,
|
||||
"quarantined": bool(forbidden_hits and not runtime_hits),
|
||||
"runtime_action_rejected": bool(runtime_hits),
|
||||
"decision": decision,
|
||||
"allowed_decisions": [_SAFE_CREDENTIAL_REVIEW_ACCEPT_DECISION],
|
||||
"evidence_ref_type": evidence_ref_type,
|
||||
"allowed_evidence_ref_types": sorted(allowed_ref_types),
|
||||
"required_field_count": len(_SAFE_CREDENTIAL_REVIEW_REQUIRED_FIELDS),
|
||||
"missing_required_fields": missing_required_fields,
|
||||
"unsupported_fields": unsupported_fields,
|
||||
"redacted_evidence_ref_count": len(evidence_refs),
|
||||
"redacted_evidence_refs": evidence_refs if not evidence_ref_hits else [],
|
||||
"evidence_ref_redacted_due_to_findings": bool(evidence_ref_hits),
|
||||
"forbidden_hits": forbidden_hits,
|
||||
"runtime_action_hits": runtime_hits,
|
||||
"evidence_ref_hits": evidence_ref_hits,
|
||||
"blockers": sorted(set(blockers)),
|
||||
"safe_credential_evidence_received": True,
|
||||
"safe_credential_evidence_accepted": False,
|
||||
"safe_credential_accepted_updated": False,
|
||||
"payload_persisted": False,
|
||||
"execution_authorized": False,
|
||||
"repo_creation_authorized": False,
|
||||
"visibility_change_authorized": False,
|
||||
"refs_sync_authorized": False,
|
||||
"workflow_trigger_authorized": False,
|
||||
}
|
||||
|
||||
|
||||
def _build_target(
|
||||
*,
|
||||
decision: dict[str, Any],
|
||||
@@ -1195,6 +1539,19 @@ def _safe_credential_evidence_intake_readiness(
|
||||
"quarantine_lane_count": len(quarantine_lanes),
|
||||
"quarantine_lanes": quarantine_lanes,
|
||||
"allowed_submission_modes": _strings(packet.get("allowed_submission_modes")),
|
||||
"reviewer_validation_endpoint": _SAFE_CREDENTIAL_REVIEW_ENDPOINT,
|
||||
"reviewer_validation_mode": _SAFE_CREDENTIAL_REVIEW_MODE,
|
||||
"reviewer_validation_ready": intake_ready,
|
||||
"reviewer_validation_required_field_count": len(
|
||||
_SAFE_CREDENTIAL_REVIEW_REQUIRED_FIELDS
|
||||
),
|
||||
"reviewer_validation_required_fields": list(
|
||||
_SAFE_CREDENTIAL_REVIEW_REQUIRED_FIELDS
|
||||
),
|
||||
"reviewer_validation_allowed_decisions": [
|
||||
_SAFE_CREDENTIAL_REVIEW_ACCEPT_DECISION
|
||||
],
|
||||
"reviewer_validation_no_persist": True,
|
||||
"stored_raw_payload_allowed": False,
|
||||
"secret_value_collection_allowed": False,
|
||||
"private_clone_url_collection_allowed": False,
|
||||
@@ -1317,9 +1674,8 @@ def _forbidden_payload_hits(
|
||||
for key, child in value.items():
|
||||
key_text = str(key)
|
||||
key_lower = key_text.lower()
|
||||
if (
|
||||
key_lower in forbidden_payloads
|
||||
or any(fragment in key_lower for fragment in _FORBIDDEN_KEY_FRAGMENTS)
|
||||
if key_lower in forbidden_payloads or any(
|
||||
fragment in key_lower for fragment in _FORBIDDEN_KEY_FRAGMENTS
|
||||
):
|
||||
hits.append(
|
||||
{
|
||||
@@ -1382,7 +1738,7 @@ def _evidence_ref_hits(evidence_refs: list[str]) -> list[dict[str, str]]:
|
||||
{
|
||||
"path": f"evidence_refs[{index}]",
|
||||
"kind": "unsupported_evidence_ref_scheme",
|
||||
"match": evidence_ref,
|
||||
"match": "unsupported_evidence_ref_scheme",
|
||||
}
|
||||
)
|
||||
for label, pattern in _SENSITIVE_VALUE_PATTERNS:
|
||||
@@ -1397,6 +1753,16 @@ def _evidence_ref_hits(evidence_refs: list[str]) -> list[dict[str, str]]:
|
||||
return hits
|
||||
|
||||
|
||||
def _runtime_action_hits(forbidden_hits: list[dict[str, str]]) -> list[dict[str, str]]:
|
||||
hits: list[dict[str, str]] = []
|
||||
for hit in forbidden_hits:
|
||||
match = str(hit.get("match") or "").lower()
|
||||
path = str(hit.get("path") or "").lower()
|
||||
if any(term in match or term in path for term in _RUNTIME_ACTION_TERMS):
|
||||
hits.append(hit)
|
||||
return hits
|
||||
|
||||
|
||||
def _dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user