controlzero 1.4.5__tar.gz → 1.4.6__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 (111) hide show
  1. {controlzero-1.4.5 → controlzero-1.4.6}/CHANGELOG.md +17 -0
  2. {controlzero-1.4.5 → controlzero-1.4.6}/PKG-INFO +1 -1
  3. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/__init__.py +1 -1
  4. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/_internal/enforcer.py +13 -2
  5. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/hosted_policy.py +10 -2
  6. {controlzero-1.4.5 → controlzero-1.4.6}/pyproject.toml +1 -1
  7. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_glob_matching.py +83 -0
  8. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_refresh.py +6 -1
  9. {controlzero-1.4.5 → controlzero-1.4.6}/.gitignore +0 -0
  10. {controlzero-1.4.5 → controlzero-1.4.6}/Dockerfile.test +0 -0
  11. {controlzero-1.4.5 → controlzero-1.4.6}/LICENSE +0 -0
  12. {controlzero-1.4.5 → controlzero-1.4.6}/README.md +0 -0
  13. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/_internal/__init__.py +0 -0
  14. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/_internal/bundle.py +0 -0
  15. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/_internal/dlp_scanner.py +0 -0
  16. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/_internal/hook_extractors.py +0 -0
  17. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/_internal/tool_extractors.json +0 -0
  18. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/_internal/types.py +0 -0
  19. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/audit_local.py +0 -0
  20. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/audit_remote.py +0 -0
  21. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/cli/__init__.py +0 -0
  22. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/cli/main.py +0 -0
  23. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/cli/templates/autogen.yaml +0 -0
  24. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/cli/templates/claude-code.yaml +0 -0
  25. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/cli/templates/codex-cli.yaml +0 -0
  26. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/cli/templates/cost-cap.yaml +0 -0
  27. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/cli/templates/crewai.yaml +0 -0
  28. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/cli/templates/cursor.yaml +0 -0
  29. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  30. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/cli/templates/generic.yaml +0 -0
  31. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/cli/templates/langchain.yaml +0 -0
  32. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/cli/templates/mcp.yaml +0 -0
  33. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/cli/templates/rag.yaml +0 -0
  34. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/client.py +0 -0
  35. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/device.py +0 -0
  36. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/enrollment.py +0 -0
  37. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/errors.py +0 -0
  38. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/__init__.py +0 -0
  39. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/anthropic.py +0 -0
  40. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/autogen.py +0 -0
  41. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/braintrust.py +0 -0
  42. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/crewai/__init__.py +0 -0
  43. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/crewai/agent.py +0 -0
  44. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/crewai/crew.py +0 -0
  45. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/crewai/task.py +0 -0
  46. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/crewai/tool.py +0 -0
  47. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/google.py +0 -0
  48. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/google_adk/__init__.py +0 -0
  49. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/google_adk/agent.py +0 -0
  50. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/google_adk/tool.py +0 -0
  51. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/langchain/__init__.py +0 -0
  52. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/langchain/agent.py +0 -0
  53. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/langchain/callbacks.py +0 -0
  54. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/langchain/chain.py +0 -0
  55. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/langchain/graph.py +0 -0
  56. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/langchain/modern.py +0 -0
  57. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/langchain/tool.py +0 -0
  58. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/langfuse.py +0 -0
  59. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/litellm.py +0 -0
  60. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/openai.py +0 -0
  61. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/pydantic_ai.py +0 -0
  62. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/integrations/vercel_ai.py +0 -0
  63. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/policy_loader.py +0 -0
  64. {controlzero-1.4.5 → controlzero-1.4.6}/controlzero/tamper.py +0 -0
  65. {controlzero-1.4.5 → controlzero-1.4.6}/examples/hello_world.py +0 -0
  66. {controlzero-1.4.5 → controlzero-1.4.6}/tests/conftest.py +0 -0
  67. {controlzero-1.4.5 → controlzero-1.4.6}/tests/integrations/__init__.py +0 -0
  68. {controlzero-1.4.5 → controlzero-1.4.6}/tests/integrations/test_google.py +0 -0
  69. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_action_canonicalization.py +0 -0
  70. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_agent_name_env.py +0 -0
  71. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_audit_remote.py +0 -0
  72. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_audit_sink_isolation.py +0 -0
  73. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_bundle_parser.py +0 -0
  74. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_bundle_translate.py +0 -0
  75. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_cli_carve_out.py +0 -0
  76. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_cli_extractor_integration.py +0 -0
  77. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_cli_hook.py +0 -0
  78. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_cli_hosted_refresh.py +0 -0
  79. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_cli_init.py +0 -0
  80. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_cli_init_templates.py +0 -0
  81. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_cli_tail.py +0 -0
  82. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_cli_test.py +0 -0
  83. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_cli_validate.py +0 -0
  84. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_coding_agent_hooks.py +0 -0
  85. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_conditions.py +0 -0
  86. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_default_action.py +0 -0
  87. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_device.py +0 -0
  88. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_dlp_scanner.py +0 -0
  89. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_enrollment.py +0 -0
  90. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_fail_closed_eval.py +0 -0
  91. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_hook_extractors.py +0 -0
  92. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_hosted_policy_e2e.py +0 -0
  93. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_hybrid_mode_strict.py +0 -0
  94. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_hybrid_mode_warn.py +0 -0
  95. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_install_hooks.py +0 -0
  96. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_local_mode_dict.py +0 -0
  97. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_local_mode_file_json.py +0 -0
  98. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_local_mode_file_yaml.py +0 -0
  99. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_log_fallback_stderr.py +0 -0
  100. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_log_options_ignored_hosted.py +0 -0
  101. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_log_rotation.py +0 -0
  102. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_no_policy_no_key.py +0 -0
  103. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_package_rename_shim.py +0 -0
  104. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_policy_freshness.py +0 -0
  105. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_policy_settings.py +0 -0
  106. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_quarantine.py +0 -0
  107. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_reason_code.py +0 -0
  108. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_sql_semantic_class.py +0 -0
  109. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_tamper.py +0 -0
  110. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_tamper_behavior.py +0 -0
  111. {controlzero-1.4.5 → controlzero-1.4.6}/tests/test_tamper_hook.py +0 -0
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.6 -- 2026-05-11
4
+
5
+ ### Fixed
6
+
7
+ - **resources:["*"] no longer requires a caller resource** (T83).
8
+ Hosted policy bundles emit `resources:["*"]` for any rule that does
9
+ not scope by resource. The enforcer's resource gate previously
10
+ required a caller-supplied `context.resource` even when the rule's
11
+ resources list was the universal wildcard, causing every rule to be
12
+ silently skipped on calls that didn't pass a resource. Result: every
13
+ `cz.guard()` returned `deny` with `reason_code=NO_RULE_MATCH` regardless
14
+ of what the policy said. Now rules with `*` in their resources match
15
+ universally; non-wildcard resource patterns still require a caller
16
+ resource (no silent broadening). Three regression tests added in
17
+ `tests/test_glob_matching.py` including the exact bundle shape from
18
+ the customer reproduction.
19
+
3
20
  ## 1.4.5 -- 2026-05-05
