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.
- {controlzero-1.4.6 → controlzero-1.4.7}/CHANGELOG.md +95 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/PKG-INFO +1 -1
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/__init__.py +1 -1
- controlzero-1.4.7/controlzero/_internal/action_aliases.py +165 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/_internal/bundle.py +10 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/_internal/enforcer.py +68 -0
- controlzero-1.4.7/controlzero/cli/debug_bundle.py +581 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/main.py +10 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/client.py +18 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/device.py +64 -15
- {controlzero-1.4.6 → controlzero-1.4.7}/pyproject.toml +1 -1
- controlzero-1.4.7/tests/parity/action_aliases.json +55 -0
- controlzero-1.4.7/tests/test_action_aliases.py +224 -0
- controlzero-1.4.7/tests/test_cli_debug_bundle.py +392 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_device.py +89 -50
- controlzero-1.4.7/tests/test_synthetic_policy_id_t79.py +287 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/.gitignore +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/Dockerfile.test +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/LICENSE +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/README.md +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/_internal/hook_extractors.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/_internal/tool_extractors.json +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/_internal/types.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/audit_local.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/audit_remote.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/enrollment.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/errors.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/hosted_policy.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/google.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/policy_loader.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/controlzero/tamper.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/examples/hello_world.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/conftest.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/integrations/__init__.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/integrations/test_google.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_audit_remote.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_bundle_translate.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_carve_out.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_extractor_integration.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_hook.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_init.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_tail.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_test.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_cli_validate.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_coding_agent_hooks.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_conditions.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_default_action.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_enrollment.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_glob_matching.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_hook_extractors.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_install_hooks.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_log_rotation.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_policy_settings.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_quarantine.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_reason_code.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_refresh.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_sql_semantic_class.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_tamper.py +0 -0
- {controlzero-1.4.6 → controlzero-1.4.7}/tests/test_tamper_behavior.py +0 -0
- {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.
|
|
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
|
|
@@ -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,
|