controlzero 1.4.3__tar.gz → 1.4.5__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.3 → controlzero-1.4.5}/.gitignore +3 -1
- controlzero-1.4.5/CHANGELOG.md +71 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/PKG-INFO +15 -3
- {controlzero-1.4.3 → controlzero-1.4.5}/README.md +13 -1
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/__init__.py +1 -1
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/_internal/bundle.py +192 -21
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/_internal/enforcer.py +155 -6
- controlzero-1.4.5/controlzero/_internal/hook_extractors.py +631 -0
- controlzero-1.4.5/controlzero/_internal/tool_extractors.json +68 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/_internal/types.py +6 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/audit_remote.py +8 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/cli/main.py +342 -9
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/client.py +262 -2
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/policy_loader.py +73 -1
- {controlzero-1.4.3 → controlzero-1.4.5}/pyproject.toml +9 -2
- controlzero-1.4.5/tests/test_bundle_translate.py +266 -0
- controlzero-1.4.5/tests/test_cli_carve_out.py +476 -0
- controlzero-1.4.5/tests/test_cli_extractor_integration.py +473 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_coding_agent_hooks.py +57 -16
- controlzero-1.4.5/tests/test_default_action.py +359 -0
- controlzero-1.4.5/tests/test_hook_extractors.py +567 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_install_hooks.py +6 -1
- controlzero-1.4.5/tests/test_reason_code.py +352 -0
- controlzero-1.4.5/tests/test_refresh.py +560 -0
- controlzero-1.4.5/tests/test_sql_semantic_class.py +213 -0
- controlzero-1.4.3/CHANGELOG.md +0 -33
- {controlzero-1.4.3 → controlzero-1.4.5}/Dockerfile.test +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/LICENSE +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/_internal/__init__.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/_internal/dlp_scanner.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/audit_local.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/cli/__init__.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/cli/templates/autogen.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/cli/templates/claude-code.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/cli/templates/codex-cli.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/cli/templates/cost-cap.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/cli/templates/crewai.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/cli/templates/cursor.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/cli/templates/gemini-cli.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/cli/templates/generic.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/cli/templates/langchain.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/cli/templates/mcp.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/cli/templates/rag.yaml +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/device.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/enrollment.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/errors.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/hosted_policy.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/__init__.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/anthropic.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/autogen.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/braintrust.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/crewai/__init__.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/crewai/agent.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/crewai/crew.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/crewai/task.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/crewai/tool.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/google.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/google_adk/__init__.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/google_adk/agent.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/google_adk/tool.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/langchain/__init__.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/langchain/agent.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/langchain/callbacks.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/langchain/chain.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/langchain/graph.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/langchain/modern.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/langchain/tool.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/langfuse.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/litellm.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/openai.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/pydantic_ai.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/integrations/vercel_ai.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/controlzero/tamper.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/examples/hello_world.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/conftest.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/integrations/__init__.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/integrations/test_google.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_action_canonicalization.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_agent_name_env.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_audit_remote.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_audit_sink_isolation.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_bundle_parser.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_cli_hook.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_cli_hosted_refresh.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_cli_init.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_cli_init_templates.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_cli_tail.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_cli_test.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_cli_validate.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_conditions.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_device.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_dlp_scanner.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_enrollment.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_fail_closed_eval.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_glob_matching.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_hosted_policy_e2e.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_hybrid_mode_strict.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_hybrid_mode_warn.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_local_mode_dict.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_local_mode_file_json.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_local_mode_file_yaml.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_log_fallback_stderr.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_log_options_ignored_hosted.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_log_rotation.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_no_policy_no_key.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_package_rename_shim.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_policy_freshness.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_policy_settings.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_quarantine.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_tamper.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_tamper_behavior.py +0 -0
- {controlzero-1.4.3 → controlzero-1.4.5}/tests/test_tamper_hook.py +0 -0
|
@@ -194,7 +194,6 @@ SUMMARY_OFFENSIVE_*.md
|
|
|
194
194
|
docs/deployment/
|
|
195
195
|
docs/SSH_RECOVERY_GUIDE.md
|
|
196
196
|
docs/HETZNER_OS_INSTALLATION.md
|
|
197
|
-
docs/BACKUP_RECOVERY.md
|
|
198
197
|
docs/VERCEL_SETUP_WEBAPPS.md
|
|
199
198
|
scripts/fix-ssh-config.sh
|
|
200
199
|
scripts/hetzner-post-install.sh
|
|
@@ -237,3 +236,6 @@ cz-revamp-live.png
|
|
|
237
236
|
secrets/*.plain
|
|
238
237
|
secrets/*.dec
|
|
239
238
|
.claude/scheduled_tasks.lock
|
|
239
|
+
|
|
240
|
+
# Local documentation, patent assets, and legal research (sensitive, never commit)
|
|
241
|
+
doc_local/
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.4.5 -- 2026-05-05
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Cross-CLI canonical tool coverage** (#341). The shared
|
|
8
|
+
`tool_extractors.json` is now spec_version 2 and adds three new
|
|
9
|
+
canonical tools (`web_search`, `file_search`, `task`) plus extended
|
|
10
|
+
aliases so PowerShell shell-command emission, Codex CLI's
|
|
11
|
+
`apply_patch`, Gemini CLI's `read_many_files`, and the WebFetch
|
|
12
|
+
family all resolve to the existing canonical tools. One rule
|
|
13
|
+
`action: Bash:rm` now covers Claude Code, Gemini CLI, Codex CLI,
|
|
14
|
+
and PowerShell on Windows.
|
|
15
|
+
- **SQL semantic-class layer** (#345/#350). New `sql_semantic_class()`
|
|
16
|
+
helper emits a parallel `database:read|write|admin|exec` action
|
|
17
|
+
alongside the existing per-keyword `database:SELECT|DROP|...`
|
|
18
|
+
action. Write portable rules like `allow: database:read` that cover
|
|
19
|
+
SELECT, EXPLAIN, SHOW, DESCRIBE, and CTE in one rule, without
|
|
20
|
+
enumerating every keyword the dialect accepts. Multi-statement
|
|
21
|
+
piggybacks like `SELECT 1; DROP TABLE x` correctly resolve to
|
|
22
|
+
`database:admin` so deny rules catch them. 21 new parity-test
|
|
23
|
+
fixtures (cross-SDK byte-identical with the Node sibling).
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
- New customer-facing reference at `docs/sdk/policies/canonical-tools.md`
|
|
28
|
+
explaining canonical tool names, alias coverage per host CLI, and
|
|
29
|
+
the contract for adding new clients.
|
|
30
|
+
- New SQL semantic class section in `canonical-tools.md` plus updated
|
|
31
|
+
quickstart and read-only-database recipe.
|
|
32
|
+
|
|
33
|
+
## 1.4.4 -- 2026-04-19
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Hosted mode now refreshes the policy bundle periodically (default 60 seconds) so dashboard edits reach long-running clients without a process restart. New `Client(refresh_interval_seconds=...)` constructor arg controls cadence: pass `None` to disable, `0` to refresh on every `guard()` call (tests only). Added public `Client.refresh()` for on-demand reloads and `Client.last_refreshed_at` for ops visibility. Swap is protected by a lock so multi-threaded callers never observe torn state.
|
|
38
|
+
- Bundle translator now reads plural `actions: [...]` rules from the hosted bundle. Previously every such rule collapsed to a universal `*` match, turning narrow denies like `deny database:execute` into deny-all.
|
|
39
|
+
- Backend now stamps `api_keys.last_used_at` on the SDK pull endpoint on both fresh-bundle and 304 paths, so the dashboard can distinguish "SDK online with cached bundle" from "SDK has never connected". Best-effort write; stamp failures never block the response.
|
|
40
|
+
|
|
41
|
+
## 1.4.1 -- 2026-04-15
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- `agent_name` arg + `CZ_AGENT_NAME` env var contract on the `Client` constructor (issue #71). Order: explicit arg > env > `default-agent`.
|
|
46
|
+
- `CZ_DEBUG=1` (or `true`/`yes`/`on`) flips the controlzero logger to DEBUG at construction. Cheap escape hatch for support.
|
|
47
|
+
- Optional install extras: `controlzero[google]`, `controlzero[openai]`, `controlzero[anthropic]` (issue #68). Pin the matching upstream SDK so users do not see import errors.
|
|
48
|
+
- Cross-SDK action canonicalization parity test (issue #69). Mirrors the same fixture in the Node and Go SDKs.
|
|
49
|
+
- Smoke + denial + error-propagation tests for the `wrap_google` integration (issue #68).
|
|
50
|
+
|
|
51
|
+
## 1.4.0 -- 2026-04-15
|
|
52
|
+
|
|
53
|
+
### Breaking changes
|
|
54
|
+
|
|
55
|
+
- Integration wrappers now emit simplified action names (`llm:generate`, `embedding:generate`, `tool:call`) instead of provider-prefixed ones (`llm:openai:chat.completions.create`). Policies targeting the old action names must be updated. Provider and model move into `context` tags. See docs/integrations for current patterns.
|
|
56
|
+
- Google integration rewritten against `google-genai` (deprecated `google.generativeai` removed). Install `google-genai`.
|
|
57
|
+
- `integrations.langchain.agent.GovernedAgent` wrapping `AgentExecutor` is deprecated in LangChain v1.x. Use `integrations.langchain.modern.create_governed_agent`.
|
|
58
|
+
|
|
59
|
+
### New integrations
|
|
60
|
+
|
|
61
|
+
- `integrations.autogen` - first-party helper for autogen-agentchat v0.7+
|
|
62
|
+
- `integrations.pydantic_ai` - first-party helper for pydantic-ai v1.x
|
|
63
|
+
- `integrations.langchain.modern` - LangGraph create_agent pattern
|
|
64
|
+
|
|
65
|
+
### New features
|
|
66
|
+
|
|
67
|
+
- Policy rule `conditions` field is now evaluated in the local enforcer. Conditions are matched against merged context + args with glob patterns. All keys must match.
|
|
68
|
+
|
|
69
|
+
### Enhancements
|
|
70
|
+
|
|
71
|
+
- `integrations.litellm` - async + success/failure hooks for streaming audit.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlzero
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.5
|
|
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
|
|
7
7
|
Project-URL: Repository, https://github.com/controlzero/controlzero
|
|
8
8
|
Project-URL: Examples, https://docs.controlzero.ai/sdk/integrations
|
|
9
|
-
Author-email: Control Zero <
|
|
9
|
+
Author-email: Control Zero <team@controlzero.ai>
|
|
10
10
|
License: Apache-2.0
|
|
11
11
|
License-File: LICENSE
|
|
12
12
|
Keywords: agents,ai,audit,governance,guardrails,llm,mcp,policy
|
|
@@ -82,7 +82,7 @@ print(cz.guard("read_file", {"path": "/tmp/foo"}).decision) # "allow"
|
|
|
82
82
|
## Install
|
|
83
83
|
|
|
84
84
|
```bash
|
|
85
|
-
pip install
|
|
85
|
+
pip install controlzero
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
## Why
|
|
@@ -254,6 +254,18 @@ cz = Client(api_key="cz_live_...", policy=local_policy, strict_hosted=True)
|
|
|
254
254
|
# RuntimeError: manual policy override detected ...
|
|
255
255
|
```
|
|
256
256
|
|
|
257
|
+
## Coding agent hooks
|
|
258
|
+
|
|
259
|
+
`controlzero hook-check` runs inside Claude Code, Gemini CLI, and Codex CLI
|
|
260
|
+
on every tool use and evaluates the call against your policy before it fires.
|
|
261
|
+
It extracts a canonical `tool:method` from the tool arguments so rules can
|
|
262
|
+
target `database:SELECT` vs `database:DROP`, or allow `Bash:git` while denying
|
|
263
|
+
`Bash:rm`. Multi-statement SQL and compound shell commands are resolved to the
|
|
264
|
+
most dangerous token, so a `SELECT ... ; DROP TABLE users;` payload matches
|
|
265
|
+
`database:DROP`, not `database:SELECT`. See
|
|
266
|
+
[Hook action extraction](https://docs.controlzero.ai/concepts/hook-extractor)
|
|
267
|
+
for the full extraction rules, security model, and per-tool examples.
|
|
268
|
+
|
|
257
269
|
## Framework examples
|
|
258
270
|
|
|
259
271
|
Full integration guides at [docs.controlzero.ai/sdk/integrations](https://docs.controlzero.ai/sdk/integrations):
|
|
@@ -30,7 +30,7 @@ print(cz.guard("read_file", {"path": "/tmp/foo"}).decision) # "allow"
|
|
|
30
30
|
## Install
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
pip install
|
|
33
|
+
pip install controlzero
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
## Why
|
|
@@ -202,6 +202,18 @@ cz = Client(api_key="cz_live_...", policy=local_policy, strict_hosted=True)
|
|
|
202
202
|
# RuntimeError: manual policy override detected ...
|
|
203
203
|
```
|
|
204
204
|
|
|
205
|
+
## Coding agent hooks
|
|
206
|
+
|
|
207
|
+
`controlzero hook-check` runs inside Claude Code, Gemini CLI, and Codex CLI
|
|
208
|
+
on every tool use and evaluates the call against your policy before it fires.
|
|
209
|
+
It extracts a canonical `tool:method` from the tool arguments so rules can
|
|
210
|
+
target `database:SELECT` vs `database:DROP`, or allow `Bash:git` while denying
|
|
211
|
+
`Bash:rm`. Multi-statement SQL and compound shell commands are resolved to the
|
|
212
|
+
most dangerous token, so a `SELECT ... ; DROP TABLE users;` payload matches
|
|
213
|
+
`database:DROP`, not `database:SELECT`. See
|
|
214
|
+
[Hook action extraction](https://docs.controlzero.ai/concepts/hook-extractor)
|
|
215
|
+
for the full extraction rules, security model, and per-tool examples.
|
|
216
|
+
|
|
205
217
|
## Framework examples
|
|
206
218
|
|
|
207
219
|
Full integration guides at [docs.controlzero.ai/sdk/integrations](https://docs.controlzero.ai/sdk/integrations):
|
|
@@ -349,7 +349,7 @@ def _zstd_decompress(data: bytes) -> bytes:
|
|
|
349
349
|
|
|
350
350
|
raise BundleVerificationError(
|
|
351
351
|
"zstd decompression library not installed; "
|
|
352
|
-
"install with: pip install
|
|
352
|
+
"install with: pip install -U controlzero (or pip install zstandard)"
|
|
353
353
|
)
|
|
354
354
|
|
|
355
355
|
|
|
@@ -369,7 +369,41 @@ def translate_to_local_policy(payload: dict) -> dict:
|
|
|
369
369
|
|
|
370
370
|
Policies are sorted by ``priority`` ascending so SDKs in every
|
|
371
371
|
language produce identical decisions from identical input.
|
|
372
|
+
|
|
373
|
+
Schema 1.1 (#228 Phase 2) adds three top-level enforcement-default
|
|
374
|
+
knobs -- ``default_action``, ``default_on_missing``, and
|
|
375
|
+
``default_on_tamper`` -- that the translator surfaces into the
|
|
376
|
+
local policy dict's ``settings`` block so downstream
|
|
377
|
+
:func:`controlzero.policy_loader.load_policy` can pick them up
|
|
378
|
+
unchanged. Missing or unknown values fall back to the canonical
|
|
379
|
+
deny/deny/warn (matches pre-1.1 hard-coded behaviour, so old
|
|
380
|
+
bundles keep working).
|
|
372
381
|
"""
|
|
382
|
+
# Lazy import to avoid a cycle: enforcer imports from this file
|
|
383
|
+
# via policy_loader via client. Importing at call time breaks the
|
|
384
|
+
# cycle cleanly while still letting translate_to_local_policy own
|
|
385
|
+
# the canonical-default fallback (one source of truth).
|
|
386
|
+
from controlzero._internal.enforcer import (
|
|
387
|
+
DEFAULT_BUNDLE_ACTION,
|
|
388
|
+
DEFAULT_BUNDLE_ON_MISSING,
|
|
389
|
+
DEFAULT_BUNDLE_ON_TAMPER,
|
|
390
|
+
VALID_DEFAULT_ACTIONS,
|
|
391
|
+
VALID_DEFAULT_ON_MISSING,
|
|
392
|
+
VALID_DEFAULT_ON_TAMPER,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
default_action = payload.get("default_action")
|
|
396
|
+
if default_action not in VALID_DEFAULT_ACTIONS:
|
|
397
|
+
default_action = DEFAULT_BUNDLE_ACTION
|
|
398
|
+
|
|
399
|
+
default_on_missing = payload.get("default_on_missing")
|
|
400
|
+
if default_on_missing not in VALID_DEFAULT_ON_MISSING:
|
|
401
|
+
default_on_missing = DEFAULT_BUNDLE_ON_MISSING
|
|
402
|
+
|
|
403
|
+
default_on_tamper = payload.get("default_on_tamper")
|
|
404
|
+
if default_on_tamper not in VALID_DEFAULT_ON_TAMPER:
|
|
405
|
+
default_on_tamper = DEFAULT_BUNDLE_ON_TAMPER
|
|
406
|
+
|
|
373
407
|
policies = payload.get("policies") or []
|
|
374
408
|
policies = sorted(
|
|
375
409
|
[p for p in policies if isinstance(p, dict)],
|
|
@@ -394,47 +428,184 @@ def translate_to_local_policy(payload: dict) -> dict:
|
|
|
394
428
|
flat.append(translated)
|
|
395
429
|
|
|
396
430
|
if not flat:
|
|
397
|
-
# Empty policy set:
|
|
398
|
-
#
|
|
431
|
+
# Empty policy set: synthetic catchall rule. The effect
|
|
432
|
+
# mirrors the bundle-level default_action so "empty bundle"
|
|
433
|
+
# continues to deliver the operator's chosen posture. Prior
|
|
434
|
+
# to #228 Phase 2 this was hard-coded to deny; now an org
|
|
435
|
+
# that ships an empty-bundle-with-default=allow gets the
|
|
436
|
+
# expected allow (with the same NO_ACTIVE_POLICIES
|
|
437
|
+
# reason_code so dashboards still bucket it as "nothing
|
|
438
|
+
# attached").
|
|
439
|
+
#
|
|
440
|
+
# Copy-choice note (2026-04-19, Bryan's P0): the previous
|
|
441
|
+
# message -- "No active policies. Define one in the Control Zero
|
|
442
|
+
# dashboard." -- presumed the user had not defined any policies.
|
|
443
|
+
# That presumption was wrong in the common case: the user had
|
|
444
|
+
# defined several, but the library-attachments state on the
|
|
445
|
+
# backend did not include any active attachment row, so the
|
|
446
|
+
# bundle arrived with zero policies while the dashboard Library
|
|
447
|
+
# tab still showed them. The new wording removes the
|
|
448
|
+
# presumption and points at the actual recovery action.
|
|
399
449
|
flat.append({
|
|
400
|
-
"effect":
|
|
450
|
+
"effect": default_action,
|
|
401
451
|
"action": "*",
|
|
402
452
|
"reason": (
|
|
403
|
-
"No active
|
|
453
|
+
"No policies are active on this project. If the dashboard "
|
|
454
|
+
"shows attached policies, regenerate the policy bundle."
|
|
404
455
|
),
|
|
456
|
+
"reason_code": "NO_ACTIVE_POLICIES",
|
|
405
457
|
})
|
|
406
458
|
|
|
407
|
-
return {
|
|
459
|
+
return {
|
|
460
|
+
"version": "1",
|
|
461
|
+
"rules": flat,
|
|
462
|
+
"settings": {
|
|
463
|
+
"default_action": default_action,
|
|
464
|
+
"default_on_missing": default_on_missing,
|
|
465
|
+
"default_on_tamper": default_on_tamper,
|
|
466
|
+
},
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def make_bundle_missing_policy(
|
|
471
|
+
default_on_missing: Optional[str] = None,
|
|
472
|
+
) -> dict:
|
|
473
|
+
"""Build the synthetic local-policy dict used when the bundle cannot load.
|
|
474
|
+
|
|
475
|
+
Returned shape matches what :func:`translate_to_local_policy`
|
|
476
|
+
produces, so downstream :func:`controlzero.policy_loader.load_policy`
|
|
477
|
+
accepts it unchanged.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
default_on_missing: Effect to apply -- ``"deny"`` or ``"allow"``.
|
|
481
|
+
``None`` or an unknown value coerces to the canonical deny
|
|
482
|
+
default, matching the pre-#228 hard-coded behaviour.
|
|
483
|
+
|
|
484
|
+
The single catchall rule carries ``reason_code=BUNDLE_MISSING`` so
|
|
485
|
+
audit pipelines can distinguish "no bundle at all" (this path) from
|
|
486
|
+
"bundle loaded but empty" (``NO_ACTIVE_POLICIES``) and from
|
|
487
|
+
"rule set evaluated, nothing matched" (``NO_RULE_MATCH``).
|
|
488
|
+
"""
|
|
489
|
+
from controlzero._internal.enforcer import (
|
|
490
|
+
DEFAULT_BUNDLE_ACTION,
|
|
491
|
+
DEFAULT_BUNDLE_ON_MISSING,
|
|
492
|
+
DEFAULT_BUNDLE_ON_TAMPER,
|
|
493
|
+
VALID_DEFAULT_ON_MISSING,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
effect = default_on_missing if default_on_missing in VALID_DEFAULT_ON_MISSING else DEFAULT_BUNDLE_ON_MISSING
|
|
497
|
+
reason = (
|
|
498
|
+
"Policy bundle could not be loaded (never synced, backend "
|
|
499
|
+
"unreachable, or decrypt failed). "
|
|
500
|
+
f"Honoring default_on_missing={effect}."
|
|
501
|
+
)
|
|
502
|
+
return {
|
|
503
|
+
"version": "1",
|
|
504
|
+
"rules": [
|
|
505
|
+
{
|
|
506
|
+
"effect": effect,
|
|
507
|
+
"action": "*",
|
|
508
|
+
"reason": reason,
|
|
509
|
+
"reason_code": "BUNDLE_MISSING",
|
|
510
|
+
}
|
|
511
|
+
],
|
|
512
|
+
"settings": {
|
|
513
|
+
# Stamp the resolved default triple onto the synthetic
|
|
514
|
+
# policy so downstream code that reads settings sees
|
|
515
|
+
# consistent values. default_action mirrors the missing
|
|
516
|
+
# effect so no-match behaviour is predictable.
|
|
517
|
+
"default_action": effect if effect in {"deny", "allow"} else DEFAULT_BUNDLE_ACTION,
|
|
518
|
+
"default_on_missing": effect,
|
|
519
|
+
"default_on_tamper": DEFAULT_BUNDLE_ON_TAMPER,
|
|
520
|
+
},
|
|
521
|
+
}
|
|
408
522
|
|
|
409
523
|
|
|
410
524
|
def _translate_rule(rule: dict, policy_id: str) -> Optional[dict]:
|
|
411
|
-
"""Translate a single backend rule to the local rule shape.
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
525
|
+
"""Translate a single backend rule to the local rule shape.
|
|
526
|
+
|
|
527
|
+
The backend (:file:`bundle_handler.go` ``PolicyRule``) emits rules
|
|
528
|
+
whose tool pattern lives under plural ``actions: [...]``. This
|
|
529
|
+
function used to ignore that key entirely and fell back to ``"*"``
|
|
530
|
+
for every such rule, causing e.g. a ``deny database:execute`` rule
|
|
531
|
+
to silently become ``deny *`` after round-tripping through the
|
|
532
|
+
bundle. Both the plural (backend wire-format) and the legacy
|
|
533
|
+
singular (``tool`` / ``pattern`` / ``action`` / ``match.tool``) forms
|
|
534
|
+
are now supported; plural wins when both are present.
|
|
535
|
+
"""
|
|
536
|
+
# Accept several spellings of "effect". Policy engine has four
|
|
537
|
+
# canonical effects; anything else is treated as ``allow`` to stay
|
|
538
|
+
# fail-safe on forward-compat.
|
|
539
|
+
effect_raw = rule.get("effect")
|
|
540
|
+
if not effect_raw:
|
|
541
|
+
# Only fall back to rule["action"] for effect if it looks like
|
|
542
|
+
# one of the canonical effects. Otherwise "action" is a tool
|
|
543
|
+
# name and we should not use it here.
|
|
544
|
+
fallback = rule.get("action")
|
|
545
|
+
if fallback in ("allow", "deny", "warn", "audit"):
|
|
546
|
+
effect_raw = fallback
|
|
547
|
+
effect = effect_raw if effect_raw in ("allow", "deny", "warn", "audit") else "allow"
|
|
548
|
+
|
|
549
|
+
# Tool pattern resolution order:
|
|
550
|
+
# 1. plural ``actions`` (list, as emitted by the backend)
|
|
551
|
+
# 2. legacy singular ``tool`` / ``pattern``
|
|
552
|
+
# 3. legacy singular ``action`` (only if it is NOT an effect keyword)
|
|
553
|
+
# 4. nested ``match.tool`` / ``match.action``
|
|
554
|
+
# 5. default ``"*"`` (universal)
|
|
555
|
+
patterns: list[str] = []
|
|
556
|
+
raw_actions = rule.get("actions")
|
|
557
|
+
if isinstance(raw_actions, list):
|
|
558
|
+
patterns = [str(a) for a in raw_actions if isinstance(a, str) and a]
|
|
559
|
+
|
|
560
|
+
if not patterns:
|
|
561
|
+
candidate = rule.get("tool") or rule.get("pattern")
|
|
562
|
+
if candidate:
|
|
563
|
+
patterns = [str(candidate)]
|
|
564
|
+
|
|
565
|
+
if not patterns:
|
|
566
|
+
# Legacy singular "action" field -- only a tool pattern when
|
|
567
|
+
# it is NOT a canonical effect name (effect already captured
|
|
568
|
+
# above).
|
|
569
|
+
candidate = rule.get("action")
|
|
570
|
+
if candidate and candidate not in ("allow", "deny", "warn", "audit"):
|
|
571
|
+
patterns = [str(candidate)]
|
|
572
|
+
|
|
573
|
+
if not patterns:
|
|
422
574
|
match = rule.get("match")
|
|
423
575
|
if isinstance(match, dict):
|
|
424
|
-
|
|
425
|
-
|
|
576
|
+
candidate = match.get("tool") or match.get("action")
|
|
577
|
+
if candidate:
|
|
578
|
+
patterns = [str(candidate)]
|
|
579
|
+
|
|
580
|
+
if not patterns:
|
|
426
581
|
# Final fallback: universal match. Combined with a non-allow
|
|
427
582
|
# effect, this is still meaningful (e.g. deny-all).
|
|
428
|
-
|
|
583
|
+
patterns = ["*"]
|
|
429
584
|
|
|
430
585
|
translated: dict = {
|
|
431
586
|
"effect": effect,
|
|
432
|
-
|
|
587
|
+
# Emit plural ``actions`` so downstream
|
|
588
|
+
# :func:`controlzero.policy_loader.load_policy` can pass the
|
|
589
|
+
# full list through to :class:`PolicyRule.actions` unchanged.
|
|
590
|
+
# The loader accepts either ``action`` or ``actions``.
|
|
591
|
+
"actions": patterns if len(patterns) > 1 else patterns,
|
|
433
592
|
"reason": rule.get("reason") or f"policy:{policy_id}",
|
|
434
593
|
}
|
|
594
|
+
# Keep the singular ``action`` alias for single-pattern rules so
|
|
595
|
+
# older consumers that read the translated dict (tests, docs) still
|
|
596
|
+
# see a scalar. The loader prefers ``action`` when scalar.
|
|
597
|
+
if len(patterns) == 1:
|
|
598
|
+
translated["action"] = patterns[0]
|
|
435
599
|
|
|
436
600
|
resources = rule.get("resources") or rule.get("resource")
|
|
437
601
|
if resources:
|
|
438
602
|
translated["resources"] = resources
|
|
439
603
|
|
|
604
|
+
# Propagate the machine-readable reason_code when the source rule
|
|
605
|
+
# carries one. Today only the synthetic empty-bundle deny (above)
|
|
606
|
+
# sets this; user-authored rules are unaffected.
|
|
607
|
+
rc = rule.get("reason_code")
|
|
608
|
+
if isinstance(rc, str) and rc:
|
|
609
|
+
translated["reason_code"] = rc
|
|
610
|
+
|
|
440
611
|
return translated
|
|
@@ -30,6 +30,64 @@ from controlzero._internal.dlp_scanner import (
|
|
|
30
30
|
from controlzero._internal.types import PolicyRule
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
# Canonical reason_code enum values. Stable machine-readable labels
|
|
34
|
+
# so integrations + dashboards can branch on decision provenance
|
|
35
|
+
# without regex-matching the human-readable `reason` string.
|
|
36
|
+
#
|
|
37
|
+
# Added 2026-04-19 after Bryan's P0: the SDK fail-closed with "No
|
|
38
|
+
# active policies. Define one in the Control Zero dashboard." when
|
|
39
|
+
# the user had in fact defined three; the three surfaces disagreed
|
|
40
|
+
# because policy_attachments was empty. reason_code lets the hosted
|
|
41
|
+
# dashboard, local-mode CLI, and audit ingestion distinguish the
|
|
42
|
+
# fail-closed paths without parsing English text.
|
|
43
|
+
#
|
|
44
|
+
# Extended 2026-04-20 (#228 Phase 2) from 5 values to 8. Additions
|
|
45
|
+
# are strictly additive: existing consumers that switch on the old
|
|
46
|
+
# five keep working because the enum only grows. The three new codes
|
|
47
|
+
# correspond to surfaces that previously had no machine label at all:
|
|
48
|
+
#
|
|
49
|
+
# - RULE_MATCH -- a user-authored rule fired (allow OR deny).
|
|
50
|
+
# - BUNDLE_MISSING -- bundle could not be loaded (never synced,
|
|
51
|
+
# backend 4xx/5xx, decrypt failure). Honors
|
|
52
|
+
# `default_on_missing`.
|
|
53
|
+
# - DLP_BLOCKED -- DLP rule overrode the policy-level allow.
|
|
54
|
+
REASON_CODE_RULE_MATCH = "RULE_MATCH"
|
|
55
|
+
REASON_CODE_NO_RULE_MATCH = "NO_RULE_MATCH"
|
|
56
|
+
REASON_CODE_NO_ACTIVE_POLICIES = "NO_ACTIVE_POLICIES"
|
|
57
|
+
REASON_CODE_BUNDLE_MISSING = "BUNDLE_MISSING"
|
|
58
|
+
REASON_CODE_BUNDLE_TAMPERED = "BUNDLE_TAMPERED"
|
|
59
|
+
REASON_CODE_MACHINE_QUARANTINED = "MACHINE_QUARANTINED"
|
|
60
|
+
REASON_CODE_NETWORK_ERROR = "NETWORK_ERROR"
|
|
61
|
+
REASON_CODE_DLP_BLOCKED = "DLP_BLOCKED"
|
|
62
|
+
|
|
63
|
+
VALID_REASON_CODES = frozenset({
|
|
64
|
+
REASON_CODE_RULE_MATCH,
|
|
65
|
+
REASON_CODE_NO_RULE_MATCH,
|
|
66
|
+
REASON_CODE_NO_ACTIVE_POLICIES,
|
|
67
|
+
REASON_CODE_BUNDLE_MISSING,
|
|
68
|
+
REASON_CODE_BUNDLE_TAMPERED,
|
|
69
|
+
REASON_CODE_MACHINE_QUARANTINED,
|
|
70
|
+
REASON_CODE_NETWORK_ERROR,
|
|
71
|
+
REASON_CODE_DLP_BLOCKED,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
# Canonical bundle-level default values. These must stay in lockstep
|
|
75
|
+
# with the backend's `DefaultBundleAction` / `DefaultBundleOnMissing`
|
|
76
|
+
# / `DefaultBundleOnTamper` constants (bundle_handler.go). They are
|
|
77
|
+
# the values the SDK falls back to when a bundle (a) was built by an
|
|
78
|
+
# old backend that predates schema 1.1 and has no default_* fields,
|
|
79
|
+
# or (b) parses one of the fields as an unknown value. Matches the
|
|
80
|
+
# pre-#228 hard-coded behaviour: no-match deny, missing-bundle deny,
|
|
81
|
+
# tamper warn.
|
|
82
|
+
DEFAULT_BUNDLE_ACTION = "deny"
|
|
83
|
+
DEFAULT_BUNDLE_ON_MISSING = "deny"
|
|
84
|
+
DEFAULT_BUNDLE_ON_TAMPER = "warn"
|
|
85
|
+
|
|
86
|
+
VALID_DEFAULT_ACTIONS = frozenset({"deny", "allow", "warn"})
|
|
87
|
+
VALID_DEFAULT_ON_MISSING = frozenset({"deny", "allow"})
|
|
88
|
+
VALID_DEFAULT_ON_TAMPER = frozenset({"warn", "deny", "deny-all", "quarantine"})
|
|
89
|
+
|
|
90
|
+
|
|
33
91
|
@dataclass
|
|
34
92
|
class PolicyDecision:
|
|
35
93
|
"""Result of a policy evaluation.
|
|
@@ -38,13 +96,21 @@ class PolicyDecision:
|
|
|
38
96
|
effect: One of "allow", "deny", "warn", "audit".
|
|
39
97
|
decision: Alias for effect, for ergonomic Hello World code.
|
|
40
98
|
policy_id: ID of the rule that matched, or None if no match.
|
|
41
|
-
reason: Human-readable reason for the decision.
|
|
99
|
+
reason: Human-readable reason for the decision. Keep this for
|
|
100
|
+
display / error messages; it can be translated, re-worded,
|
|
101
|
+
or localized without breaking consumers.
|
|
102
|
+
reason_code: Machine-readable code from the REASON_CODE_* set.
|
|
103
|
+
Stable across SDK versions -- automation should branch on
|
|
104
|
+
this, not on `reason`. Empty string when the decision
|
|
105
|
+
carries no structured reason (e.g. a plain user-policy
|
|
106
|
+
match).
|
|
42
107
|
evaluated_rules: How many rules were checked before a match.
|
|
43
108
|
dlp_findings: List of DLP match dicts for the audit trail.
|
|
44
109
|
"""
|
|
45
110
|
effect: str
|
|
46
111
|
policy_id: Optional[str] = None
|
|
47
112
|
reason: Optional[str] = None
|
|
113
|
+
reason_code: str = ""
|
|
48
114
|
evaluated_rules: int = 0
|
|
49
115
|
dlp_findings: list[dict] = field(default_factory=list)
|
|
50
116
|
|
|
@@ -96,9 +162,16 @@ class PolicyEvaluator:
|
|
|
96
162
|
self,
|
|
97
163
|
rules: Optional[list[PolicyRule]] = None,
|
|
98
164
|
dlp_scanner: Optional[DLPScanner] = None,
|
|
165
|
+
default_action: str = DEFAULT_BUNDLE_ACTION,
|
|
99
166
|
):
|
|
100
167
|
self._rules: list[PolicyRule] = rules or []
|
|
101
168
|
self._dlp_scanner: Optional[DLPScanner] = dlp_scanner
|
|
169
|
+
# Coerce unknown values to the canonical deny default. Keeps the
|
|
170
|
+
# evaluator safe even if a caller hands in a stale / bad value
|
|
171
|
+
# from a future schema -- SDK fails closed, never raises.
|
|
172
|
+
self._default_action: str = (
|
|
173
|
+
default_action if default_action in VALID_DEFAULT_ACTIONS else DEFAULT_BUNDLE_ACTION
|
|
174
|
+
)
|
|
102
175
|
|
|
103
176
|
def load(self, rules: list[PolicyRule]) -> None:
|
|
104
177
|
"""Replace the rule set."""
|
|
@@ -108,6 +181,24 @@ class PolicyEvaluator:
|
|
|
108
181
|
"""Set or replace the DLP scanner."""
|
|
109
182
|
self._dlp_scanner = scanner
|
|
110
183
|
|
|
184
|
+
def set_default_action(self, action: str) -> None:
|
|
185
|
+
"""Replace the no-match default action.
|
|
186
|
+
|
|
187
|
+
Called by the Client after a hosted-policy refresh so the
|
|
188
|
+
bundle's resolved ``default_action`` takes effect without
|
|
189
|
+
rebuilding the evaluator. Unknown values coerce to the
|
|
190
|
+
canonical deny default, matching pre-#228 fail-closed
|
|
191
|
+
behaviour.
|
|
192
|
+
"""
|
|
193
|
+
self._default_action = (
|
|
194
|
+
action if action in VALID_DEFAULT_ACTIONS else DEFAULT_BUNDLE_ACTION
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def default_action(self) -> str:
|
|
199
|
+
"""The effective no-match default action."""
|
|
200
|
+
return self._default_action
|
|
201
|
+
|
|
111
202
|
def evaluate(
|
|
112
203
|
self,
|
|
113
204
|
tool: str,
|
|
@@ -128,24 +219,58 @@ class PolicyEvaluator:
|
|
|
128
219
|
PolicyDecision. Always returns; never raises.
|
|
129
220
|
"""
|
|
130
221
|
action = f"{tool}:{method}"
|
|
131
|
-
|
|
222
|
+
|
|
223
|
+
# Semantic-class layer (#345): when the action carries a
|
|
224
|
+
# canonical SQL class (database:read|write|admin|exec) the
|
|
225
|
+
# evaluator matches rules against BOTH the per-keyword action
|
|
226
|
+
# above AND this class action. A rule whose actions list
|
|
227
|
+
# contains `database:read` therefore fires for any read-shaped
|
|
228
|
+
# SQL (SELECT, EXPLAIN, SHOW, CTE, ...) regardless of dialect
|
|
229
|
+
# spelling, while existing per-keyword rules
|
|
230
|
+
# (`database:DROP`) keep working unchanged. Two sources for
|
|
231
|
+
# the class action: the caller may provide it in
|
|
232
|
+
# ``context['action_semantic_class']`` (used by the hook-check
|
|
233
|
+
# CLI), or we derive it on the fly from ``args['sql']`` when
|
|
234
|
+
# the tool is ``database`` and no class was provided.
|
|
235
|
+
ctx_dict = context or {}
|
|
236
|
+
semantic_action = ctx_dict.get("action_semantic_class") or ""
|
|
237
|
+
if not semantic_action and tool == "database" and args:
|
|
238
|
+
from controlzero._internal.hook_extractors import sql_semantic_class
|
|
239
|
+
sql_text = args.get("sql") if isinstance(args, dict) else None
|
|
240
|
+
if isinstance(sql_text, str) and sql_text:
|
|
241
|
+
cls = sql_semantic_class(sql_text)
|
|
242
|
+
if cls:
|
|
243
|
+
semantic_action = f"{tool}:{cls}"
|
|
244
|
+
candidate_actions: list[str] = [action]
|
|
245
|
+
if semantic_action and semantic_action != action:
|
|
246
|
+
candidate_actions.append(semantic_action)
|
|
247
|
+
|
|
248
|
+
resource = ctx_dict.get("resource")
|
|
132
249
|
evaluated = 0
|
|
133
250
|
|
|
134
251
|
for rule in self._rules:
|
|
135
252
|
evaluated += 1
|
|
136
|
-
if not _glob_any(rule.actions,
|
|
253
|
+
if not any(_glob_any(rule.actions, a) for a in candidate_actions):
|
|
137
254
|
continue
|
|
138
255
|
if rule.resources:
|
|
139
256
|
if not resource or not _glob_any(rule.resources, resource):
|
|
140
257
|
continue
|
|
141
258
|
if not self._conditions_match(rule.conditions, context, args):
|
|
142
259
|
continue
|
|
260
|
+
# Prefer the rule-declared reason_code (only synthetic
|
|
261
|
+
# rules set one today -- e.g. the empty-bundle
|
|
262
|
+
# NO_ACTIVE_POLICIES deny). Everything else defaults to
|
|
263
|
+
# RULE_MATCH so consumers can tell a user-authored rule
|
|
264
|
+
# firing apart from the no-match fallthrough below.
|
|
265
|
+
rule_code = getattr(rule, "reason_code", "") or ""
|
|
266
|
+
decision_code = rule_code or REASON_CODE_RULE_MATCH
|
|
143
267
|
decision = PolicyDecision(
|
|
144
268
|
effect=rule.effect,
|
|
145
269
|
policy_id=rule.id or rule.name or None,
|
|
146
270
|
# User-provided reason wins. Falls back to canned text only if
|
|
147
271
|
# the rule had no `reason:` field in the source policy.
|
|
148
272
|
reason=rule.reason or f"Matched rule {rule.id or rule.name}".strip(),
|
|
273
|
+
reason_code=decision_code,
|
|
149
274
|
evaluated_rules=evaluated,
|
|
150
275
|
)
|
|
151
276
|
# DLP scan: only when the policy decision is "allow" and args exist
|
|
@@ -153,10 +278,27 @@ class PolicyEvaluator:
|
|
|
153
278
|
decision = self._apply_dlp_scan(decision, args)
|
|
154
279
|
return decision
|
|
155
280
|
|
|
156
|
-
#
|
|
281
|
+
# No-match default. Pre-#228 this was hard-coded to deny; as
|
|
282
|
+
# of Phase 2 the bundle can override it (default_action =
|
|
283
|
+
# deny|allow|warn). The reason_code stays NO_RULE_MATCH so
|
|
284
|
+
# "rule set evaluated, nothing matched" is distinguishable
|
|
285
|
+
# from "bundle was empty" (NO_ACTIVE_POLICIES) and "bundle
|
|
286
|
+
# was missing" (BUNDLE_MISSING).
|
|
287
|
+
#
|
|
288
|
+
# Reason-text note: the historical "fail-closed default"
|
|
289
|
+
# phrase is kept for the deny case so downstream consumers
|
|
290
|
+
# that regex-match the reason string (a pattern we actively
|
|
291
|
+
# discourage -- use reason_code instead) keep working.
|
|
292
|
+
if self._default_action == "deny":
|
|
293
|
+
reason = "No matching policy rule (fail-closed default)"
|
|
294
|
+
elif self._default_action == "allow":
|
|
295
|
+
reason = "No matching policy rule (default_action=allow)"
|
|
296
|
+
else: # "warn"
|
|
297
|
+
reason = "No matching policy rule (default_action=warn)"
|
|
157
298
|
return PolicyDecision(
|
|
158
|
-
effect=
|
|
159
|
-
reason=
|
|
299
|
+
effect=self._default_action,
|
|
300
|
+
reason=reason,
|
|
301
|
+
reason_code=REASON_CODE_NO_RULE_MATCH,
|
|
160
302
|
evaluated_rules=evaluated,
|
|
161
303
|
)
|
|
162
304
|
|
|
@@ -224,6 +366,13 @@ class PolicyEvaluator:
|
|
|
224
366
|
f"DLP rule '{blocking.rule_name}' matched "
|
|
225
367
|
f"{blocking.category} content in tool arguments"
|
|
226
368
|
),
|
|
369
|
+
# DLP_BLOCKED means: the tool-name policy said allow,
|
|
370
|
+
# but a DLP rule found a hit in the arguments and
|
|
371
|
+
# overrode the decision. Dashboards bucket this as a
|
|
372
|
+
# different failure surface from a rule-authored deny
|
|
373
|
+
# so ops can tell "my tool is blocked" from "my
|
|
374
|
+
# argument tripped a DLP guard" apart.
|
|
375
|
+
reason_code=REASON_CODE_DLP_BLOCKED,
|
|
227
376
|
evaluated_rules=decision.evaluated_rules,
|
|
228
377
|
dlp_findings=findings,
|
|
229
378
|
)
|