controlzero 1.4.6__tar.gz → 1.4.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. {controlzero-1.4.6 → controlzero-1.4.7}/CHANGELOG.md +95 -0
  2. {controlzero-1.4.6 → controlzero-1.4.7}/PKG-INFO +1 -1
  3. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/__init__.py +1 -1
  4. controlzero-1.4.7/controlzero/_internal/action_aliases.py +165 -0
  5. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/_internal/bundle.py +10 -0
  6. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/_internal/enforcer.py +68 -0
  7. controlzero-1.4.7/controlzero/cli/debug_bundle.py +581 -0
  8. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/main.py +10 -0
  9. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/client.py +18 -0
  10. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/device.py +64 -15
  11. {controlzero-1.4.6 → controlzero-1.4.7}/pyproject.toml +1 -1
  12. controlzero-1.4.7/tests/parity/action_aliases.json +55 -0
  13. controlzero-1.4.7/tests/test_action_aliases.py +224 -0
  14. controlzero-1.4.7/tests/test_cli_debug_bundle.py +392 -0
  15. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_device.py +89 -50
  16. controlzero-1.4.7/tests/test_synthetic_policy_id_t79.py +287 -0
  17. {controlzero-1.4.6 → controlzero-1.4.7}/.gitignore +0 -0
  18. {controlzero-1.4.6 → controlzero-1.4.7}/Dockerfile.test +0 -0
  19. {controlzero-1.4.6 → controlzero-1.4.7}/LICENSE +0 -0
  20. {controlzero-1.4.6 → controlzero-1.4.7}/README.md +0 -0
  21. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/_internal/__init__.py +0 -0
  22. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/_internal/dlp_scanner.py +0 -0
  23. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/_internal/hook_extractors.py +0 -0
  24. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/_internal/tool_extractors.json +0 -0
  25. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/_internal/types.py +0 -0
  26. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/audit_local.py +0 -0
  27. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/audit_remote.py +0 -0
  28. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/__init__.py +0 -0
  29. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/autogen.yaml +0 -0
  30. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/claude-code.yaml +0 -0
  31. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/codex-cli.yaml +0 -0
  32. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/cost-cap.yaml +0 -0
  33. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/crewai.yaml +0 -0
  34. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/cursor.yaml +0 -0
  35. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  36. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/generic.yaml +0 -0
  37. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/langchain.yaml +0 -0
  38. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/mcp.yaml +0 -0
  39. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/rag.yaml +0 -0
  40. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/enrollment.py +0 -0
  41. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/errors.py +0 -0
  42. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/hosted_policy.py +0 -0
  43. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/__init__.py +0 -0
  44. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/anthropic.py +0 -0
  45. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/autogen.py +0 -0
  46. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/braintrust.py +0 -0
  47. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/crewai/__init__.py +0 -0
  48. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/crewai/agent.py +0 -0
  49. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/crewai/crew.py +0 -0
  50. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/crewai/task.py +0 -0
  51. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/crewai/tool.py +0 -0
  52. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/google.py +0 -0
  53. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/google_adk/__init__.py +0 -0
  54. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/google_adk/agent.py +0 -0
  55. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/google_adk/tool.py +0 -0
  56. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langchain/__init__.py +0 -0
  57. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langchain/agent.py +0 -0
  58. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langchain/callbacks.py +0 -0
  59. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langchain/chain.py +0 -0
  60. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langchain/graph.py +0 -0
  61. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langchain/modern.py +0 -0
  62. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langchain/tool.py +0 -0
  63. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langfuse.py +0 -0
  64. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/litellm.py +0 -0
  65. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/openai.py +0 -0
  66. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/pydantic_ai.py +0 -0
  67. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/vercel_ai.py +0 -0
  68. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/policy_loader.py +0 -0
  69. {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/tamper.py +0 -0
  70. {controlzero-1.4.6 → controlzero-1.4.7}/examples/hello_world.py +0 -0
  71. {controlzero-1.4.6 → controlzero-1.4.7}/tests/conftest.py +0 -0
  72. {controlzero-1.4.6 → controlzero-1.4.7}/tests/integrations/__init__.py +0 -0
  73. {controlzero-1.4.6 → controlzero-1.4.7}/tests/integrations/test_google.py +0 -0
  74. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_action_canonicalization.py +0 -0
  75. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_agent_name_env.py +0 -0
  76. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_audit_remote.py +0 -0
  77. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_audit_sink_isolation.py +0 -0
  78. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_bundle_parser.py +0 -0
  79. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_bundle_translate.py +0 -0
  80. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_carve_out.py +0 -0
  81. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_extractor_integration.py +0 -0
  82. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_hook.py +0 -0
  83. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_hosted_refresh.py +0 -0
  84. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_init.py +0 -0
  85. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_init_templates.py +0 -0
  86. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_tail.py +0 -0
  87. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_test.py +0 -0
  88. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_validate.py +0 -0
  89. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_coding_agent_hooks.py +0 -0
  90. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_conditions.py +0 -0
  91. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_default_action.py +0 -0
  92. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_dlp_scanner.py +0 -0
  93. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_enrollment.py +0 -0
  94. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_fail_closed_eval.py +0 -0
  95. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_glob_matching.py +0 -0
  96. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_hook_extractors.py +0 -0
  97. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_hosted_policy_e2e.py +0 -0
  98. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_hybrid_mode_strict.py +0 -0
  99. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_hybrid_mode_warn.py +0 -0
  100. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_install_hooks.py +0 -0
  101. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_local_mode_dict.py +0 -0
  102. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_local_mode_file_json.py +0 -0
  103. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_local_mode_file_yaml.py +0 -0
  104. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_log_fallback_stderr.py +0 -0
  105. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_log_options_ignored_hosted.py +0 -0
  106. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_log_rotation.py +0 -0
  107. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_no_policy_no_key.py +0 -0
  108. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_package_rename_shim.py +0 -0
  109. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_policy_freshness.py +0 -0
  110. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_policy_settings.py +0 -0
  111. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_quarantine.py +0 -0
  112. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_reason_code.py +0 -0
  113. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_refresh.py +0 -0
  114. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_sql_semantic_class.py +0 -0
  115. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_tamper.py +0 -0
  116. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_tamper_behavior.py +0 -0
  117. {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_tamper_hook.py +0 -0
@@ -1,5 +1,94 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.7 -- 2026-05-11
4
+
5
+ ### Added
6
+ - **`controlzero debug bundle` -- inspect SDK-loaded rules + simulate guards**
7
+ (T87, GH #392). The bundle on disk is encrypted+signed; `cat` returns
8
+ nothing useful, so until now diagnosing a deny-deny incident meant
9
+ shipping the bundle back to engineering or attaching a debugger
10
+ (Bryan's deny-deny took ~3 hours to root-cause for exactly this
11
+ reason). The new subcommand reads the matching `bootstrap-<prefix>.json`
12
+ for keys, decrypts and verifies the cached bundle blob via the same
13
+ parser the SDK uses, and prints a human-readable summary: bundle id,
14
+ created_at / expires_at, default_action / default_on_missing /
15
+ default_on_tamper, every policy (id, name, version, is_enabled,
16
+ priority), and every rule (id, effect, principals, actions, resources,
17
+ conditions).
18
+
19
+ With `--simulate "tool method args"` it also runs the request through
20
+ the same `PolicyEvaluator` the SDK uses and prints the decision plus
21
+ which rule matched and why. The args grammar accepts either a JSON
22
+ object or whitespace-separated `key=value` pairs (values may contain
23
+ spaces, so `sql=SELECT id FROM orders` parses as a single value).
24
+
25
+ The output is designed to be pasted into a support thread: the
26
+ encryption key, signing public key, and API key are NEVER printed,
27
+ enforced by a final `_redact_sensitive` pass before emit.
28
+
29
+ Six tests in `tests/test_cli_debug_bundle.py` cover the happy path,
30
+ simulate-allow, simulate-no-rule-match, missing bootstrap, missing
31
+ bundle, and the privacy contract.
32
+
33
+ ### Fixed
34
+
35
+ - **Pre-#350 customer rules using legacy database action names keep
36
+ matching** (T84, GitHub #389). The SDK started emitting canonical
37
+ SQL semantic classes (`database:read`, `database:write`,
38
+ `database:admin`, `database:exec`) in 1.4.x via #345/#350. That
39
+ silently broke any policy authored before #350 that used legacy
40
+ per-keyword actions like `database:query`, `database:DROP`, or
41
+ `database:execute`: the legacy rule and the modern call no longer
42
+ shared a string, so the rule never fired and the call fell through
43
+ to the default deny.
44
+
45
+ The fix is a bidirectional alias shim. The enforcer now expands
46
+ candidate actions through a single-source-of-truth alias table
47
+ (`controlzero/_internal/action_aliases.py`) so:
48
+
49
+ - A pre-#350 rule with `actions: ["database:query"]` keeps matching
50
+ modern SELECT calls (which the SDK emits as `database:read`).
51
+ - A modern rule with `actions: ["database:read"]` keeps matching
52
+ legacy guard calls that pass `method="SELECT"`.
53
+ - The legacy ambiguous `database:delete` (used historically for
54
+ both row DELETE and table DROP) maps to BOTH `database:write`
55
+ and `database:admin`, so neither modern intent silently breaks.
56
+
57
+ The alias table is byte-identical across Python, Node, and Go SDKs
58
+ and is locked by the cross-SDK fixture at
59
+ `tests/parity/action_aliases.json`. Drift in any SDK fails its
60
+ parity test.
61
+
62
+ This is a NO-BREAKING-CHANGES fix: every existing rule continues to
63
+ work, modern rules continue to work, and only previously-broken
64
+ legacy rules start matching again.
65
+
66
+ - **Synthetic policy_id sentinels** (T79, Bryan deny-deny postmortem).
67
+ `PolicyDecision.policy_id` is now stamped with one of six canonical
68
+ `synthetic:*` values whenever the deny was emitted by a fail-closed
69
+ code path (no rule matched, empty bundle, missing bundle, T83-class
70
+ resource gate skip, machine quarantine, evaluator crash). Previously
71
+ these all carried `policy_id=None` and rendered as a blank Policy
72
+ column on the audit dashboard, making four very different bug
73
+ classes look identical. The new sentinels are:
74
+
75
+ - `synthetic:NO_RULE_MATCH` -- bundle loaded, no rule's actions
76
+ matched the call.
77
+ - `synthetic:NO_ACTIVE_POLICIES` -- bundle was structurally empty.
78
+ - `synthetic:BUNDLE_MISSING` -- enrolled but no bundle loadable.
79
+ - `synthetic:RESOURCE_GATE_SKIP` -- a rule's actions matched but its
80
+ `resources:` list excluded the call (T83-class signature).
81
+ - `synthetic:QUARANTINE` -- machine in local quarantine.
82
+ - `synthetic:ENGINE_UNAVAILABLE` -- evaluator crashed mid-evaluate.
83
+
84
+ The `reason_code` field is unchanged; the synthetic policy_id is a
85
+ parallel signal that the dashboard reads to switch chip styling and
86
+ link to the matching troubleshooting anchor. Backwards-compatible:
87
+ consumers that only branch on `reason_code` keep working.
88
+
89
+ Constants exported from `controlzero._internal.enforcer` as
90
+ `SYNTHETIC_POLICY_ID_PREFIX`, `SYNTHETIC_NO_RULE_MATCH`, etc., plus
91
+ `VALID_SYNTHETIC_POLICY_IDS` for runtime validation.
3
92
  ## 1.4.6 -- 2026-05-11
4
93
 
5
94
  ### Fixed
@@ -39,6 +128,12 @@
39
128
  `database:admin` so deny rules catch them. 21 new parity-test
40
129
  fixtures (cross-SDK byte-identical with the Node sibling).
41
130
 
131
+ Legacy action names continue to work: `database:query`,
132
+ `database:execute`, and `database:delete` are still matched against
133
+ the same calls and remain valid in policy rules. New policies
134
+ should prefer the canonical class names; existing rules keyed on
135
+ the legacy names need no migration.
136
+
42
137
  ### Documentation
43
138
 
44
139
  - New customer-facing reference at `docs/sdk/policies/canonical-tools.md`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.4.6
3
+ Version: 1.4.7
4
4
  Summary: AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup.
5
5
  Project-URL: Homepage, https://controlzero.ai
6
6
  Project-URL: Documentation, https://docs.controlzero.ai
@@ -28,7 +28,7 @@ from controlzero.errors import (
28
28
  )
29
29
  from controlzero.policy_loader import load_policy
30
30
 
31
- __version__ = "1.4.6"
31
+ __version__ = "1.4.7"
32
32
 
33
33
  __all__ = [
34
34
  "Client",
@@ -0,0 +1,165 @@
1
+ """Bidirectional alias table between legacy and canonical action names.
2
+
3
+ T84 / GitHub #389. Mandate: NO BREAKING CHANGES.
4
+
5
+ Pre-#350 customer rules used legacy database action names directly: a
6
+ rule with ``actions: ["database:query"]`` or ``actions: ["database:DROP"]``
7
+ matched a guard call where the SDK passed ``method="query"`` /
8
+ ``method="DROP"``. Starting with #345/#350 the SDK emits canonical SQL
9
+ semantic classes instead (``database:read``, ``database:write``,
10
+ ``database:admin``, ``database:exec``).
11
+
12
+ Without an alias shim, that change broke every pre-#350 rule on the
13
+ day a customer upgraded the SDK. The alias table fixes this in both
14
+ directions:
15
+
16
+ - A pre-#350 rule with ``actions: ["database:query"]`` keeps matching a
17
+ modern SELECT call (which the SDK emits as ``database:read``)
18
+ because ``database:read`` is the canonical form of ``query``.
19
+ - A modern rule with ``actions: ["database:read"]`` keeps matching a
20
+ legacy guard call where the host passed ``method="SELECT"`` because
21
+ ``SELECT`` is one of the read-class aliases.
22
+
23
+ The enforcer's candidate_actions list is expanded via
24
+ ``expand_candidate_actions`` to include every alias of every
25
+ candidate. Any rule whose actions list contains ANY alias of the
26
+ caller's action will match.
27
+
28
+ The alias table itself is the single source of truth across all three
29
+ SDKs (Python / Node / Go). The cross-SDK fixture at
30
+ ``tests/parity/action_aliases.json`` is byte-identical to the JSON
31
+ dump of this table; drift is caught by the parity test in each SDK.
32
+
33
+ The legacy ``database:delete`` action is intentionally ambiguous:
34
+ older policies used it for both row-level DELETE and table-level DROP.
35
+ We map it to BOTH ``database:write`` AND ``database:admin`` so neither
36
+ modern intent is silently broken.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ from typing import Iterable
43
+
44
+ # Tool covered by this alias table. Currently only "database" carries
45
+ # the legacy <-> canonical split; other tools were added post-#350 and
46
+ # always emitted canonical names from day one.
47
+ TOOL = "database"
48
+
49
+ # Canonical class -> ordered list of legacy aliases. Order matters for
50
+ # the JSON dump that the parity fixture compares against.
51
+ _CLASSES: dict[str, list[str]] = {
52
+ "read": ["query", "SELECT", "EXPLAIN", "SHOW", "DESCRIBE", "FETCH", "READ"],
53
+ "write": ["UPDATE", "INSERT", "DELETE", "MERGE", "UPSERT", "REPLACE"],
54
+ "admin": ["DROP", "CREATE", "TRUNCATE", "ALTER", "GRANT", "REVOKE", "RENAME"],
55
+ "exec": ["execute", "EXECUTE", "EXEC", "CALL", "do"],
56
+ }
57
+
58
+ # Legacy aliases that map to MORE THAN ONE canonical class. A rule
59
+ # written against the legacy ambiguous name keeps firing on either
60
+ # modern intent. The match direction (legacy -> canonical) is the
61
+ # important one here; reverse (canonical -> legacy) is handled by the
62
+ # class table above.
63
+ _AMBIGUOUS: dict[str, list[str]] = {
64
+ "delete": ["write", "admin"],
65
+ }
66
+
67
+
68
+ def _canonical(method: str) -> str:
69
+ return f"{TOOL}:{method}"
70
+
71
+
72
+ # Forward map: legacy alias method -> set of canonical actions.
73
+ # Built once at import time so guard() stays cheap on the hot path.
74
+ _LEGACY_TO_CANONICAL: dict[str, set[str]] = {}
75
+ for cls, aliases in _CLASSES.items():
76
+ canon = _canonical(cls)
77
+ for alias in aliases:
78
+ _LEGACY_TO_CANONICAL.setdefault(alias, set()).add(canon)
79
+ for alias, classes in _AMBIGUOUS.items():
80
+ _LEGACY_TO_CANONICAL.setdefault(alias, set()).update(_canonical(c) for c in classes)
81
+
82
+ # Reverse map: canonical method -> set of legacy aliases (as full
83
+ # tool:method actions).
84
+ _CANONICAL_TO_LEGACY: dict[str, set[str]] = {}
85
+ for cls, aliases in _CLASSES.items():
86
+ canon = _canonical(cls)
87
+ _CANONICAL_TO_LEGACY[canon] = {_canonical(a) for a in aliases}
88
+
89
+
90
+ def expand_candidate_actions(actions: Iterable[str]) -> list[str]:
91
+ """Expand an iterable of candidate actions to include every known alias.
92
+
93
+ Both directions are expanded:
94
+
95
+ - Legacy ``database:query`` adds canonical ``database:read``.
96
+ - Canonical ``database:read`` adds every legacy alias
97
+ (``database:query``, ``database:SELECT``, ...).
98
+ - Ambiguous legacy ``database:delete`` adds BOTH ``database:write``
99
+ and ``database:admin``.
100
+
101
+ The original action is always preserved at the head of the list so
102
+ callers that read the first element (audit trail provenance) keep
103
+ seeing the un-expanded form.
104
+
105
+ Order is stable: original actions first (in input order), then
106
+ expansions in deterministic class+alias order.
107
+ """
108
+ seen: set[str] = set()
109
+ out: list[str] = []
110
+
111
+ for action in actions:
112
+ if action and action not in seen:
113
+ seen.add(action)
114
+ out.append(action)
115
+
116
+ # Second pass: walk every original action and append its aliases.
117
+ # Two-pass keeps the input order for the originals and gives a
118
+ # deterministic ordering for the expansions.
119
+ for action in list(out):
120
+ if not action or ":" not in action:
121
+ continue
122
+ tool, method = action.split(":", 1)
123
+ if tool != TOOL:
124
+ continue
125
+ # Legacy method -> canonical(s).
126
+ for canon in sorted(_LEGACY_TO_CANONICAL.get(method, ())):
127
+ if canon not in seen:
128
+ seen.add(canon)
129
+ out.append(canon)
130
+ # Canonical method -> legacy aliases.
131
+ for legacy in sorted(_CANONICAL_TO_LEGACY.get(action, ())):
132
+ if legacy not in seen:
133
+ seen.add(legacy)
134
+ out.append(legacy)
135
+
136
+ return out
137
+
138
+
139
+ def alias_table_json() -> str:
140
+ """Return the alias table as a deterministic JSON string.
141
+
142
+ Used by the parity test in each SDK to confirm byte-identical
143
+ alias content across Python / Node / Go. The on-disk fixture at
144
+ ``tests/parity/action_aliases.json`` carries an extra ``comment``
145
+ key documenting the contract; this dump omits the comment so each
146
+ SDK's hash check can compare apples to apples.
147
+ """
148
+ payload = {
149
+ "version": 1,
150
+ "tool": TOOL,
151
+ "classes": {
152
+ cls: {
153
+ "canonical": _canonical(cls),
154
+ "aliases": list(aliases),
155
+ }
156
+ for cls, aliases in _CLASSES.items()
157
+ },
158
+ "ambiguous_aliases": {
159
+ alias: list(classes) for alias, classes in _AMBIGUOUS.items()
160
+ },
161
+ }
162
+ return json.dumps(payload, indent=2, sort_keys=False)
163
+
164
+
165
+ __all__ = ["TOOL", "expand_candidate_actions", "alias_table_json"]
@@ -449,6 +449,12 @@ def translate_to_local_policy(payload: dict) -> dict:
449
449
  flat.append({
450
450
  "effect": default_action,
451
451
  "action": "*",
452
+ # T79: stamp a synthetic policy_id so the audit dashboard
453
+ # can render a recognizable chip + tooltip linking to the
454
+ # right troubleshooting anchor. The reason_code stays the
455
+ # same; the synthetic id is purely an audit-presentation
456
+ # contract (audit ingest stores it verbatim).
457
+ "id": "synthetic:NO_ACTIVE_POLICIES",
452
458
  "reason": (
453
459
  "No policies are active on this project. If the dashboard "
454
460
  "shows attached policies, regenerate the policy bundle."
@@ -505,6 +511,10 @@ def make_bundle_missing_policy(
505
511
  {
506
512
  "effect": effect,
507
513
  "action": "*",
514
+ # T79: stamp a synthetic policy_id so the audit
515
+ # dashboard can render a recognizable chip + link to
516
+ # the BUNDLE_MISSING troubleshooting anchor.
517
+ "id": "synthetic:BUNDLE_MISSING",
508
518
  "reason": reason,
509
519
  "reason_code": "BUNDLE_MISSING",
510
520
  }
@@ -71,6 +71,39 @@ VALID_REASON_CODES = frozenset({
71
71
  REASON_CODE_DLP_BLOCKED,
72
72
  })
73
73
 
74
+ # Synthetic policy_id sentinels (T79 / Bryan deny-deny postmortem,
75
+ # 2026-05-11). When a deny is emitted by anything OTHER than a
76
+ # user-authored rule, the SDK stamps the audit row's `policy_id` with
77
+ # one of these `synthetic:*` values so the audit dashboard can render
78
+ # a recognizable chip and link it to the right troubleshooting
79
+ # anchor. Without this, four very different bug classes (stale
80
+ # bundle, missing resource gate, vocabulary mismatch, genuine
81
+ # no-match) all looked identical in the Policy column (blank
82
+ # placeholder + Decision=Deny + reason_code=NO_RULE_MATCH).
83
+ #
84
+ # The `synthetic:` prefix is a contract: the backend audit ingest
85
+ # stores it verbatim (no validation on policy_id content), the
86
+ # frontend matches on the prefix to switch chip styling, and the
87
+ # values themselves mirror the reason_code enum 1:1 PLUS one new
88
+ # value (RESOURCE_GATE_SKIP) that captures the T83-class bug where
89
+ # every action-matching rule was skipped purely on the resource gate.
90
+ SYNTHETIC_POLICY_ID_PREFIX = "synthetic:"
91
+ SYNTHETIC_NO_RULE_MATCH = "synthetic:NO_RULE_MATCH"
92
+ SYNTHETIC_NO_ACTIVE_POLICIES = "synthetic:NO_ACTIVE_POLICIES"
93
+ SYNTHETIC_BUNDLE_MISSING = "synthetic:BUNDLE_MISSING"
94
+ SYNTHETIC_RESOURCE_GATE_SKIP = "synthetic:RESOURCE_GATE_SKIP"
95
+ SYNTHETIC_QUARANTINE = "synthetic:QUARANTINE"
96
+ SYNTHETIC_ENGINE_UNAVAILABLE = "synthetic:ENGINE_UNAVAILABLE"
97
+
98
+ VALID_SYNTHETIC_POLICY_IDS = frozenset({
99
+ SYNTHETIC_NO_RULE_MATCH,
100
+ SYNTHETIC_NO_ACTIVE_POLICIES,
101
+ SYNTHETIC_BUNDLE_MISSING,
102
+ SYNTHETIC_RESOURCE_GATE_SKIP,
103
+ SYNTHETIC_QUARANTINE,
104
+ SYNTHETIC_ENGINE_UNAVAILABLE,
105
+ })
106
+
74
107
  # Canonical bundle-level default values. These must stay in lockstep
75
108
  # with the backend's `DefaultBundleAction` / `DefaultBundleOnMissing`
76
109
  # / `DefaultBundleOnTamper` constants (bundle_handler.go). They are
@@ -245,13 +278,31 @@ class PolicyEvaluator:
245
278
  if semantic_action and semantic_action != action:
246
279
  candidate_actions.append(semantic_action)
247
280
 
281
+ # T84: expand candidate_actions through the legacy <-> canonical
282
+ # alias table so pre-#350 customer rules using legacy database
283
+ # action names (database:query, database:DROP, database:execute,
284
+ # ...) keep matching modern SDK calls that emit canonical
285
+ # semantic classes (database:read|write|admin|exec), and vice
286
+ # versa. NO BREAKING CHANGES contract -- see #389.
287
+ from controlzero._internal.action_aliases import expand_candidate_actions
288
+ candidate_actions = expand_candidate_actions(candidate_actions)
289
+
248
290
  resource = ctx_dict.get("resource")
249
291
  evaluated = 0
292
+ # T79: track whether the no-match path was caused PURELY by the
293
+ # resource gate (every action-matching rule was skipped because
294
+ # the resource didn't match) so the synthetic deny can be
295
+ # tagged RESOURCE_GATE_SKIP rather than the more generic
296
+ # NO_RULE_MATCH. This is the T83-class signature -- a rule's
297
+ # actions matched the call but its `resources:` list excluded it.
298
+ action_matched_resource_skipped = False
299
+ action_matched_any = False
250
300
 
251
301
  for rule in self._rules:
252
302
  evaluated += 1
253
303
  if not any(_glob_any(rule.actions, a) for a in candidate_actions):
254
304
  continue
305
+ action_matched_any = True
255
306
  if rule.resources:
256
307
  # T83: a rule whose resources list contains "*" matches
257
308
  # universally and must NOT require the caller to supply
@@ -265,6 +316,7 @@ class PolicyEvaluator:
265
316
  # require a caller-supplied resource and glob-match it.
266
317
  if "*" not in rule.resources:
267
318
  if not resource or not _glob_any(rule.resources, resource):
319
+ action_matched_resource_skipped = True
268
320
  continue
269
321
  if not self._conditions_match(rule.conditions, context, args):
270
322
  continue
@@ -306,8 +358,24 @@ class PolicyEvaluator:
306
358
  reason = "No matching policy rule (default_action=allow)"
307
359
  else: # "warn"
308
360
  reason = "No matching policy rule (default_action=warn)"
361
+
362
+ # T79: distinguish the T83-class signature ("a rule's actions
363
+ # matched but its resources gate excluded the call") from the
364
+ # generic no-match. Both still apply default_action; the
365
+ # synthetic policy_id is what the audit dashboard reads to
366
+ # surface the right remediation.
367
+ if (
368
+ self._default_action == "deny"
369
+ and action_matched_any
370
+ and action_matched_resource_skipped
371
+ ):
372
+ synthetic_id = SYNTHETIC_RESOURCE_GATE_SKIP
373
+ else:
374
+ synthetic_id = SYNTHETIC_NO_RULE_MATCH
375
+
309
376
  return PolicyDecision(
310
377
  effect=self._default_action,
378
+ policy_id=synthetic_id,
311
379
  reason=reason,
312
380
  reason_code=REASON_CODE_NO_RULE_MATCH,
313
381
  evaluated_rules=evaluated,