4
21
 
5
22
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.4.5
3
+ Version: 1.4.6
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.5"
31
+ __version__ = "1.4.6"
32
32
 
33
33
  __all__ = [
34
34
  "Client",
@@ -253,8 +253,19 @@ class PolicyEvaluator:
253
253
  if not any(_glob_any(rule.actions, a) for a in candidate_actions):
254
254
  continue
255
255
  if rule.resources:
256
- if not resource or not _glob_any(rule.resources, resource):
257
- continue
256
+ # T83: a rule whose resources list contains "*" matches
257
+ # universally and must NOT require the caller to supply
258
+ # context.resource. The dashboard always emits
259
+ # resources:["*"] for unscoped rules, so prior to this
260
+ # fix every cz.guard() call without an explicit resource
261
+ # was skipping every rule and falling through to the
262
+ # default deny (Bryan deny-deny incident, 2026-05-10).
263
+ # Logic now: if any pattern in rule.resources is the
264
+ # universal "*", treat the gate as satisfied; otherwise
265
+ # require a caller-supplied resource and glob-match it.
266
+ if "*" not in rule.resources:
267
+ if not resource or not _glob_any(rule.resources, resource):
268
+ continue
258
269
  if not self._conditions_match(rule.conditions, context, args):
259
270
  continue
260
271
  # Prefer the rule-declared reason_code (only synthetic
@@ -409,5 +409,13 @@ def _parse_and_translate(
409
409
 
410
410
 
411
411
  def _compute_etag(blob: bytes) -> str:
412
- """Fallback ETag when the server doesn't provide one."""
413
- return hashlib.sha256(blob).hexdigest()[:32]
412
+ """Fallback ETag when the server doesn't provide one.
413
+
414
+ Returns the full 64-char hex sha256 of the bundle blob. The backend
415
+ stores and serves the full hex string in its ETag header, so the
416
+ SDK must hash to the same width for `If-None-Match` to ever match.
417
+ Truncating to 32 chars (the prior shape) silently broke the 304
418
+ short-circuit, surfacing as Bryan's investigation finding 1 in
419
+ docs/investigations/bryan-db-read-only-fail-closed-2026-05-08.md.
420
+ """
421
+ return hashlib.sha256(blob).hexdigest()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "controlzero"
7
- version = "1.4.5"
7
+ version = "1.4.6"
8
8
  description = "AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup."
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -116,3 +116,86 @@ def test_universal_wildcard(tmp_log):
116
116
  assert cz.guard("anything").allowed
117
117
  assert cz.guard("anything", method="anything").allowed
118
118
  cz.close()
119
+
120
+
121
+ # ---------------------------------------------------------------------
122
+ # T83 regression: resources:["*"] must NOT require a caller resource.
123
+ # ---------------------------------------------------------------------
124
+ # Pre-T83 the dashboard's universal-resource shape (every rule emitted
125
+ # resources:["*"]) skipped every rule when callers passed no
126
+ # context.resource, falling through to default deny. Bryan deny-deny
127
+ # incident, 2026-05-10. The exact bundle Bryan ran against is the
128
+ # fixture below.
129
+
130
+ def test_resources_wildcard_matches_without_context_resource(tmp_log):
131
+ cz = Client(
132
+ policy={
133
+ "rules": [
134
+ {
135
+ "id": "rule-0",
136
+ "allow": "database:read",
137
+ "resources": ["*"],
138
+ },
139
+ ],
140
+ },
141
+ log_path=str(tmp_log),
142
+ )
143
+ # SELECT classifies as database:read via sql_semantic_class.
144
+ r = cz.guard("database", method="read", args={"sql": "SELECT id FROM orders"})
145
+ assert r.decision == "allow", f"resources:['*'] without context.resource should match; got {r.decision} ({r.reason})"
146
+ cz.close()
147
+
148
+
149
+ def test_bryan_db_read_only_full_bundle_shape(tmp_log):
150
+ """End-to-end against the EXACT rule shape from Bryan's published bundle.
151
+
152
+ This is the smoking-gun reproduction:
153
+ - rule-0 allow database:read (should fire on SELECT)
154
+ - rule-1 deny database:exec (should fire on DROP via canonical class)
155
+ - rule-2 deny database:delete (separate guard rail)
156
+ All rules carry resources:["*"]. Pre-T83 every call returned deny.
157
+ """
158
+ cz = Client(
159
+ policy={
160
+ "rules": [
161
+ {"id": "rule-0", "allow": "database:read", "resources": ["*"]},
162
+ {"id": "rule-1", "deny": "database:exec", "resources": ["*"]},
163
+ {"id": "rule-2", "deny": "database:delete", "resources": ["*"]},
164
+ ],
165
+ },
166
+ log_path=str(tmp_log),
167
+ )
168
+ # SELECT must allow.
169
+ r = cz.guard("database", method="read", args={"sql": "SELECT id FROM orders"})
170
+ assert r.decision == "allow", f"SELECT should allow, got {r.decision} ({r.reason})"
171
+
172
+ # DROP TABLE: caller passes method="exec" so candidate_actions
173
+ # contains database:exec which matches rule-1 deny.
174
+ r2 = cz.guard("database", method="exec", args={"sql": "DROP TABLE orders"})
175
+ assert r2.decision == "deny", f"DROP via method=exec should deny, got {r2.decision} ({r2.reason})"
176
+ cz.close()
177
+
178
+
179
+ def test_specific_resource_pattern_still_requires_caller_resource(tmp_log):
180
+ """T83 must NOT regress non-wildcard resource scoping. A rule that
181
+ names a concrete resource pattern should still skip when the caller
182
+ passes no resource."""
183
+ cz = Client(
184
+ policy={
185
+ "rules": [
186
+ {"id": "scoped", "allow": "fs:read", "resources": ["/home/*"]},
187
+ {"deny": "*"},
188
+ ],
189
+ },
190
+ log_path=str(tmp_log),
191
+ )
192
+ # No resource in context -- scoped rule must NOT match, fall through to deny.
193
+ r = cz.guard("fs", method="read")
194
+ assert r.decision == "deny", f"scoped rule must not match without context.resource, got {r.decision}"
195
+ # With matching resource, allow.
196
+ r2 = cz.guard("fs", method="read", context={"resource": "/home/alice/notes.txt"})
197
+ assert r2.decision == "allow"
198
+ # With non-matching resource, deny via fall-through.
199
+ r3 = cz.guard("fs", method="read", context={"resource": "/etc/passwd"})
200
+ assert r3.decision == "deny"
201
+ cz.close()
@@ -140,7 +140,12 @@ def mock_backend(tmp_path, monkeypatch):
140
140
  import hashlib
141
141
 
142
142
  def current_etag() -> str:
143
- return hashlib.sha256(backend.bundle_bytes).hexdigest()[:32]
143
+ # Match the SDK's _compute_etag (full 64-char hex). The earlier
144
+ # [:32] truncation here mirrored the bug in hosted_policy.py
145
+ # that PR #374 fixed; keeping the truncation here would make
146
+ # the mock backend's ETag perpetually mismatch the SDK's
147
+ # If-None-Match and the 304 short-circuit would never fire.
148
+ return hashlib.sha256(backend.bundle_bytes).hexdigest()
144
149
 
145
150
  class Handler(BaseHTTPRequestHandler):
146
151
  def _bearer_ok(self):
File without changes
File without changes
File without changes
File without changes