From 00ea28ef8c03c58331e64bfae1ac347610397dd6 Mon Sep 17 00:00:00 2001 From: Colby Farley Date: Fri, 10 Apr 2026 16:41:13 -0500 Subject: [PATCH 1/2] Align chain wording and issue scope contract --- schemas/chains.schema.json | 1 + src/azurefox/chains/runner.py | 61 ++++++++---------- src/azurefox/collectors/provider.py | 2 + src/azurefox/help.py | 18 +++++- src/azurefox/models/chains.py | 1 + src/azurefox/models/common.py | 12 +++- src/azurefox/render/table.py | 98 ++++++++++++++++++++++++++--- tests/test_cli_smoke.py | 6 ++ tests/test_collectors.py | 34 +++++++++- tests/test_golden_outputs.py | 16 +++++ tests/test_help.py | 6 ++ tests/test_models.py | 22 +++++++ tests/test_terminal_ux.py | 50 ++++++++++----- tests/test_visibility_tiers.py | 15 +++-- 14 files changed, 272 insertions(+), 70 deletions(-) diff --git a/schemas/chains.schema.json b/schemas/chains.schema.json index 9455de0..62d6d10 100644 --- a/schemas/chains.schema.json +++ b/schemas/chains.schema.json @@ -8,6 +8,7 @@ "command_state", "summary", "claim_boundary", + "current_gap", "artifact_preference_order", "backing_commands", "source_artifacts", diff --git a/src/azurefox/chains/runner.py b/src/azurefox/chains/runner.py index c5332f0..d7c3dd0 100644 --- a/src/azurefox/chains/runner.py +++ b/src/azurefox/chains/runner.py @@ -11,6 +11,7 @@ ) from azurefox.chains.registry import ( GROUPED_COMMAND_NAME, + ChainFamilySpec, get_chain_family_spec, implemented_chain_family_names, is_implemented_chain_family, @@ -151,23 +152,10 @@ def _build_credential_path_output( ) ) - return ChainsCommandOutput( - metadata=CommandMetadata( - command=GROUPED_COMMAND_NAME, - tenant_id=options.tenant, - subscription_id=options.subscription, - devops_organization=options.devops_organization, - token_source=None, - ), - grouped_command_name=GROUPED_COMMAND_NAME, - family=family_name, - input_mode="live", - command_state="extraction-only", - summary=family.summary, - claim_boundary=family.allowed_claim, - artifact_preference_order=[], - backing_commands=[source.command for source in family.source_commands], - source_artifacts=[], + return _build_chains_command_output( + options=options, + family=family, + family_name=family_name, paths=paths, issues=issues, ) @@ -352,23 +340,10 @@ def _build_deployment_path_output( ): issues.extend(getattr(loaded[source_name], "issues", [])) - return ChainsCommandOutput( - metadata=CommandMetadata( - command=GROUPED_COMMAND_NAME, - tenant_id=options.tenant, - subscription_id=options.subscription, - devops_organization=options.devops_organization, - token_source=None, - ), - grouped_command_name=GROUPED_COMMAND_NAME, - family=family_name, - input_mode="live", - command_state="extraction-only", - summary=family.summary, - claim_boundary=family.allowed_claim, - artifact_preference_order=[], - backing_commands=[source.command for source in family.source_commands], - source_artifacts=[], + return _build_chains_command_output( + options=options, + family=family, + family_name=family_name, paths=paths, issues=issues, ) @@ -437,6 +412,23 @@ def _build_escalation_path_output( for source_name in ("privesc", "permissions", "role-trusts"): issues.extend(getattr(loaded[source_name], "issues", [])) + return _build_chains_command_output( + options=options, + family=family, + family_name=family_name, + paths=paths, + issues=issues, + ) + + +def _build_chains_command_output( + *, + options: GlobalOptions, + family: ChainFamilySpec, + family_name: str, + paths: list[ChainPathRecord], + issues: list[CollectionIssue], +) -> ChainsCommandOutput: return ChainsCommandOutput( metadata=CommandMetadata( command=GROUPED_COMMAND_NAME, @@ -451,6 +443,7 @@ def _build_escalation_path_output( command_state="extraction-only", summary=family.summary, claim_boundary=family.allowed_claim, + current_gap=family.current_gap, artifact_preference_order=[], backing_commands=[source.command for source in family.source_commands], source_artifacts=[], diff --git a/src/azurefox/collectors/provider.py b/src/azurefox/collectors/provider.py index 7d2d069..f8ae40b 100644 --- a/src/azurefox/collectors/provider.py +++ b/src/azurefox/collectors/provider.py @@ -3679,6 +3679,7 @@ def _issue_from_exception(area: str, exc: Exception) -> dict: return { "kind": classify_exception(exc).value, "message": f"{area}: {exc}", + "scope": area, "context": {"collector": area}, } @@ -8310,6 +8311,7 @@ def _partial_collection_issue( return { "kind": ErrorKind.PARTIAL_COLLECTION.value, "message": f"{area}: {message}", + "scope": area, "context": context, } diff --git a/src/azurefox/help.py b/src/azurefox/help.py index b5fa406..fa0095d 100644 --- a/src/azurefox/help.py +++ b/src/azurefox/help.py @@ -1111,6 +1111,8 @@ class SectionHelpTopic: output_highlights=( "family selectors", "backing_commands", + "claim_boundary", + "current_gap", "target_resolution", "priority", "target_names", @@ -1363,6 +1365,14 @@ def _render_root_help() -> str: "still landing." ), " - ATT&CK references are investigative context, not proof that a technique occurred.", + ( + " - Default output prefers exact claims when proven, and bounded weaker claims " + "when they stay honest and decision-useful." + ), + ( + " - When visibility is partial, AzureFox says what current scope did not " + "confirm instead of treating a path as proven." + ), ] ) return "\n".join(lines) @@ -1438,7 +1448,13 @@ def _render_command_help(topic: CommandHelpTopic) -> str: "Example:", f" {topic.example}", "", - "Note: ATT&CK references are leads to investigate, not proof of observed behavior.", + "Notes:", + " - ATT&CK references are leads to investigate, not proof of observed behavior.", + " - Output wording keeps proof strength separate from actionability.", + ( + " - If current visibility is partial, AzureFox keeps only the honest weaker " + "claim and names the current gap explicitly." + ), ] ) return "\n".join(lines) diff --git a/src/azurefox/models/chains.py b/src/azurefox/models/chains.py index 992b67b..c88eb46 100644 --- a/src/azurefox/models/chains.py +++ b/src/azurefox/models/chains.py @@ -88,6 +88,7 @@ class ChainsOutput(BaseModel): command_state: str summary: str claim_boundary: str + current_gap: str | None = None artifact_preference_order: list[str] = Field(default_factory=list) backing_commands: list[str] = Field(default_factory=list) source_artifacts: list[ChainSourceArtifact] = Field(default_factory=list) diff --git a/src/azurefox/models/common.py b/src/azurefox/models/common.py index 5101abd..6ab5d6d 100644 --- a/src/azurefox/models/common.py +++ b/src/azurefox/models/common.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime from enum import StrEnum -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator SCHEMA_VERSION = "1.3.0" @@ -34,8 +34,18 @@ class CommandMetadata(BaseModel): class CollectionIssue(BaseModel): kind: str message: str + scope: str | None = None context: dict[str, str] = Field(default_factory=dict) + @model_validator(mode="after") + def _populate_scope_from_context(self) -> CollectionIssue: + if self.scope: + return self + collector = self.context.get("collector") + if collector: + self.scope = collector + return self + class SubscriptionRef(BaseModel): id: str diff --git a/src/azurefox/render/table.py b/src/azurefox/render/table.py index 79b0f74..471d777 100644 --- a/src/azurefox/render/table.py +++ b/src/azurefox/render/table.py @@ -21,7 +21,7 @@ def render_table(command: str, payload: dict) -> str: _render_devops_table(console, payload) elif command == "role-trusts": _render_role_trusts_table(console, payload) - elif command == "chains" and payload.get("families"): + elif command == "chains" and "families" in payload: _render_chains_overview_table(console, payload) elif command == "chains" and str(payload.get("family") or "") == "deployment-path": _render_deployment_path_table(console, payload) @@ -33,7 +33,7 @@ def render_table(command: str, payload: dict) -> str: if not records: table.add_column("info") - table.add_row("No records") + table.add_row(_empty_state_message(command, payload)) else: for _key, label in columns: table.add_column(label) @@ -57,13 +57,15 @@ def render_table(command: str, payload: dict) -> str: issues = payload.get("issues", []) if issues: console.print("") - console.print("Credential-scope issues:") + console.print("Current-scope issues:") for issue in issues[:5]: kind = issue.get("kind") or "unknown" console.print(f"- {kind}: {issue.get('message')}", markup=False) remaining = len(issues) - 5 if remaining > 0: - console.print(f"- ... plus {remaining} more credential-scope issues in JSON artifacts.") + console.print(f"- ... plus {remaining} more current-scope issues in JSON artifacts.") + + _render_scope_boundary_notes(console, command, payload) takeaway = _takeaway_for_command(command, payload) if takeaway: @@ -80,7 +82,7 @@ def _render_devops_table(console: Console, payload: dict) -> None: if not records: table = Table(title="azurefox devops") table.add_column("info") - table.add_row("No records") + table.add_row(_empty_state_message("devops", payload)) console.print(table) return @@ -105,7 +107,7 @@ def _render_role_trusts_table(console: Console, payload: dict) -> None: if not records: table = Table(title="azurefox role-trusts") table.add_column("info") - table.add_row("No records") + table.add_row(_empty_state_message("role-trusts", payload)) console.print(table) return @@ -149,7 +151,7 @@ def _render_chains_path_table( if not records: table = Table(title="azurefox chains") table.add_column("info") - table.add_row("No records") + table.add_row(_empty_state_message("chains", payload)) console.print(table) return @@ -174,7 +176,7 @@ def _render_chains_overview_table(console: Console, payload: dict) -> None: if not records: table.add_column("info") - table.add_row("No records") + table.add_row(_empty_state_message("chains", payload)) console.print(table) return @@ -184,6 +186,19 @@ def _render_chains_overview_table(console: Console, payload: dict) -> None: table.add_row(*[_value_to_string(record.get(key)) for key, _ in columns]) console.print(table) + boundary_table = Table(title="chains claim boundaries") + boundary_table.add_column("family") + boundary_table.add_column("allowed claim") + boundary_table.add_column("current gap") + for item in payload.get("families", []): + boundary_table.add_row( + _value_to_string(item.get("family")), + _value_to_string(item.get("allowed_claim")), + _value_to_string(item.get("current_gap")), + ) + console.print("") + console.print(boundary_table) + def _table_spec(command: str, payload: dict) -> tuple[list[tuple[str, str]], list[dict]]: if command == "whoami": @@ -599,7 +614,7 @@ def _table_spec(command: str, payload: dict) -> tuple[list[tuple[str, str]], lis ) if command == "chains": - if payload.get("families"): + if "families" in payload: return ( [ ("family", "family"), @@ -1226,7 +1241,7 @@ def _takeaway_for_command(command: str, payload: dict) -> str: issues = payload.get("issues", []) return ( f"{len(policies)} policy rows, {len(findings)} findings, and " - f"{len(issues)} credential-scope issues visible from current credentials." + f"{len(issues)} current-scope issues." ) if command == "permissions": @@ -3023,3 +3038,66 @@ def _role_trust_visible_transform(item: dict) -> str | None: return escalation_mechanism return None + + +def _empty_state_message(command: str, payload: dict) -> str: + if command == "chains" and "families" in payload: + return "No chain families are currently registered." + + if command == "chains": + family = str(payload.get("family") or "") + family_subjects = { + "credential-path": "credential paths", + "deployment-path": "deployment paths", + "escalation-path": "escalation paths", + "workload-identity-path": "workload-identity paths", + } + subject = family_subjects.get(family, "chain rows") + return f"No visible {subject} were confirmed from current scope." + + subjects = { + "acr": "container registries", + "aks": "AKS clusters", + "api-mgmt": "API Management services", + "app-services": "App Service apps", + "application-gateway": "Application Gateways", + "arm-deployments": "ARM deployments", + "auth-policies": "authentication policy rows", + "automation": "Automation accounts", + "cross-tenant": "cross-tenant signals", + "databases": "database servers", + "devops": "Azure DevOps build definitions", + "dns": "DNS zones", + "endpoints": "reachable surfaces", + "env-vars": "environment-variable signals", + "functions": "Function Apps", + "keyvault": "Key Vaults", + "lighthouse": "Azure Lighthouse delegations", + "managed-identities": "managed identities", + "network-effective": "reachability rows", + "network-ports": "port-exposure rows", + "permissions": "permission rows", + "principals": "principals", + "resource-trusts": "resource trust surfaces", + "role-trusts": "role trust edges", + "storage": "storage accounts", + "tokens-credentials": "token or credential surfaces", + } + subject = subjects.get(command, f"{command} rows") + return f"No visible {subject} were confirmed from current scope." + + +def _render_scope_boundary_notes(console: Console, command: str, payload: dict) -> None: + if command != "chains": + return + + claim_boundary = str(payload.get("claim_boundary") or "").strip() + current_gap = str(payload.get("current_gap") or "").strip() + if not claim_boundary and not current_gap: + return + + console.print("") + if claim_boundary: + console.print(f"Claim boundary: {claim_boundary}") + if current_gap: + console.print(f"Current gap: {current_gap}") diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index b051a29..ff22e63 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -90,6 +90,8 @@ def test_cli_smoke_chains_credential_path_json(tmp_path: Path) -> None: assert payload["metadata"]["command"] == "chains" assert payload["family"] == "credential-path" assert payload["command_state"] == "extraction-only" + assert payload["claim_boundary"].startswith("Can claim that the visible evidence suggests") + assert payload["current_gap"].startswith("The live family now joins backing evidence") assert payload["artifact_preference_order"] == [] assert payload["source_artifacts"] == [] assert payload["backing_commands"] == [ @@ -148,6 +150,8 @@ def test_cli_smoke_chains_credential_path_table_output(tmp_path: Path) -> None: normalized_output = " ".join(result.stdout.split()) assert "narrowed" in normalized_output assert "candidates" in normalized_output + assert "Claim boundary:" in result.stdout + assert "Current gap:" in result.stdout assert "Takeaway: 3 visible credential paths" in result.stdout @@ -365,6 +369,8 @@ def test_cli_smoke_chains_overview_table_output(tmp_path: Path) -> None: assert result.exit_code == 0 assert "azurefox chains" in result.stdout + assert "allowed claim" in result.stdout + assert "current gap" in result.stdout assert "credential-path" in result.stdout assert "deployment-path" in result.stdout assert "escalation-path" in result.stdout diff --git a/tests/test_collectors.py b/tests/test_collectors.py index 336f65c..35c0653 100644 --- a/tests/test_collectors.py +++ b/tests/test_collectors.py @@ -196,7 +196,6 @@ def acr(self) -> dict: ], "issues": [], } - def databases(self) -> dict: return { "database_servers": [ @@ -973,6 +972,39 @@ def application_gateway(self) -> dict: } +def test_collectors_emit_scoped_issues_in_contract(fixture_dir: Path, options) -> None: + cases = ( + ( + collect_databases, + PartialDatabasesFixtureProvider(fixture_dir), + "database inventory partial visibility", + ), + ( + collect_api_mgmt, + PartialApiMgmtFixtureProvider(fixture_dir), + "api management staged detail visibility", + ), + ( + collect_auth_policies, + ConditionalAccessUnreadableFixtureProvider(fixture_dir), + "auth policy conditional access boundary", + ), + ( + collect_network_effective, + NetworkEffectiveSnapshotFixtureProvider(fixture_dir), + "propagated endpoint issue reuse", + ), + ) + + for collector, provider, scenario in cases: + output = collector(provider, options) + assert output.issues, f"{collector.__name__} did not emit issues for {scenario}" + for issue in output.issues: + assert issue.scope == issue.context["collector"], ( + f"{collector.__name__} lost scoped issue meaning for {scenario}: {issue}" + ) + + def test_collect_whoami(fixture_provider, options) -> None: output = collect_whoami(fixture_provider, options) assert output.principal is not None diff --git a/tests/test_golden_outputs.py b/tests/test_golden_outputs.py index 699b67a..c263161 100644 --- a/tests/test_golden_outputs.py +++ b/tests/test_golden_outputs.py @@ -47,6 +47,21 @@ } +def _backfill_issue_scope(node: object) -> object: + if isinstance(node, dict): + collector = ( + node.get("context", {}).get("collector") + if isinstance(node.get("context"), dict) + else None + ) + if node.get("kind") and node.get("message") and collector and "scope" not in node: + node["scope"] = collector + return {key: _backfill_issue_scope(value) for key, value in node.items()} + if isinstance(node, list): + return [_backfill_issue_scope(item) for item in node] + return node + + def _scrub_prose_heavy_fields(node: object) -> object: if isinstance(node, dict): cleaned: dict[str, object] = {} @@ -63,6 +78,7 @@ def _scrub_prose_heavy_fields(node: object) -> object: def _normalize(payload: dict) -> dict: payload = json.loads(json.dumps(payload)) + payload = _backfill_issue_scope(payload) payload = _scrub_prose_heavy_fields(payload) metadata = payload["metadata"] metadata["generated_at"] = "" diff --git a/tests/test_help.py b/tests/test_help.py index afcc5ec..7706de7 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -20,6 +20,8 @@ def test_help_command_generic() -> None: assert "chains: Grouped family runner for higher-value preset paths" in result.stdout assert "Planned grouped commands:" not in result.stdout assert "all-checks" not in result.stdout + assert "bounded weaker claims" in result.stdout + assert "current scope did not confirm" in result.stdout def test_help_command_section() -> None: @@ -46,6 +48,8 @@ def test_help_command_command_topic() -> None: assert "Offensive question:" in result.stdout assert "ATT&CK cloud leads:" in result.stdout assert "Temporary Elevated Cloud Access" in result.stdout + assert "proof strength separate from actionability" in result.stdout + assert "names the current gap explicitly" in result.stdout def test_help_command_arm_deployments_topic() -> None: @@ -302,6 +306,8 @@ def test_help_command_chains_topic_sets_planned_runtime_expectations() -> None: assert "escalation-path" in result.stdout assert "workload-identity-path remains planned" in result.stdout assert "credential-path" in result.stdout + assert "claim_boundary" in result.stdout + assert "current_gap" in result.stdout def test_help_command_env_vars_topic() -> None: diff --git a/tests/test_models.py b/tests/test_models.py index af1f9fe..5b0945c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -8,6 +8,7 @@ AppServiceAsset, ArmDeploymentSummary, AuthPolicySummary, + CollectionIssue, DatabaseServerAsset, DnsZoneAsset, EndpointSummary, @@ -31,6 +32,27 @@ def test_schema_version() -> None: assert SCHEMA_VERSION == "1.3.0" +def test_collection_issue_scope_defaults_from_context_collector() -> None: + issue = CollectionIssue( + kind="permission_denied", + message="app_services.web_apps: 403 Forbidden", + context={"collector": "app_services.web_apps"}, + ) + + assert issue.scope == "app_services.web_apps" + + +def test_collection_issue_keeps_explicit_scope() -> None: + issue = CollectionIssue( + kind="permission_denied", + message="app_services.web_apps: 403 Forbidden", + scope="app-services.configuration", + context={"collector": "app_services.web_apps"}, + ) + + assert issue.scope == "app-services.configuration" + + def test_arm_deployment_summary_defaults() -> None: deployment = ArmDeploymentSummary( id="d-1", diff --git a/tests/test_terminal_ux.py b/tests/test_terminal_ux.py index bb4ee00..89beac6 100644 --- a/tests/test_terminal_ux.py +++ b/tests/test_terminal_ux.py @@ -89,10 +89,9 @@ def test_auth_policies_table_mode_surfaces_findings_and_issues(tmp_path: Path) - ) assert result.exit_code == 0 - assert "current credentials" in result.stdout assert "Findings:" in result.stdout assert "Security defaults are disabled" in result.stdout - assert "Takeaway: 4 policy rows, 5 findings, and 0 credential-scope issues" in result.stdout + assert "Takeaway: 4 policy rows, 5 findings, and 0 current-scope issues." in result.stdout def test_lighthouse_table_mode_surfaces_cross_tenant_scope_and_access(tmp_path: Path) -> None: @@ -816,6 +815,8 @@ def test_deployment_chains_table_mode_surfaces_source_oriented_columns(tmp_path: assert "support-only" in normalized_output assert "Redeploy-App" in normalized_output assert "Lab-Maintenance" in normalized_output + assert "Claim boundary:" in result.stdout + assert "Current gap:" in result.stdout def test_deployment_chains_table_mode_renders_why_care_as_detail_rows(tmp_path: Path) -> None: @@ -836,6 +837,7 @@ def test_deployment_chains_table_mode_renders_why_care_as_detail_rows(tmp_path: assert "Current credentials can already poison that source" in result.stdout assert "If that trusted input becomes attacker-controlled" in result.stdout assert len(detail_header_lines) == 6 + assert "Current gap:" in result.stdout def test_escalation_chains_table_mode_renders_defended_current_foothold_story( @@ -882,7 +884,7 @@ def test_auth_policies_partial_read_surfaces_collection_issue() -> None: } rendered = render_table("auth-policies", payload) - assert "Credential-scope issues:" in rendered + assert "Current-scope issues:" in rendered assert "permission_denied" in rendered assert "auth_policies.security_defaults" in rendered @@ -928,7 +930,21 @@ def test_chains_partial_target_visibility_prefers_issue_over_candidate_list() -> assert "visibility blocked" in rendered assert "permission_denied: databases.servers" in rendered assert "sql-public-legacy" not in rendered - assert "Credential-scope issues:" in rendered + assert "Current-scope issues:" in rendered + + +def test_chains_overview_empty_state_stays_on_family_index_surface() -> None: + payload = { + "metadata": {"command": "chains"}, + "families": [], + "issues": [], + } + + rendered = render_table("chains", payload) + + assert "No chain families are currently registered." in rendered + assert "azurefox chains" in rendered + assert "target resolution" not in rendered def test_chains_keyvault_note_prefers_current_identity_access_sentence() -> None: @@ -1027,7 +1043,7 @@ def test_app_services_partial_read_surfaces_collection_issue() -> None: } rendered = render_table("app-services", payload) - assert "Credential-scope issues:" in rendered + assert "Current-scope issues:" in rendered assert "permission_denied" in rendered assert "app_services[rg-apps/app-empty-mi].configuration" in rendered @@ -1047,8 +1063,8 @@ def test_acr_collection_issue_surfaces_in_table_output() -> None: } rendered = render_table("acr", payload) - assert "No records" in rendered - assert "Credential-scope issues:" in rendered + assert "No visible container registries were confirmed from current scope." in rendered + assert "Current-scope issues:" in rendered assert "permission_denied" in rendered assert "acr.registries" in rendered @@ -1118,7 +1134,7 @@ def test_databases_partial_read_surfaces_collection_issue() -> None: } rendered = render_table("databases", payload) - assert "Credential-scope issues:" in rendered + assert "Current-scope issues:" in rendered assert "permission_denied" in rendered assert "databases[rg-data/sql-public-legacy].databases" in rendered assert ( @@ -1143,8 +1159,8 @@ def test_dns_collection_issue_surfaces_in_table_output() -> None: } rendered = render_table("dns", payload) - assert "No records" in rendered - assert "Credential-scope issues:" in rendered + assert "No visible DNS zones were confirmed from current scope." in rendered + assert "Current-scope issues:" in rendered assert "permission_denied" in rendered assert "dns.resources" in rendered @@ -1198,8 +1214,8 @@ def test_application_gateway_collection_issue_surfaces_in_table_output() -> None } rendered = render_table("application-gateway", payload) - assert "No records" in rendered - assert "Credential-scope issues:" in rendered + assert "No visible Application Gateways were confirmed from current scope." in rendered + assert "Current-scope issues:" in rendered assert "permission_denied" in rendered assert "application_gateway.gateways" in rendered @@ -1245,7 +1261,7 @@ def test_application_gateway_partial_read_keeps_public_frontend_without_ip_strin assert "public=1; subnets=1" in rendered assert "20.30.40.50" not in rendered - assert "Credential-scope issues:" in rendered + assert "Current-scope issues:" in rendered def test_application_gateway_takeaway_counts_backend_pool_breadth_as_shared_signal() -> None: @@ -1372,7 +1388,7 @@ def test_functions_partial_read_surfaces_collection_issue() -> None: } rendered = render_table("functions", payload) - assert "Credential-scope issues:" in rendered + assert "Current-scope issues:" in rendered assert "permission_denied" in rendered assert "functions[rg-apps/func-orders].app_settings" in rendered @@ -1408,7 +1424,7 @@ def test_api_mgmt_partial_read_surfaces_collection_issue() -> None: } rendered = render_table("api-mgmt", payload) - assert "Credential-scope issues:" in rendered + assert "Current-scope issues:" in rendered assert "permission_denied" in rendered assert "api_mgmt[rg-apps/apim-edge-01].named_values" in rendered assert "current credentials do not show named values" in " ".join(rendered.split()) @@ -1430,7 +1446,7 @@ def test_aks_collection_issue_surfaces_in_table_output() -> None: } rendered = render_table("aks", payload) - assert "No records" in rendered - assert "Credential-scope issues:" in rendered + assert "No visible AKS clusters were confirmed from current scope." in rendered + assert "Current-scope issues:" in rendered assert "permission_denied" in rendered assert "aks.managed_clusters" in rendered diff --git a/tests/test_visibility_tiers.py b/tests/test_visibility_tiers.py index f62bc0e..edaa5c3 100644 --- a/tests/test_visibility_tiers.py +++ b/tests/test_visibility_tiers.py @@ -319,6 +319,7 @@ def _chains_payload(*, path: dict, issues: list[dict] | None = None) -> dict: "command_state": "extraction-only", "summary": "Visibility-tier test payload.", "claim_boundary": "Only visible edges are shown.", + "current_gap": "Deeper confirmation still depends on the backing command visibility.", "artifact_preference_order": [], "backing_commands": ["env-vars", "keyvault"], "source_artifacts": [], @@ -686,7 +687,7 @@ def test_devops_visibility_tiers_keep_routing_honest(options) -> None: assert "visibility" in low_rendered assert "next Azure" in low_rendered assert "follow-up." in low_rendered - assert "Credential-scope issues:" in low_rendered + assert "Current-scope issues:" in low_rendered assert "partial_collection" in low_rendered assert low.issues[0].kind == "partial_collection" @@ -719,11 +720,11 @@ def test_functions_visibility_tiers_keep_service_shell_visible(options) -> None: low_rendered = render_table("functions", low.model_dump(mode="json")) assert "func-orders" in medium_rendered - assert "Credential-scope issues:" in medium_rendered + assert "Current-scope issues:" in medium_rendered assert "functions[rg-apps/func-orders].app_settings" in medium_rendered assert "func-orders" in low_rendered - assert "Credential-scope issues:" in low_rendered + assert "Current-scope issues:" in low_rendered assert "functions[rg-apps/func-orders].configuration" in low_rendered @@ -764,13 +765,13 @@ def test_env_vars_visibility_tiers_keep_next_review_honest(options) -> None: assert "Check keyvault for the" in medium_rendered assert "referenced secret" in medium_rendered assert "managed-identities" in medium_rendered - assert "Credential-scope issues:" in medium_rendered + assert "Current-scope issues:" in medium_rendered assert "env_vars[rg-apps/func-orders].key_vault_reference" in medium_rendered assert "unknown" in low_rendered assert "Review the workload config" in low_rendered assert "deeper follow-up." in low_rendered - assert "Credential-scope issues:" in low_rendered + assert "Current-scope issues:" in low_rendered assert "env_vars[rg-apps/func-orders].app_settings" in low_rendered assert "keyvault-ref" not in low_rendered @@ -865,5 +866,7 @@ def test_chains_visibility_tiers_avoid_fake_target_certainty() -> None: assert "target." in low_rendered assert "Restore keyvault" in low_rendered assert "choosing a target." in low_rendered - assert "Credential-scope issues:" in low_rendered + assert "Current-scope issues:" in low_rendered + assert "Claim boundary:" in low_rendered + assert "Current gap:" in low_rendered assert "kv-orders,kv-shared" not in low_rendered From 09906bb51ca5f328329bea9abc6411b5fee2a502 Mon Sep 17 00:00:00 2001 From: Colby Farley Date: Fri, 10 Apr 2026 16:43:23 -0500 Subject: [PATCH 2/2] Refresh PR checks after metadata fix